diff --git a/README.md b/README.md
index 9a67308a11..98a7079f96 100644
--- a/README.md
+++ b/README.md
@@ -708,15 +708,21 @@ All marks support the following optional channels:
* **stroke** - a stroke color; bound to the *color* scale
* **strokeOpacity** - a stroke opacity; bound to the *opacity* scale
* **strokeWidth** - a stroke width (in pixels)
+* **strokeDasharray** - a comma-separated list of dash lengths (typically in pixels)
+* **strokeDashoffset** - the [stroke dash offset](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dashoffset) (typically in pixels)
* **opacity** - an object opacity; bound to the *opacity* scale
* **title** - a tooltip (a string of text, possibly with newlines)
* **href** - a URL to link to
* **ariaLabel** - a short label representing the value in the accessibility tree
+* **dx** - horizontal offset (in pixels; defaults to 0)
+* **dy** - vertical offset (in pixels; defaults to 0)
-The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, **strokeOpacity**, and **opacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill opacity, stroke opacity, object opacity, stroke width, or radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.
+The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, **strokeOpacity**, **strokeDasharray**, **strokeDashoffset**, and **opacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill opacity, stroke opacity, object opacity, stroke width, stroke-dashoffset or radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When the stroke-dasharray is specified as a number, a string, or an array of numbers, is is interpreted as a constant; otherwise, it is interpreted as a channel.
The **title**, **href**, and **ariaLabel** options can *only* be specified as channels. When these options are specified as a string, the string refers to the name of a column in the mark’s associated data. If you’d like every instance of a particular mark to have the same value, specify the option as a function that returns the desired value, *e.g.* `() => "Hello, world!"`.
+Currently, the **dx** option can be specified as a channel only when **x** is a channel; similarly, the **dy** option can be specified as a channel only when **y** is a channel.
+
The rectangular marks ([bar](#bar), [cell](#cell), and [rect](#rect)) support insets and rounded corner constant options:
* **insetTop** - inset the top edge
@@ -1398,7 +1404,7 @@ The following text-specific constant options are also supported:
If a **lineWidth** is specified, input text values will be wrapped as needed to fit while preserving existing newlines. The line wrapping implementation is rudimentary; for non-ASCII, non-U.S. English text, or for when a different font is used, you may get better results by hard-wrapping the text yourself (by supplying newlines in the input). If the **monospace** option is truthy, the default **fontFamily** changes to “ui-monospace, monospace”, and the **lineWidth** option is interpreted as characters (ch) rather than ems.
-The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.
+The **fontSize**, **fontFamily**, **fontStyle**, **fontVariant**, **fontWeight**, **textAnchor** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When fontFamily, fontStyle, or fontVariant is specified as a string, it is interpreted as a constant, otherwise as a channel. When fontWeight is specified as a string or a number, it is interpreted as a constant, otherwise as a channel. When textAnchor is specified as valid keyword, it is interpreted as a constant, otherwise as a channel.
If the **frameAnchor** option is not specified, then **textAnchor** and **lineAnchor** default to middle. Otherwise, **textAnchor** defaults to start if **frameAnchor** is on the left, end if **frameAnchor** is on the right, and otherwise middle. Similarly, **lineAnchor** defaults to top if **frameAnchor** is on the top, bottom if **frameAnchor** is on the bottom, and otherwise middle.
diff --git a/src/channel.js b/src/channel.js
index afcd3992e5..6569423ef0 100644
--- a/src/channel.js
+++ b/src/channel.js
@@ -28,7 +28,12 @@ export function valueObject(channels, scales) {
return Object.fromEntries(
Object.entries(channels).map(([name, {scale: scaleName, value}]) => {
const scale = scales[scaleName];
- return [name, scale === undefined ? value : map(value, scale)];
+ value = scale === undefined ? value : map(value, scale);
+ let k = channels.dx;
+ if ((k && ["x", "x1", "x2"].includes(name)) || ((k = channels.dy) && ["y", "y1", "y2"].includes(name))) {
+ value = map(value, (d, i) => d + (+k.value[i] || 0));
+ }
+ return [name, value];
})
);
}
diff --git a/src/marks/text.js b/src/marks/text.js
index 38bfdb31bc..b8e66260c6 100644
--- a/src/marks/text.js
+++ b/src/marks/text.js
@@ -14,7 +14,9 @@ import {
keyword,
maybeFrameAnchor,
isTextual,
- isIterable
+ isIterable,
+ labelof,
+ valueof
} from "../options.js";
import {Mark} from "../plot.js";
import {
@@ -55,7 +57,12 @@ export class Text extends Mark {
rotate
} = options;
const [vrotate, crotate] = maybeNumberChannel(rotate, 0);
+ const [vtextAnchor, ctextAnchor] = maybeTextAnchorChannel(textAnchor);
const [vfontSize, cfontSize] = maybeFontSizeChannel(fontSize);
+ const [vfontFamily, cfontFamily] = maybeFontChannel(fontFamily);
+ const [vfontStyle, cfontStyle] = maybeFontChannel(fontStyle);
+ const [vfontVariant, cfontVariant] = maybeFontChannel(fontVariant);
+ const [vfontWeight, cfontWeight] = maybeFontChannel(fontWeight);
super(
data,
{
@@ -63,26 +70,42 @@ export class Text extends Mark {
y: {value: y, scale: "y", optional: true},
fontSize: {value: vfontSize, optional: true},
rotate: {value: numberChannel(vrotate), optional: true},
- text: {value: text, filter: nonempty}
+ text: {value: text, filter: nonempty},
+ textAnchor: {value: keywordChannel(vtextAnchor, ["start", "end"]), optional: true, filter: null},
+ fontFamily: {value: vfontFamily, optional: true, filter: null},
+ fontStyle: {value: vfontStyle, optional: true, filter: null},
+ fontVariant: {value: vfontVariant, optional: true, filter: null},
+ fontWeight: {value: vfontWeight, optional: true, filter: null}
},
options,
defaults
);
this.rotate = crotate;
- this.textAnchor = impliedString(textAnchor, "middle");
+ this.textAnchor = ctextAnchor;
this.lineAnchor = keyword(lineAnchor, "lineAnchor", ["top", "middle", "bottom"]);
this.lineHeight = +lineHeight;
this.lineWidth = +lineWidth;
this.monospace = !!monospace;
- this.fontFamily = string(fontFamily);
this.fontSize = cfontSize;
- this.fontStyle = string(fontStyle);
- this.fontVariant = string(fontVariant);
- this.fontWeight = string(fontWeight);
+ this.fontFamily = cfontFamily;
+ this.fontStyle = cfontStyle;
+ this.fontVariant = cfontVariant;
+ this.fontWeight = cfontWeight;
this.frameAnchor = maybeFrameAnchor(frameAnchor);
}
render(index, scales, channels, dimensions, context) {
- const {x: X, y: Y, rotate: R, text: T, fontSize: FS} = channels;
+ const {
+ x: X,
+ y: Y,
+ rotate: R,
+ text: T,
+ fontSize: FS,
+ textAnchor: TA,
+ fontFamily: FF,
+ fontStyle: FT,
+ fontVariant: FV,
+ fontWeight: FW
+ } = channels;
const {rotate} = this;
const [cx, cy] = applyFrameAnchor(this, dimensions);
return create("svg:g", context)
@@ -124,6 +147,11 @@ export class Text extends Mark {
: `translate(${cx},${cy})`
)
.call(applyAttr, "font-size", FS && ((i) => FS[i]))
+ .call(applyAttr, "text-anchor", TA && ((i) => TA[i]))
+ .call(applyAttr, "font-family", FF && ((i) => FF[i]))
+ .call(applyAttr, "font-style", FT && ((i) => FT[i]))
+ .call(applyAttr, "font-variant", FV && ((i) => FV[i]))
+ .call(applyAttr, "font-weight", FW && ((i) => FW[i]))
.call(applyChannelStyles, this, channels)
)
.node();
@@ -219,6 +247,29 @@ function maybeFontSizeChannel(fontSize) {
: [fontSize, undefined];
}
+// The text-anchor can be a constant "middle" | "start" | "end"
+// Any other value is a channel definition.
+function maybeTextAnchorChannel(textAnchor) {
+ if (textAnchor == null || ["middle", "start", "end"].includes(textAnchor))
+ return [undefined, impliedString(textAnchor, "middle")];
+ return [textAnchor, undefined];
+}
+
+// Other font properties can only be set as channels if they are defined as functions
+// and a constant strings otherwise.
+function maybeFontChannel(font) {
+ return ["string", "number"].includes(typeof font) ? [undefined, string(font)] : [font, undefined];
+}
+
+function keywordChannel(value, words) {
+ return value == null
+ ? null
+ : {
+ transform: (data) => valueof(data, value).map((d) => (words.includes((d = `${d}`)) ? d : null)),
+ label: labelof(value)
+ };
+}
+
// This is a greedy algorithm for line wrapping. It would be better to use the
// Knuth–Plass line breaking algorithm (but that would be much more complex).
// https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap
diff --git a/src/plot.js b/src/plot.js
index a9a27a7b20..df65dbd71f 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -303,7 +303,7 @@ export function plot(options = {}) {
export class Mark {
constructor(data, channels = {}, options = {}, defaults) {
- const {facet = "auto", sort, dx, dy, clip, channels: extraChannels} = options;
+ const {facet = "auto", sort, clip, channels: extraChannels} = options;
this.data = data;
this.sort = isDomainSort(sort) ? sort : null;
this.initializer = initializer(options).initializer;
@@ -322,8 +322,6 @@ export class Mark {
throw new Error(`missing channel value: ${name}`);
})
);
- this.dx = +dx || 0;
- this.dy = +dy || 0;
this.clip = maybeClip(clip);
}
initialize(facets, facetChannels) {
diff --git a/src/style.js b/src/style.js
index 3547c50f1e..4121d5660f 100644
--- a/src/style.js
+++ b/src/style.js
@@ -31,7 +31,9 @@ export function styles(
mixBlendMode,
paintOrder,
pointerEvents,
- shapeRendering
+ shapeRendering,
+ dx,
+ dy
},
{
ariaLabel: cariaLabel,
@@ -75,6 +77,8 @@ export function styles(
const [vstroke, cstroke] = maybeColorChannel(stroke, defaultStroke);
const [vstrokeOpacity, cstrokeOpacity] = maybeNumberChannel(strokeOpacity, defaultStrokeOpacity);
const [vopacity, copacity] = maybeNumberChannel(opacity);
+ const [vstrokeDasharray, cstrokeDasharray] = maybeDasharrayChannel(strokeDasharray);
+ const [vstrokeDashoffset, cstrokeDashoffset] = maybeNumberChannel(strokeDashoffset);
// For styles that have no effect if there is no stroke, only apply the
// defaults if the stroke is not the constant none. (If stroke is a channel,
@@ -110,8 +114,8 @@ export function styles(
mark.strokeLinejoin = impliedString(strokeLinejoin, "miter");
mark.strokeLinecap = impliedString(strokeLinecap, "butt");
mark.strokeMiterlimit = impliedNumber(strokeMiterlimit, 4);
- mark.strokeDasharray = impliedString(strokeDasharray, "none");
- mark.strokeDashoffset = impliedString(strokeDashoffset, "0");
+ mark.strokeDasharray = impliedString(cstrokeDasharray, "none");
+ mark.strokeDashoffset = impliedString(cstrokeDashoffset, "0");
}
mark.target = string(target);
@@ -124,6 +128,11 @@ export function styles(
mark.pointerEvents = impliedString(pointerEvents, "auto");
mark.shapeRendering = impliedString(shapeRendering, "auto");
+ const [vdx, cdx] = maybeNumberChannel(dx);
+ const [vdy, cdy] = maybeNumberChannel(dy);
+ mark.dx = cdx || 0;
+ mark.dy = cdy || 0;
+
return {
title: {value: title, optional: true},
href: {value: href, optional: true},
@@ -131,9 +140,13 @@ export function styles(
fill: {value: vfill, scale: "color", optional: true},
fillOpacity: {value: vfillOpacity, scale: "opacity", optional: true},
stroke: {value: vstroke, scale: "color", optional: true},
+ strokeDasharray: {value: vstrokeDasharray, optional: true},
+ strokeDashoffset: {value: vstrokeDashoffset, optional: true},
strokeOpacity: {value: vstrokeOpacity, scale: "opacity", optional: true},
strokeWidth: {value: vstrokeWidth, optional: true},
- opacity: {value: vopacity, scale: "opacity", optional: true}
+ opacity: {value: vopacity, scale: "opacity", optional: true},
+ dx: {value: vdx, optional: true, filter: null},
+ dy: {value: vdy, optional: true, filter: null}
};
}
@@ -175,7 +188,9 @@ export function applyChannelStyles(
strokeOpacity: SO,
strokeWidth: SW,
opacity: O,
- href: H
+ href: H,
+ strokeDasharray: SDA,
+ strokeDashoffset: SDO
}
) {
if (AL) applyAttr(selection, "aria-label", (i) => AL[i]);
@@ -184,6 +199,8 @@ export function applyChannelStyles(
if (S) applyAttr(selection, "stroke", (i) => S[i]);
if (SO) applyAttr(selection, "stroke-opacity", (i) => SO[i]);
if (SW) applyAttr(selection, "stroke-width", (i) => SW[i]);
+ if (SDA) applyAttr(selection, "stroke-dasharray", (i) => SDA[i]);
+ if (SDO) applyAttr(selection, "stroke-dashoffset", (i) => SDO[i]);
if (O) applyAttr(selection, "opacity", (i) => O[i]);
if (H) applyHref(selection, (i) => H[i], target);
applyTitle(selection, T);
@@ -201,7 +218,9 @@ export function applyGroupedChannelStyles(
strokeOpacity: SO,
strokeWidth: SW,
opacity: O,
- href: H
+ href: H,
+ strokeDasharray: SDA,
+ strokeDashoffset: SDO
}
) {
if (AL) applyAttr(selection, "aria-label", ([i]) => AL[i]);
@@ -210,6 +229,8 @@ export function applyGroupedChannelStyles(
if (S) applyAttr(selection, "stroke", ([i]) => S[i]);
if (SO) applyAttr(selection, "stroke-opacity", ([i]) => SO[i]);
if (SW) applyAttr(selection, "stroke-width", ([i]) => SW[i]);
+ if (SDA) applyAttr(selection, "stroke-dasharray", ([i]) => SDA[i]);
+ if (SDO) applyAttr(selection, "stroke-dashoffset", ([i]) => SDO[i]);
if (O) applyAttr(selection, "opacity", ([i]) => O[i]);
if (H) applyHref(selection, ([i]) => H[i], target);
applyTitleGroup(selection, T);
@@ -402,3 +423,15 @@ export function applyFrameAnchor({frameAnchor}, {width, height, marginTop, margi
: (marginTop + height - marginBottom) / 2
];
}
+
+// A constant stroke-dasharray can be specified as a number, an array of numbers
+// or as a string joining numbers with white space or comma; any other non-null
+// value is considered as a channel.
+function maybeDasharrayChannel(value) {
+ if (value == null) return [undefined, "none"];
+ return (typeof value === "string" && value.match(/^([\d.]+(\s+|,)){1,}[\d.]+$/)) ||
+ typeof value === "number" ||
+ (Array.isArray(value) && typeof value[0] === "number")
+ ? [undefined, `${value}`]
+ : [value, undefined];
+}
diff --git a/test/output/athletesBirthdaysLabel.svg b/test/output/athletesBirthdaysLabel.svg
new file mode 100644
index 0000000000..20a9675456
--- /dev/null
+++ b/test/output/athletesBirthdaysLabel.svg
@@ -0,0 +1,92 @@
+
\ No newline at end of file
diff --git a/test/output/strokeDasharrayConstant.svg b/test/output/strokeDasharrayConstant.svg
new file mode 100644
index 0000000000..cad613ec87
--- /dev/null
+++ b/test/output/strokeDasharrayConstant.svg
@@ -0,0 +1,156 @@
+
\ No newline at end of file
diff --git a/test/output/strokeDasharrayVariable.svg b/test/output/strokeDasharrayVariable.svg
new file mode 100644
index 0000000000..acc66b8540
--- /dev/null
+++ b/test/output/strokeDasharrayVariable.svg
@@ -0,0 +1,148 @@
+
\ No newline at end of file
diff --git a/test/output/wordCloudCrazy.svg b/test/output/wordCloudCrazy.svg
new file mode 100644
index 0000000000..da594eec22
--- /dev/null
+++ b/test/output/wordCloudCrazy.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/test/plots/athletes-birthdays-label.js b/test/plots/athletes-birthdays-label.js
new file mode 100644
index 0000000000..2e153f0b33
--- /dev/null
+++ b/test/plots/athletes-birthdays-label.js
@@ -0,0 +1,29 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const athletes = await d3.csv("data/athletes.csv", d3.autoType);
+ return Plot.plot({
+ marginRight: 40,
+ y: {
+ tickFormat: Plot.formatMonth()
+ },
+ marks: [
+ Plot.barX(athletes, Plot.groupY({x: "count"}, {y: (d) => d.date_of_birth.getUTCMonth()})),
+ Plot.textX(
+ athletes,
+ Plot.groupY(
+ {
+ x: "count",
+ text: "count",
+ textAnchor: (data) => (data.length >= 1000 ? "end" : "start"),
+ fill: (data) => (data.length >= 1000 ? "white" : "black"),
+ dx: (data) => (data.length >= 1000 ? -4 : 4)
+ },
+ {y: (d) => d.date_of_birth.getUTCMonth()}
+ )
+ ),
+ Plot.ruleX([0])
+ ]
+ });
+}
diff --git a/test/plots/athletes-birthdays.js b/test/plots/athletes-birthdays.js
index 208007707c..9fb83f7db8 100644
--- a/test/plots/athletes-birthdays.js
+++ b/test/plots/athletes-birthdays.js
@@ -12,7 +12,7 @@ export default async function () {
Plot.barX(athletes, Plot.groupY({x: "count"}, {y: (d) => d.date_of_birth.getUTCMonth()})),
Plot.textX(
athletes,
- Plot.groupY({x: "count", text: "count"}, {y: (d) => d.date_of_birth.getUTCMonth(), dx: 4, frameAnchor: "left"})
+ Plot.groupY({x: "count", text: "count"}, {y: (d) => d.date_of_birth.getUTCMonth(), dx: 4, textAnchor: "start"})
),
Plot.ruleX([0])
]
diff --git a/test/plots/index.js b/test/plots/index.js
index b3f4d78380..f6ba74e417 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -9,6 +9,7 @@ export {default as aaplVolumeRect} from "./aapl-volume-rect.js";
export {default as anscombeQuartet} from "./anscombe-quartet.js";
export {default as athletesBinsColors} from "./athletes-bins-colors.js";
export {default as athletesBirthdays} from "./athletes-birthdays.js";
+export {default as athletesBirthdaysLabel} from "./athletes-birthdays-label.js";
export {default as athletesHeightWeight} from "./athletes-height-weight.js";
export {default as athletesHeightWeightBin} from "./athletes-height-weight-bin.js";
export {default as athletesHeightWeightBinStroke} from "./athletes-height-weight-bin-stroke.js";
@@ -212,6 +213,8 @@ export {default as stargazersBinned} from "./stargazers-binned.js";
export {default as stargazersHourly} from "./stargazers-hourly.js";
export {default as stargazersHourlyGroup} from "./stargazers-hourly-group.js";
export {default as stocksIndex} from "./stocks-index.js";
+export {default as strokeDasharrayConstant} from "./strokedasharray-constant.js";
+export {default as strokeDasharrayVariable} from "./strokedasharray-variable.js";
export {default as thisIsJustToSay} from "./this-is-just-to-say.js";
export {default as trafficHorizon} from "./traffic-horizon.js";
export {default as travelersYearOverYear} from "./travelers-year-over-year.js";
@@ -235,6 +238,7 @@ export {default as vectorFrame} from "./vector-frame.js";
export {default as wealthBritainBar} from "./wealth-britain-bar.js";
export {default as wealthBritainProportionPlot} from "./wealth-britain-proportion-plot.js";
export {default as wordCloud} from "./word-cloud.js";
+export {default as wordCloudCrazy} from "./word-cloud-crazy.js";
export {default as wordLengthMobyDick} from "./word-length-moby-dick.js";
export {default as yearlyRequests} from "./yearly-requests.js";
export {default as yearlyRequestsDot} from "./yearly-requests-dot.js";
diff --git a/test/plots/strokedasharray-constant.js b/test/plots/strokedasharray-constant.js
new file mode 100644
index 0000000000..f3578b0621
--- /dev/null
+++ b/test/plots/strokedasharray-constant.js
@@ -0,0 +1,28 @@
+import * as Plot from "@observablehq/plot";
+
+const data = Array.from(["none", "10 5", [20, 3], [30, 5, 10, 10], null], (strokeDasharray) =>
+ Array.from([0, 2, 20, 60, NaN], (strokeDashoffset) => ({strokeDasharray, strokeDashoffset}))
+).flat();
+
+export default async function () {
+ return Plot.plot({
+ height: 640,
+ x: {inset: 10},
+ y: {inset: 10},
+ axis: null,
+ facet: {data, x: "strokeDasharray", y: "strokeDashoffset"},
+ marks: [
+ Plot.frame(),
+ Plot.arrow(data, {
+ x1: 0,
+ x2: 1,
+ y1: 0,
+ y2: 1,
+ strokeDasharray: "1,2,3",
+ strokeDashoffset: "strokeDashoffset",
+ bend: true,
+ headLength: 0
+ })
+ ]
+ });
+}
diff --git a/test/plots/strokedasharray-variable.js b/test/plots/strokedasharray-variable.js
new file mode 100644
index 0000000000..f42965841f
--- /dev/null
+++ b/test/plots/strokedasharray-variable.js
@@ -0,0 +1,28 @@
+import * as Plot from "@observablehq/plot";
+
+const data = Array.from(["none", "10 5", [20, 3], [30, 5, 10, 10], null], (strokeDasharray) =>
+ Array.from([0, 2, 20, 60, NaN], (strokeDashoffset) => ({strokeDasharray, strokeDashoffset}))
+).flat();
+
+export default async function () {
+ return Plot.plot({
+ height: 640,
+ x: {inset: 10},
+ y: {inset: 10},
+ axis: null,
+ facet: {data, x: "strokeDasharray", y: "strokeDashoffset"},
+ marks: [
+ Plot.frame(),
+ Plot.arrow(data, {
+ x1: 0,
+ x2: 1,
+ y1: 0,
+ y2: 1,
+ strokeDasharray: "strokeDasharray",
+ strokeDashoffset: "strokeDashoffset",
+ bend: true,
+ headLength: 0
+ })
+ ]
+ });
+}
diff --git a/test/plots/word-cloud-crazy.js b/test/plots/word-cloud-crazy.js
new file mode 100644
index 0000000000..626d658081
--- /dev/null
+++ b/test/plots/word-cloud-crazy.js
@@ -0,0 +1,41 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const random = d3.randomLcg(32);
+
+ // Compute a set of “words” from the text. As with any natural language task,
+ // this is messy and approximate.
+ const words = (await d3.text("data/moby-dick-chapter-1.txt"))
+ .replace(/’/g, "") // remove apostrophes
+ .split(/\b/g) // split at word boundaries
+ .map((word) => word.replace(/[^a-z]+/gi, "")) // strip non-letters
+ .filter((word) => word) // ignore non-letter words
+ .map((word) => word.toLowerCase()); // normalize to lowercase
+
+ return Plot.plot({
+ inset: 20,
+ x: {axis: null},
+ y: {axis: null},
+ marks: [
+ Plot.text(
+ words,
+ Plot.groupZ(
+ {
+ text: (d) => (d.length > 1 ? `${d[0]} (${d.length})` : ""),
+ fontSize: (d) => 4 * Math.sqrt(d.length),
+ fontFamily: (d) => (d.length % 2 ? "sans-serif" : "serif"),
+ fontStyle: (d) => (d.length % 3 == 0 ? "italic" : null),
+ fontVariant: (d) => (d.length % 5 == 0 ? "small-caps" : null),
+ fontWeight: (d) => (d.length > 40 ? "bold" : "normal")
+ },
+ {
+ x: random,
+ y: random,
+ z: (d) => d
+ }
+ )
+ )
+ ]
+ });
+}