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 @@
+
\ 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 @@
+
\ 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";