Skip to content

Commit 46c496e

Browse files
committed
faceted selections
1 parent e42236e commit 46c496e

File tree

3 files changed

+77
-17
lines changed

3 files changed

+77
-17
lines changed

src/marks/brush.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {brush as brusher, brushX as brusherX, brushY as brusherY, create} from "d3";
1+
import {brush as brusher, brushX as brusherX, brushY as brusherY, create, select} from "d3";
22
import {identity, maybeTuple} from "../options.js";
33
import {Mark} from "../plot.js";
44

@@ -24,20 +24,33 @@ export class Brush extends Mark {
2424
options,
2525
defaults
2626
);
27+
this.currentElement = null;
2728
}
2829
render(index, scales, {x: X, y: Y}, dimensions) {
2930
const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
3031
const left = marginLeft;
3132
const top = marginTop;
3233
const right = width - marginRight;
3334
const bottom = height - marginBottom;
34-
return create("svg:g")
35+
const mark = this;
36+
const g = create("svg:g")
3537
.call((X && Y ? brusher : X ? brusherX : brusherY)()
3638
.extent([[left, top], [right, bottom]])
3739
.on("start brush end", function(event) {
38-
const {selection} = event;
39-
let S = index;
40+
const {type, selection} = event;
41+
// For faceting, when starting a brush in a new facet, clear the
42+
// brush and selection on the old facet. In the future, we might
43+
// allow independent brushes across facets by disabling this?
44+
if (type === "start" && mark.currentElement !== this) {
45+
if (mark.currentElement !== null) {
46+
select(mark.currentElement).call(event.target.clear, event);
47+
mark.currentElement.selection = null;
48+
}
49+
mark.currentElement = this;
50+
}
51+
let S = null;
4052
if (selection) {
53+
S = index;
4154
if (X) {
4255
const [x0, x1] = Y ? [selection[0][0], selection[1][0]] : selection;
4356
S = S.filter(i => x0 <= X[i] && X[i] <= x1);
@@ -50,8 +63,9 @@ export class Brush extends Mark {
5063
this.selection = S;
5164
this.dispatchEvent(new Event("input", {bubbles: true}));
5265
}))
53-
.property("selection", index)
5466
.node();
67+
g.selection = null;
68+
return g;
5569
}
5670
}
5771

src/plot.js

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {create, cross, difference, groups, InternMap} from "d3";
1+
import {create, cross, difference, groups, InternMap, select, union} from "d3";
22
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
33
import {Channel, channelSort} from "./channel.js";
44
import {defined} from "./defined.js";
@@ -93,18 +93,19 @@ export function plot(options = {}) {
9393
.call(applyInlineStyles, style)
9494
.node();
9595

96-
let value;
96+
let initialValue;
9797
for (const mark of marks) {
9898
const channels = markChannels.get(mark) ?? [];
9999
const values = applyScales(channels, scales);
100100
const index = filter(markIndex.get(mark), channels, values);
101101
const node = mark.render(index, scales, values, dimensions, axes);
102102
if (node != null) {
103-
// TODO More explicit indication that a mark defines a value?
104-
// TODO Will the name “selection” lead to a false positive on random SVG elements?
103+
// TODO A more explicit indication that a mark defines a value (e.g., a symbol)?
105104
if (node.selection !== undefined) {
106-
value = take(mark.data, node.selection);
107-
node.addEventListener("input", () => figure.value = take(mark.data, node.selection));
105+
initialValue = markValue(mark, node.selection);
106+
node.addEventListener("input", () => {
107+
figure.value = markValue(mark, node.selection);
108+
});
108109
}
109110
svg.appendChild(node);
110111
}
@@ -126,7 +127,7 @@ export function plot(options = {}) {
126127

127128
figure.scale = exposeScales(scaleDescriptors);
128129
figure.legend = exposeLegends(scaleDescriptors, options);
129-
figure.value = value;
130+
figure.value = initialValue;
130131
return figure;
131132
}
132133

@@ -197,6 +198,10 @@ function markify(mark) {
197198
return mark instanceof Mark ? mark : new Render(mark);
198199
}
199200

201+
function markValue(mark, selection) {
202+
return selection === null ? mark.data : take(mark.data, selection);
203+
}
204+
200205
class Render extends Mark {
201206
constructor(render) {
202207
super();
@@ -271,16 +276,17 @@ class Facet extends Mark {
271276
}
272277
return {index, channels: [...channels, ...subchannels]};
273278
}
274-
render(I, scales, channels, dimensions, axes) {
275-
const {marks, marksChannels, marksIndexByFacet} = this;
279+
render(I, scales, _, dimensions, axes) {
280+
const {data, channels, marks, marksChannels, marksIndexByFacet} = this;
276281
const {fx, fy} = scales;
277282
const fyDomain = fy && fy.domain();
278283
const fxDomain = fx && fx.domain();
279284
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
280285
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
281286
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
282287
const marksValues = marksChannels.map(channels => applyScales(channels, scales));
283-
return create("svg:g")
288+
let selectionByFacet;
289+
const parent = create("svg:g")
284290
.call(g => {
285291
if (fy && axes.y) {
286292
const axis1 = axes.y, axis2 = nolabel(axis1);
@@ -324,10 +330,25 @@ class Facet extends Mark {
324330
const values = marksValues[i];
325331
const index = filter(marksFacetIndex[i], marksChannels[i], values);
326332
const node = marks[i].render(index, scales, values, subdimensions);
327-
if (node != null) this.appendChild(node);
333+
if (node != null) {
334+
if (node.selection !== undefined) {
335+
if (marks[i].data !== data) throw new Error("selection must use facet data");
336+
if (selectionByFacet === undefined) selectionByFacet = facetMap(channels);
337+
selectionByFacet.set(key, node.selection);
338+
node.addEventListener("input", () => {
339+
selectionByFacet.set(key, node.selection);
340+
parent.selection = facetSelection(selectionByFacet);
341+
});
342+
}
343+
this.appendChild(node);
344+
}
328345
}
329346
}))
330347
.node();
348+
if (selectionByFacet !== undefined) {
349+
parent.selection = facetSelection(selectionByFacet);
350+
}
351+
return parent;
331352
}
332353
}
333354

@@ -370,6 +391,17 @@ function facetTranslate(fx, fy) {
370391
: ky => `translate(0,${fy(ky)})`;
371392
}
372393

394+
// If multiple facets define a selection, then the overall selection is the
395+
// union of the defined selections.
396+
function facetSelection(selectionByFacet) {
397+
let selection = null;
398+
for (const value of selectionByFacet.values()) {
399+
if (value === null) continue;
400+
selection = selection === null ? value : union(selection, value);
401+
}
402+
return selection;
403+
}
404+
373405
function facetMap(channels) {
374406
return new (channels.length > 1 ? FacetMap2 : FacetMap);
375407
}
@@ -387,6 +419,9 @@ class FacetMap {
387419
set(key, value) {
388420
return this._.set(key, value), this;
389421
}
422+
values() {
423+
return this._.values();
424+
}
390425
}
391426

392427
// A Map-like interface that supports paired keys.
@@ -405,4 +440,9 @@ class FacetMap2 extends FacetMap {
405440
else super.set(key1, new InternMap([[key2, value]]));
406441
return this;
407442
}
443+
*values() {
444+
for (const map of this._.values()) {
445+
yield* map.values();
446+
}
447+
}
408448
}

test/plots/penguin-culmen.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as d3 from "d3";
33

44
export default async function() {
55
const data = await d3.csv("data/penguins.csv", d3.autoType);
6-
return Plot.plot({
6+
const plot = Plot.plot({
77
height: 600,
88
grid: true,
99
facet: {
@@ -24,7 +24,13 @@ export default async function() {
2424
Plot.dot(data, {
2525
x: "culmen_depth_mm",
2626
y: "culmen_length_mm"
27+
}),
28+
Plot.brush(data, {
29+
x: "culmen_depth_mm",
30+
y: "culmen_length_mm"
2731
})
2832
]
2933
});
34+
plot.oninput = () => console.log(plot.value);
35+
return plot;
3036
}

0 commit comments

Comments
 (0)