diff --git a/docs/features/marks.md b/docs/features/marks.md index 230ebc51ee..58844b4620 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -510,6 +510,7 @@ All marks support the following optional channels: * **title** - an accessible, short-text description (a string of text, possibly with newlines) * **href** - a URL to link to * **ariaLabel** - a short label representing the value in the accessibility tree +* **dataset** - the [dataset property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset) 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. @@ -533,7 +534,9 @@ In addition to functions of data, arrays, and column names, channel values can b Plot.dot(numbers, {x: {transform: (data) => data}}) ``` -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!"`. +The **title**, **href**, **ariaLabel**, and **dataset** 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!"`. + +When the **dataset** channel contains boolean, number, string or date values, they are applied as a `data-{key}` property, where the key is the channel’s label if present, and otherwise defaults to "value". When the values are objects, each entry is applied individually as a `data-{key}` property. Values are coerced to strings, with dates in [short ISO format](https://github.com/mbostock/isoformat). Keys must follow the naming specification for [custom data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). For marks that support the **frameAnchor** option, it may be specified as one of the four sides (*top*, *right*, *bottom*, *left*), one of the four corners (*top-left*, *top-right*, *bottom-right*, *bottom-left*), or the *middle* of the frame. diff --git a/src/mark.d.ts b/src/mark.d.ts index 43e61da15f..17934ad4d1 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -466,6 +466,21 @@ export interface MarkOptions { */ target?: string; + /** + * A [dataset][1] for the mark; a channel specifying arbitrary data. When the + * **dataset** channel contains boolean, number, string or date values, they + * are applied as a `data-{key}` property, where the key is the channel’s + * label if present, and otherwise defaults to "value". When the values are + * objects, each entry is applied individually as a `data-{key}` property. + * Values are coerced to strings, with dates in [short ISO format][2]. Keys + * must follow the naming specification for [custom data attributes][3]. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset + * [2]: https://github.com/mbostock/isoformat + * [3]: https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes + */ + dataset?: ChannelValueSpec; + /** * An object defining additional custom channels. This meta option may be used * by an **initializer** to declare extra channels. diff --git a/src/style.js b/src/style.js index 4fc1c9828a..0207f75b9c 100644 --- a/src/style.js +++ b/src/style.js @@ -1,7 +1,7 @@ import {group, namespaces, select} from "d3"; import {create} from "./context.js"; import {defined, nonempty} from "./defined.js"; -import {formatDefault} from "./format.js"; +import {formatDefault, formatIsoDate} from "./format.js"; import {isNone, isNoneish, isRound, maybeColorChannel, maybeNumberChannel} from "./options.js"; import {keyof, number, string} from "./options.js"; import {warn} from "./warnings.js"; @@ -44,7 +44,8 @@ export function styles( paintOrder, pointerEvents, shapeRendering, - channels + channels, + dataset }, { ariaLabel: cariaLabel, @@ -137,6 +138,7 @@ export function styles( mark.paintOrder = impliedString(paintOrder, "normal"); mark.pointerEvents = impliedString(pointerEvents, "auto"); mark.shapeRendering = impliedString(shapeRendering, "auto"); + mark.datakey = typeof dataset === "string" ? dataset : dataset?.label; return { title: {value: title, optional: true, filter: null}, @@ -147,7 +149,8 @@ export function styles( stroke: {value: vstroke, scale: "auto", optional: true}, strokeOpacity: {value: vstrokeOpacity, scale: "auto", optional: true}, strokeWidth: {value: vstrokeWidth, optional: true}, - opacity: {value: vopacity, scale: "auto", optional: true} + opacity: {value: vopacity, scale: "auto", optional: true}, + dataset: {value: dataset, optional: true, filter: null} }; } @@ -179,7 +182,7 @@ export function applyTextGroup(selection, T) { export function applyChannelStyles( selection, - {target, tip}, + {target, tip, datakey}, { ariaLabel: AL, title: T, @@ -189,7 +192,8 @@ export function applyChannelStyles( strokeOpacity: SO, strokeWidth: SW, opacity: O, - href: H + href: H, + dataset: D } ) { if (AL) applyAttr(selection, "aria-label", (i) => AL[i]); @@ -200,6 +204,7 @@ export function applyChannelStyles( if (SW) applyAttr(selection, "stroke-width", (i) => SW[i]); if (O) applyAttr(selection, "opacity", (i) => O[i]); if (H) applyHref(selection, (i) => H[i], target); + if (D) applyDataset(selection, (i) => D[i], datakey); if (!tip) applyTitle(selection, T); } @@ -422,6 +427,21 @@ export function applyTransform(selection, mark, {x, y}, tx = offset, ty = offset if (tx || ty) selection.attr("transform", `translate(${tx},${ty})`); } +export function applyDataset(selection, value, keyname = "value") { + selection.each(function (i) { + const V = value(i); + if (V == null) return; + const O = + typeof V === "number" || typeof V === "boolean" || typeof V === "string" || V instanceof Date + ? {[keyname]: V} + : V; + if (typeof O !== "object") throw new Error(`Unsupported dataset property: ${value}`); + for (const [key, v] of Object.entries(O)) { + this.setAttribute(`data-${key}`, v instanceof Date ? formatIsoDate(v) : String(v)); + } + }); +} + export function impliedString(value, impliedValue) { if ((value = string(value)) !== impliedValue) return value; } diff --git a/test/output/dataset.svg b/test/output/dataset.svg new file mode 100644 index 0000000000..8b99fd6888 --- /dev/null +++ b/test/output/dataset.svg @@ -0,0 +1,155 @@ + + + + + + 1.60 + 1.65 + 1.70 + 1.75 + 1.80 + 1.85 + 1.90 + 1.95 + 2.00 + 2.05 + 2.10 + + + ↑ height + + + + + 50 + 60 + 70 + 80 + 90 + 100 + 110 + 120 + 130 + + + weight → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/datasetBars.svg b/test/output/datasetBars.svg new file mode 100644 index 0000000000..3f8e66d279 --- /dev/null +++ b/test/output/datasetBars.svg @@ -0,0 +1,72 @@ + + + + + athletics + aquatics + football + rowing + cycling + hockey + judo + shooting + volleyball + sailing + + + sport + + + + 0 + 500 + 1,000 + 1,500 + 2,000 + + + Frequency → + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/datasetFrame.svg b/test/output/datasetFrame.svg new file mode 100644 index 0000000000..e76c4cc89b --- /dev/null +++ b/test/output/datasetFrame.svg @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/test/plots/dataset.ts b/test/plots/dataset.ts new file mode 100644 index 0000000000..3492e8c212 --- /dev/null +++ b/test/plots/dataset.ts @@ -0,0 +1,44 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function dataset() { + const athletes = await d3.csv("data/athletes.csv", d3.autoType); + const xy = {x: "weight", y: "height"}; + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(athletes.slice(0, 10), {...xy, className: "named", dataset: "name", fill: "olive"}), + Plot.dot(athletes.slice(10, 20), {...xy, className: "number", dataset: "height", fill: "steelblue"}), + Plot.dot(athletes.slice(20, 30), {...xy, className: "identity", dataset: Plot.identity, fill: "orange"}), + Plot.dot(athletes.slice(30, 40), {...xy, className: "date", dataset: "date_of_birth", fill: "green"}), + Plot.dot(athletes.slice(40, 50), {...xy, className: "nullish", dataset: "nothing", fill: "grey"}), + Plot.dot(athletes.slice(50, 60), {...xy, className: "labeled", dataset: {label: "sport", value: "sport"}}) + ] + }); +} + +export async function datasetBars() { + const athletes = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.plot({ + marginLeft: 90, + marks: [ + Plot.barX( + athletes, + Plot.groupY( + {x: "count", dataset: (names: string[]) => JSON.stringify(names.slice(0, 10).concat(["..."]))}, + { + y: "sport", + dataset: "name", + sort: {y: "-x", limit: 10} + } + ) + ) + ] + }); +} + +export async function datasetFrame() { + return Plot.frame({ + dataset: (_, i) => ({hello: "world", epoch: new Date(0), i}) + }).plot(); +} diff --git a/test/plots/index.ts b/test/plots/index.ts index f54398dca7..667615f9d8 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -68,6 +68,7 @@ export * from "./curves.js"; export * from "./d3-survey-2015-comfort.js"; export * from "./d3-survey-2015-why.js"; export * from "./darker-dodge.js"; +export * from "./dataset.js"; export * from "./decathlon.js"; export * from "./diamonds-boxplot.js"; export * from "./diamonds-carat-price-dots.js";