Skip to content

Commit 574b470

Browse files
committed
Selection
* internal selection boolean channel & dataflow * internal dispatch * clickable option on dots and rules * merge #71 * onchange handler * marks always have a nodes property and share a default select function which hides/shows the children * facets: when using faceted brushes, clicking on a brush cancels the others; and a programmatic selection cancels all the brushes
1 parent 4db8fe9 commit 574b470

14 files changed

+258
-39
lines changed

src/facet.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,28 @@ import {Mark, first, second, markify, where} from "./mark.js";
44
import {applyScales} from "./scales.js";
55
import {filterStyles} from "./style.js";
66

7-
export function facets(data, {x, y, ...options}, marks) {
7+
export function facets(data, {x, y, ...options}, marks, selectionData) {
88
return x === undefined && y === undefined
99
? marks // if no facets are specified, ignore!
10-
: [new Facet(data, {x, y, ...options}, marks)];
10+
: [new Facet(data, {x, y, ...options}, marks, selectionData)];
1111
}
1212

1313
class Facet extends Mark {
14-
constructor(data, {x, y, ...options} = {}, marks = []) {
14+
constructor(data, {x, y, ...options} = {}, marks = [], selectionData) {
1515
if (data == null) throw new Error("missing facet data");
1616
super(
1717
data,
1818
[
1919
{name: "fx", value: x, scale: "fx", optional: true},
2020
{name: "fy", value: y, scale: "fy", optional: true}
2121
],
22-
options
22+
{selection: !!selectionData, ...options}
2323
);
2424
this.marks = marks.flat(Infinity).map(markify);
2525
// The following fields are set by initialize:
2626
this.marksChannels = undefined; // array of mark channels
2727
this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes
28+
this.selectionData = selectionData;
2829
}
2930
initialize() {
3031
const {index, channels} = super.initialize();
@@ -34,18 +35,20 @@ class Facet extends Mark {
3435
const subchannels = [];
3536
const marksChannels = this.marksChannels = [];
3637
const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels);
38+
const {selectionData} = this;
3739
for (const facetKey of facetsKeys) {
3840
marksIndexByFacet.set(facetKey, new Array(this.marks.length));
3941
}
4042
let facetsExclude;
43+
let selectable;
4144
for (let i = 0; i < this.marks.length; ++i) {
4245
const mark = this.marks[i];
4346
const {facet} = mark;
4447
const markFacets = facet === "auto" ? mark.data === this.data ? facetsIndex : undefined
4548
: facet === "include" ? facetsIndex
4649
: facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(index, f))))
4750
: undefined;
48-
const {index: I, channels: markChannels} = mark.initialize(markFacets, channels);
51+
const {index: I, channels: markChannels} = mark.initialize(markFacets, channels, selectionData);
4952
// If an index is returned by mark.initialize, its structure depends on
5053
// whether or not faceting has been applied: it is a flat index ([0, 1, 2,
5154
// …]) when not faceted, and a nested index ([[0, 1, …], [2, 3, …], …])
@@ -64,9 +67,11 @@ class Facet extends Mark {
6467
for (const [, channel] of markChannels) {
6568
subchannels.push([, channel]);
6669
}
70+
if (mark.selectable) selectable = true;
71+
mark.onchange = (event) => this.onchange(event, I);
6772
marksChannels.push(markChannels);
6873
}
69-
return {index, channels: [...channels, ...subchannels]};
74+
return {index, channels: [...channels, ...subchannels], selectable};
7075
}
7176
render(I, scales, channels, dimensions, axes) {
7277
const {marks, marksChannels, marksIndexByFacet} = this;
@@ -126,11 +131,19 @@ class Facet extends Mark {
126131
values,
127132
subdimensions
128133
);
129-
if (node != null) this.appendChild(node);
134+
if (node != null) {
135+
marks[i].nodes.push(node);
136+
this.appendChild(node);
137+
}
130138
}
131139
}))
132140
.node();
133141
}
142+
select(S, options) {
143+
for (const mark of this.marks) {
144+
if (mark.selectable) mark.select(S, options);
145+
}
146+
}
134147
}
135148

