Skip to content

delaunay + voronoi #917

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jun 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

[<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)

[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

[<img src="./img/dot.png" width="320" height="198" alt="a scatterplot">](https://observablehq.com/@observablehq/plot-dot)
Expand Down
Binary file added img/voronoi.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
258 changes: 258 additions & 0 deletions src/marks/delaunay.js
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 2 additions & 2 deletions src/marks/marker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
}

Expand Down
3 changes: 3 additions & 0 deletions src/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function styles(
opacity,
mixBlendMode,
paintOrder,
pointerEvents,
shapeRendering
},
{
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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
Expand Down
Loading