diff --git a/README.md b/README.md
index 6fb96f2eb3..7359aec171 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/src/index.js b/src/index.js
index 7ac7a63778..3d282cd381 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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";
@@ -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";
diff --git a/src/marks/brush.js b/src/marks/brush.js
new file mode 100644
index 0000000000..d319d6b732
--- /dev/null
+++ b/src/marks/brush.js
@@ -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});
+}
diff --git a/src/plot.js b/src/plot.js
index 8e7ed9e028..a26ba715cf 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -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";
@@ -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.
@@ -119,6 +129,7 @@ export function plot(options = {}) {
figure.scale = exposeScales(scaleDescriptors);
figure.legend = exposeLegends(scaleDescriptors, options);
+ figure.value = initialValue;
return figure;
}
@@ -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();
@@ -263,8 +278,8 @@ 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();
@@ -272,7 +287,8 @@ class Facet extends Mark {
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);
@@ -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;
}
}
@@ -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);
}
@@ -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.
@@ -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();
+ }
+ }
}
diff --git a/src/selection.js b/src/selection.js
new file mode 100644
index 0000000000..228a22743c
--- /dev/null
+++ b/src/selection.js
@@ -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;
+}
diff --git a/src/style.js b/src/style.js
index e45c4bc550..138df2e06c 100644
--- a/src/style.js
+++ b/src/style.js
@@ -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,
@@ -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
diff --git a/test/jsdom.js b/test/jsdom.js
index 28781b2f70..94e9193c25 100644
--- a/test/jsdom.js
+++ b/test/jsdom.js
@@ -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;
@@ -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;
diff --git a/test/output/gistempAnomalyBrush.svg b/test/output/gistempAnomalyBrush.svg
new file mode 100644
index 0000000000..e374013d66
--- /dev/null
+++ b/test/output/gistempAnomalyBrush.svg
@@ -0,0 +1,1742 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenBrush.html b/test/output/penguinCulmenBrush.html
new file mode 100644
index 0000000000..b5a07679a1
--- /dev/null
+++ b/test/output/penguinCulmenBrush.html
@@ -0,0 +1,3036 @@
+
\ No newline at end of file
diff --git a/test/plots/gistemp-anomaly-brush.js b/test/plots/gistemp-anomaly-brush.js
new file mode 100644
index 0000000000..3a3b2f97d8
--- /dev/null
+++ b/test/plots/gistemp-anomaly-brush.js
@@ -0,0 +1,22 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const data = await d3.csv("data/gistemp.csv", d3.autoType);
+ return Plot.plot({
+ y: {
+ label: "↑ Temperature anomaly (°C)",
+ tickFormat: "+f",
+ grid: true
+ },
+ color: {
+ type: "diverging",
+ reverse: true
+ },
+ marks: [
+ Plot.ruleY([0]),
+ Plot.dot(data, {x: "Date", y: "Anomaly", stroke: "Anomaly"}),
+ Plot.brush(data, {x: "Date", y: "Anomaly"})
+ ]
+ });
+}
diff --git a/test/plots/index.js b/test/plots/index.js
index 88ef7e17ed..a4e4b8c788 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -46,6 +46,7 @@ export {default as footballCoverage} from "./football-coverage.js";
export {default as fruitSales} from "./fruit-sales.js";
export {default as fruitSalesDate} from "./fruit-sales-date.js";
export {default as gistempAnomaly} from "./gistemp-anomaly.js";
+export {default as gistempAnomalyBrush} from "./gistemp-anomaly-brush.js";
export {default as gistempAnomalyMoving} from "./gistemp-anomaly-moving.js";
export {default as gistempAnomalyTransform} from "./gistemp-anomaly-transform.js";
export {default as googleTrendsRidgeline} from "./google-trends-ridgeline.js";
@@ -87,6 +88,7 @@ export {default as musicRevenue} from "./music-revenue.js";
export {default as ordinalBar} from "./ordinal-bar.js";
export {default as penguinCulmen} from "./penguin-culmen.js";
export {default as penguinCulmenArray} from "./penguin-culmen-array.js";
+export {default as penguinCulmenBrush} from "./penguin-culmen-brush.js";
export {default as penguinIslandUnknown} from "./penguin-island-unknown.js";
export {default as penguinMass} from "./penguin-mass.js";
export {default as penguinMassSex} from "./penguin-mass-sex.js";
diff --git a/test/plots/penguin-culmen-brush.js b/test/plots/penguin-culmen-brush.js
new file mode 100644
index 0000000000..4f08af205e
--- /dev/null
+++ b/test/plots/penguin-culmen-brush.js
@@ -0,0 +1,39 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+import {html} from "htl";
+
+export default async function() {
+ const data = await d3.csv("data/penguins.csv", d3.autoType);
+ const plot = Plot.plot({
+ height: 600,
+ grid: true,
+ facet: {
+ data,
+ x: "sex",
+ y: "species",
+ marginRight: 80
+ },
+ marks: [
+ Plot.frame(),
+ Plot.dot(data, {
+ facet: "exclude",
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm",
+ r: 2,
+ fill: "#ddd"
+ }),
+ Plot.dot(data, {
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm"
+ }),
+ Plot.brush(data, {
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm"
+ })
+ ]
+ });
+ const output = html`