136149
// Derives a copy of the specified axis with the label disabled.

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
1313
export {Text, text, textX, textY} from "./marks/text.js";
1414
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
1515
export {Vector, vector} from "./marks/vector.js";
16+
export {Brush, brush, brushX, brushY} from "./marks/brush.js";
1617
export {filter} from "./transforms/filter.js";
1718
export {reverse} from "./transforms/reverse.js";
1819
export {sort, shuffle} from "./transforms/sort.js";

src/mark.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {ascending, color, descending, rollup, sort} from "d3";
1+
import {ascending, color, descending, rollup, selectAll, sort} from "d3";
22
import {plot} from "./plot.js";
33
import {registry} from "./scales/index.js";
44
import {styles} from "./style.js";
@@ -11,11 +11,12 @@ const objectToString = Object.prototype.toString;
1111

1212
export class Mark {
1313
constructor(data, channels = [], options = {}, defaults) {
14-
const {facet = "auto", sort, dx, dy} = options;
14+
const {facet = "auto", selection, sort, dx, dy} = options;
1515
const names = new Set();
1616
this.data = data;
1717
this.sort = isOptions(sort) ? sort : null;
1818
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
19+
this.selection = selection == null || selection === false ? null : keyword(selection === true ? "include" : selection, "selection", ["auto", "include"]);
1920
const {transform} = basic(options);
2021
this.transform = transform;
2122
if (defaults !== undefined) channels = styles(this, options, channels, defaults);
@@ -35,8 +36,9 @@ export class Mark {
3536
});
3637
this.dx = +dx || 0;
3738
this.dy = +dy || 0;
39+
this.nodes = [];
3840
}
39-
initialize(facets, facetChannels) {
41+
initialize(facets, facetChannels, selection) {
4042
let data = arrayify(this.data);
4143
let index = facets === undefined && data != null ? range(data) : facets;
4244
if (data !== undefined && this.transform !== undefined) {
@@ -50,8 +52,22 @@ export class Mark {
5052
return [name == null ? undefined : `${name}`, Channel(data, channel)];
5153
});
5254
if (this.sort != null) channelSort(channels, facetChannels, data, this.sort);
55+
this.selectable = this.selection === "include" || (this.selection === "auto" && this.data === selection);
5356
return {index, channels};
5457
}
58+
select(S, {transition}) {
59+
let sel = selectAll(this.nodes).selectChildren();
60+
if (transition) {
61+
const {delay, duration} = typeof transition === "object" ? transition : {};
62+
sel = sel.transition();
63+
if (delay !== undefined) sel.delay(delay);
64+
if (duration !== undefined) sel.duration(duration);
65+
}
66+
return sel
67+
.style("opacity", 1e-6)
68+
.filter(i => S[i])
69+
.style("opacity", 1);
70+
}
5571
plot({marks = [], ...options} = {}) {
5672
return plot({...options, marks: [...marks, this]});
5773
}

src/marks/bar.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ export class AbstractBar extends Mark {
2121
this.ry = impliedString(ry, "auto");
2222
}
2323
render(I, scales, channels, dimensions) {
24-
const {dx, dy, rx, ry} = this;
24+
const {dx, dy, rx, ry, onchange} = this;
2525
const index = filter(I, ...this._positions(channels));
26+
let selected;
2627
return create("svg:g")
2728
.call(applyIndirectStyles, this)
2829
.call(this._transform, scales, dx, dy)
@@ -36,7 +37,14 @@ export class AbstractBar extends Mark {
3637
.attr("height", this._height(scales, channels, dimensions))
3738
.call(applyAttr, "rx", rx)
3839
.call(applyAttr, "ry", ry)
39-
.call(applyChannelStyles, this, channels))
40+
.call(applyChannelStyles, this, channels)
41+
.call(!(onchange && this.clickable)
42+
? () => {}
43+
: e => e.on("click", function(event, i) {
44+
selected = selected === this || event.shiftKey ? undefined : this;
45+
onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }});
46+
})
47+
))
4048
.node();
4149
}
4250
_x(scales, {x: X}, {marginLeft}) {

src/marks/brush.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {brush as brusher, brushX as brusherX, brushY as brusherY, create} from "d3";
2+
import {Mark, identity, first, second} from "../mark.js";
3+
4+
const defaults = {};
5+
export class Brush extends Mark {
6+
constructor(data, {x = first, y = second, quiet = false, selection, ...options} = {}) {
7+
super(
8+
data,
9+
[
10+
{name: "x", value: x, scale: "x", optional: true},
11+
{name: "y", value: y, scale: "y", optional: true}
12+
],
13+
{...options, selection: true},
14+
defaults
15+
);
16+
this.initialSelection = selection;
17+
this.quiet = !!quiet;
18+
this.brushes = [];
19+
}
20+
render(
21+
I,
22+
scales,
23+
{x: X, y: Y},
24+
{marginLeft, width, marginRight, marginTop, height, marginBottom}
25+
) {
26+
const bounds = [
27+
[Math.floor(marginLeft), Math.floor(marginTop)],
28+
[Math.ceil(width - marginRight), Math.ceil(height - marginBottom)]
29+
];
30+
const F = new Uint8Array(X ? X.length : Y.length);
31+
const {brushes, quiet} = this;
32+
const origin = brushes.length;
33+
for (const i of I) F[i] = 1;
34+
const {onchange} = this;
35+
const brush = (X && Y ? brusher : X ? brusherX : brusherY)()
36+
.extent(bounds)
37+
.on("start brush end", (event) => {
38+
const {selection, sourceEvent} = event;
39+
if (sourceEvent === undefined) return; // a programmatic selection clears all the brushes
40+
if (!selection) {
41+
onchange({detail: {filter: quiet, origin}});
42+
} else {
43+
let x0, x1, y0, y1;
44+
if (X) ([x0, x1] = Y ? [selection[0][0], selection[1][0]] : selection);
45+
if (Y) ([y0, y1] = X ? [selection[0][1], selection[1][1]] : selection);
46+
onchange({detail: {
47+
filter: X && Y ? (d, i) => F[i] && X[i] >= x0 && X[i] <= x1 && Y[i] >= y0 && Y[i] <= y1
48+
: X ? (d, i) => F[i] && X[i] >= x0 && X[i] <= x1
49+
: (d, i) => F[i] && Y[i] >= y0 && Y[i] <= y1,
50+
origin
51+
}});
52+
}
53+
});
54+
const g = create("svg:g").call(brush);
55+
brushes.push(() => g.call(brush.clear));
56+
return g.node();
57+
}
58+
select(event, {origin}) {
59+
this.brushes.forEach((clear, i) => i !== origin && clear());
60+
}
61+
}
62+
63+
export function brush(data, options) {
64+
return new Brush(data, options);
65+
}
66+
67+
export function brushX(data, {x = identity, ...options} = {}) {
68+
return new Brush(data, {...options, x, y: null});
69+
}
70+
71+
export function brushY(data, {y = identity, ...options} = {}) {
72+
return new Brush(data, {...options, x: null, y});
73+
}

src/marks/dot.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const defaults = {
1212

1313
export class Dot extends Mark {
1414
constructor(data, options = {}) {
15-
const {x, y, r, rotate, symbol = symbolCircle} = options;
15+
const {x, y, r, rotate, symbol = symbolCircle, clickable} = options;
1616
const [vrotate, crotate] = maybeNumberChannel(rotate, 0);
1717
const [vsymbol, csymbol] = maybeSymbolChannel(symbol);
1818
const [vr, cr] = maybeNumberChannel(r, vsymbol == null ? 3 : 4.5);
@@ -31,6 +31,7 @@ export class Dot extends Mark {
3131
this.r = cr;
3232
this.rotate = crotate;
3333
this.symbol = csymbol;
34+
this.clickable = !!clickable;
3435

3536
// Give a hint to the symbol scale; this allows the symbol scale to chose
3637
// appropriate default symbols based on whether the dots are filled or
@@ -53,12 +54,13 @@ export class Dot extends Mark {
5354
{width, height, marginTop, marginRight, marginBottom, marginLeft}
5455
) {
5556
const {x: X, y: Y, r: R, rotate: A, symbol: S} = channels;
56-
const {dx, dy} = this;
57+
const {dx, dy, onchange} = this;
5758
const cx = (marginLeft + width - marginRight) / 2;
5859
const cy = (marginTop + height - marginBottom) / 2;
5960
let index = filter(I, X, Y, A, S);
6061
if (R) index = index.filter(i => positive(R[i]));
6162
const circle = this.symbol === symbolCircle;
63+
let selected;
6264
return create("svg:g")
6365
.call(applyIndirectStyles, this)
6466
.call(applyTransform, x, y, offset + dx, offset + dy)
@@ -88,7 +90,14 @@ export class Dot extends Mark {
8890
return p;
8991
});
9092
})
91-
.call(applyChannelStyles, this, channels))
93+
.call(applyChannelStyles, this, channels)
94+
.call(!(onchange && this.clickable)
95+
? () => {}
96+
: e => e.on("click", function(event, i) {
97+
selected = selected === this || event.shiftKey ? undefined : this;
98+
onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }});
99+
})
100+
))
92101
.node();
93102
}
94103
}

