Skip to content

Interactions: brush #71

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,38 @@ Plot.vector(wind, {x: "longitude", y: "latitude", length: "speed", rotate: "dire

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₂*, …].

## Interactions

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).

### Brush

[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.

#### Plot.brush(*data*, *options*)

```js
Plot.brush(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"})
```

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₂*, …].

#### Plot.brushX(*data*, *options*)

```js
Plot.brushX(penguins, {x: "culmen_depth_mm"})
```

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₂*, …].

#### Plot.brushY(*data*, *options*)

```js
Plot.brushY(penguins, {y: "culmen_length_mm"})
```

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₂*, …].

## Decorations

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.
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {plot, Mark, marks} from "./plot.js";
export {Area, area, areaX, areaY} from "./marks/area.js";
export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {brush, brushX, brushY} from "./marks/brush.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
Expand All @@ -13,6 +14,7 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
export {Text, text, textX, textY} from "./marks/text.js";
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {Vector, vector} from "./marks/vector.js";
export {selection} from "./selection.js";
export {valueof} from "./options.js";
export {filter, reverse, sort, shuffle} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
Expand Down
87 changes: 87 additions & 0 deletions src/marks/brush.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {brush as brusher, brushX as brusherX, brushY as brusherY, create, select} from "d3";
import {identity, maybeTuple} from "../options.js";
import {Mark} from "../plot.js";
import {selection, selectionEquals} from "../selection.js";
import {applyDirectStyles, applyIndirectStyles} from "../style.js";

const defaults = {
ariaLabel: "brush",
fill: "#777",
fillOpacity: 0.3,
stroke: "#fff"
};

export class Brush extends Mark {
constructor(data, {x, y, ...options} = {}) {
super(
data,
[
{name: "x", value: x, scale: "x", optional: true},
{name: "y", value: y, scale: "y", optional: true}
],
options,
defaults
);
this.activeElement = null;
}
render(index, {x, y}, {x: X, y: Y}, dimensions) {
const {ariaLabel, ariaDescription, ariaHidden, ...options} = this;
const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
const brush = this;
const g = create("svg:g")
.call(applyIndirectStyles, {ariaLabel, ariaDescription, ariaHidden})
.call((X && Y ? brusher : X ? brusherX : brusherY)()
.extent([[marginLeft, marginTop], [width - marginRight, height - marginBottom]])
.on("start brush end", function(event) {
const {type, selection: extent} = event;
// For faceting, when starting a brush in a new facet, clear the
// brush and selection on the old facet. In the future, we might
// allow independent brushes across facets by disabling this?
if (type === "start" && brush.activeElement !== this) {
if (brush.activeElement !== null) {
select(brush.activeElement).call(event.target.clear, event);
brush.activeElement[selection] = null;
}
brush.activeElement = this;
}
let S = null;
if (extent) {
S = index;
if (X) {
let [x0, x1] = Y ? [extent[0][0], extent[1][0]] : extent;
if (x.bandwidth) x0 -= x.bandwidth();
S = S.filter(i => x0 <= X[i] && X[i] <= x1);
}
if (Y) {
let [y0, y1] = X ? [extent[0][1], extent[1][1]] : extent;
if (y.bandwidth) y0 -= y.bandwidth();
S = S.filter(i => y0 <= Y[i] && Y[i] <= y1);
}
}
if (!selectionEquals(this[selection], S)) {
this[selection] = S;
this.dispatchEvent(new Event("input", {bubbles: true}));
}
}))
.call(g => g.selectAll(".selection")
.attr("shape-rendering", null) // reset d3-brush
.call(applyIndirectStyles, options)
.call(applyDirectStyles, options))
.node();
g[selection] = null;
return g;
}
}

export function brush(data, {x, y, ...options} = {}) {
([x, y] = maybeTuple(x, y));
return new Brush(data, {...options, x, y});
}

export function brushX(data, {x = identity, ...options} = {}) {
return new Brush(data, {...options, x, y: null});
}

export function brushY(data, {y = identity, ...options} = {}) {
return new Brush(data, {...options, x: null, y});
}
67 changes: 60 additions & 7 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {create, cross, difference, groups, InternMap} from "d3";
import {create, cross, difference, groups, InternMap, union} from "d3";
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
import {Channel, channelSort} from "./channel.js";
import {defined} from "./defined.js";
import {Dimensions} from "./dimensions.js";
import {Legends, exposeLegends} from "./legends.js";
import {arrayify, isOptions, keyword, range, first, second, where} from "./options.js";
import {arrayify, isOptions, keyword, range, first, second, where, take} from "./options.js";
import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js";
import {selection} from "./selection.js";
import {applyInlineStyles, maybeClassName, styles} from "./style.js";
import {basic} from "./transforms/basic.js";

Expand Down Expand Up @@ -95,12 +96,21 @@ export function plot(options = {}) {
.call(applyInlineStyles, style)
.node();

let initialValue;
for (const mark of marks) {
const channels = markChannels.get(mark) ?? [];
const values = applyScales(channels, scales);
const index = filter(markIndex.get(mark), channels, values);
const node = mark.render(index, scales, values, dimensions, axes);
if (node != null) svg.appendChild(node);
if (node != null) {
if (node[selection] !== undefined) {
initialValue = markValue(mark, node[selection]);
node.addEventListener("input", () => {
figure.value = markValue(mark, node[selection]);
});
}
svg.appendChild(node);
}
}

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

figure.scale = exposeScales(scaleDescriptors);
figure.legend = exposeLegends(scaleDescriptors, options);
figure.value = initialValue;
return figure;
}

