forked from observablehq/plot
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhexbin.js
More file actions
129 lines (117 loc) · 4.85 KB
/
hexbin.js
File metadata and controls
129 lines (117 loc) · 4.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import {sqrt3} from "../symbols.js";
import {isNoneish, number, valueof} from "../options.js";
import {initializer} from "./basic.js";
import {hasOutput, maybeGroup, maybeOutputs, maybeSubgroup} from "./group.js";
import {Position} from "../projection.js";
// We don’t want the hexagons to align with the edges of the plot frame, as that
// would cause extreme x-values (the upper bound of the default x-scale domain)
// to be rounded up into a floating bin to the right of the plot. Therefore,
// rather than centering the origin hexagon around ⟨0,0⟩ in screen coordinates,
// we offset slightly to ⟨0.5,0⟩. The hexgrid mark uses the same origin.
export const ox = 0.5,
oy = 0;
/** @jsdoc hexbin */
export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) {
// TODO filter e.g. to show empty hexbins?
// TODO disallow x, x1, x2, y, y1, y2 reducers?
binWidth = binWidth === undefined ? 20 : number(binWidth);
outputs = maybeOutputs(outputs, options);
// A fill output means a fill channel, and hence the stroke should default to
// none (assuming a mark that defaults to fill and no stroke, such as dot).
// Note that it’s safe to mutate options here because we just created it with
// the rest operator above.
const {z, fill, stroke} = options;
if (stroke === undefined && isNoneish(fill) && hasOutput(outputs, "fill")) options.stroke = "none";
// Populate default values for the r and symbol options, as appropriate.
if (options.symbol === undefined) options.symbol = "hexagon";
if (options.r === undefined && !hasOutput(outputs, "r")) options.r = binWidth / 2;
return initializer(options, (data, facets, channels, scales, _, context) => {
let {x: X, y: Y, z: Z, fill: F, stroke: S, symbol: Q} = channels;
if (X === undefined) throw new Error("missing channel: x");
if (Y === undefined) throw new Error("missing channel: y");
// Get the (either scaled or projected) xy channels.
({x: X, y: Y} = Position(channels, scales, context));
// Extract the values for channels that are eligible for grouping; not all
// marks define a z channel, so compute one if it not already computed. If z
// was explicitly set to null, ensure that we don’t subdivide bins.
Z = Z ? Z.value : valueof(data, z);
F = F?.value;
S = S?.value;
Q = Q?.value;
// Group on the first of z, fill, stroke, and symbol. Implicitly reduce
// these channels using the first corresponding value for each bin.
const G = maybeSubgroup(outputs, {z: Z, fill: F, stroke: S, symbol: Q});
const GZ = Z && [];
const GF = F && [];
const GS = S && [];
const GQ = Q && [];
// Construct the hexbins and populate the output channels.
const binFacets = [];
const BX = [];
const BY = [];
let i = -1;
for (const o of outputs) o.initialize(data);
for (const facet of facets) {
const binFacet = [];
for (const o of outputs) o.scope("facet", facet);
for (const [f, I] of maybeGroup(facet, G)) {
for (const bin of hbin(I, X, Y, binWidth)) {
binFacet.push(++i);
BX.push(bin.x);
BY.push(bin.y);
if (Z) GZ.push(G === Z ? f : Z[bin[0]]);
if (F) GF.push(G === F ? f : F[bin[0]]);
if (S) GS.push(G === S ? f : S[bin[0]]);
if (Q) GQ.push(G === Q ? f : Q[bin[0]]);
for (const o of outputs) o.reduce(bin);
}
}
binFacets.push(binFacet);
}
// Construct the output channels, and populate the radius scale hint.
const binChannels = {
x: {value: BX},
y: {value: BY},
...(Z && {z: {value: GZ}}),
...(F && {fill: {value: GF, scale: true}}),
...(S && {stroke: {value: GS, scale: true}}),
...(Q && {symbol: {value: GQ, scale: true}}),
...Object.fromEntries(
outputs.map(({name, output}) => [
name,
{scale: true, radius: name === "r" ? binWidth / 2 : undefined, value: output.transform()}
])
)
};
return {data, facets: binFacets, channels: binChannels};
});
}
function hbin(I, X, Y, dx) {
const dy = dx * (1.5 / sqrt3);
const bins = new Map();
for (const i of I) {
let px = X[i],
py = Y[i];
if (isNaN(px) || isNaN(py)) continue;
let pj = Math.round((py = (py - oy) / dy)),
pi = Math.round((px = (px - ox) / dx - (pj & 1) / 2)),
py1 = py - pj;
if (Math.abs(py1) * 3 > 1) {
let px1 = px - pi,
pi2 = pi + (px < pi ? -1 : 1) / 2,
pj2 = pj + (py < pj ? -1 : 1),
px2 = px - pi2,
py2 = py - pj2;
if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) (pi = pi2 + (pj & 1 ? 1 : -1) / 2), (pj = pj2);
}
const key = `${pi},${pj}`;
let bin = bins.get(key);
if (bin === undefined) {
bins.set(key, (bin = []));
bin.x = (pi + (pj & 1) / 2) * dx + ox;
bin.y = pj * dy + oy;
}
bin.push(i);
}
return bins.values();
}