Skip to content

Commit b8eb6c1

Browse files
mbostockFil
andauthored
density mark (#943)
* density mark * density contours stroke & fill (#948) * density contours as an initializer: first pass * density by z, carrying styles and channels (no reducer, only "first") * fill: "density" * density weight * consistent thresholds across facets and series note: we have to go around this bug in d3-contour: d3/d3-contour#57 * allow initializer composition; move initializer to the class; clean up * density weight example * error if x or y is undefined (in the future we might route those to 1-dimensional transforms—KDE) * document * reduce img * * don't apply the scales if we are already in pixel space (e.g. when composing with the hexbin transform) * avoids a crash when there is no contour * 1d density contours with frameAnchor * adopt [email protected] for https://github.com/d3/d3-contour/releases/tag/v3.0.2 (not entirely sure if it's good practice to have all the yarn.lock changes in the PR, beyond those that are relevant) * replace example image * mike’s edits * fix image dimensions * isDensity * distinct * tweak tests Co-authored-by: Mike Bostock <[email protected]> Co-authored-by: Philippe Rivière <[email protected]>
1 parent 1d4da1f commit b8eb6c1

22 files changed

+2768
-236
lines changed

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,22 @@ Draws a mesh for the cell boundaries of the Voronoi tesselation of the points gi
10221022
10231023
If a **z** channel is specified, the input points are grouped by *z*, and separate Voronoi tesselations are constructed for each group.
10241024
1025+
### Density
1026+
1027+
[<img src="./img/density-contours.png" width="320" height="200" alt="A scatterplot showing the relationship between the idle duration and eruption duration for Old Faithful">](https://observablehq.com/@observablehq/plot-density)
1028+
1029+
[Source](./src/marks/density.js) · [Examples](https://observablehq.com/@observablehq/plot-density) · Draws regions of a two-dimensional point distribution in which the number of points per unit of screen space exceeds a certain density.
1030+
1031+
#### Plot.density(*data*, *options*)
1032+
1033+
Draws a region for each density level where the number of points given by the **x** and **y** channels, and possibly weighted by the **weight** channel, exceeds the given level. The **thresholds** option, which defaults to 20, indicates the approximate number of levels that will be computed at even intervals between 0 and the maximum density.
1034+
1035+
If a **z**, **stroke** or **fill** channel is specified, the input points are grouped by series, and separate sets of contours are generated for each series.
1036+
1037+
If stroke or fill is specified as *density*, a color channel is returned with values representing the density normalized between 0 and 1.
1038+
1039+
If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option.
1040+
10251041
### Dot
10261042
10271043
[<img src="./img/dot.png" width="320" height="198" alt="a scatterplot">](https://observablehq.com/@observablehq/plot-dot)
@@ -1135,7 +1151,7 @@ Returns a new image with the given *data* and *options*. If neither the **x** no
11351151
11361152
### Linear regression
11371153
1138-
[<img src="./img/linear-regression.png" width="600" alt="a scatterplot of penguin culmens, showing the length and depth of several species, with linear regression models by species and for the whole population, illustrating Simpson’s paradox">](https://observablehq.com/@observablehq/plot-linear-regression)
1154+
[<img src="./img/linear-regression.png" width="320" height="200" alt="a scatterplot of penguin culmens, showing the length and depth of several species, with linear regression models by species and for the whole population, illustrating Simpson’s paradox">](https://observablehq.com/@observablehq/plot-linear-regression)
11391155
11401156
[Source](./src/marks/linearRegression.js) · [Examples](https://observablehq.com/@observablehq/plot-linear-regression) · Draws [linear regression](https://en.wikipedia.org/wiki/Linear_regression) lines with confidence bands, representing the estimated relation of a dependent variable (typically *y*) on an independent variable (typically *x*). The linear regression line is fit using the [least squares](https://en.wikipedia.org/wiki/Least_squares) approach. See Torben Jansen’s [“Linear regression with confidence bands”](https://observablehq.com/@toja/linear-regression-with-confidence-bands) and [this StatExchange question](https://stats.stackexchange.com/questions/101318/understanding-shape-and-calculation-of-confidence-bands-in-linear-regression) for details on the confidence interval calculation.
11411157

img/density-contours.png

154 KB
Loading

img/linear-regression.png

71.2 KB
Loading

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"vite": "2"
5151
},
5252
"dependencies": {
53-
"d3": "^7.3.0",
53+
"d3": "^7.4.5",
5454
"interval-tree-1d": "1",
5555
"isoformat": "0.2"
5656
},

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ 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";
77
export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
8+
export {Density, density} from "./marks/density.js";
89
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
910
export {Frame, frame} from "./marks/frame.js";
1011
export {Hexgrid, hexgrid} from "./marks/hexgrid.js";

src/marks/density.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import {contourDensity, create, geoPath} from "d3";
2+
import {identity, maybeTuple, maybeZ, valueof} from "../options.js";
3+
import {Mark} from "../plot.js";
4+
import {coerceNumbers} from "../scales.js";
5+
import {applyFrameAnchor, applyDirectStyles, applyIndirectStyles, applyChannelStyles, applyTransform, distinct, groupZ} from "../style.js";
6+
import {initializer} from "../transforms/basic.js";
7+
8+
const defaults = {
9+
ariaLabel: "density",
10+
fill: "none",
11+
stroke: "currentColor",
12+
strokeMiterlimit: 1
13+
};
14+
15+
export class Density extends Mark {
16+
constructor(data, {x, y, z, weight, fill, stroke, ...options} = {}) {
17+
// If fill or stroke is specified as “density”, then temporarily treat these
18+
// as a literal color when computing defaults and maybeZ; below, we’ll unset
19+
// these constant colors back to undefined since they will instead be
20+
// populated by a channel generated by the initializer.
21+
const fillDensity = isDensity(fill) && (fill = "currentColor", true);
22+
const strokeDensity = isDensity(stroke) && (stroke = "currentColor", true);
23+
super(
24+
data,
25+
[
26+
{name: "x", value: x, scale: "x", optional: true},
27+
{name: "y", value: y, scale: "y", optional: true},
28+
{name: "z", value: maybeZ({z, fill, stroke}), optional: true},
29+
{name: "weight", value: weight, optional: true}
30+
],
31+
densityInitializer({...options, fill, stroke}, fillDensity, strokeDensity),
32+
defaults
33+
);
34+
if (fillDensity) this.fill = undefined;
35+
if (strokeDensity) this.stroke = undefined;
36+
this.z = z;
37+
}
38+
filter(index) {
39+
return index; // don’t filter contours constructed by initializer
40+
}
41+
render(index, scales, channels, dimensions) {
42+
const {contours} = channels;
43+
const path = geoPath();
44+
return create("svg:g")
45+
.call(applyIndirectStyles, this, scales, dimensions)
46+
.call(applyTransform, this, scales)
47+
.call(g => g.selectAll()
48+
.data(index)
49+
.enter()
50+
.append("path")
51+
.call(applyDirectStyles, this)
52+
.call(applyChannelStyles, this, channels)
53+
.attr("d", i => path(contours[i])))
54+
.node();
55+
}
56+
}
57+
58+
export function density(data, {x, y, ...options} = {}) {
59+
([x, y] = maybeTuple(x, y));
60+
return new Density(data, {...options, x, y});
61+
}
62+
63+
const dropChannels = new Set(["x", "y", "z", "weight"]);
64+
65+
function densityInitializer(options, fillDensity, strokeDensity) {
66+
let {bandwidth, thresholds} = options;
67+
bandwidth = bandwidth === undefined ? 20 : +bandwidth;
68+
thresholds = thresholds === undefined ? 20 : +thresholds; // TODO Allow an array of thresholds?
69+
return initializer(options, function(data, facets, channels, scales, dimensions) {
70+
const X = channels.x ? coerceNumbers(valueof(channels.x.value, scales[channels.x.scale] || identity)) : null;
71+
const Y = channels.y ? coerceNumbers(valueof(channels.y.value, scales[channels.y.scale] || identity)) : null;
72+
const W = channels.weight ? coerceNumbers(channels.weight.value) : null;
73+
const Z = channels.z?.value;
74+
const {z} = this;
75+
const [cx, cy] = applyFrameAnchor(this, dimensions);
76+
const {width, height} = dimensions;
77+
78+
// Group any of the input channels according to the first index associated
79+
// with each z-series or facet. Drop any channels not be needed for
80+
// rendering after the contours are computed.
81+
const newChannels = Object.fromEntries(Object.entries(channels)
82+
.filter(([key]) => !dropChannels.has(key))
83+
.map(([key, channel]) => [key, {...channel, value: []}]));
84+
85+
// If the fill or stroke encodes density, construct new output channels.
86+
const FD = fillDensity && [];
87+
const SD = strokeDensity && [];
88+
const k = 100; // arbitrary scale factor for readability
89+
90+
const density = contourDensity()
91+
.x(X ? i => X[i] : cx)
92+
.y(Y ? i => Y[i] : cy)
93+
.weight(W ? i => W[i] : 1)
94+
.size([width, height])
95+
.bandwidth(bandwidth)
96+
.thresholds(thresholds);
97+
98+
// If there are multiple facets or multiple series, first compute the
99+
// contours for each facet-series independently; choose the set of contours
100+
// with the maximum threshold value (density), and then apply this set’s
101+
// thresholds to all the other facet-series. TODO With API changes to
102+
// d3-contour, we could avoid recomputing the blurred grid and cache
103+
// individual contours, making this more efficient.
104+
if (facets.length > 1 || Z && facets.length > 0 && distinct(facets[0], Z)) {
105+
let maxValue = 0;
106+
let maxContours = [];
107+
for (const facet of facets) {
108+
for (const index of Z ? groupZ(facet, Z, z) : [facet]) {
109+
const C = density(index);
110+
if (C.length > 0) {
111+
const c = C[C.length - 1];
112+
if (c.value > maxValue) {
113+
maxValue = c.value;
114+
maxContours = C;
115+
}
116+
}
117+
}
118+
}
119+
density.thresholds(maxContours.map(c => c.value));
120+
}
121+
122+
// Generate contours for each facet-series.
123+
const newFacets = [];
124+
const contours = [];
125+
for (const facet of facets) {
126+
const contourFacet = [];
127+
newFacets.push(contourFacet);
128+
for (const index of Z ? groupZ(facet, Z, z) : [facet]) {
129+
for (const contour of density(index)) {
130+
contourFacet.push(contours.length);
131+
contours.push(contour);
132+
if (FD) FD.push(contour.value * k);
133+
if (SD) SD.push(contour.value * k);
134+
for (const key in newChannels) {
135+
newChannels[key].value.push(channels[key].value[index[0]]);
136+
}
137+
}
138+
}
139+
}
140+
141+
// If the fill or stroke encodes density, ensure that a zero value is
142+
// included so that the default color scale domain starts at zero. Otherwise
143+
// if the starting range value is the same as the background color, the
144+
// first contour might not be visible.
145+
if (FD) FD.push(0);
146+
if (SD) SD.push(0);
147+
148+
return {
149+
data,
150+
facets: newFacets,
151+
channels: {
152+
...newChannels,
153+
...FD && {fill: {value: FD, scale: "color"}},
154+
...SD && {stroke: {value: SD, scale: "color"}},
155+
contours: {value: contours}
156+
}
157+
};
158+
});
159+
}
160+
161+
function isDensity(value) {
162+
return /^density$/i.test(value);
163+
}

src/style.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ function groupAesthetics({ariaLabel: AL, title: T, fill: F, fillOpacity: FO, str
184184
return [AL, T, F, FO, S, SO, SW, O, H].filter(c => c !== undefined);
185185
}
186186

187+
export function distinct(I, X) {
188+
const x = keyof(X[0]);
189+
for (const i of I) {
190+
if (keyof(X[i]) !== x) {
191+
return true;
192+
}
193+
}
194+
return false;
195+
}
196+
187197
export function groupZ(I, Z, z) {
188198
const G = group(I, i => Z[i]);
189199
if (z === undefined && G.size > I.length >> 1) {

0 commit comments

Comments
 (0)