Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,7 @@ 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); if the string contains line break characters, the mark will wrap each line in a tspan and create a multiline text.

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).

Expand All @@ -1081,6 +1081,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); start, end, or middle (default)
* **lineAnchor** - for multiline text, the vertical line anchor; top, bottom, or middle (default)
* **lineHeight** - for multiline text,the line height factor; defaults to 1.0, which corresponds to a CSS length of 1em
* **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
Expand Down
54 changes: 36 additions & 18 deletions src/marks/text.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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);
Expand All @@ -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]})`
Expand All @@ -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});
Expand All @@ -99,9 +123,3 @@ function applyIndirectTextStyles(selection, mark, T) {
applyAttr(selection, "font-variant", mark.fontVariant === undefined && (isNumeric(T) || isTemporal(T)) ? "tabular-nums" : mark.fontVariant);
applyAttr(selection, "font-weight", mark.fontWeight);
}

function applyDirectTextStyles(selection, mark) {
applyDirectStyles(selection, mark);
applyAttr(selection, "dx", mark.dx);
applyAttr(selection, "dy", mark.dy);
}
5 changes: 3 additions & 2 deletions test/marks/text-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion test/output/covidIhmeProjectedDeaths.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/documentationLinks.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/firstLadies.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading