diff --git a/README.md b/README.md
index 977dbfa8f8..8e28472ab1 100644
--- a/README.md
+++ b/README.md
@@ -1070,9 +1070,9 @@ If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be der
The following channels are required:
-* **text** - the text contents (a string)
+* **text** - the text contents (a string, possibly with multiple lines)
-If **text** is not specified, it defaults to [0, 1, 2, …] so that something is visible by default. Due to the design of SVG, each label is currently limited to one line; in the future we may support multiline text. [#327](https://github.com/observablehq/plot/pull/327) For embedding numbers and dates into text, consider [*number*.toLocaleString](https://observablehq.com/@mbostock/number-formatting), [*date*.toLocaleString](https://observablehq.com/@mbostock/date-formatting), [d3-format](https://github.com/d3/d3-format), or [d3-time-format](https://github.com/d3/d3-time-format).
+If the **text** contains `\n`, `\r\n`, or `\r`, it will be rendered as multiple lines via tspan elements. If the **text** is specified as numbers or dates, a default formatter will automatically be applied, and the **fontVariant** will default to tabular-nums instead of normal. For more control over number and date formatting, consider [*number*.toLocaleString](https://observablehq.com/@mbostock/number-formatting), [*date*.toLocaleString](https://observablehq.com/@mbostock/date-formatting), [d3-format](https://github.com/d3/d3-format), or [d3-time-format](https://github.com/d3/d3-time-format). If **text** is not specified, it defaults to [0, 1, 2, …] so that something is visible by default.
In addition to the [standard mark options](#marks), the following optional channels are supported:
@@ -1083,6 +1083,9 @@ In addition to the [standard mark options](#marks), the following optional chann
The following text-specific constant options are also supported:
+* **textAnchor** - the [text anchor](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor) for horizontal position; start, end, or middle (default)
+* **lineAnchor** - the line anchor for vertical bposition; top, bottom, or middle (default)
+* **lineHeight** - the line height in ems; defaults to 1
* **fontFamily** - the font name; defaults to [system-ui](https://drafts.csswg.org/css-fonts-4/#valdef-font-family-system-ui)
* **fontSize** - the font size in pixels; defaults to 10
* **fontStyle** - the [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style); defaults to normal
diff --git a/src/marks/text.js b/src/marks/text.js
index e3745d862a..31e203b56c 100644
--- a/src/marks/text.js
+++ b/src/marks/text.js
@@ -1,8 +1,9 @@
-import {create} from "d3";
+import {create, isoFormat, namespaces} from "d3";
import {nonempty} from "../defined.js";
-import {indexOf, identity, string, maybeNumberChannel, maybeTuple, numberChannel, isNumeric, isTemporal} from "../options.js";
+import {formatNumber} from "../format.js";
+import {indexOf, identity, string, maybeNumberChannel, maybeTuple, numberChannel, isNumeric, isTemporal, keyword} from "../options.js";
import {Mark} from "../plot.js";
-import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyText, applyTransform, offset} from "../style.js";
+import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyTransform, offset, impliedString} from "../style.js";
const defaults = {
strokeLinejoin: "round"
@@ -15,13 +16,13 @@ export class Text extends Mark {
y,
text = indexOf,
textAnchor,
+ lineAnchor = "middle",
+ lineHeight = 1,
fontFamily,
fontSize,
fontStyle,
fontVariant,
fontWeight,
- dx,
- dy = "0.32em",
rotate
} = options;
const [vrotate, crotate] = maybeNumberChannel(rotate, 0);
@@ -39,28 +40,29 @@ export class Text extends Mark {
defaults
);
this.rotate = crotate;
- this.textAnchor = string(textAnchor);
+ this.textAnchor = impliedString(textAnchor, "middle");
+ this.lineAnchor = keyword(lineAnchor, "lineAnchor", ["top", "middle", "bottom"]);
+ this.lineHeight = +lineHeight;
this.fontFamily = string(fontFamily);
this.fontSize = cfontSize;
this.fontStyle = string(fontStyle);
this.fontVariant = string(fontVariant);
this.fontWeight = string(fontWeight);
- this.dx = string(dx);
- this.dy = string(dy);
}
render(index, {x, y}, channels, dimensions) {
const {x: X, y: Y, rotate: R, text: T, fontSize: FS} = channels;
const {width, height, marginTop, marginRight, marginBottom, marginLeft} = dimensions;
- const {rotate} = this;
+ const {dx, dy, rotate} = this;
const cx = (marginLeft + width - marginRight) / 2;
const cy = (marginTop + height - marginBottom) / 2;
return create("svg:g")
.call(applyIndirectTextStyles, this, T)
- .call(applyTransform, x, y, offset, offset)
+ .call(applyTransform, x, y, offset + dx, offset + dy)
.call(g => g.selectAll()
.data(index)
.join("text")
- .call(applyDirectTextStyles, this)
+ .call(applyDirectStyles, this)
+ .call(applyMultilineText, this, T)
.call(R ? text => text.attr("transform", X && Y ? i => `translate(${X[i]},${Y[i]}) rotate(${R[i]})`
: X ? i => `translate(${X[i]},${cy}) rotate(${R[i]})`
: Y ? i => `translate(${cx},${Y[i]}) rotate(${R[i]})`
@@ -71,12 +73,34 @@ export class Text extends Mark {
: `translate(${cx},${cy}) rotate(${rotate})`)
: text => text.attr("x", X ? i => X[i] : cx).attr("y", Y ? i => Y[i] : cy))
.call(applyAttr, "font-size", FS && (i => FS[i]))
- .call(applyText, T)
.call(applyChannelStyles, this, channels))
.node();
}
}
+function applyMultilineText(selection, {lineAnchor, lineHeight}, T) {
+ if (!T) return;
+ const format = isTemporal(T) ? isoFormat : isNumeric(T) ? formatNumber() : string;
+ selection.each(function(i) {
+ const lines = format(T[i]).split(/\r\n?|\n/g);
+ const n = lines.length;
+ const y = lineAnchor === "top" ? 0.71 : lineAnchor === "bottom" ? 1 - n : (164 - n * 100) / 200;
+ if (n > 1) {
+ for (let i = 0; i < n; ++i) {
+ if (!lines[i]) continue;
+ const tspan = document.createElementNS(namespaces.svg, "tspan");
+ tspan.setAttribute("x", 0);
+ tspan.setAttribute("y", `${(y + i) * lineHeight}em`);
+ tspan.textContent = lines[i];
+ this.appendChild(tspan);
+ }
+ } else {
+ if (y) this.setAttribute("dy", `${y * lineHeight}em`);
+ this.textContent = lines[0];
+ }
+ });
+}
+
export function text(data, {x, y, ...options} = {}) {
([x, y] = maybeTuple(x, y));
return new Text(data, {...options, x, y});
@@ -100,12 +124,6 @@ function applyIndirectTextStyles(selection, mark, T) {
applyAttr(selection, "font-weight", mark.fontWeight);
}
-function applyDirectTextStyles(selection, mark) {
- applyDirectStyles(selection, mark);
- applyAttr(selection, "dx", mark.dx);
- applyAttr(selection, "dy", mark.dy);
-}
-
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-size
const fontSizes = new Set([
// global keywords
diff --git a/test/marks/text-test.js b/test/marks/text-test.js
index 167779a0ca..69218891f0 100644
--- a/test/marks/text-test.js
+++ b/test/marks/text-test.js
@@ -20,8 +20,9 @@ it("text() has the expected defaults", () => {
assert.strictEqual(text.mixBlendMode, undefined);
assert.strictEqual(text.shapeRendering, undefined);
assert.strictEqual(text.textAnchor, undefined);
- assert.strictEqual(text.dx, undefined);
- assert.strictEqual(text.dy, "0.32em");
+ assert.strictEqual(text.lineAnchor, "middle");
+ assert.strictEqual(text.dx, 0);
+ assert.strictEqual(text.dy, 0);
assert.strictEqual(text.rotate, 0);
});
diff --git a/test/output/covidIhmeProjectedDeaths.svg b/test/output/covidIhmeProjectedDeaths.svg
index e1c3b1b8d6..4ea91fd276 100644
--- a/test/output/covidIhmeProjectedDeaths.svg
+++ b/test/output/covidIhmeProjectedDeaths.svg
@@ -238,5 +238,5 @@
- 900
+ 900
\ No newline at end of file
diff --git a/test/output/documentationLinks.svg b/test/output/documentationLinks.svg
index 7702a2f523..04a71dd877 100644
--- a/test/output/documentationLinks.svg
+++ b/test/output/documentationLinks.svg
@@ -178,7 +178,7 @@
- ⚡︎ 296 16 18 6 10 3 9 17 5 11 8 2 2 4 5 1 3 7 12 13 6 3 6 7 18
+ ⚡︎ 296 16 18 6 10 3 9 17 5 11 8 2 2 4 5 1 3 7 12 13 6 3 6 7 18
diff --git a/test/output/firstLadies.svg b/test/output/firstLadies.svg
index 7d2f7f0637..9090b3f4d4 100644
--- a/test/output/firstLadies.svg
+++ b/test/output/firstLadies.svg
@@ -171,5 +171,5 @@
- Martha WashingtonAbigail AdamsMartha JeffersonDolly MadisonElizabeth MonroeLouisa AdamsEmily DonelsonSarah JacksonSarah Van BurenAnna HarrisonJane HarrisonLetitia TylerElizabeth Priscilla TylerJulia TylerSarah PolkMargaret "Peggy" TaylorAbigail FillmoreJane PierceHarriet LaneMary LincolnEliza JohnsonJulia GrantLucy HayesLucretia GarfieldMary McElroyRose ClevelandFrances ClevelandCaroline HarrisonMary Harrison McKeeFrances ClevelandIda McKinleyEdith RooseveltHelen "Nellie" TaftEllen WilsonMargaret WilsonEdith WilsonFlorence HardingGrace CoolidgeLou HooverAnna Eleanor RooseveltElizabeth "Bess" TrumanMamie EisenhowerJacqueline "Jackie" KennedyClaudia "Lady Bird" JohnsonThelma "Pat" NixonElizabeth "Betty" FordEleanor Rosalynn CarterNancy ReaganBarbara BushHillary ClintonLaura BushMichelle ObamaMelania TrumpJill Biden
+ Martha WashingtonAbigail AdamsMartha JeffersonDolly MadisonElizabeth MonroeLouisa AdamsEmily DonelsonSarah JacksonSarah Van BurenAnna HarrisonJane HarrisonLetitia TylerElizabeth Priscilla TylerJulia TylerSarah PolkMargaret "Peggy" TaylorAbigail FillmoreJane PierceHarriet LaneMary LincolnEliza JohnsonJulia GrantLucy HayesLucretia GarfieldMary McElroyRose ClevelandFrances ClevelandCaroline HarrisonMary Harrison McKeeFrances ClevelandIda McKinleyEdith RooseveltHelen "Nellie" TaftEllen WilsonMargaret WilsonEdith WilsonFlorence HardingGrace CoolidgeLou HooverAnna Eleanor RooseveltElizabeth "Bess" TrumanMamie EisenhowerJacqueline "Jackie" KennedyClaudia "Lady Bird" JohnsonThelma "Pat" NixonElizabeth "Betty" FordEleanor Rosalynn CarterNancy ReaganBarbara BushHillary ClintonLaura BushMichelle ObamaMelania TrumpJill Biden
\ No newline at end of file
diff --git a/test/output/gridChoropleth.svg b/test/output/gridChoropleth.svg
index 0a5b1f8837..660982a626 100644
--- a/test/output/gridChoropleth.svg
+++ b/test/output/gridChoropleth.svg
@@ -65,6 +65,6 @@
- CATXFLNYPAILOHGANCMINJVAWAAZMATNINMOMDWICOMNSCALLAKYOROKCTUTIANVARMSKSNMNEWVIDHINHMEMTRIDESDNDAKDCVTWY
- +6%+15%+14%+0%+1%−1%+1%+10%+10%+1%+1%+7%+13%+14%+6%+8%+4%+2%+5%+2%+15%+6%+11%+3%+3%+3%+10%+5%−0%+16%+4%+14%+3%+0%+2%+2%+6%−3%+14%+4%+3%+1%+8%+1%+8%+9%+13%+3%+17%−0%+3%
+ CATXFLNYPAILOHGANCMINJVAWAAZMATNINMOMDWICOMNSCALLAKYOROKCTUTIANVARMSKSNMNEWVIDHINHMEMTRIDESDNDAKDCVTWY
+ +6%+15%+14%+0%+1%−1%+1%+10%+10%+1%+1%+7%+13%+14%+6%+8%+4%+2%+5%+2%+15%+6%+11%+3%+3%+3%+10%+5%−0%+16%+4%+14%+3%+0%+2%+2%+6%−3%+14%+4%+3%+1%+8%+1%+8%+9%+13%+3%+17%−0%+3%
\ No newline at end of file
diff --git a/test/output/letterFrequencyWheel.svg b/test/output/letterFrequencyWheel.svg
new file mode 100644
index 0000000000..3bcf744ec7
--- /dev/null
+++ b/test/output/letterFrequencyWheel.svg
@@ -0,0 +1,125 @@
+
\ No newline at end of file
diff --git a/test/output/metroInequalityChange.svg b/test/output/metroInequalityChange.svg
index f6fe6f40c9..cd3547434b 100644
--- a/test/output/metroInequalityChange.svg
+++ b/test/output/metroInequalityChange.svg
@@ -333,5 +333,5 @@
- New YorkChicagoHoustonWashington, D.C.San FranciscoSan JoseFairfield, Conn.Binghamton, N.Y.
+ New YorkChicagoHoustonWashington, D.C.San FranciscoSan JoseFairfield, Conn.Binghamton, N.Y.
\ No newline at end of file
diff --git a/test/output/polylinear.svg b/test/output/polylinear.svg
index 7d8f7a8607..6f973470aa 100644
--- a/test/output/polylinear.svg
+++ b/test/output/polylinear.svg
@@ -141,5 +141,5 @@
- InitiateBeginEntryTestDriveDriveBrakeStopShutdown
+ InitiateBeginEntryTestDriveDriveBrakeStopShutdown
\ No newline at end of file
diff --git a/test/output/stargazers.svg b/test/output/stargazers.svg
index 00b0b68830..6f9aff8119 100644
--- a/test/output/stargazers.svg
+++ b/test/output/stargazers.svg
@@ -87,5 +87,5 @@
- 1,096
+ 1,096
\ No newline at end of file
diff --git a/test/output/stocksIndex.svg b/test/output/stocksIndex.svg
index 1accf66c91..5f1be8bfd5 100644
--- a/test/output/stocksIndex.svg
+++ b/test/output/stocksIndex.svg
@@ -80,5 +80,5 @@
- AAPLAMZNGOOGIBM
+ AAPLAMZNGOOGIBM
\ No newline at end of file
diff --git a/test/output/travelersYearOverYear.svg b/test/output/travelersYearOverYear.svg
index 0a70a65223..a6e1fe5b48 100644
--- a/test/output/travelersYearOverYear.svg
+++ b/test/output/travelersYearOverYear.svg
@@ -119,6 +119,6 @@
- 2019
- 2020
+ 2019
+ 2020
\ No newline at end of file
diff --git a/test/output/usPopulationStateAgeDots.svg b/test/output/usPopulationStateAgeDots.svg
index 3c8e6e8190..8d50ce0190 100644
--- a/test/output/usPopulationStateAgeDots.svg
+++ b/test/output/usPopulationStateAgeDots.svg
@@ -585,5 +585,5 @@
- ALAKAZARCACOCTDEDCFLGAHIIDILINIAKSKYLAMEMDMAMIMNMSMOMTNENVNHNJNMNYNCNDOHOKORPARISCSDTNTXUTVTVAWAWVWIWYPR
+ ALAKAZARCACOCTDEDCFLGAHIIDILINIAKSKYLAMEMDMAMIMNMSMOMTNENVNHNJNMNYNCNDOHOKORPARISCSDTNTXUTVTVAWAWVWIWYPR
\ No newline at end of file
diff --git a/test/output/wealthBritainProportionPlot.svg b/test/output/wealthBritainProportionPlot.svg
index 5e86d14ea6..78a18fb315 100644
--- a/test/output/wealthBritainProportionPlot.svg
+++ b/test/output/wealthBritainProportionPlot.svg
@@ -26,7 +26,7 @@
- 30%33%28%9%
- 3%32%52%13%
- 16-34’s35-54’s55-74’sOver 75’s
+ 30%33%28%9%
+ 3%32%52%13%
+ 16-34’s35-54’s55-74’sOver 75’s
\ No newline at end of file
diff --git a/test/plots/grid-choropleth.js b/test/plots/grid-choropleth.js
index 07aec6a82d..6b8c57fce2 100644
--- a/test/plots/grid-choropleth.js
+++ b/test/plots/grid-choropleth.js
@@ -23,8 +23,8 @@ export default async function() {
},
marks: [
Plot.cell(states, {x: "x", y: "y", fill: change}),
- Plot.text(states, {x: "x", y: "y", text: "key", dy: -2}),
- Plot.text(states, {x: "x", y: "y", text: (f => d => f(change(d) - 1))(d3.format("+.0%")), dy: 10, fillOpacity: 0.6})
+ Plot.text(states, {x: "x", y: "y", text: "key", dy: -6}),
+ Plot.text(states, {x: "x", y: "y", text: (f => d => f(change(d) - 1))(d3.format("+.0%")), dy: 6, fillOpacity: 0.6})
]
});
}
diff --git a/test/plots/index.js b/test/plots/index.js
index d1dc461f9e..dda412f97b 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -64,6 +64,7 @@ export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js";
export {default as letterFrequencyColumn} from "./letter-frequency-column.js";
export {default as letterFrequencyDot} from "./letter-frequency-dot.js";
export {default as letterFrequencyLollipop} from "./letter-frequency-lollipop.js";
+export {default as letterFrequencyWheel} from "./letter-frequency-wheel.js";
export {default as logDegenerate} from "./log-degenerate.js";
export {default as metroInequality} from "./metro-inequality.js";
export {default as metroInequalityChange} from "./metro-inequality-change.js";
diff --git a/test/plots/letter-frequency-wheel.js b/test/plots/letter-frequency-wheel.js
new file mode 100644
index 0000000000..646cbc8b5f
--- /dev/null
+++ b/test/plots/letter-frequency-wheel.js
@@ -0,0 +1,32 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const alphabet = d3.sort(await d3.csv("data/alphabet.csv", d3.autoType), d => d.letter);
+ const m = d3.max(alphabet, d => d.frequency) * 1.1;
+ return Plot.plot({
+ width: 500,
+ height: 250,
+ inset: 10,
+ x: {axis: null},
+ y: {axis: null},
+ marks: [
+ Plot.ruleY([0], {strokeOpacity: 0.2}),
+ Plot.link(alphabet, {
+ x1: 0,
+ y1: 0,
+ x2: (d, i) => -Math.cos((0.5 + i) * Math.PI / 26) * d.frequency / m,
+ y2: (d, i) => Math.sin((0.5 + i) * Math.PI / 26) * d.frequency / m,
+ strokeWidth: 2
+ }),
+ Plot.text(alphabet, {
+ x: (d, i) => -Math.cos((0.5 + i) * Math.PI / 26),
+ y: (d, i) => Math.sin((0.5 + i) * Math.PI / 26),
+ text: d => `${d.letter}\n${(d.frequency * 100).toFixed(1)}%`,
+ lineHeight: 1.2,
+ fontSize: 8,
+ rotate: (d, i) => -90 + (0.5 + i) * 180 / 26
+ })
+ ]
+ });
+}
diff --git a/test/plots/metro-inequality-change.js b/test/plots/metro-inequality-change.js
index 7f7afc2a75..1fe81655bd 100644
--- a/test/plots/metro-inequality-change.js
+++ b/test/plots/metro-inequality-change.js
@@ -31,7 +31,7 @@ export default async function() {
x: "POP_2015",
y: "R90_10_2015",
text: d => d.highlight && d.nyt_display,
- dy: -6
+ dy: -8
})
]
});
diff --git a/test/plots/travelers-year-over-year.js b/test/plots/travelers-year-over-year.js
index bd5b7aa74f..bf7c75ff90 100644
--- a/test/plots/travelers-year-over-year.js
+++ b/test/plots/travelers-year-over-year.js
@@ -26,13 +26,13 @@ export default async function() {
y: "previous",
text: ["2019"],
fill: "#bab0ab",
- dy: "-0.5em"
+ dy: -8
}),
Plot.text(data.slice(0, 1), {
x: "date",
y: "current",
text: ["2020"],
- dy: "1.2em"
+ dy: 8
})
]
});