Skip to content

Commit

Permalink
multiline text (observablehq#677)
Browse files Browse the repository at this point in the history
* multiline text

* lineHeight option

* skip empty lines

* use x, y and dy when there is no rotate (observablehq#682)

* use x, y and dy when there is no rotate
test multiline (with 1 empty line)
document

* use selection.call to branch

* remove duplicate test

Co-authored-by: Mike Bostock <mbostock@gmail.com>

* minimize diff

* fix choropleth label alignment

* more test fixes

* tweak wheel example

* Update README

* Update README

* Update README

Co-authored-by: Philippe Rivière <fil@rezo.net>
  • Loading branch information
mbostock and Fil authored Jan 19, 2022
1 parent 65ebe0c commit 136fc2f
Show file tree
Hide file tree
Showing 20 changed files with 222 additions and 42 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
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 @@ -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
Expand Down
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

0 comments on commit 136fc2f

Please sign in to comment.