Skip to content

Commit 886615b

Browse files
Filmbostock
authored andcommitted
reinitialize (#823)
* document layouts (as "scale-aware transforms") * document binWidth * document the initialize option after 42ac4f0 * sort hex bins by radius (descending) group by z inline hexbin binWidth is the distance between two centers (rebased on mbostock/reinitialize) * dodge rebased on mbostock/reinitialize * compose intializers * use composeInitialize to make dodge composable * add new channels as you compose initializers * darker transform, to demonstrate composition with dodgeY (added as an example, but we could promote it to a transform) * a more generic "remap" * jiggle layout (using the same remap intializer as in the darkerDodge plot)
1 parent 073ae8e commit 886615b

37 files changed

+5400
-241
lines changed

README.md

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,14 @@ Plot.dotY(cars.map(d => d["economy (mpg)"]))
10371037
10381038
Equivalent to [Plot.dot](#plotdotdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …].
10391039
1040+
### Hexgrid
1041+
1042+
The hexgrid mark can be used to support marks using the [hexbin](#hexbin) layout.
1043+
1044+
#### Plot.hexgrid([*options*])
1045+
1046+
The *binWidth* option specifies the distance between the centers of neighboring hexagons, in pixels (defaults to 20). The *clip* option defaults to true, clipping the mark to the frame’s dimensions.
1047+
10401048
### Image
10411049
10421050
[<img src="./img/image.png" width="320" height="198" alt="a scatterplot of Presidential portraits">](https://observablehq.com/@observablehq/plot-image)
@@ -1524,10 +1532,10 @@ The following aggregation methods are supported:
15241532
* *pXX* - the percentile value, where XX is a number in [00,99]
15251533
* *deviation* - the standard deviation
15261534
* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm)
1527-
* *x* - the middle the bin’s *x*-extent (when binning on *x*)
1535+
* *x* - the middle of the bin’s *x*-extent (when binning on *x*)
15281536
* *x1* - the lower bound of the bin’s *x*-extent (when binning on *x*)
15291537
* *x2* - the upper bound of the bin’s *x*-extent (when binning on *x*)
1530-
* *y* - the middle the bin’s *y*-extent (when binning on *y*)
1538+
* *y* - the middle of the bin’s *y*-extent (when binning on *y*)
15311539
* *y1* - the lower bound of the bin’s *y*-extent (when binning on *y*)
15321540
* *y2* - the upper bound of the bin’s *y*-extent (when binning on *y*)
15331541
* a function to be passed the array of values for each bin and the extent of the bin
@@ -2145,6 +2153,69 @@ This helper for constructing derived columns returns a [*column*, *setColumn*] a
21452153
21462154
Plot.column is typically used by options transforms to define new channels; the associated columns are populated (derived) when the **transform** option function is invoked.
21472155
2156+
## Scale-aware transforms
2157+
2158+
Some transforms need to operate in representation space (such as pixels and colors, *i.e.* after scales have been applied) rather than data space. Such a transform might, for example, modify the marks’ positions in screen space to avoid occlusion. These scale-aware transforms are applied *after* the initial setting of the scales, and can modify the channels or derive new channels—which can in turn be passed to scales.
2159+
2160+
### Dodge
2161+
2162+
The dodge transform can be applied to any mark that consumes *x* or *y*, such as the Dot, Image, Text and Vector marks.
2163+
#### Plot.dodgeY([*layoutOptions*, ]*options*)
2164+
2165+
```js
2166+
Plot.dodgeY({x: "date"})
2167+
```
2168+
2169+
If the marks are arranged along the *x* axis, the dodgeY transform piles them vertically, keeping their *x* position unchanged, and creating a *y* position that avoids overlapping.
2170+
2171+
#### Plot.dodgeX([*layoutOptions*, ]*options*)
2172+
2173+
```js
2174+
Plot.dodgeX({y: "value"})
2175+
```
2176+
2177+
Equivalent to Plot.dodgeY, but the piling is horizontal, keeping the marks’ *y* position unchanged, and creating an *x* position that avoids overlapping.
2178+
The dodge transforms accept the following options:
2179+
* **padding** — a number of pixels added to the radius of the mark to estimate its size
2180+
* **anchor** - the frame anchor: one of *middle*, *right*, and *left* (default) for dodgeX, and one of *middle*, *top*, and *bottom* (default) for dodgeY. With the *middle* anchor the piles will grow from the center in both directions; with the other anchors, the piles will grow from the specified anchor towards the opposite direction.
2181+
2182+
### Hexbin
2183+
2184+
The hexbin transform can be applied to any mark that consumes *x* and *y*, such as the Dot, Image, Text and Vector marks. It aggregates the values into hexagonal bins of the given *radius* (in pixel space), and computes new values *x* and *y* as the centers of each bin. It can also return new channels by applying a reducer to each bin, such as the number of elements in the bin.
2185+
2186+
#### Plot.hexbin(*outputs*, *options*)
2187+
2188+
[Source](./src/transforms/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · Aggregates the given inputs into hexagonal bins, and creates output channels with the reduced data. The options must specify the *x* and *y* channels, and can optionally indicate the *binWidth* in pixels (defaults to 20), defined as the distance between the centers of two neighboring hexagons. If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. The *outputs* options are similar to Plot.bin’s outputs; each output channel receives as input, for each hexagon, the subset of the data which has been matched to its center. The outputs object specifies the aggregation method for each output channel.
2189+
2190+
The following aggregation methods are supported:
2191+
2192+
* *first* - the first value, in input order
2193+
* *last* - the last value, in input order
2194+
* *count* - the number of elements (frequency)
2195+
* *distinct* - the number of distinct values
2196+
* *sum* - the sum of values
2197+
* *proportion* - the sum proportional to the overall total (weighted frequency)
2198+
* *proportion-facet* - the sum proportional to the facet total
2199+
* *min* - the minimum value
2200+
* *min-index* - the zero-based index of the minimum value
2201+
* *max* - the maximum value
2202+
* *max-index* - the zero-based index of the maximum value
2203+
* *mean* - the mean value (average)
2204+
* *median* - the median value
2205+
* *deviation* - the standard deviation
2206+
* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm)
2207+
* *mode* - the value with the most occurrences
2208+
* a function to be passed the array of values for each bin and the extent of the bin
2209+
* an object with a *reduce* method
2210+
2211+
When the hexbin transform has an *r* output, the bins are returned in decreasing size order.
2212+
2213+
See also the [hexgrid](#hexgrid) mark.
2214+
2215+
### Custom scale-aware transforms
2216+
2217+
When its *options* have an *initialize* property, the initialize function is called after the scales have been computed. It receives as inputs the (possibly transformed) data array, the index of elements of this array that belong to each facet, the input channels (as a key: array object), the scales, and the dimensions, with the mark as this. It must return the data, index, and the channels that need to be scaled in a second pass.
2218+
21482219
## Curves
21492220
21502221
A curve defines how to turn a discrete representation of a line as a sequence of points [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] into a continuous path; *i.e.*, how to interpolate between points. Curves are used by the [line](#line), [area](#area), and [link](#link) mark, and are implemented by [d3-shape](https://github.com/d3/d3-shape/blob/master/README.md#curves).

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"sideEffects": false,
3737
"devDependencies": {
38+
"@rollup/plugin-commonjs": "^21.0.1",
3839
"@rollup/plugin-json": "4",
3940
"@rollup/plugin-node-resolve": "13",
4041
"canvas": "2",
@@ -50,7 +51,7 @@
5051
},
5152
"dependencies": {
5253
"d3": "^7.3.0",
53-
"d3-hexbin": "^0.2.2",
54+
"interval-tree-1d": "1",
5455
"isoformat": "0.2"
5556
},
5657
"engines": {

rollup.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "fs";
22
import {terser} from "rollup-plugin-terser";
3+
import commonjs from "@rollup/plugin-commonjs";
34
import json from "@rollup/plugin-json";
45
import node from "@rollup/plugin-node-resolve";
56
import * as meta from "./package.json";
@@ -25,6 +26,7 @@ const config = {
2526
banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}`
2627
},
2728
plugins: [
29+
commonjs(),
2830
json(),
2931
node()
3032
]

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {Vector, vector, vectorX, vectorY} from "./marks/vector.js";
1919
export {valueof, column} from "./options.js";
2020
export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js";
2121
export {bin, binX, binY} from "./transforms/bin.js";
22+
export {dodgeX, dodgeY} from "./transforms/dodge.js";
2223
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
2324
export {hexbin} from "./transforms/hexbin.js";
2425
export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js";

src/marks/hexgrid.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@ export function hexgrid(options) {
1717
}
1818

1919
export class Hexgrid extends Mark {
20-
constructor({radius = 10, clip = true, ...options} = {}) {
20+
constructor({binWidth = 20, clip = true, ...options} = {}) {
2121
super(undefined, undefined, {clip, ...options}, defaults);
22-
this.radius = number(radius);
22+
this.binWidth = number(binWidth);
2323
}
2424
render(index, scales, channels, dimensions) {
25-
const {dx, dy, radius: rx} = this;
25+
const {dx, dy, binWidth} = this;
2626
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
2727
const x0 = marginLeft - ox, x1 = width - marginRight - ox, y0 = marginTop - oy, y1 = height - marginBottom - oy;
28-
const ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5;
28+
const rx = binWidth / 2, ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5;
2929
const path = `m0,${-ry}l${rx},${hy}v${ry}l${-rx},${hy}`;
3030
const i0 = Math.floor(x0 / wx), i1 = Math.ceil(x1 / wx);
3131
const j0 = Math.floor((y0 + hy) / wy), j1 = Math.ceil((y1 - hy) / wy) + 1;

src/plot.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,16 @@ export function plot(options = {}) {
9090
autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options);
9191
autoAxisTicks(scaleDescriptors, axes);
9292

93+
const {fx, fy} = scales;
94+
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
95+
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
96+
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
97+
9398
// Reinitialize; for deriving channels dependent on other channels.
9499
const newByScale = new Set();
95100
for (const [mark, state] of stateByMark) {
96101
if (mark.reinitialize != null) {
97-
const {facets, channels} = mark.reinitialize(state.data, state.facets, state.channels, scales);
102+
const {facets, channels} = mark.reinitialize(state.data, state.facets, state.channels, scales, subdimensions);
98103
if (facets !== undefined) state.facets = facets;
99104
if (channels !== undefined) {
100105
inferChannelScale(channels, mark);
@@ -148,7 +153,6 @@ export function plot(options = {}) {
148153
.node();
149154

150155
// When faceting, render axes for fx and fy instead of x and y.
151-
const {fx, fy} = scales;
152156
const axisY = axes[facets !== undefined && fy ? "fy" : "y"];
153157
const axisX = axes[facets !== undefined && fx ? "fx" : "x"];
154158
if (axisY) svg.appendChild(axisY.render(null, scales, dimensions));
@@ -158,9 +162,6 @@ export function plot(options = {}) {
158162
if (facets !== undefined) {
159163
const fyDomain = fy && fy.domain();
160164
const fxDomain = fx && fx.domain();
161-
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
162-
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
163-
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
164165
const indexByFacet = facetMap(facetChannels);
165166
facets.forEach(([key], i) => indexByFacet.set(key, i));
166167
const selection = select(svg);

src/symbols.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3";
22
import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3";
33

4-
export const sqrt4_3 = 2 / Math.sqrt(3);
4+
export const sqrt3 = Math.sqrt(3);
5+
export const sqrt4_3 = 2 / sqrt3;
56

67
const symbolHexagon = {
78
draw(context, size) {

src/transforms/basic.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ export function basic({
99
sort: s1,
1010
reverse: r1,
1111
transform: t1,
12+
initialize: i1,
1213
...options
1314
} = {}, t2) {
1415
if (t1 === undefined) { // explicit transform overrides filter, sort, and reverse
1516
if (f1 != null) t1 = filterTransform(f1);
1617
if (s1 != null && !isOptions(s1)) t1 = composeTransform(t1, sortTransform(s1));
1718
if (r1) t1 = composeTransform(t1, reverseTransform);
1819
}
20+
if (t2 != null && i1 != null) throw new Error("Data transforms must appear before any channel transform");
1921
return {
2022
...options,
2123
...isOptions(s1) && {sort: s1},
@@ -32,6 +34,20 @@ function composeTransform(t1, t2) {
3234
};
3335
}
3436

37+
export function composeInitialize({initialize: i1, ...options} = {}, i2) {
38+
return i1 == null
39+
? {...options, initialize: i2}
40+
: {
41+
...options,
42+
initialize(data, facets, channels, scales, dimensions) {
43+
let newChannels;
44+
({data, facets, channels: newChannels} = i1.call(this, data, facets, channels, scales, dimensions));
45+
Object.assign(channels, newChannels);
46+
return i2.call(this, data, facets, channels, scales, dimensions);
47+
}
48+
};
49+
}
50+
3551
export function filter(value, options) {
3652
return basic(options, filterTransform(value));
3753
}

src/transforms/dodge.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {max} from "d3";
2+
import IntervalTree from "interval-tree-1d";
3+
import {finite, positive} from "../defined.js";
4+
import {composeInitialize} from "./basic.js";
5+
6+
const anchorXLeft = ({marginLeft}) => [1, marginLeft];
7+
const anchorXRight = ({width, marginRight}) => [-1, width - marginRight];
8+
const anchorXMiddle = ({width, marginLeft, marginRight}) => [0, (marginLeft + width - marginRight) / 2];
9+
const anchorYTop = ({marginTop}) => [1, marginTop];
10+
const anchorYBottom = ({height, marginBottom}) => [-1, height - marginBottom];
11+
const anchorYMiddle = ({height, marginTop, marginBottom}) => [0, (marginTop + height - marginBottom) / 2];
12+
13+
function maybeAnchor(anchor) {
14+
return typeof anchor === "string" ? {anchor} : anchor;
15+
}
16+
17+
export function dodgeX(dodgeOptions = {}, options = {}) {
18+
if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
19+
let {anchor = "left", padding = 1} = maybeAnchor(dodgeOptions);
20+
switch (`${anchor}`.toLowerCase()) {
21+
case "left": anchor = anchorXLeft; break;
22+
case "right": anchor = anchorXRight; break;
23+
case "middle": anchor = anchorXMiddle; break;
24+
default: throw new Error(`unknown dodge anchor: ${anchor}`);
25+
}
26+
return dodge("x", "y", anchor, +padding, options);
27+
}
28+
29+
export function dodgeY(dodgeOptions = {}, options = {}) {
30+
if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
31+
let {anchor = "bottom", padding = 1} = maybeAnchor(dodgeOptions);
32+
switch (`${anchor}`.toLowerCase()) {
33+
case "top": anchor = anchorYTop; break;
34+
case "bottom": anchor = anchorYBottom; break;
35+
case "middle": anchor = anchorYMiddle; break;
36+
default: throw new Error(`unknown dodge anchor: ${anchor}`);
37+
}
38+
return dodge("y", "x", anchor, +padding, options);
39+
}
40+
41+
function dodge(y, x, anchor, padding, options) {
42+
return composeInitialize(options, function(data, facets, {[x]: X, r: R}, {[x]: xscale, r: rscale}, dimensions) {
43+
if (!X) throw new Error(`missing channel ${x}`);
44+
X = X.value.map(xscale);
45+
const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? +options.r : 3;
46+
if (R) R = R.value.map(rscale);
47+
if (X == null) throw new Error(`missing channel: ${x}`);
48+
let [ky, ty] = anchor(dimensions);
49+
const compare = ky ? compareAscending : compareSymmetric;
50+
if (ky) ty += ky * ((R ? max(facets.flat(), i => R[i]) : r) + padding); else ky = 1;
51+
const Y = new Float64Array(X.length);
52+
const radius = R ? i => R[i] : () => r;
53+
for (let I of facets) {
54+
const tree = IntervalTree();
55+
I = I.filter(R
56+
? i => finite(X[i]) && positive(R[i])
57+
: i => finite(X[i]));
58+
for (const i of I) {
59+
const intervals = [];
60+
const l = X[i] - radius(i);
61+
const h = X[i] + radius(i);
62+
63+
// For any previously placed circles that may overlap this circle, compute
64+
// the y-positions that place this circle tangent to these other circles.
65+
// https://observablehq.com/@mbostock/circle-offset-along-line
66+
tree.queryInterval(l - padding, h + padding, ([,, j]) => {
67+
const yj = Y[j];
68+
const dx = X[i] - X[j];
69+
const dr = padding + (R ? R[i] + R[j] : 2 * r);
70+
const dy = Math.sqrt(dr * dr - dx * dx);
71+
intervals.push([yj - dy, yj + dy]);
72+
});
73+
74+
// Find the best y-value where this circle can fit.
75+
for (let y of intervals.flat().sort(compare)) {
76+
if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) {
77+
Y[i] = y;
78+
break;
79+
}
80+
}
81+
82+
// Insert the placed circle into the interval tree.
83+
tree.insert([l, h, i]);
84+
}
85+
for (const i of I) Y[i] = Y[i] * ky + ty;
86+
}
87+
return {data, facets, channels: {
88+
[x]: {value: X},
89+
[y]: {value: Y},
90+
...R && {r: {value: R}}
91+
}};
92+
});
93+
}
94+
95+
function compareSymmetric(a, b) {
96+
return Math.abs(a) - Math.abs(b);
97+
}
98+
99+
function compareAscending(a, b) {
100+
return (a < 0) - (b < 0) || (a - b);
101+
}

0 commit comments

Comments
 (0)