Skip to content

Commit b9f97d0

Browse files
committed
layouts can reindex and rescale
1 parent 2dae268 commit b9f97d0

File tree

8 files changed

+673
-619
lines changed

8 files changed

+673
-619
lines changed

src/layouts/dodge.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ function dodge(y, x, anchor, padding, options) {
7575
// Insert the placed circle into the interval tree.
7676
tree.insert([l, r, i]);
7777
}
78-
return {[y]: Y.map(y => y * ky + ty)};
78+
return {
79+
reindex: true,
80+
[x]: Float64Array.from(I, i => X[i]),
81+
[y]: Float64Array.from(I, i => Y[i] * ky + ty)
82+
};
7983
});
8084
}
8185

src/plot.js

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,59 @@ export function plot(options = {}) {
5252
}
5353

5454
const scaleDescriptors = Scales(scaleChannels, options);
55-
const scales = ScaleFunctions(scaleDescriptors);
5655
const axes = Axes(scaleDescriptors, options);
5756
const dimensions = Dimensions(scaleDescriptors, axes, options);
5857

5958
autoScaleRange(scaleDescriptors, dimensions);
6059
autoScaleLabels(scaleChannels, scaleDescriptors, axes, dimensions, options);
6160
autoAxisTicks(scaleDescriptors, axes);
6261

62+
// layouts might return new data to scale with existing or new scales
63+
const scales = ScaleFunctions(scaleDescriptors);
64+
const markValues = new Map();
65+
const newChannels = new Map();
66+
const newOptions = {};
67+
for (const mark of marks) {
68+
const channels = markChannels.get(mark) ?? [];
69+
const values = applyScales(channels, scales);
70+
let index = filter(markIndex.get(mark), channels, values);
71+
const rescale = [];
72+
if (mark.layout != null) {
73+
let {reindex, ...newValues} = mark.layout(index, scales, values, dimensions) || {};
74+
for (let key in newValues) {
75+
let c = newValues[key];
76+
const {scale} = c;
77+
if (scale) {
78+
if (!newChannels.has(scale)) newChannels.set(scale, []);
79+
newChannels.get(scale).push({scale, value: c.values});
80+
newOptions[scale] = {...c.options, ...options[scale]};
81+
values[key] = c.values;
82+
rescale.push([scale, values[key]]);
83+
} else {
84+
values[key] = c;
85+
}
86+
if (reindex) {
87+
index = range(values[key]);
88+
reindex = false;
89+
}
90+
}
91+
}
92+
markValues.set(mark, {index, values, rescale});
93+
}
94+
const newScaleDescriptors = Scales(newChannels, newOptions);
95+
Object.assign(scaleDescriptors, newScaleDescriptors);
96+
Object.assign(scales, ScaleFunctions(newScaleDescriptors));
97+
for (const [, {rescale}] of markValues) {
98+
for (const [scale, values] of rescale) {
99+
for (let i = 0; i < values.length; i++) {
100+
values[i] = scales[scale](values[i]);
101+
}
102+
}
103+
}
104+
63105
// When faceting, render axes for fx and fy instead of x and y.
64-
const x = facet !== undefined && scales.fx ? "fx" : "x";
65-
const y = facet !== undefined && scales.fy ? "fy" : "y";
106+
const x = facet !== undefined && scaleDescriptors.fx ? "fx" : "x";
107+
const y = facet !== undefined && scaleDescriptors.fy ? "fy" : "y";
66108
if (axes[x]) marks.unshift(axes[x]);
67109
if (axes[y]) marks.unshift(axes[y]);
68110