Expand Down Expand Up @@ -189,6 +200,10 @@ function markify(mark) {
return mark instanceof Mark ? mark : new Render(mark);
}

function markValue(mark, selection) {
return selection === null ? mark.data : take(mark.data, selection);
}

class Render extends Mark {
constructor(render) {
super();
Expand Down Expand Up @@ -263,16 +278,17 @@ class Facet extends Mark {
}
return {index, channels: [...channels, ...subchannels]};
}
render(I, scales, channels, dimensions, axes) {
const {marks, marksChannels, marksIndexByFacet} = this;
render(I, scales, _, dimensions, axes) {
const {data, channels, marks, marksChannels, marksIndexByFacet} = this;
const {fx, fy} = scales;
const fyDomain = fy && fy.domain();
const fxDomain = fx && fx.domain();
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
const marksValues = marksChannels.map(channels => applyScales(channels, scales));
return create("svg:g")
let selectionByFacet;
const parent = create("svg:g")
.call(g => {
if (fy && axes.y) {
const axis1 = axes.y, axis2 = nolabel(axis1);
Expand Down Expand Up @@ -316,10 +332,25 @@ class Facet extends Mark {
const values = marksValues[i];
const index = filter(marksFacetIndex[i], marksChannels[i], values);
const node = marks[i].render(index, scales, values, subdimensions);
if (node != null) this.appendChild(node);
if (node != null) {
if (node[selection] !== undefined) {
if (marks[i].data !== data) throw new Error("selection must use facet data");
if (selectionByFacet === undefined) selectionByFacet = facetMap(channels);
selectionByFacet.set(key, node[selection]);
node.addEventListener("input", () => {
selectionByFacet.set(key, node[selection]);
parent[selection] = facetSelection(selectionByFacet);
});
}
this.appendChild(node);
}
}
}))
.node();
if (selectionByFacet !== undefined) {
parent[selection] = facetSelection(selectionByFacet);
}
return parent;
}
}

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

// If multiple facets define a selection, then the overall selection is the
// union of the defined selections. As with non-faceted plots, we assume that
// only a single mark is defining the selection; if multiple marks define a
// selection, generally speaking the last one wins, although the behavior is not
// explicitly defined.
function facetSelection(selectionByFacet) {
let selection = null;
for (const value of selectionByFacet.values()) {
if (value === null) continue;
selection = selection === null ? value : union(selection, value);
}
return selection;
}

function facetMap(channels) {
return new (channels.length > 1 ? FacetMap2 : FacetMap);
}
Expand All @@ -379,6 +424,9 @@ class FacetMap {
set(key, value) {
return this._.set(key, value), this;
}
values() {
return this._.values();
}
}

// A Map-like interface that supports paired keys.
Expand All @@ -397,4 +445,9 @@ class FacetMap2 extends FacetMap {
else super.set(key1, new InternMap([[key2, value]]));
return this;
}
*values() {
for (const map of this._.values()) {
yield* map.values();
}
}
}
18 changes: 18 additions & 0 deletions src/selection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// This symbol is used by interactive marks to define which data are selected. A
// node returned by mark.render may expose a selection as node[selection], whose
// value may be an array of numbers (e.g., [0, 1, 2, …]) representing an
// in-order subset of the rendered index, or null if the selection is undefined.
// The selection can be updated during interaction by emitting an input event.
export const selection = Symbol("selection");

// Given two (possibly null, possibly an index, but not undefined) selections,
// returns true if the two represent the same selection, and false otherwise.
// This assumes that the selection is a in-order subset of the original index.
export function selectionEquals(s1, s2) {
if (s1 === s2) return true;
if (s1 == null || s2 == null) return false;
const n = s1.length;
if (n !== s2.length) return false;
for (let i = 0; i < n; ++i) if (s1[i] !== s2[i]) return false;
return true;
}
6 changes: 4 additions & 2 deletions src/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export function styles(
{
ariaLabel: cariaLabel,
fill: defaultFill = "currentColor",
fillOpacity: defaultFillOpacity,
stroke: defaultStroke = "none",
strokeOpacity: defaultStrokeOpacity,
strokeWidth: defaultStrokeWidth,
strokeLinecap: defaultStrokeLinecap,
strokeLinejoin: defaultStrokeLinejoin,
Expand Down Expand Up @@ -66,9 +68,9 @@ export function styles(
}

const [vfill, cfill] = maybeColorChannel(fill, defaultFill);
const [vfillOpacity, cfillOpacity] = maybeNumberChannel(fillOpacity);
const [vfillOpacity, cfillOpacity] = maybeNumberChannel(fillOpacity, defaultFillOpacity);
const [vstroke, cstroke] = maybeColorChannel(stroke, defaultStroke);
const [vstrokeOpacity, cstrokeOpacity] = maybeNumberChannel(strokeOpacity);
const [vstrokeOpacity, cstrokeOpacity] = maybeNumberChannel(strokeOpacity, defaultStrokeOpacity);
const [vopacity, copacity] = maybeNumberChannel(opacity);

// For styles that have no effect if there is no stroke, only apply the
Expand Down
2 changes: 2 additions & 0 deletions test/jsdom.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function withJsdom(run) {
const jsdom = new JSDOM("");
global.window = jsdom.window;
global.document = jsdom.window.document;
global.navigator = jsdom.window.navigator;
global.Event = jsdom.window.Event;
global.Node = jsdom.window.Node;
global.NodeList = jsdom.window.NodeList;
Expand All @@ -29,6 +30,7 @@ function withJsdom(run) {
} finally {
delete global.window;
delete global.document;
delete global.navigator;
delete global.Event;
delete global.Node;
delete global.NodeList;
Expand Down
Loading