diff --git a/README.md b/README.md
index cb65820f43..15bd682ec9 100644
--- a/README.md
+++ b/README.md
@@ -691,6 +691,7 @@ All marks support the following style options:
* **target** - link target (e.g., “_blank” for a new window); for use with the **href** channel
* **ariaDescription** - a textual description of the mark’s contents
* **ariaHidden** - if true, hide this content from the accessibility tree
+* **pointerEvents** - the [pointer events](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events) (*e.g.*, *none*)
* **clip** - if true, the mark is clipped to the frame’s dimensions
For all marks except [text](#plottextdata-options), the **dx** and **dy** options are rendered as a transform property, possibly including a 0.5px offset on low-density screens.
@@ -982,6 +983,42 @@ Plot.cellY(simpsons.map(d => d.imdb_rating))
Equivalent to [Plot.cell](#plotcelldata-options), except that if the **y** option is not specified, it defaults to [0, 1, 2, …], and if the **fill** option is not specified and **stroke** is not a channel, the fill defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …].
+### Delaunay
+
+[
](https://observablehq.com/@observablehq/plot-delaunay)
+
+[Source](./src/marks/delaunay.js) · [Examples](https://observablehq.com/@observablehq/plot-delaunay) · Plot provides a handful of marks for Delaunay and Voronoi diagrams (using [d3-delaunay](https://github.com/d3/d3-delaunay) and [Delaunator](https://github.com/mapbox/delaunator)). These marks require the **x** and **y** channels to be specified.
+
+#### Plot.delaunayLink(*data*, *options*)
+
+Draws links for each edge of the Delaunay triangulation of the points given by the **x** and **y** channels. Supports the same options as the [link mark](#link), except that **x1**, **y1**, **x2**, and **y2** are derived automatically from **x** and **y**. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the link inherits the corresponding channel value from one of its two endpoints arbitrarily.
+
+If a **z** channel is specified, the input points are grouped by *z*, and separate Delaunay triangulations are constructed for each group.
+
+#### Plot.delaunayMesh(*data*, *options*)
+
+Draws a mesh of the Delaunay triangulation of the points given by the **x** and **y** channels. The **stroke** option defaults to _currentColor_, and the **strokeOpacity** defaults to 0.2. The **fill** option is not supported. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the mesh inherits the corresponding channel value from one of its constituent points arbitrarily.
+
+If a **z** channel is specified, the input points are grouped by *z*, and separate Delaunay triangulations are constructed for each group.
+
+#### Plot.hull(*data*, *options*)
+
+Draws a convex hull around the points given by the **x** and **y** channels. The **stroke** option defaults to _currentColor_ and the **fill** option defaults to _none_. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the hull inherits the corresponding channel value from one of its constituent points arbitrarily.
+
+If a **z** channel is specified, the input points are grouped by *z*, and separate convex hulls are constructed for each group. If the **z** channel is not specified, it defaults to either the **fill** channel, if any, or the **stroke** channel, if any.
+
+#### Plot.voronoi(*data*, *options*)
+
+Draws polygons for each cell of the Voronoi tesselation of the points given by the **x** and **y** channels.
+
+If a **z** channel is specified, the input points are grouped by *z*, and separate Voronoi tesselations are constructed for each group.
+
+#### Plot.voronoiMesh(*data*, *options*)
+
+Draws a mesh for the cell boundaries of the Voronoi tesselation of the points given by the **x** and **y** channels. The **stroke** option defaults to _currentColor_, and the **strokeOpacity** defaults to 0.2. The **fill** option is not supported. When an aesthetic channel is specified (such as **stroke** or **strokeWidth**), the mesh inherits the corresponding channel value from one of its constituent points arbitrarily.
+
+If a **z** channel is specified, the input points are grouped by *z*, and separate Voronoi tesselations are constructed for each group.
+
### Dot
[
](https://observablehq.com/@observablehq/plot-dot)
diff --git a/img/voronoi.png b/img/voronoi.png
new file mode 100644
index 0000000000..90a8431a3f
Binary files /dev/null and b/img/voronoi.png differ
diff --git a/src/index.js b/src/index.js
index 6bb74d0e41..21315476bb 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,6 +4,7 @@ export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {boxX, boxY} from "./marks/box.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
+export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
export {Hexgrid, hexgrid} from "./marks/hexgrid.js";
diff --git a/src/marks/delaunay.js b/src/marks/delaunay.js
new file mode 100644
index 0000000000..16d4d85287
--- /dev/null
+++ b/src/marks/delaunay.js
@@ -0,0 +1,258 @@
+import {create, group, path, select, Delaunay} from "d3";
+import {Curve} from "../curve.js";
+import {maybeTuple, maybeZ} from "../options.js";
+import {Mark} from "../plot.js";
+import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
+import {markers, applyMarkers} from "./marker.js";
+
+const delaunayLinkDefaults = {
+ ariaLabel: "delaunay link",
+ fill: "none",
+ stroke: "currentColor",
+ strokeMiterlimit: 1
+};
+
+const delaunayMeshDefaults = {
+ ariaLabel: "delaunay mesh",
+ fill: null,
+ stroke: "currentColor",
+ strokeOpacity: 0.2
+};
+
+const hullDefaults = {
+ ariaLabel: "hull",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 1.5,
+ strokeMiterlimit: 1
+};
+
+const voronoiDefaults = {
+ ariaLabel: "voronoi",
+ fill: "none",
+ stroke: "currentColor",
+ strokeMiterlimit: 1
+};
+
+const voronoiMeshDefaults = {
+ ariaLabel: "voronoi mesh",
+ fill: null,
+ stroke: "currentColor",
+ strokeOpacity: 0.2
+};
+
+class DelaunayLink extends Mark {
+ constructor(data, options = {}) {
+ const {x, y, z, curve, tension} = options;
+ super(
+ data,
+ [
+ {name: "x", value: x, scale: "x"},
+ {name: "y", value: y, scale: "y"},
+ {name: "z", value: z, optional: true}
+ ],
+ options,
+ delaunayLinkDefaults
+ );
+ this.curve = Curve(curve, tension);
+ markers(this, options);
+ }
+ render(index, {x, y}, channels, dimensions) {
+ const {x: X, y: Y, z: Z} = channels;
+ const {dx, dy, curve} = this;
+ const mark = this;
+
+ function links(index) {
+ let i = -1;
+ const newIndex = [];
+ const newChannels = {};
+ for (const k in channels) newChannels[k] = [];
+ const X1 = [];
+ const X2 = [];
+ const Y1 = [];
+ const Y2 = [];
+
+ function link(ti, tj) {
+ ti = index[ti];
+ tj = index[tj];
+ newIndex.push(++i);
+ X1[i] = X[ti];
+ Y1[i] = Y[ti];
+ X2[i] = X[tj];
+ Y2[i] = Y[tj];
+ for (const k in channels) newChannels[k].push(channels[k][tj]);
+ }
+
+ const {halfedges, hull, triangles} = Delaunay.from(index, i => X[i], i => Y[i]);
+ for (let i = 0; i < halfedges.length; ++i) { // inner edges
+ const j = halfedges[i];
+ if (j > i) link(triangles[i], triangles[j]);
+ }
+ for (let i = 0; i < hull.length; ++i) { // convex hull
+ link(hull[i], hull[(i + 1) % hull.length]);
+ }
+
+ select(this)
+ .selectAll()
+ .data(newIndex)
+ .join("path")
+ .call(applyDirectStyles, mark)
+ .attr("d", i => {
+ const p = path();
+ const c = curve(p);
+ c.lineStart();
+ c.point(X1[i], Y1[i]);
+ c.point(X2[i], Y2[i]);
+ c.lineEnd();
+ return p;
+ })
+ .call(applyChannelStyles, mark, newChannels)
+ .call(applyMarkers, mark, newChannels);
+ }
+
+ return create("svg:g")
+ .call(applyIndirectStyles, this, dimensions)
+ .call(applyTransform, x, y, offset + dx, offset + dy)
+ .call(Z
+ ? g => g.selectAll().data(group(index, i => Z[i]).values()).enter().append("g").each(links)
+ : g => g.datum(index).each(links))
+ .node();
+ }
+}
+
+class AbstractDelaunayMark extends Mark {
+ constructor(data, options = {}, defaults, zof = ({z}) => z) {
+ const {x, y} = options;
+ super(
+ data,
+ [
+ {name: "x", value: x, scale: "x"},
+ {name: "y", value: y, scale: "y"},
+ {name: "z", value: zof(options), optional: true}
+ ],
+ options,
+ defaults
+ );
+ }
+ render(index, {x, y}, {x: X, y: Y, z: Z, ...channels}, dimensions) {
+ const {dx, dy} = this;
+ const mark = this;
+ function mesh(render) {
+ return function(index) {
+ const delaunay = Delaunay.from(index, i => X[i], i => Y[i]);
+ select(this).append("path")
+ .datum(index[0])
+ .call(applyDirectStyles, mark)
+ .attr("d", render(delaunay, dimensions))
+ .call(applyChannelStyles, mark, channels);
+ };
+ }
+ return create("svg:g")
+ .call(applyIndirectStyles, this, dimensions)
+ .call(applyTransform, x, y, offset + dx, offset + dy)
+ .call(Z
+ ? g => g.selectAll().data(group(index, i => Z[i]).values()).enter().append("g").each(mesh(this._render))
+ : g => g.datum(index).each(mesh(this._render)))
+ .node();
+ }
+}
+
+class DelaunayMesh extends AbstractDelaunayMark {
+ constructor(data, options = {}) {
+ super(data, options, delaunayMeshDefaults);
+ this.fill = "none";
+ }
+ _render(delaunay) {
+ return delaunay.render();
+ }
+}
+
+class Hull extends AbstractDelaunayMark {
+ constructor(data, options = {}) {
+ super(data, options, hullDefaults, maybeZ);
+ }
+ _render(delaunay) {
+ return delaunay.renderHull();
+ }
+}
+
+class Voronoi extends Mark {
+ constructor(data, options = {}) {
+ const {x, y, z} = options;
+ super(
+ data,
+ [
+ {name: "x", value: x, scale: "x"},
+ {name: "y", value: y, scale: "y"},
+ {name: "z", value: z, optional: true}
+ ],
+ options,
+ voronoiDefaults
+ );
+ }
+ render(index, {x, y}, channels, dimensions) {
+ const {x: X, y: Y, z: Z} = channels;
+ const {dx, dy} = this;
+
+ function cells(index) {
+ const delaunay = Delaunay.from(index, i => X[i], i => Y[i]);
+ const voronoi = voronoiof(delaunay, dimensions);
+ select(this)
+ .selectAll()
+ .data(index)
+ .enter()
+ .append("path")
+ .call(applyDirectStyles, this)
+ .attr("d", (_, i) => voronoi.renderCell(i))
+ .call(applyChannelStyles, this, channels);
+ }
+
+ return create("svg:g")
+ .call(applyIndirectStyles, this, dimensions)
+ .call(applyTransform, x, y, offset + dx, offset + dy)
+ .call(Z
+ ? g => g.selectAll().data(group(index, i => Z[i]).values()).enter().append("g").each(cells)
+ : g => g.datum(index).each(cells))
+ .node();
+ }
+}
+
+class VoronoiMesh extends AbstractDelaunayMark {
+ constructor(data, options) {
+ super(data, options, voronoiMeshDefaults);
+ this.fill = "none";
+ }
+ _render(delaunay, dimensions) {
+ return voronoiof(delaunay, dimensions).render();
+ }
+}
+
+function voronoiof(delaunay, dimensions) {
+ const {width, height, marginTop, marginRight, marginBottom, marginLeft} = dimensions;
+ return delaunay.voronoi([marginLeft, marginTop, width - marginRight, height - marginBottom]);
+}
+
+function delaunayMark(DelaunayMark, data, {x, y, ...options} = {}) {
+ ([x, y] = maybeTuple(x, y));
+ return new DelaunayMark(data, {...options, x, y});
+}
+
+export function delaunayLink(data, options) {
+ return delaunayMark(DelaunayLink, data, options);
+}
+
+export function delaunayMesh(data, options) {
+ return delaunayMark(DelaunayMesh, data, options);
+}
+
+export function hull(data, options) {
+ return delaunayMark(Hull, data, options);
+}
+
+export function voronoi(data, options) {
+ return delaunayMark(Voronoi, data, options);
+}
+
+export function voronoiMesh(data, options) {
+ return delaunayMark(VoronoiMesh, data, options);
+}
diff --git a/src/marks/marker.js b/src/marks/marker.js
index cb18d14905..8d63d7fb90 100644
--- a/src/marks/marker.js
+++ b/src/marks/marker.js
@@ -77,11 +77,11 @@ function markerCircleStroke(color) {
let nextMarkerId = 0;
-export function applyMarkers(path, mark, {stroke: S}) {
+export function applyMarkers(path, mark, {stroke: S} = {}) {
return applyMarkersColor(path, mark, S && (i => S[i]));
}
-export function applyGroupedMarkers(path, mark, {stroke: S}) {
+export function applyGroupedMarkers(path, mark, {stroke: S} = {}) {
return applyMarkersColor(path, mark, S && (([i]) => S[i]));
}
diff --git a/src/style.js b/src/style.js
index 919c6bf2a6..4fc0804dff 100644
--- a/src/style.js
+++ b/src/style.js
@@ -30,6 +30,7 @@ export function styles(
opacity,
mixBlendMode,
paintOrder,
+ pointerEvents,
shapeRendering
},
{
@@ -121,6 +122,7 @@ export function styles(
mark.opacity = impliedNumber(copacity, 1);
mark.mixBlendMode = impliedString(mixBlendMode, "normal");
mark.paintOrder = impliedString(paintOrder, "normal");
+ mark.pointerEvents = impliedString(pointerEvents, "auto");
mark.shapeRendering = impliedString(shapeRendering, "auto");
return [
@@ -261,6 +263,7 @@ export function applyIndirectStyles(selection, mark, {width, height, marginLeft,
applyAttr(selection, "stroke-dashoffset", mark.strokeDashoffset);
applyAttr(selection, "shape-rendering", mark.shapeRendering);
applyAttr(selection, "paint-order", mark.paintOrder);
+ applyAttr(selection, "pointer-events", mark.pointerEvents);
if (mark.clip === "frame") {
const id = `plot-clip-${++nextClipId}`;
selection
diff --git a/test/output/penguinCulmenDelaunay.svg b/test/output/penguinCulmenDelaunay.svg
new file mode 100644
index 0000000000..cd7b5bd723
--- /dev/null
+++ b/test/output/penguinCulmenDelaunay.svg
@@ -0,0 +1,1086 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenDelaunayMesh.svg b/test/output/penguinCulmenDelaunayMesh.svg
new file mode 100644
index 0000000000..5386cc79cd
--- /dev/null
+++ b/test/output/penguinCulmenDelaunayMesh.svg
@@ -0,0 +1,430 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenDelaunaySpecies.svg b/test/output/penguinCulmenDelaunaySpecies.svg
new file mode 100644
index 0000000000..5677e8ed9e
--- /dev/null
+++ b/test/output/penguinCulmenDelaunaySpecies.svg
@@ -0,0 +1,105 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenVoronoi.svg b/test/output/penguinCulmenVoronoi.svg
new file mode 100644
index 0000000000..7c9cd5a4a7
--- /dev/null
+++ b/test/output/penguinCulmenVoronoi.svg
@@ -0,0 +1,771 @@
+
\ No newline at end of file
diff --git a/test/plots/index.js b/test/plots/index.js
index bfcf4724a3..c0665b8749 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -122,6 +122,10 @@ export {default as musicRevenue} from "./music-revenue.js";
export {default as ordinalBar} from "./ordinal-bar.js";
export {default as penguinCulmen} from "./penguin-culmen.js";
export {default as penguinCulmenArray} from "./penguin-culmen-array.js";
+export {default as penguinCulmenDelaunay} from "./penguin-culmen-delaunay.js";
+export {default as penguinCulmenDelaunayMesh} from "./penguin-culmen-delaunay-mesh.js";
+export {default as penguinCulmenDelaunaySpecies} from "./penguin-culmen-delaunay-species.js";
+export {default as penguinCulmenVoronoi} from "./penguin-culmen-voronoi.js";
export {default as penguinDodge} from "./penguin-dodge.js";
export {default as penguinDodgeHexbin} from "./penguin-dodge-hexbin.js";
export {default as penguinFacetDodge} from "./penguin-facet-dodge.js";
diff --git a/test/plots/penguin-culmen-delaunay-mesh.js b/test/plots/penguin-culmen-delaunay-mesh.js
new file mode 100644
index 0000000000..d86203533e
--- /dev/null
+++ b/test/plots/penguin-culmen-delaunay-mesh.js
@@ -0,0 +1,12 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const data = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marks: [
+ Plot.delaunayMesh(data, {x: "culmen_depth_mm", y: "culmen_length_mm"}),
+ Plot.dot(data, {x: "culmen_depth_mm", y: "culmen_length_mm"})
+ ]
+ });
+}
diff --git a/test/plots/penguin-culmen-delaunay-species.js b/test/plots/penguin-culmen-delaunay-species.js
new file mode 100644
index 0000000000..a3ad29e4e3
--- /dev/null
+++ b/test/plots/penguin-culmen-delaunay-species.js
@@ -0,0 +1,12 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const data = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marks: [
+ Plot.delaunayMesh(data, {x: "culmen_depth_mm", y: "culmen_length_mm", z: "species", stroke: "species", strokeOpacity: 1}),
+ Plot.hull(data, {x: "culmen_depth_mm", y: "culmen_length_mm", stroke: "species", strokeWidth: 3})
+ ]
+ });
+}
diff --git a/test/plots/penguin-culmen-delaunay.js b/test/plots/penguin-culmen-delaunay.js
new file mode 100644
index 0000000000..beb612db25
--- /dev/null
+++ b/test/plots/penguin-culmen-delaunay.js
@@ -0,0 +1,11 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const data = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marks: [
+ Plot.delaunayLink(data, {x: "culmen_depth_mm", y: "culmen_length_mm", stroke: "culmen_length_mm"})
+ ]
+ });
+}
diff --git a/test/plots/penguin-culmen-voronoi.js b/test/plots/penguin-culmen-voronoi.js
new file mode 100644
index 0000000000..0bcf10cf7b
--- /dev/null
+++ b/test/plots/penguin-culmen-voronoi.js
@@ -0,0 +1,12 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marks: [
+ Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "currentColor", r: 1.5}),
+ Plot.voronoi(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", stroke: "species"})
+ ]
+ });
+}