Skip to content

Commit 30eca95

Browse files
mbostockFil
andauthored
delaunay + voronoi (#917)
* delaunayLink * voronoi * delaunayLink as mark; delaunayMesh * hull * DRY * update test * pointerEvents * voronoi z * DRY * voronoiMesh * delaunay updates: (#925) * delaunay updates: - group by z or stroke for delaunayLink - group by z or stroke for delaunayMesh * a few fixes * no group by stroke for delaunayLink Co-authored-by: Mike Bostock <[email protected]> * Update README * merge voronoi into delaunay * remove unused export * fix hull fill * fix hull group by fill * Update README * no group by stroke for voronoiMesh * no group by stroke for delaunayMesh * document pointerEvents * auto instead of visiblePainted Co-authored-by: Philippe Rivière <[email protected]>
1 parent 9c63d25 commit 30eca95

15 files changed

+2744
-2
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,7 @@ All marks support the following style options:
691691
* **target** - link target (e.g., “_blank” for a new window); for use with the **href** channel
692692
* **ariaDescription** - a textual description of the mark’s contents
693693
* **ariaHidden** - if true, hide this content from the accessibility tree
694+
* **pointerEvents** - the [pointer events](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events) (*e.g.*, *none*)
694695
* **clip** - if true, the mark is clipped to the frame’s dimensions
695696

696697
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))
982983
983984
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₂*, …].
984985
986+
### Delaunay
987+
988+
[<img src="./img/voronoi.png" width="320" height="198" alt="a Voronoi diagram of penguin culmens, showing the length and depth of several species">](https://observablehq.com/@observablehq/plot-delaunay)
989+
990+
[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.
991+
992+
#### Plot.delaunayLink(*data*, *options*)
993+
994+
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.
995+
996+
If a **z** channel is specified, the input points are grouped by *z*, and separate Delaunay triangulations are constructed for each group.
997+
998+
#### Plot.delaunayMesh(*data*, *options*)
999+
1000+
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.
1001+
1002+
If a **z** channel is specified, the input points are grouped by *z*, and separate Delaunay triangulations are constructed for each group.
1003+
1004+
#### Plot.hull(*data*, *options*)
1005+
1006+
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.
1007+
1008+
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.
1009+
1010+
#### Plot.voronoi(*data*, *options*)
1011+
1012+
Draws polygons for each cell of the Voronoi tesselation of the points given by the **x** and **y** channels.
1013+
1014+
If a **z** channel is specified, the input points are grouped by *z*, and separate Voronoi tesselations are constructed for each group.
1015+
1016+
#### Plot.voronoiMesh(*data*, *options*)
1017+
1018+
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.
1019+
1020+
If a **z** channel is specified, the input points are grouped by *z*, and separate Voronoi tesselations are constructed for each group.
1021+
9851022
### Dot
9861023
9871024
[<img src="./img/dot.png" width="320" height="198" alt="a scatterplot">](https://observablehq.com/@observablehq/plot-dot)

img/voronoi.png

174 KB
Loading

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {Arrow, arrow} from "./marks/arrow.js";
44
export {BarX, BarY, barX, barY} from "./marks/bar.js";
55
export {boxX, boxY} from "./marks/box.js";
66
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
7+
export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
78
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
89
export {Frame, frame} from "./marks/frame.js";
910
export {Hexgrid, hexgrid} from "./marks/hexgrid.js";

src/marks/delaunay.js

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import {create, group, path, select, Delaunay} from "d3";
2+
import {Curve} from "../curve.js";
3+
import {maybeTuple, maybeZ} from "../options.js";
4+
import {Mark} from "../plot.js";
5+
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
6+
import {markers, applyMarkers} from "./marker.js";
7+
8+
const delaunayLinkDefaults = {
9+
ariaLabel: "delaunay link",
10+
fill: "none",
11+
stroke: "currentColor",
12+
strokeMiterlimit: 1
13+
};
14+
15+
const delaunayMeshDefaults = {
16+
ariaLabel: "delaunay mesh",
17+
fill: null,
18+
stroke: "currentColor",
19+
strokeOpacity: 0.2
20+
};
21+
22+
const hullDefaults = {
23+
ariaLabel: "hull",
24+
fill: "none",
25+
stroke: "currentColor",
26+
strokeWidth: 1.5,
27+
strokeMiterlimit: 1
28+
};
29+
30+
const voronoiDefaults = {
31+
ariaLabel: "voronoi",
32+
fill: "none",
33+
stroke: "currentColor",
34+
strokeMiterlimit: 1
35+
};
36+
37+
const voronoiMeshDefaults = {
38+
ariaLabel: "voronoi mesh",
39+
fill: null,
40+
stroke: "currentColor",
41+
strokeOpacity: 0.2
42+
};
43+
44+
class DelaunayLink extends Mark {
45+
constructor(data, options = {}) {
46+
const {x, y, z, curve, tension} = options;
47+
super(
48+
data,
49+
[
50+
{name: "x", value: x, scale: "x"},
51+
{name: "y", value: y, scale: "y"},
52+
{name: "z", value: z, optional: true}
53+
],
54+
options,
55+
delaunayLinkDefaults
56+
);
57+
this.curve = Curve(curve, tension);
58+
markers(this, options);
59+
}
60+
render(index, {x, y}, channels, dimensions) {
61+
const {x: X, y: Y, z: Z} = channels;
62+
const {dx, dy, curve} = this;
63+
const mark = this;
64+
65+
function links(index) {
66+
let i = -1;
67+
const newIndex = [];
68+
const newChannels = {};
69+
for (const k in channels) newChannels[k] = [];
70+
const X1 = [];
71+
const X2 = [];
72+
const Y1 = [];
73+
const Y2 = [];
74+
75+
function link(ti, tj) {
76+
ti = index[ti];
77+
tj = index[tj];
78+
newIndex.push(++i);
79+
X1[i] = X[ti];
80+
Y1[i] = Y[ti];
81+
X2[i] = X[tj];
82+
Y2[i] = Y[tj];
83+
for (const k in channels) newChannels[k].push(channels[k][tj]);
84+
}
85+
86+
const {halfedges, hull, triangles} = Delaunay.from(index, i => X[i], i => Y[i]);
87+
for (let i = 0; i < halfedges.length; ++i) { // inner edges
88+
const j = halfedges[i];
89+
if (j > i) link(triangles[i], triangles[j]);
90+
}
91+
for (let i = 0; i < hull.length; ++i) { // convex hull
92+
link(hull[i], hull[(i + 1) % hull.length]);
93+
}
94+
95+
select(this)
96+
.selectAll()
97+
.data(newIndex)
98+
.join("path")
99+
.call(applyDirectStyles, mark)
100+
.attr("d", i => {
101+
const p = path();
102+
const c = curve(p);
103+
c.lineStart();
104+
c.point(X1[i], Y1[i]);
105+
c.point(X2[i], Y2[i]);
106+
c.lineEnd();
107+
return p;
108+
})
109+
.call(applyChannelStyles, mark, newChannels)
110+
.call(applyMarkers, mark, newChannels);
111+
}
112+
113+
return create("svg:g")
114+
.call(applyIndirectStyles, this, dimensions)
115+
.call(applyTransform, x, y, offset + dx, offset + dy)
116+
.call(Z
117+
? g => g.selectAll().data(group(index, i => Z[i]).values()).enter().append("g").each(links)
118+
: g => g.datum(index).each(links))
119+
.node();
120+
}
121+
}
122+
123+
class AbstractDelaunayMark extends Mark {
124+
constructor(data, options = {}, defaults, zof = ({z}) => z) {
125+
const {x, y} = options;
126+
super(
127+
data,
128+
[
129+
{name: "x", value: x, scale: "x"},
130+
{name: "y", value: y, scale: "y"},
131+
{name: "z", value: zof(options), optional: true}
132+
],
133+
options,
134+
defaults
135+
);
136+
}
137+
render(index, {x, y}, {x: X, y: Y, z: Z, ...channels}, dimensions) {
138+
const {dx, dy} = this;
139+
const mark = this;
140+
function mesh(render) {
141+
return function(index) {
142+
const delaunay = Delaunay.from(index, i => X[i], i => Y[i]);
143+
select(this).append("path")
144+
.datum(index[0])
145+
.call(applyDirectStyles, mark)
146+
.attr("d", render(delaunay, dimensions))
147+
.call(applyChannelStyles, mark, channels);
148+
};
149+
}
150+
return create("svg:g")
151+
.call(applyIndirectStyles, this, dimensions)
152+
.call(applyTransform, x, y, offset + dx, offset + dy)
153+
.call(Z
154+
? g => g.selectAll().data(group(index, i => Z[i]).values()).enter().append("g").each(mesh(this._render))
155+
: g => g.datum(index).each(mesh(this._render)))
156+
.node();
157+
}
158+
}
159+
160+
class DelaunayMesh extends AbstractDelaunayMark {
161+
constructor(data, options = {}) {
162+
super(data, options, delaunayMeshDefaults);
163+
this.fill = "none";
164+
}
165+
_render(delaunay) {
166+
return delaunay.render();
167+
}
168+
}
169+
170+
class Hull extends AbstractDelaunayMark {
171+
constructor(data, options = {}) {
172+
super(data, options, hullDefaults, maybeZ);
173+
}
174+
_render(delaunay) {
175+
return delaunay.renderHull();
176+
}
177+
}
178+
179+
class Voronoi extends Mark {
180+
constructor(data, options = {}) {
181+
const {x, y, z} = options;
182+
super(
183+
data,
184+
[
185+
{name: "x", value: x, scale: "x"},
186+
{name: "y", value: y, scale: "y"},
187+
{name: "z", value: z, optional: true}
188+
],
189+
options,
190+
voronoiDefaults
191+
);
192+
}
193+
render(index, {x, y}, channels, dimensions) {
194+
const {x: X, y: Y, z: Z} = channels;
195+
const {dx, dy} = this;
196+
197+
function cells(index) {
198+
const delaunay = Delaunay.from(index, i => X[i], i => Y[i]);
199+
const voronoi = voronoiof(delaunay, dimensions);
200+
select(this)
201+
.selectAll()
202+
.data(index)
203+
.enter()
204+
.append("path")
205+
.call(applyDirectStyles, this)
206+
.attr("d", (_, i) => voronoi.renderCell(i))
207+
.call(applyChannelStyles, this, channels);
208+
}
209+
210+
return create("svg:g")
211+
.call(applyIndirectStyles, this, dimensions)
212+
.call(applyTransform, x, y, offset + dx, offset + dy)
213+
.call(Z
214+
? g => g.selectAll().data(group(index, i => Z[i]).values()).enter().append("g").each(cells)
215+
: g => g.datum(index).each(cells))
216+
.node();
217+
}
218+
}
219+
220+
class VoronoiMesh extends AbstractDelaunayMark {
221+
constructor(data, options) {
222+
super(data, options, voronoiMeshDefaults);
223+
this.fill = "none";
224+
}
225+
_render(delaunay, dimensions) {
226+
return voronoiof(delaunay, dimensions).render();
227+
}
228+
}
229+
230+
function voronoiof(delaunay, dimensions) {
231+
const {width, height, marginTop, marginRight, marginBottom, marginLeft} = dimensions;
232+
return delaunay.voronoi([marginLeft, marginTop, width - marginRight, height - marginBottom]);
233+
}
234+
235+
function delaunayMark(DelaunayMark, data, {x, y, ...options} = {}) {
236+
([x, y] = maybeTuple(x, y));
237+
return new DelaunayMark(data, {...options, x, y});
238+
}
239+
240+
export function delaunayLink(data, options) {
241+
return delaunayMark(DelaunayLink, data, options);
242+
}
243+
244+
export function delaunayMesh(data, options) {
245+
return delaunayMark(DelaunayMesh, data, options);
246+
}
247+
248+
export function hull(data, options) {
249+
return delaunayMark(Hull, data, options);
250+
}
251+
252+
export function voronoi(data, options) {
253+
return delaunayMark(Voronoi, data, options);
254+
}
255+
256+
export function voronoiMesh(data, options) {
257+
return delaunayMark(VoronoiMesh, data, options);
258+
}

src/marks/marker.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ function markerCircleStroke(color) {
7777

7878
let nextMarkerId = 0;
7979

80-
export function applyMarkers(path, mark, {stroke: S}) {
80+
export function applyMarkers(path, mark, {stroke: S} = {}) {
8181
return applyMarkersColor(path, mark, S && (i => S[i]));
8282
}
8383

84-
export function applyGroupedMarkers(path, mark, {stroke: S}) {
84+
export function applyGroupedMarkers(path, mark, {stroke: S} = {}) {
8585
return applyMarkersColor(path, mark, S && (([i]) => S[i]));
8686
}
8787

src/style.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function styles(
3030
opacity,
3131
mixBlendMode,
3232
paintOrder,
33+
pointerEvents,
3334
shapeRendering
3435
},
3536
{
@@ -121,6 +122,7 @@ export function styles(
121122
mark.opacity = impliedNumber(copacity, 1);
122123
mark.mixBlendMode = impliedString(mixBlendMode, "normal");
123124
mark.paintOrder = impliedString(paintOrder, "normal");
125+
mark.pointerEvents = impliedString(pointerEvents, "auto");
124126
mark.shapeRendering = impliedString(shapeRendering, "auto");
125127

126128
return [
@@ -261,6 +263,7 @@ export function applyIndirectStyles(selection, mark, {width, height, marginLeft,
261263
applyAttr(selection, "stroke-dashoffset", mark.strokeDashoffset);
262264
applyAttr(selection, "shape-rendering", mark.shapeRendering);
263265
applyAttr(selection, "paint-order", mark.paintOrder);
266+
applyAttr(selection, "pointer-events", mark.pointerEvents);
264267
if (mark.clip === "frame") {
265268
const id = `plot-clip-${++nextClipId}`;
266269
selection

0 commit comments

Comments
 (0)