Skip to content

Commit e2b36da

Browse files
Filmbostock
andcommitted
brush
Co-authored-by: Mike Bostock <[email protected]>
1 parent 9f64f5a commit e2b36da

10 files changed

+4996
-9
lines changed

src/index.js

+1
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";

src/marks/brush.js

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

src/plot.js

+60-7
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
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";
99
import {applyInlineStyles, maybeClassName, styles} from "./style.js";
1010
import {basic} from "./transforms/basic.js";
@@ -95,12 +95,22 @@ export function plot(options = {}) {
9595
.call(applyInlineStyles, style)
9696
.node();
9797

98+
let initialValue;
9899
for (const mark of marks) {
99100
const channels = markChannels.get(mark) ?? [];
100101
const values = applyScales(channels, scales);
101102
const index = filter(markIndex.get(mark), channels, values);
102103
const node = mark.render(index, scales, values, dimensions, axes);
103-
if (node != null) svg.appendChild(node);
104+
if (node != null) {
105+
// TODO A more explicit indication that a mark defines a value (e.g., a symbol)?
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/style.js

+4-2
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

+2
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)