@@ -94,10 +136,7 @@ export function plot(options = {}) {
94136
.node();
95137

96138
for (const mark of marks) {
97-
const channels = markChannels.get(mark) ?? [];
98-
let values = applyScales(channels, scales);
99-
const index = filter(markIndex.get(mark), channels, values);
100-
if (mark.layout != null) values = mark.layout(index, scales, values, dimensions);
139+
const {index, values} = markValues.get(mark) || {};
101140
const node = mark.render(index, scales, values, dimensions, axes);
102141
if (node != null) svg.appendChild(node);
103142
}
@@ -220,6 +259,7 @@ class Facet extends Mark {
220259
// The following fields are set by initialize:
221260
this.marksChannels = undefined; // array of mark channels
222261
this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes
262+
this.marksLayouts = undefined;
223263
}
224264
initialize() {
225265
const {index, channels} = super.initialize();
@@ -229,6 +269,7 @@ class Facet extends Mark {
229269
const subchannels = [];
230270
const marksChannels = this.marksChannels = [];
231271
const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels);
272+
const marksLayouts = this.marksLayouts = [];
232273
for (const facetKey of facetsKeys) {
233274
marksIndexByFacet.set(facetKey, new Array(this.marks.length));
234275
}
@@ -260,18 +301,73 @@ class Facet extends Mark {
260301
subchannels.push([, channel]);
261302
}
262303
marksChannels.push(markChannels);
304+
if (mark.layout) marksLayouts.push(mark);
263305
}
306+
this.layout = function(index, scales, channels, dimensions) {
307+
const {fx, fy} = scales;
308+
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
309+
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
310+
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
311+
312+
this.marksValues = marksChannels.map(channels => applyScales(channels, scales));
313+
const rescaleChannels = [];
314+
for (let i = 0; i < this.marks.length; ++i) {
315+
const mark = this.marks[i];
316+
if (!mark.layout) continue;
317+
let values = facetsKeys.map(facet => [
318+
facet,
319+
mark.layout(
320+
filter(marksIndexByFacet.get(facet)[i], marksChannels[i], this.marksValues[i]),
321+
scales,
322+
this.marksValues[i],
323+
subdimensions
324+
)
325+
]);
326+
if (values.some(([, d]) => d.reindex)) {
327+
const index = [];
328+
const newValues = new Map();
329+
for (const [facet, value] of values) {
330+
const j = index.length;
331+
const newIndex = new Set();
332+
for (let key in value) {
333+
if (key === "reindex") continue; // TODO: better internal API
334+
if (!newValues.has(key)) newValues.set(key, []);
335+
const V = newValues.get(key);
336+
const {scale, options} = value[key];
337+
if (scale) Object.assign(V, {scale, options});
338+
const U = scale !== undefined ? value[key].values : value[key];
339+
for (let i = 0; i < U.length; i++) {
340+
const k = i + j;
341+
newIndex.add(k);
342+
V[k] = U[i];
343+
}
344+
}
345+
for (const i of newIndex) index.push(i);
346+
marksIndexByFacet.get(facet)[i] = [...newIndex];
347+
}
348+
values = Object.fromEntries(newValues);
349+
} else {
350+
values = values[0][1];
351+
}
352+
this.marksValues[i] = values;
353+
for (let k in values) {
354+
if (values[k].scale !== undefined) {
355+
rescaleChannels.push([rescaleChannels.length, {values: values[k], scale: values[k].scale, options: values[k].options}]);
356+
}
357+
}
358+
}
359+
return Object.fromEntries(rescaleChannels);
360+
};
264361
return {index, channels: [...channels, ...subchannels]};
265362
}
266363
render(I, scales, channels, dimensions, axes) {
267-
const {marks, marksChannels, marksIndexByFacet} = this;
364+
const {marks, marksChannels, marksValues, marksIndexByFacet} = this;
268365
const {fx, fy} = scales;
269366
const fyDomain = fy && fy.domain();
270367
const fxDomain = fx && fx.domain();
271368
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
272369
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
273370
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
274-
const marksValues = marksChannels.map(channels => applyScales(channels, scales));
275371
return create("svg:g")
276372
.call(g => {
277373
if (fy && axes.y) {
@@ -316,7 +412,6 @@ class Facet extends Mark {
316412
const mark = marks[i];
317413
let values = marksValues[i];
318414
const index = filter(marksFacetIndex[i], marksChannels[i], values);
319-
if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions);
320415
const node = mark.render(index, scales, values, subdimensions);
321416
if (node != null) this.appendChild(node);
322417
}

src/transforms/hexbin.js

Lines changed: 38 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {groups, max} from "d3";
1+
import {groups} from "d3";
22
import {basic} from "./basic.js";
33
import {layout} from "../layouts/index.js";
44
import {Dot} from "../marks/dot.js";
5-
import {maybeTuple, range, take, valueof} from "../options.js";
5+
import {maybeTuple, take, valueof} from "../options.js";
66

77
const defaults = {
88
ariaLabel: "hex",
@@ -13,18 +13,19 @@ const defaults = {
1313
};
1414

1515
// width factor (allows the hexbin transform to work with circular dots!)
16-
const w0 = 1 / Math.sin(Math.PI / 3);
16+
const w0 = Math.sin(Math.PI / 3);
1717

1818
function hbin(I, X, Y, r) {
19-
const dx = r * 2 / w0;
19+
const dx = r * 2 * w0;
2020
const dy = r * 1.5;
21+
const keys = new Map();
2122
return groups(I, i => {
22-
let px = X[i] / dy;
23-
let py = Y[i] / dx;
23+
let px = X[i] / dx;
24+
let py = Y[i] / dy;
2425
if (isNaN(px) || isNaN(py)) return;
2526
let pj = Math.round(py),
26-
pi = Math.round(px - (pj & 1) / 2),
27-
py1 = py - pj;
27+
pi = Math.round(px = px - (pj & 1) / 2),
28+
py1 = py - pj;
2829
if (Math.abs(py1) * 3 > 1) {
2930
let px1 = px - pi,
3031
pi2 = pi + (px < pi ? -1 : 1) / 2,
@@ -33,63 +34,46 @@ function hbin(I, X, Y, r) {
3334
py2 = py - pj2;
3435
if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) pi = pi2 + (pj & 1 ? 1 : -1) / 2, pj = pj2;
3536
}
36-
return `${pi}|${pj}`;
37+
const key = `${pi}|${pj}`;
38+
keys.set(key, [pi, pj]);
39+
return key;
3740
})
3841
.filter(([p]) => p)
3942
.map(([p, bin]) => {
40-
const [pi, pj] = p.split("|");
41-
bin.x = (+pi + (pj & 1) / 2) * dx;
42-
bin.y = +pj * dy;
43+
const [pi, pj] = keys.get(p);
44+
bin.x = (pi + (pj & 1) / 2) * dx;
45+
bin.y = pj * dy;
4346
return bin;
4447
});
4548
}
4649

47-
function hexbinLayout({_store, radius, r: _r, opacity: _o, fill: _f, text: _t, title: _l, value: _v}, options) {
50+
function hexbinLayout({radius, r, opacity, fill, text, title, value}, options) {
4851
radius = +radius;
49-
_r = !!_r;
50-
_o = !!_o;
51-
_f = !!_f;
52-
return layout({_store, ...options}, function(I, {r, color, opacity}, {x: X, y: Y}) {
53-
if (!_store.channels) {
54-
const bins = _store.facets.map(I => hbin(I, X, Y, radius));
55-
const values = bins.map(b => valueof(b, _v));
56-
const maxValue = max(values.flat());
57-
color = _f && color.copy().domain([1, maxValue]);
58-
opacity = _o && opacity.copy().domain([1, maxValue]);
59-
r = _r && r && r.copy().domain([0, maxValue]).range([0, radius / w0]);
60-
const {data} = this;
61-
_store.channels = Array.from(bins, (bin, i) => ({
62-
x: valueof(bin, "x"),
63-
y: valueof(bin, "y"),
64-
..._t != null && {text: valueof(bin.map(I => take(data, I)), _t)},
65-
..._l != null && {title: valueof(bin.map(I => take(data, I)), _l)},
66-
..._r && r && {r: values[i].map(r)},
67-
..._o && opacity && {fillOpacity: values[i].map(opacity)},
68-
..._f && color && {fill: values[i].map(color)}
69-
}));
70-
}
71-
for (let j = 0; j < _store.facets.length; ++j) {
72-
if (_store.facets[j].some(i => I.includes(i))) {
73-
const channels = _store.channels[j];
74-
// mutates I!
75-
I.splice(0, I.length, ...range(channels.x));
76-
return channels;
77-
}
78-
}
79-
throw new Error("what are we doing here?");
52+
r = !!r;
53+
opacity = !!opacity;
54+
fill = !!fill;
55+
return layout(options, function(index, scales, {x: X, y: Y}) {
56+
const bins = hbin(index, X, Y, radius);
57+
const values = (r || opacity || fill) && valueof(bins, value);
58+
const {data} = this;
59+
return {
60+
reindex: true, // we're sending transformed data!
61+
x: valueof(bins, "x"),
62+
y: valueof(bins, "y"),
63+
...text != null && {text: valueof(bins, I => text(take(data, I)))},
64+
...title != null && {title: valueof(bins, I => title(take(data, I)))},
65+
...r && {r: {values, scale: "r", options: {label: "frequency", range: [0, radius * w0]}}},
66+
...opacity && {fillOpacity: {values, scale: "opacity", options: {label: "frequency"}}},
67+
...fill && {fill: {values, scale: "color", options: {scheme: "blues", label: "frequency"}}}
68+
};
8069
});
8170
}
8271

83-
// The transform does nothing but divert some information that is necessary
84-
// to do the layout on multiple facets
85-
// It is a bit of a hack: we want to do all facets at once in order to compute
86-
// the maximum value. We also convert the facets to plain arrays, since we’re
72+
// The transform does nothing but convert the facets to plain arrays, since we’re
8773
// going to splice them
8874
export function hexbinTransform(hexbinOptions, options) {
89-
const _store = {};
90-
return basic(hexbinLayout({_store, ...hexbinOptions}, options), (data, facets) => {
75+
return basic(hexbinLayout(hexbinOptions, options), (data, facets) => {
9176
facets = Array.from(facets, facet => Array.from(facet));
92-
_store.facets = facets;
9377
return {data, facets};
9478
});
9579
}
@@ -103,17 +87,17 @@ export function hexbin(data, options) {
10387

10488
export function hexbinR({x, y, value = "length", radius = 10, title, ...options} = {}) {
10589
if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y));
106-
return hexbinTransform({value, radius, title, r: true}, {...defaults, x, y, r: () => 1, ...options});
90+
return hexbinTransform({value, radius, title, r: true}, {...defaults, x, y, ...options});
10791
}
10892

10993
export function hexbinFill({x, y, value = "length", radius = 10, title, ...options} = {}) {
11094
if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y));
111-
return hexbinTransform({value, radius, title, fill: true}, {...defaults, x, y, r: radius, fill: () => 1, ...options});
95+
return hexbinTransform({value, radius, title, fill: true}, {...defaults, x, y, r: radius, ...options});
11296
}
11397

11498
export function hexbinOpacity({x, y, value = "length", radius = 10, title, ...options} = {}) {
11599
if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y));
116-
return hexbinTransform({value, radius, title, opacity: true}, {...defaults, x, y, r: radius, opacity: () => 1,...options});
100+
return hexbinTransform({value, radius, title, opacity: true}, {...defaults, x, y, r: radius, ...options});
117101
}
118102

119103
export function hexbinText({x, y, value = "length", radius = 10, text = d => d.length, ...options} = {}) {

0 commit comments

Comments
 (0)