src/marks/image.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export class Image extends Mark {
6868
if (H) index = index.filter(i => positive(H[i]));
6969
const cx = (marginLeft + width - marginRight) / 2;
7070
const cy = (marginTop + height - marginBottom) / 2;
71-
const {dx, dy} = this;
71+
const {dx, dy, onchange} = this;
72+
let selected;
7273
return create("svg:g")
7374
.call(applyIndirectStyles, this)
7475
.call(applyTransform, x, y, offset + dx, offset + dy)
@@ -83,7 +84,14 @@ export class Image extends Mark {
8384
.call(applyAttr, "href", S ? i => S[i] : this.src)
8485
.call(applyAttr, "preserveAspectRatio", this.preserveAspectRatio)
8586
.call(applyAttr, "crossorigin", this.crossOrigin)
86-
.call(applyChannelStyles, this, channels))
87+
.call(applyChannelStyles, this, channels)
88+
.call(!(onchange && this.clickable)
89+
? () => {}
90+
: e => e.on("click", function(event, i) {
91+
selected = selected === this || event.shiftKey ? undefined : this;
92+
onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }});
93+
})
94+
))
8795
.node();
8896
}
8997
}

src/marks/link.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ export class Link extends Mark {
2828
}
2929
render(I, {x, y}, channels) {
3030
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels;
31-
const {dx, dy} = this;
31+
const {dx, dy, onchange} = this;
3232
const index = filter(I, X1, Y1, X2, Y2);
33+
let selected;
3334
return create("svg:g")
3435
.call(applyIndirectStyles, this)
3536
.call(applyTransform, x, y, offset + dx, offset + dy)
@@ -46,7 +47,14 @@ export class Link extends Mark {
4647
c.lineEnd();
4748
return `${p}`;
4849
})
49-
.call(applyChannelStyles, this, channels))
50+
.call(applyChannelStyles, this, channels)
51+
.call(!(onchange && this.clickable)
52+
? () => {}
53+
: e => e.on("click", function(event, i) {
54+
selected = selected === this || event.shiftKey ? undefined : this;
55+
onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }});
56+
})
57+
))
5058
.node();
5159
}
5260
}

