Skip to content

Commit 77fb872

Browse files
committed
Merge branch 'fil/brush-dataless' into fil/brush-merge
Combines multi-plot brush reuse with data/styling support and brush-across-facets (context.interaction.brushing flag).
2 parents 1f84fd2 + f8b593b commit 77fb872

File tree

4 files changed

+1783
-62
lines changed

4 files changed

+1783
-62
lines changed

src/interactions/brush.js

Lines changed: 132 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ export class Brush extends Mark {
6363
defaults
6464
);
6565
this._dimension = keyword(dimension, "dimension", ["x", "y", "xy"]);
66-
this._brush = this._dimension === "x" ? d3BrushX() : this._dimension === "y" ? d3BrushY() : d3Brush();
6766
this._interval = interval == null ? null : maybeInterval(interval);
67+
this._sync = sync;
68+
this._states = []; // per-plot state: {brush, nodes, applyX, applyY, svg}
69+
this._syncing = false;
6870
const channelDefaults = {x, y, z, fx: this.fx, fy: this.fy};
6971
this.inactive = renderFilter(true, channelDefaults);
7072
this.context = renderFilter(false, channelDefaults);
7173
this.focus = renderFilter(false, channelDefaults);
72-
this._brushNodes = [];
73-
this._sync = sync;
7474
}
7575
render(index, scales, values, dimensions, context) {
7676
if (typeof document === "undefined") return null;
@@ -79,19 +79,27 @@ export class Brush extends Mark {
7979
const Y = values.channels?.y?.value;
8080
const FX = values.channels?.fx?.value;
8181
const FY = values.channels?.fy?.value;
82-
const {data, _brush, _brushNodes, inactive, context: ctx, focus} = this;
83-
let target, currentNode, syncing;
82+
const {inactive, context: ctx, focus, _states} = this;
8483

85-
if (!index?.fi) {
84+
// Per-plot state; context.interaction is fresh for each plot.
85+
let state = context.interaction.brush;
86+
if (state) {
87+
if (state.mark !== this) throw new Error("only one brush per plot");
88+
} else {
8689
const dim = this._dimension;
8790
const interval = this._interval;
8891
if (context.projection && dim !== "xy") throw new Error(`brush${dim.toUpperCase()} does not support projections`);
8992
const invertX = precisionInvert(x, context.projection);
9093
const invertY = precisionInvert(y, context.projection);
91-
const applyX = (this._applyX = (!context.projection && x) || ((d) => d));
92-
const applyY = (this._applyY = (!context.projection && y) || ((d) => d));
94+
const applyX = (!context.projection && x) || ((d) => d);
95+
const applyY = (!context.projection && y) || ((d) => d);
96+
const brush = dim === "x" ? d3BrushX() : dim === "y" ? d3BrushY() : d3Brush();
97+
const nodes = [];
98+
context.interaction.brush = state = {mark: this, brush, nodes, applyX, applyY, svg: context.ownerSVGElement};
99+
_states.push(state);
93100
context.dispatchValue(null);
94101
const sync = this._sync;
102+
const {data} = this;
95103
const filterData =
96104
data != null &&
97105
((region) =>
@@ -105,8 +113,10 @@ export class Brush extends Mark {
105113
)
106114
)
107115
));
108-
let snapping;
109-
_brush
116+
const self = this;
117+
let target, currentNode, snapping;
118+
119+
brush
110120
.extent([
111121
[dimensions.marginLeft - (dim !== "y"), dimensions.marginTop - (dim !== "x")],
112122
[
@@ -115,42 +125,56 @@ export class Brush extends Mark {
115125
]
116126
])
117127
.on("start brush end", function (event) {
118-
if (syncing) return;
128+
if (self._syncing) return;
119129
const {selection, type} = event;
120130
if (type === "start" && !snapping) {
121131
target = event.sourceEvent?.currentTarget ?? this;
122-
currentNode = _brushNodes.indexOf(target);
132+
currentNode = nodes.indexOf(target);
133+
// Clear other facets within this plot
123134
if (!sync) {
124-
syncing = true;
125-
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null);
126-
syncing = false;
135+
self._syncing = true;
136+
selectAll(nodes.filter((_, i) => i !== currentNode)).call(brush.move, null);
137+
self._syncing = false;
127138
}
128-
for (let i = 0; i < _brushNodes.length; ++i) {
129-
inactive.update(false, i);
130-
ctx.update(true, i);
131-
focus.update(false, i);
139+
for (const p of _states) {
140+
inactive.update(false, p);
141+
ctx.update(true, p);
142+
focus.update(false, p);
132143
}
133144
}
134145

135146
if (selection === null) {
136147
if (type === "end") {
137148
context.interaction.brushing = false;
138149
if (sync) {
139-
syncing = true;
140-
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null);
141-
syncing = false;
150+
self._syncing = true;
151+
selectAll(nodes.filter((_, i) => i !== currentNode)).call(brush.move, null);
152+
self._syncing = false;
153+
}
154+
// Clear all other plots
155+
self._syncing = true;
156+
for (const p of _states) {
157+
if (p === state) continue;
158+
selectAll(p.nodes).call(p.brush.move, null);
142159
}
143-
for (let i = 0; i < _brushNodes.length; ++i) {
144-
inactive.update(true, i);
145-
ctx.update(false, i);
146-
focus.update(false, i);
160+
self._syncing = false;
161+
for (const p of _states) {
162+
inactive.update(true, p);
163+
ctx.update(false, p);
164+
focus.update(false, p);
147165
}
148166
context.dispatchValue(null);
149167
} else {
150-
for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) {
151-
inactive.update(false, i);
152-
ctx.update(true, i);
153-
focus.update(false, i);
168+
if (sync) {
169+
for (const p of _states) {
170+
inactive.update(false, p);
171+
ctx.update(true, p);
172+
focus.update(false, p);
173+
}
174+
} else {
175+
inactive.update(false, state, currentNode);
176+
ctx.update(true, state, currentNode);
177+
focus.update(false, state, currentNode);
154178
}
155179
let value = null;
156180
if (event.sourceEvent) {
@@ -175,14 +199,20 @@ export class Brush extends Mark {
175199
const inX = dim !== "y" && ((xi) => px1 <= xi && xi < px2);
176200
const inY = dim !== "x" && ((yi) => py1 <= yi && yi < py2);
177201
if (sync) {
178-
syncing = true;
179-
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, selection);
180-
syncing = false;
202+
self._syncing = true;
203+
selectAll(nodes.filter((_, i) => i !== currentNode)).call(brush.move, selection);
204+
self._syncing = false;
181205
}
182-
for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) {
183-
inactive.update(false, i);
184-
ctx.update(!inX ? (_, yi) => !inY(yi) : !inY ? (xi) => !inX(xi) : (xi, yi) => !(inX(xi) && inY(yi)), i);
185-
focus.update(!inX ? (_, yi) => inY(yi) : !inY ? inX : (xi, yi) => inX(xi) && inY(yi), i);
206+
const ctxTest = !inX ? (_, yi) => !inY(yi) : !inY ? (xi) => !inX(xi) : (xi, yi) => !(inX(xi) && inY(yi));
207+
const focusTest = !inX ? (_, yi) => inY(yi) : !inY ? inX : (xi, yi) => inX(xi) && inY(yi);
208+
if (sync) {
209+
inactive.update(false, state);
210+
ctx.update(ctxTest, state);
211+
focus.update(focusTest, state);
212+
} else {
213+
inactive.update(false, state, currentNode);
214+
ctx.update(ctxTest, state, currentNode);
215+
focus.update(focusTest, state, currentNode);
186216
}
187217

188218
const [x1, x2] = invertX && [invertX(px1), invertX(px2)].sort(ascending);
@@ -195,7 +225,7 @@ export class Brush extends Mark {
195225
let r2 = intervalRound(interval, s2);
196226
if (+r1 === +r2) r2 = interval.offset(r1);
197227
snapping = true;
198-
select(this).call(_brush.move, [r1, r2].map(dim === "x" ? applyX : applyY).sort(ascending));
228+
select(this).call(brush.move, [r1, r2].map(dim === "x" ? applyX : applyY).sort(ascending));
199229
snapping = false;
200230
return;
201231
}
@@ -210,45 +240,78 @@ export class Brush extends Mark {
210240
});
211241
if (filterData) region.data = filterData(region);
212242
context.dispatchValue(region);
243+
244+
// Sync other plots in data space
245+
if (type !== "start") {
246+
self._syncing = true;
247+
for (const p of _states) {
248+
if (p === state) continue;
249+
const [pX1, pX2] = [p.applyX(x1), p.applyX(x2)].sort(ascending);
250+
const [pY1, pY2] = [p.applyY(y1), p.applyY(y2)].sort(ascending);
251+
const selection =
252+
dim === "xy"
253+
? [
254+
[pX1, pY1],
255+
[pX2, pY2]
256+
]
257+
: dim === "x"
258+
? [pX1, pX2]
259+
: [pY1, pY2];
260+
selectAll(p.nodes).call(p.brush.move, selection);
261+
const inXp = dim !== "y" && ((xi) => p.applyX(x1) <= xi && xi < p.applyX(x2));
262+
const inYp = dim !== "x" && ((yi) => p.applyY(y1) <= yi && yi < p.applyY(y2));
263+
inactive.update(false, p);
264+
ctx.update(
265+
!inXp ? (_, yi) => !inYp(yi) : !inYp ? (xi) => !inXp(xi) : (xi, yi) => !(inXp(xi) && inYp(yi)),
266+
p
267+
);
268+
focus.update(!inXp ? (_, yi) => inYp(yi) : !inYp ? inXp : (xi, yi) => inXp(xi) && inYp(yi), p);
269+
}
270+
self._syncing = false;
271+
}
213272
}
214273
});
215274
}
216275

217276
const g = create("svg:g").attr("aria-label", this._dimension === "xy" ? "brush" : `brush-${this._dimension}`);
218-
g.call(this._brush);
277+
g.call(state.brush);
219278
const sel = g.select(".selection");
220279
applyAttr(sel, "fill", this.fill);
221280
applyAttr(sel, "fill-opacity", this.fillOpacity);
222281
applyAttr(sel, "stroke", this.stroke);
223282
applyAttr(sel, "stroke-width", this.strokeWidth);
224283
applyAttr(sel, "stroke-opacity", this.strokeOpacity);
225284
const node = g.node();
226-
this._brushNodes.push(node);
285+
state.nodes.push(node);
227286
return node;
228287
}
229288
move(value) {
230289
if (value == null) {
231-
selectAll(this._brushNodes).call(this._brush.move, null);
290+
for (const {brush, nodes} of this._states) {
291+
selectAll(nodes).call(brush.move, null);
292+
}
232293
return;
233294
}
295+
const dim = this._dimension;
234296
const {x1, x2, y1, y2, fx, fy} = value;
235-
const node = this._brushNodes.find(
236-
(n) => (fx === undefined || n.__data__?.x === fx) && (fy === undefined || n.__data__?.y === fy)
237-
);
238-
if (!node) return;
239-
const [px1, px2] = [x1, x2].map(this._applyX).sort(ascending);
240-
const [py1, py2] = [y1, y2].map(this._applyY).sort(ascending);
241-
select(node).call(
242-
this._brush.move,
243-
this._dimension === "xy"
244-
? [
245-
[px1, py1],
246-
[px2, py2]
247-
]
248-
: this._dimension === "x"
249-
? [px1, px2]
250-
: [py1, py2]
251-
);
297+
for (const {brush, nodes, applyX, applyY} of this._states) {
298+
const node = nodes.find(
299+
(n) => (fx === undefined || n.__data__?.x === fx) && (fy === undefined || n.__data__?.y === fy)
300+
);
301+
if (!node) continue;
302+
const [px1, px2] = dim !== "y" ? [x1, x2].map(applyX).sort(ascending) : [];
303+
const [py1, py2] = dim !== "x" ? [y1, y2].map(applyY).sort(ascending) : [];
304+
const selection =
305+
dim === "xy"
306+
? [
307+
[px1, py1],
308+
[px2, py2]
309+
]
310+
: dim === "x"
311+
? [px1, px2]
312+
: [py1, py2];
313+
select(node).call(brush.move, selection);
314+
}
252315
}
253316
}
254317

@@ -281,7 +344,7 @@ function intervalRound(interval, v) {
281344
}
282345

283346
function renderFilter(initialTest, channelDefaults = {}) {
284-
const updatePerFacet = [];
347+
const updates = new WeakMap();
285348
return Object.assign(
286349
function ({render, ...options} = {}) {
287350
return {
@@ -306,7 +369,9 @@ function renderFilter(initialTest, channelDefaults = {}) {
306369
return next(I, scales, {...values, z: G}, dimensions, context);
307370
};
308371
let g = render(initialTest);
309-
updatePerFacet.push((test) => {
372+
const svg = context.ownerSVGElement;
373+
if (!updates.has(svg)) updates.set(svg, []);
374+
updates.get(svg).push((test) => {
310375
const transform = g.getAttribute("transform");
311376
g.replaceWith((g = render(test)));
312377
if (transform) g.setAttribute("transform", transform);
@@ -316,8 +381,13 @@ function renderFilter(initialTest, channelDefaults = {}) {
316381
};
317382
},
318383
{
319-
update(test, i) {
320-
return updatePerFacet[i]?.(test);
384+
update(test, state, facet) {
385+
if (facet === undefined) {
386+
const fns = updates.get(state.svg);
387+
if (fns) for (const fn of fns) fn(test);
388+
} else {
389+
updates.get(state.svg)?.[facet]?.(test);
390+
}
321391
}
322392
}
323393
);

0 commit comments

Comments
 (0)