Skip to content

Commit 9d7131f

Browse files
Filclaude
andcommitted
Merge branch 'fil/brush-dataless' into fil/brush-data-options
Combines multi-plot brush reuse (per-plot state via context.interaction, cross-plot sync in data space, WeakMap-based renderFilter updates) with data/styling support (brush(data, options), filterData, applyAttr). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 7b6c060 + f8b593b commit 9d7131f

File tree

5 files changed

+1785
-62
lines changed

5 files changed

+1785
-62
lines changed

src/interactions/brush.js

Lines changed: 132 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ export class Brush extends Mark {
6060
defaults
6161
);
6262
this._dimension = keyword(dimension, "dimension", ["x", "y", "xy"]);
63-
this._brush = this._dimension === "x" ? d3BrushX() : this._dimension === "y" ? d3BrushY() : d3Brush();
6463
this._interval = interval == null ? null : maybeInterval(interval);
64+
this._sync = sync;
65+
this._states = []; // per-plot state: {brush, nodes, applyX, applyY, svg}
66+
this._syncing = false;
6567
const channelDefaults = {x, y, z, fx: this.fx, fy: this.fy};
6668
this.inactive = renderFilter(true, channelDefaults);
6769
this.context = renderFilter(false, channelDefaults);
6870
this.focus = renderFilter(false, channelDefaults);
69-
this._brushNodes = [];
70-
this._sync = sync;
7171
}
7272
render(index, scales, values, dimensions, context) {
7373
if (typeof document === "undefined") return null;
@@ -76,19 +76,27 @@ export class Brush extends Mark {
7676
const Y = values.channels?.y?.value;
7777
const FX = values.channels?.fx?.value;
7878
const FY = values.channels?.fy?.value;
79-
const {data, _brush, _brushNodes, inactive, context: ctx, focus} = this;
80-
let target, currentNode, syncing;
79+
const {inactive, context: ctx, focus, _states} = this;
8180

82-
if (!index?.fi) {
81+
// Per-plot state; context.interaction is fresh for each plot.
82+
let state = context.interaction.brush;
83+
if (state) {
84+
if (state.mark !== this) throw new Error("only one brush per plot");
85+
} else {
8386
const dim = this._dimension;
8487
const interval = this._interval;
8588
if (context.projection && dim !== "xy") throw new Error(`brush${dim.toUpperCase()} does not support projections`);
8689
const invertX = precisionInvert(x, context.projection);
8790
const invertY = precisionInvert(y, context.projection);
88-
const applyX = (this._applyX = (!context.projection && x) || ((d) => d));
89-
const applyY = (this._applyY = (!context.projection && y) || ((d) => d));
91+
const applyX = (!context.projection && x) || ((d) => d);
92+
const applyY = (!context.projection && y) || ((d) => d);
93+
const brush = dim === "x" ? d3BrushX() : dim === "y" ? d3BrushY() : d3Brush();
94+
const nodes = [];
95+
context.interaction.brush = state = {mark: this, brush, nodes, applyX, applyY, svg: context.ownerSVGElement};
96+
_states.push(state);
9097
context.dispatchValue(null);
9198
const sync = this._sync;
99+
const {data} = this;
92100
const filterData =
93101
data != null &&
94102
((region) =>
@@ -102,8 +110,10 @@ export class Brush extends Mark {
102110
)
103111
)
104112
));
105-
let snapping;
106-
_brush
113+
const self = this;
114+
let target, currentNode, snapping;
115+
116+
brush
107117
.extent([
108118
[dimensions.marginLeft - (dim !== "y"), dimensions.marginTop - (dim !== "x")],
109119
[
@@ -112,41 +122,55 @@ export class Brush extends Mark {
112122
]
113123
])
114124
.on("start brush end", function (event) {
115-
if (syncing) return;
125+
if (self._syncing) return;
116126
const {selection, type} = event;
117127
if (type === "start" && !snapping) {
118128
target = event.sourceEvent?.currentTarget ?? this;
119-
currentNode = _brushNodes.indexOf(target);
129+
currentNode = nodes.indexOf(target);
130+
// Clear other facets within this plot
120131
if (!sync) {
121-
syncing = true;
122-
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null);
123-
syncing = false;
132+
self._syncing = true;
133+
selectAll(nodes.filter((_, i) => i !== currentNode)).call(brush.move, null);
134+
self._syncing = false;
124135
}
125-
for (let i = 0; i < _brushNodes.length; ++i) {
126-
inactive.update(false, i);
127-
ctx.update(true, i);
128-
focus.update(false, i);
136+
for (const p of _states) {
137+
inactive.update(false, p);
138+
ctx.update(true, p);
139+
focus.update(false, p);
129140
}
130141
}
131142

132143
if (selection === null) {
133144
if (type === "end") {
134145
if (sync) {
135-
syncing = true;
136-
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null);
137-
syncing = false;
146+
self._syncing = true;
147+
selectAll(nodes.filter((_, i) => i !== currentNode)).call(brush.move, null);
148+
self._syncing = false;
149+
}
150+
// Clear all other plots
151+
self._syncing = true;
152+
for (const p of _states) {
153+
if (p === state) continue;
154+
selectAll(p.nodes).call(p.brush.move, null);
138155
}
139-
for (let i = 0; i < _brushNodes.length; ++i) {
140-
inactive.update(true, i);
141-
ctx.update(false, i);
142-
focus.update(false, i);
156+
self._syncing = false;
157+
for (const p of _states) {
158+
inactive.update(true, p);
159+
ctx.update(false, p);
160+
focus.update(false, p);
143161
}
144162
context.dispatchValue(null);
145163
} else {
146-
for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) {
147-
inactive.update(false, i);
148-
ctx.update(true, i);
149-
focus.update(false, i);
164+
if (sync) {
165+
for (const p of _states) {
166+
inactive.update(false, p);
167+
ctx.update(true, p);
168+
focus.update(false, p);
169+
}
170+
} else {
171+
inactive.update(false, state, currentNode);
172+
ctx.update(true, state, currentNode);
173+
focus.update(false, state, currentNode);
150174
}
151175
let value = null;
152176
if (event.sourceEvent) {
@@ -170,14 +194,20 @@ export class Brush extends Mark {
170194
const inX = dim !== "y" && ((xi) => px1 <= xi && xi < px2);
171195
const inY = dim !== "x" && ((yi) => py1 <= yi && yi < py2);
172196
if (sync) {
173-
syncing = true;
174-
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, selection);
175-
syncing = false;
197+
self._syncing = true;
198+
selectAll(nodes.filter((_, i) => i !== currentNode)).call(brush.move, selection);
199+
self._syncing = false;
176200
}
177-
for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) {
178-
inactive.update(false, i);
179-
ctx.update(!inX ? (_, yi) => !inY(yi) : !inY ? (xi) => !inX(xi) : (xi, yi) => !(inX(xi) && inY(yi)), i);
180-
focus.update(!inX ? (_, yi) => inY(yi) : !inY ? inX : (xi, yi) => inX(xi) && inY(yi), i);
201+
const ctxTest = !inX ? (_, yi) => !inY(yi) : !inY ? (xi) => !inX(xi) : (xi, yi) => !(inX(xi) && inY(yi));
202+
const focusTest = !inX ? (_, yi) => inY(yi) : !inY ? inX : (xi, yi) => inX(xi) && inY(yi);
203+
if (sync) {
204+
inactive.update(false, state);
205+
ctx.update(ctxTest, state);
206+
focus.update(focusTest, state);
207+
} else {
208+
inactive.update(false, state, currentNode);
209+
ctx.update(ctxTest, state, currentNode);
210+
focus.update(focusTest, state, currentNode);
181211
}
182212

183213
const [x1, x2] = invertX && [invertX(px1), invertX(px2)].sort(ascending);
@@ -190,7 +220,7 @@ export class Brush extends Mark {
190220
let r2 = intervalRound(interval, s2);
191221
if (+r1 === +r2) r2 = interval.offset(r1);
192222
snapping = true;
193-
select(this).call(_brush.move, [r1, r2].map(dim === "x" ? applyX : applyY).sort(ascending));
223+
select(this).call(brush.move, [r1, r2].map(dim === "x" ? applyX : applyY).sort(ascending));
194224
snapping = false;
195225
return;
196226
}
@@ -205,45 +235,78 @@ export class Brush extends Mark {
205235
});
206236
if (filterData) region.data = filterData(region);
207237
context.dispatchValue(region);
238+
239+
// Sync other plots in data space
240+
if (type !== "start") {
241+
self._syncing = true;
242+
for (const p of _states) {
243+
if (p === state) continue;
244+
const [pX1, pX2] = [p.applyX(x1), p.applyX(x2)].sort(ascending);
245+
const [pY1, pY2] = [p.applyY(y1), p.applyY(y2)].sort(ascending);
246+
const selection =
247+
dim === "xy"
248+
? [
249+
[pX1, pY1],
250+
[pX2, pY2]
251+
]
252+
: dim === "x"
253+
? [pX1, pX2]
254+
: [pY1, pY2];
255+
selectAll(p.nodes).call(p.brush.move, selection);
256+
const inXp = dim !== "y" && ((xi) => p.applyX(x1) <= xi && xi < p.applyX(x2));
257+
const inYp = dim !== "x" && ((yi) => p.applyY(y1) <= yi && yi < p.applyY(y2));
258+
inactive.update(false, p);
259+
ctx.update(
260+
!inXp ? (_, yi) => !inYp(yi) : !inYp ? (xi) => !inXp(xi) : (xi, yi) => !(inXp(xi) && inYp(yi)),
261+
p
262+
);
263+
focus.update(!inXp ? (_, yi) => inYp(yi) : !inYp ? inXp : (xi, yi) => inXp(xi) && inYp(yi), p);
264+
}
265+
self._syncing = false;
266+
}
208267
}
209268
});
210269
}
211270

212271
const g = create("svg:g").attr("aria-label", this._dimension === "xy" ? "brush" : `brush-${this._dimension}`);
213-
g.call(this._brush);
272+
g.call(state.brush);
214273
const sel = g.select(".selection");
215274
applyAttr(sel, "fill", this.fill);
216275
applyAttr(sel, "fill-opacity", this.fillOpacity);
217276
applyAttr(sel, "stroke", this.stroke);
218277
applyAttr(sel, "stroke-width", this.strokeWidth);
219278
applyAttr(sel, "stroke-opacity", this.strokeOpacity);
220279
const node = g.node();
221-
this._brushNodes.push(node);
280+
state.nodes.push(node);
222281
return node;
223282
}
224283
move(value) {
225284
if (value == null) {
226-
selectAll(this._brushNodes).call(this._brush.move, null);
285+
for (const {brush, nodes} of this._states) {
286+
selectAll(nodes).call(brush.move, null);
287+
}
227288
return;
228289
}
290+
const dim = this._dimension;
229291
const {x1, x2, y1, y2, fx, fy} = value;
230-
const node = this._brushNodes.find(
231-
(n) => (fx === undefined || n.__data__?.x === fx) && (fy === undefined || n.__data__?.y === fy)
232-
);
233-
if (!node) return;
234-
const [px1, px2] = [x1, x2].map(this._applyX).sort(ascending);
235-
const [py1, py2] = [y1, y2].map(this._applyY).sort(ascending);
236-
select(node).call(
237-
this._brush.move,
238-
this._dimension === "xy"
239-
? [
240-
[px1, py1],
241-
[px2, py2]
242-
]
243-
: this._dimension === "x"
244-
? [px1, px2]
245-
: [py1, py2]
246-
);
292+
for (const {brush, nodes, applyX, applyY} of this._states) {
293+
const node = nodes.find(
294+
(n) => (fx === undefined || n.__data__?.x === fx) && (fy === undefined || n.__data__?.y === fy)
295+
);
296+
if (!node) continue;
297+
const [px1, px2] = dim !== "y" ? [x1, x2].map(applyX).sort(ascending) : [];
298+
const [py1, py2] = dim !== "x" ? [y1, y2].map(applyY).sort(ascending) : [];
299+
const selection =
300+
dim === "xy"
301+
? [
302+
[px1, py1],
303+
[px2, py2]
304+
]
305+
: dim === "x"
306+
? [px1, px2]
307+
: [py1, py2];
308+
select(node).call(brush.move, selection);
309+
}
247310
}
248311
}
249312

@@ -276,7 +339,7 @@ function intervalRound(interval, v) {
276339
}
277340

278341
function renderFilter(initialTest, channelDefaults = {}) {
279-
const updatePerFacet = [];
342+
const updates = new WeakMap();
280343
return Object.assign(
281344
function ({render, ...options} = {}) {
282345
return {
@@ -301,7 +364,9 @@ function renderFilter(initialTest, channelDefaults = {}) {
301364
return next(I, scales, {...values, z: G}, dimensions, context);
302365
};
303366
let g = render(initialTest);
304-
updatePerFacet.push((test) => {
367+
const svg = context.ownerSVGElement;
368+
if (!updates.has(svg)) updates.set(svg, []);
369+
updates.get(svg).push((test) => {
305370
const transform = g.getAttribute("transform");
306371
g.replaceWith((g = render(test)));
307372
if (transform) g.setAttribute("transform", transform);
@@ -311,8 +376,13 @@ function renderFilter(initialTest, channelDefaults = {}) {
311376
};
312377
},
313378
{
314-
update(test, i) {
315-
return updatePerFacet[i]?.(test);
379+
update(test, state, facet) {
380+
if (facet === undefined) {
381+
const fns = updates.get(state.svg);
382+
if (fns) for (const fn of fns) fn(test);
383+
} else {
384+
updates.get(state.svg)?.[facet]?.(test);
385+
}
316386
}
317387
}
318388
);

src/plot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export function plot(options = {}) {
157157
let figure = svg; // replaced with the figure element, if any
158158
context.ownerSVGElement = svg;
159159
context.className = className;
160+
context.interaction = {};
160161
context.projection = createProjection(options, subdimensions);
161162

162163
// A path generator for marks that want to draw GeoJSON.

0 commit comments

Comments
 (0)