Skip to content

Commit 5ce6cc0

Browse files
Filmbostock
andauthored
Interactions: brush (#71)
* brush Co-authored-by: Mike Bostock <mbostock@gmail.com> * only emit input on selection change * shorten * node[Plot.selection] * Update README * activeElement Co-authored-by: Mike Bostock <mbostock@gmail.com>
1 parent 9f64f5a commit 5ce6cc0

File tree

12 files changed

+5046
-9
lines changed

12 files changed

+5046
-9
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,38 @@ Plot.vector(wind, {x: "longitude", y: "latitude", length: "speed", rotate: "dire
12201220

12211221
Returns a new vector with the given *data* and *options*. If neither the **x** nor **y** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …].
12221222

1223+
## Interactions
1224+
1225+
Interactions are special marks that handle user input and define interactive selections. When a plot has an interaction mark, the returned *plot*.value represents the current selection as an array subset of the interaction mark’s data. As the user modifies the selection through interaction with the plot, *input* events are emitted. This design is compatible with [Observable’s viewof operator](https://observablehq.com/@observablehq/introduction-to-views), but you can also listen to *input* events directly via the [EventTarget interface](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget).
1226+
1227+
### Brush
1228+
1229+
[Source](./src/marks/brush.js) · [Examples](https://observablehq.com/@observablehq/plot-brush) · Selects points within a single contiguous rectangular region, such as nearby dots in a scatterplot.
1230+
1231+
#### Plot.brush(*data*, *options*)
1232+
1233+
```js
1234+
Plot.brush(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"})
1235+
```
1236+
1237+
Returns a new brush with the given *data* and *options*. If neither the **x** nor **y** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …].
1238+
1239+
#### Plot.brushX(*data*, *options*)
1240+
1241+
```js
1242+
Plot.brushX(penguins, {x: "culmen_depth_mm"})
1243+
```
1244+
1245+
Equivalent to [Plot.brush](#plotbrushdata-options) except that if the **x** option is not specified, it defaults to the identity function and assumes that *data* = [*x₀*, *x₁*, *x₂*, …].
1246+
1247+
#### Plot.brushY(*data*, *options*)
1248+
1249+
```js
1250+
Plot.brushY(penguins, {y: "culmen_length_mm"})
1251+
```
1252+
1253+
Equivalent to [Plot.brush](#plotbrushdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …].
1254+
12231255
## Decorations
12241256

12251257
Decorations are static marks that do not represent data. Currently this includes only [Plot.frame](#frame), although internally Plot’s axes are implemented as decorations and may in the future be exposed here for more flexible configuration.

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export {plot, Mark, marks} from "./plot.js";
22
export {Area, area, areaX, areaY} from "./marks/area.js";
33
export {Arrow, arrow} from "./marks/arrow.js";
44
export {BarX, BarY, barX, barY} from "./marks/bar.js";
5+
export {brush, brushX, brushY} from "./marks/brush.js";
56
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
67
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
78
export {Frame, frame} from "./marks/frame.js";
@@ -13,6 +14,7 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
1314
export {Text, text, textX, textY} from "./marks/text.js";
1415
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
1516
export {Vector, vector} from "./marks/vector.js";
17+
export {selection} from "./selection.js";
1618
export {valueof} from "./options.js";
1719
export {filter, reverse, sort, shuffle} from "./transforms/basic.js";
1820
export {bin, binX, binY} from "./transforms/bin.js";

src/marks/brush.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {brush as brusher, brushX as brusherX, brushY as brusherY, create, select} from "d3";
2+
import {identity, maybeTuple} from "../options.js";
3+
import {Mark} from "../plot.js";
4+
import {selection, selectionEquals} from "../selection.js";
5+
import {applyDirectStyles, applyIndirectStyles} from "../style.js";
6+
7+
const defaults = {
8+
ariaLabel: "brush",
9+
fill: "#777",
10+
fillOpacity: 0.3,
11+
stroke: "#fff"
12+
};
13+
14+
export class Brush extends Mark {
15+
constructor(data, {x, y, ...options} = {}) {
16+
super(
17+
data,
18+
[
19+
{name: "x", value: x, scale: "x", optional: true},
20+
{name: "y", value: y, scale: "y", optional: true}
21+
],
22+
options,
23+
defaults
24+
);
25+
this.activeElement = null;
26+
}
27+
render(index, {x, y}, {x: X, y: Y}, dimensions) {
28+
const {ariaLabel, ariaDescription, ariaHidden, ...options} = this;
29+
const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
30+
const brush = this;
31+
const g = create("svg:g")
32+
.call(applyIndirectStyles, {ariaLabel, ariaDescription, ariaHidden})
33+
.call((X && Y ? brusher : X ? brusherX : brusherY)()
34+
.extent([[marginLeft, marginTop], [width - marginRight, height - marginBottom]])
35+
.on("start brush end", function(event) {
36+
const {type, selection: extent} = event;
37+
// For faceting, when starting a brush in a new facet, clear the
38+
// brush and selection on the old facet. In the future, we might
39+
// allow independent brushes across facets by disabling this?
40+
if (type === "start" && brush.activeElement !== this) {
41+
if (brush.activeElement !== null) {
42+
select(brush.activeElement).call(event.target.clear, event);
43+
brush.activeElement[selection] = null;
44+
}
45+
brush.activeElement = this;
46+
}
47+
let S = null;
48+
if (extent) {
49+
S = index;
50+
if (X) {
51+
let [x0, x1] = Y ? [extent[0][0], extent[1][0]] : extent;
52+
if (x.bandwidth) x0 -= x.bandwidth();
53+
S = S.filter(i => x0 <= X[i] && X[i] <= x1);
54+
}
55+
if (Y) {
56+
let [y0, y1] = X ? [extent[0][1], extent[1][1]] : extent;
57+
if (y.bandwidth) y0 -= y.bandwidth();
58+
S = S.filter(i => y0 <= Y[i] && Y[i] <= y1);
59+
}
60+
}
61+
if (!selectionEquals(this[selection], S)) {
62+
this[selection] = S;
63+
this.dispatchEvent(new Event("input", {bubbles: true}));
64+
}
65+
}))
66+
.call(g => g.selectAll(".selection")
67+
.attr("shape-rendering", null) // reset d3-brush
68+
.call(applyIndirectStyles, options)
69+
.call(applyDirectStyles, options))
70+
.node();
71+
g[selection] = null;
72+
return g;
73+
}
74+
}
75+
76+
export function brush(data, {x, y, ...options} = {}) {
77+
([x, y] = maybeTuple(x, y));
78+
return new Brush(data, {...options, x, y});
79+
}
80+
81+
export function brushX(data, {x = identity, ...options} = {}) {
82+
return new Brush(data, {...options, x, y: null});
83+
}
84+
85+
export function brushY(data, {y = identity, ...options} = {}) {
86+
return new Brush(data, {...options, x: null, y});
87+
}

src/plot.js

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import {create, cross, difference, groups, InternMap} from "d3";
1+
import {create, cross, difference, groups, InternMap, union} from "d3";
22
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
33
import {Channel, channelSort} from "./channel.js";
44
import {defined} from "./defined.js";
55
import {Dimensions} from "./dimensions.js";
66
import {Legends, exposeLegends} from "./legends.js";
7-
import {arrayify, isOptions, keyword, range, first, second, where} from "./options.js";
7+
import {arrayify, isOptions, keyword, range, first, second, where, take} from "./options.js";
88
import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js";
9+
import {selection} from "./selection.js";
910
import {applyInlineStyles, maybeClassName, styles} from "./style.js";
1011
import {basic} from "./transforms/basic.js";
1112

@@ -95,12 +96,21 @@ export function plot(options = {}) {
9596
.call(applyInlineStyles, style)
9697
.node();
9798

99+
let initialValue;
98100
for (const mark of marks) {
99101
const channels = markChannels.get(mark) ?? [];
100102
const values = applyScales(channels, scales);
101103
const index = filter(markIndex.get(mark), channels, values);
102104
const node = mark.render(index, scales, values, dimensions, axes);
103-
if (node != null) svg.appendChild(node);
105+
if (node != null) {
106+
if (node[selection] !== undefined) {
107+
initialValue = markValue(mark, node[selection]);
108+
node.addEventListener("input", () => {
109+
figure.value = markValue(mark, node[selection]);
110+
});
111+
}
112+
svg.appendChild(node);
113+
}
104114
}
105115

106116
// Wrap the plot in a figure with a caption, if desired.
@@ -119,6 +129,7 @@ export function plot(options = {}) {
119129

120130
figure.scale = exposeScales(scaleDescriptors);
121131
figure.legend = exposeLegends(scaleDescriptors, options);
132+
figure.value = initialValue;
122133
return figure;
123134
}
124135

@@ -189,6 +200,10 @@ function markify(mark) {
189200
return mark instanceof Mark ? mark : new Render(mark);
190201
}
191202

203+
function markValue(mark, selection) {
204+
return selection === null ? mark.data : take(mark.data, selection);
205+
}
206+
192207
class Render extends Mark {
193208
constructor(render) {
194209
super();
@@ -263,16 +278,17 @@ class Facet extends Mark {
263278
}
264279
return {index, channels: [...channels, ...subchannels]};
265280
}
266-
render(I, scales, channels, dimensions, axes) {
267-
const {marks, marksChannels, marksIndexByFacet} = this;
281+
render(I, scales, _, dimensions, axes) {
282+
const {data, channels, marks, marksChannels, marksIndexByFacet} = this;
268283
const {fx, fy} = scales;
269284
const fyDomain = fy && fy.domain();
270285
const fxDomain = fx && fx.domain();
271286
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
272287
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
273288
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
274289
const marksValues = marksChannels.map(channels => applyScales(channels, scales));
275-
return create("svg:g")
290+
let selectionByFacet;
291+
const parent = create("svg:g")
276292
.call(g => {
277293
if (fy && axes.y) {
278294
const axis1 = axes.y, axis2 = nolabel(axis1);
@@ -316,10 +332,25 @@ class Facet extends Mark {
316332
const values = marksValues[i];
317333
const index = filter(marksFacetIndex[i], marksChannels[i], values);
318334
const node = marks[i].render(index, scales, values, subdimensions);
319-
if (node != null) this.appendChild(node);
335+
if (node != null) {
336+
if (node[selection] !== undefined) {
337+
if (marks[i].data !== data) throw new Error("selection must use facet data");
338+
if (selectionByFacet === undefined) selectionByFacet = facetMap(channels);
339+
selectionByFacet.set(key, node[selection]);
340+
node.addEventListener("input", () => {
341+
selectionByFacet.set(key, node[selection]);
342+
parent[selection] = facetSelection(selectionByFacet);
343+
});
344+
}
345+
this.appendChild(node);
346+
}
320347
}
321348
}))
322349
.node();
350+
if (selectionByFacet !== undefined) {
351+
parent[selection] = facetSelection(selectionByFacet);
352+
}
353+
return parent;
323354
}
324355
}
325356

@@ -362,6 +393,20 @@ function facetTranslate(fx, fy) {
362393
: ky => `translate(0,${fy(ky)})`;
363394
}
364395

396+
// If multiple facets define a selection, then the overall selection is the
397+
// union of the defined selections. As with non-faceted plots, we assume that
398+
// only a single mark is defining the selection; if multiple marks define a
399+
// selection, generally speaking the last one wins, although the behavior is not
400+
// explicitly defined.
401+
function facetSelection(selectionByFacet) {
402+
let selection = null;
403+
for (const value of selectionByFacet.values()) {
404+
if (value === null) continue;
405+
selection = selection === null ? value : union(selection, value);
406+
}
407+
return selection;
408+
}
409+
365410
function facetMap(channels) {
366411
return new (channels.length > 1 ? FacetMap2 : FacetMap);
367412
}
@@ -379,6 +424,9 @@ class FacetMap {
379424
set(key, value) {
380425
return this._.set(key, value), this;
381426
}
427+
values() {
428+
return this._.values();
429+
}
382430
}
383431

384432
// A Map-like interface that supports paired keys.
@@ -397,4 +445,9 @@ class FacetMap2 extends FacetMap {
397445
else super.set(key1, new InternMap([[key2, value]]));
398446
return this;
399447
}
448+
*values() {
449+
for (const map of this._.values()) {
450+
yield* map.values();
451+
}
452+
}
400453
}

src/selection.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// This symbol is used by interactive marks to define which data are selected. A
2+
// node returned by mark.render may expose a selection as node[selection], whose
3+
// value may be an array of numbers (e.g., [0, 1, 2, …]) representing an
4+
// in-order subset of the rendered index, or null if the selection is undefined.
5+
// The selection can be updated during interaction by emitting an input event.
6+
export const selection = Symbol("selection");
7+
8+
// Given two (possibly null, possibly an index, but not undefined) selections,
9+
// returns true if the two represent the same selection, and false otherwise.
10+
// This assumes that the selection is a in-order subset of the original index.
11+
export function selectionEquals(s1, s2) {
12+
if (s1 === s2) return true;
13+
if (s1 == null || s2 == null) return false;
14+
const n = s1.length;
15+
if (n !== s2.length) return false;
16+
for (let i = 0; i < n; ++i) if (s1[i] !== s2[i]) return false;
17+
return true;
18+
}

src/style.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ export function styles(
3232
{
3333
ariaLabel: cariaLabel,
3434
fill: defaultFill = "currentColor",
35+
fillOpacity: defaultFillOpacity,
3536
stroke: defaultStroke = "none",
37+
strokeOpacity: defaultStrokeOpacity,
3638
strokeWidth: defaultStrokeWidth,
3739
strokeLinecap: defaultStrokeLinecap,
3840
strokeLinejoin: defaultStrokeLinejoin,
@@ -66,9 +68,9 @@ export function styles(
6668
}
6769

6870
const [vfill, cfill] = maybeColorChannel(fill, defaultFill);
69-
const [vfillOpacity, cfillOpacity] = maybeNumberChannel(fillOpacity);
71+
const [vfillOpacity, cfillOpacity] = maybeNumberChannel(fillOpacity, defaultFillOpacity);
7072
const [vstroke, cstroke] = maybeColorChannel(stroke, defaultStroke);
71-
const [vstrokeOpacity, cstrokeOpacity] = maybeNumberChannel(strokeOpacity);
73+
const [vstrokeOpacity, cstrokeOpacity] = maybeNumberChannel(strokeOpacity, defaultStrokeOpacity);
7274
const [vopacity, copacity] = maybeNumberChannel(opacity);
7375

7476
// For styles that have no effect if there is no stroke, only apply the

test/jsdom.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function withJsdom(run) {
1919
const jsdom = new JSDOM("");
2020
global.window = jsdom.window;
2121
global.document = jsdom.window.document;
22+
global.navigator = jsdom.window.navigator;
2223
global.Event = jsdom.window.Event;
2324
global.Node = jsdom.window.Node;
2425
global.NodeList = jsdom.window.NodeList;
@@ -29,6 +30,7 @@ function withJsdom(run) {
2930
} finally {
3031
delete global.window;
3132
delete global.document;
33+
delete global.navigator;
3234
delete global.Event;
3335
delete global.Node;
3436
delete global.NodeList;

0 commit comments

Comments
 (0)