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 @@ + + + + + Jan + + + Feb + + + Mar + + + Apr + + + May + + + Jun + + + Jul + + + Aug + + + Sep + + + Oct + + + Nov + + + Dec + + + + + 0 + + + 200 + + + 400 + + + 600 + + + 800 + + + 1,000 + Frequency → + + + + + + + + + + + + + + + + 1,0989561,0229621,028961988989961849827897 + + + + \ 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 @@ + + + a (69)about (7)account (2)ago (2)air (2)all (23)almost (3)aloft (2)always (2)am (4)among (2)an (4)and (73)any (2)are (5)as (26)at (5)be (9)because (4)been (2)before (3)begin (2)being (4)besides (2)better (2)between (2)broiled (3)but (15)by (8)can (6)cannot (2)captain (2)care (2)chief (2)city (2)come (3)commodore (2)content (2)cook (3)could (2)country (2)crowds (2)deck (2)deep (2)did (5)distant (2)do (8)does (2)down (6)each (2)else (2)ever (5)every (4)exactly (2)fates (2)find (2)first (4)fixed (2)for (16)forecastle (2)from (11)get (6)glory (2)go (12)going (4)grand (3)great (3)grow (2)hand (3)have (8)having (2)he (10)head (4)healthy (2)here (5)high (4)hill (2)him (3)himself (2)his (10)how (3)however (2)hunks (2)i (43)if (9)image (3)in (48)into (9)is (34)ishmael (2)it (33)its (2)just (2)land (6)lead (2)leaders (2)leaves (2)let (2)like (6)little (4)long (2)look (2)magic (2)make (2)man (3)mast (2)may (3)me (25)meadow (2)mean (2)meaning (2)men (4)metaphysical (2)miles (3)money (4)more (6)most (5)motives (2)much (4)must (4)my (14)myself (3)never (5)no (6)not (11)nothing (3)now (5)ocean (2)of (81)off (3)officer (2)old (6)on (12)once (2)one (10)or (10)order (2)other (5)others (2)ourselves (2)out (3)over (2)own (2)paid (2)part (7)particular (2)parts (3)passenger (4)passengers (3)pay (2)paying (3)perhaps (2)phantom (2)plunged (2)point (2)previous (2)purse (3)requires (2)respectfully (2)reveries (2)right (3)robust (2)round (2)sail (2)sailor (5)same (5)say (3)schoolmaster (2)scores (2)sea (13)seas (2)see (6)set (3)shepherds (2)ship (3)ships (3)should (3)sight (2)sleep (2)so (4)some (11)something (3)sort (3)soul (3)spar (2)stand (5)still (3)stream (2)streets (2)strong (2)such (5)take (6)tell (4)than (4)that (31)the (124)their (4)them (5)themselves (2)then (5)there (16)these (4)they (12)thing (2)things (4)think (2)thinks (2)this (17)those (4)though (7)thousand (2)thousands (2)thump (2)time (6)to (53)two (4)under (2)unless (2)up (4)upon (9)voyage (6)warehouses (2)was (8)water (8)way (6)we (3)well (2)were (7)whale (3)whaling (5)what (9)when (5)whenever (5)where (2)which (4)who (5)why (7)wild (2)will (6)winds (3)with (13)without (3)world (4)would (4)yet (4)yonder (2)you (23)your (6) + \ 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 + } + ) + ) + ] + }); +}