src/marks/rect.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ export class Rect extends Mark {
4545
render(I, {x, y}, channels, dimensions) {
4646
const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels;
4747
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
48-
const {insetTop, insetRight, insetBottom, insetLeft, dx, dy, rx, ry} = this;
48+
const {insetTop, insetRight, insetBottom, insetLeft, dx, dy, rx, ry, onchange} = this;
4949
const index = filter(I, X1, Y2, X2, Y2);
50+
let selected;
5051
return create("svg:g")
5152
.call(applyIndirectStyles, this)
5253
.call(applyTransform, x, y, dx, dy)
@@ -60,7 +61,14 @@ export class Rect extends Mark {
6061
.attr("height", Y1 && Y2 && !isCollapsed(y) ? i => Math.max(0, Math.abs(Y1[i] - Y2[i]) - insetTop - insetBottom) : height - marginTop - marginBottom - insetTop - insetBottom)
6162
.call(applyAttr, "rx", rx)
6263
.call(applyAttr, "ry", ry)
63-
.call(applyChannelStyles, this, channels))
64+
.call(applyChannelStyles, this, channels)
65+
.call(!(onchange && this.clickable)
66+
? () => {}
67+
: e => e.on("click", function(event, i) {
68+
selected = selected === this || event.shiftKey ? undefined : this;
69+
onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }});
70+
})
71+
))
6472
.node();
6573
}
6674
}

0 commit comments

Comments
 (0)