diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf8b8525cd..777743c7c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Observable Plot - Changelog
+## 0.4.2
+
+*Not yet released. These are forthcoming changes in the main branch.*
+
+Plot now supports [interaction marks](./README.md#interactions)! An interaction mark defines an interactive selection represented as a subset of the mark’s data. For example, the [brush mark](./README.md#brush) allows rectangular selection by clicking and dragging; you can use a brush to select points of interest from a scatterplot and show them in a table. The interactive selection is exposed as *plot*.value. When the selection changes during interaction, the plot emits *input* events. This allows plots to be [Observable views](https://observablehq.com/@observablehq/introduction-to-views), but you can also [listen to *input* events](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) directly.
+
## 0.4.1
Released February 17, 2022.
diff --git a/README.md b/README.md
index 345ceb7ae3..81a85590bf 100644
--- a/README.md
+++ b/README.md
@@ -1227,6 +1227,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 8ce1218383..5cda607e4e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -3,17 +3,20 @@ 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 {boxX, boxY} from "./marks/box.js";
+export {Brush, 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";
export {Image, image} from "./marks/image.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
export {Link, link} from "./marks/link.js";
+export {Pointer, pointer, pointerX, pointerY} from "./marks/pointer.js";
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
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..68a6afe7ef
--- /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}, dimensions)
+ .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, dimensions)
+ .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});
+}
+
+export function brushY(data, {y = identity, ...options} = {}) {
+ return new Brush(data, {...options, y});
+}
diff --git a/src/marks/pointer.js b/src/marks/pointer.js
new file mode 100644
index 0000000000..aa0d345de1
--- /dev/null
+++ b/src/marks/pointer.js
@@ -0,0 +1,248 @@
+import {create, namespaces, pointer as pointerof, quickselect, union} from "d3";
+import {identity, maybeTuple} from "../options.js";
+import {Mark} from "../plot.js";
+import {selection} from "../selection.js";
+import {applyDirectStyles, applyIndirectStyles} from "../style.js";
+
+const defaults = {
+ ariaLabel: "pointer",
+ fill: "none",
+ stroke: "#3b5fc0",
+ strokeWidth: 1.5
+};
+
+export class Pointer extends Mark {
+ constructor(data, {
+ x,
+ y,
+ n = 1,
+ r = isFinite(n) ? 120 : 20,
+ mode = "auto",
+ ...options
+ } = {}) {
+ super(
+ data,
+ [
+ {name: "x", value: x, scale: "x", optional: true},
+ {name: "y", value: y, scale: "y", optional: true}
+ ],
+ options,
+ defaults
+ );
+ this.n = +n;
+ this.r = +r;
+ this.mode = maybeMode(mode, x, y);
+ }
+ render(index, {x, y}, {x: X, y: Y}, dimensions) {
+ const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
+ const {mode, n, r} = this;
+ const r2 = r * r; // the squared radius; to determine points in proximity to the pointer
+ const down = new Set(); // the set of pointers that are currently down
+ let C = []; // a sparse index from index[i] to an svg:circle element
+ let P = null; // the persistent selection; a subset of index, or null
+
+ const g = create("svg:g");
+
+ const parent = g.append("g")
+ .call(applyIndirectStyles, this, dimensions)
+ .call(applyDirectStyles, this)
+ .node();
+
+ // Note that point scales also expose a bandwidth function, but that always
+ // returns zero. SVG will not render a stroked rect with zero width or
+ // height, so we’ll render these as lines instead.
+ const bx = x?.bandwidth?.();
+ const by = y?.bandwidth?.();
+
+ // The visual representation of the logical selection depends on which
+ // channels are available (x, y, or both) and whether the corresponding
+ // scales are band scales.
+ const createElement = X && Y
+ ? (bx && by ? i => element("rect", {x: X[i], y: Y[i], width: bx, height: by})
+ : bx ? i => element("line", {x1: X[i], x2: X[i] + bx, y1: Y[i], y2: Y[i]})
+ : by ? i => element("line", {x1: X[i], x2: X[i], y1: Y[i], y2: Y[i] + by})
+ : i => element("circle", {cx: X[i], cy: Y[i], r: 4}))
+ : X ? (bx ? i => element("rect", {x: X[i], y: marginTop, width: bx, height: height - marginBottom - marginTop})
+ : i => element("line", {x1: X[i], x2: X[i], y1: marginTop, y2: height - marginBottom}))
+ : (by ? i => element("rect", {x: marginLeft, y: Y[i], width: width - marginRight - marginLeft, height: by})
+ : i => element("line", {y1: Y[i], y2: Y[i], x1: marginLeft, x2: width - marginRight}));
+
+ // Renders the given logical selection S, a subset of index. Applies
+ // copy-on-write to the array of elements C. Returns true if the selection
+ // changed, and false otherwise.
+ function render(S) {
+ const SC = [];
+ let changed = false;
+
+ // Enter (append) the newly-selected elements. The order of elements is
+ // arbitrary, with the most recently selected datum on top.
+ S.forEach(i => {
+ let c = C[i];
+ if (!c) {
+ c = createElement(i);
+ parent.appendChild(c);
+ changed = true;
+ }
+ SC[i] = c;
+ });
+
+ // Exit (remove) the no-longer-selected elements.
+ C.forEach((c, i) => {
+ if (!SC[i]) {
+ c.remove();
+ changed = true;
+ }
+ });
+
+ if (changed) C = SC;
+ return changed;
+ }
+
+ // Selects the given logical selection S, a subset of index, or null if
+ // there is no selection.
+ function select(S) {
+ if (S === null) render([]);
+ else if (!render(S)) return;
+ node[selection] = S;
+ node.dispatchEvent(new Event("input", {bubbles: true}));
+ }
+
+ g.append("rect")
+ .attr("fill", "none")
+ .attr("pointer-events", "all")
+ .attr("width", width + marginLeft + marginRight)
+ .attr("height", height + marginTop + marginBottom)
+ .on("pointerdown pointerover pointermove", event => {
+
+ // On pointerdown, initiate a new persistent selection, P, or extend
+ // the existing persistent selection if the shift key is down; then
+ // add to P for as long as the pointer remains down. If there is no
+ // existing persistent selection on pointerdown, initialize P to the
+ // empty selection rather than the points near the pointer such that
+ // you can clear the persistent selection with a pointerdown followed
+ // by a pointerup. (See below.)
+ if (event.type === "pointerdown") {
+ const nop = !P;
+ down.add(event.pointerId);
+ if (nop || !event.shiftKey) P = [];
+ if (!nop && !event.shiftKey) return select(P);
+ }
+
+ // If any pointer is down, only consider pointers that are down.
+ if (P && !down.has(event.pointerId)) return;
+
+ // Adjust the pointer to account for band scales; for band scales, the
+ // data is mapped to the start of the band (e.g., a bar’s left edge).
+ let [mx, my] = pointerof(event);
+ if (x.bandwidth) mx -= x.bandwidth() / 2;
+ if (y.bandwidth) my -= y.bandwidth() / 2;
+
+ // Compute the current selection, S: the subset of index that is
+ // logically selected. Normally this should be an in-order subset of
+ // index, but it isn’t here because quickselect will reorder in-place
+ // if the n option is used!
+ let S = index;
+ switch (mode) {
+ case "xy": {
+ if (r < Infinity) {
+ S = S.filter(i => {
+ const dx = X[i] - mx, dy = Y[i] - my;
+ return dx * dx + dy * dy <= r2;
+ });
+ }
+ if (S.length > n) {
+ S = S.slice();
+ quickselect(S, n, undefined, undefined, (i, j) => {
+ const ix = X[i] - mx, iy = Y[i] - my;
+ const jx = X[j] - mx, jy = Y[j] - my;
+ return (ix * ix + iy * iy) - (jx * jx + jy * jy);
+ });
+ S = S.slice(0, n);
+ }
+ break;
+ }
+ case "x": {
+ if (r < Infinity) {
+ const [x0, x1] = [mx - r, mx + r];
+ S = S.filter(i => x0 <= X[i] && X[i] <= x1);
+ }
+ if (S.length > n) {
+ S = S.slice();
+ quickselect(S, n, undefined, undefined, (i, j) => {
+ const ix = X[i] - mx;
+ const jx = X[j] - mx;
+ return ix * ix - jx * jx;
+ });
+ S = S.slice(0, n);
+ }
+ break;
+ }
+ case "y": {
+ if (r < Infinity) {
+ const [y0, y1] = [my - r, my + r];
+ S = S.filter(i => y0 <= Y[i] && Y[i] <= y1);
+ }
+ if (S.length > n) {
+ S = S.slice();
+ quickselect(S, n, undefined, undefined, (i, j) => {
+ const iy = Y[i] - my;
+ const jy = Y[j] - my;
+ return iy * iy - jy * jy;
+ });
+ S = S.slice(0, n);
+ }
+ break;
+ }
+ }
+
+ // If there is a persistent selection, add the new selection to the
+ // persistent selection; otherwise just use the current selection.
+ select(P ? (P = Array.from(union(P, S))) : S);
+ })
+ .on("pointerup", event => {
+ // On pointerup, if the selection is empty, clear the persistent to
+ // selection to allow the ephemeral selection on subsequent hover.
+ if (P && !P.length) select(P = null);
+ down.delete(event.pointerId);
+ })
+ .on("pointerout", () => {
+ // On pointerout, if there is no persistent selection, clear the
+ // ephemeral selection.
+ if (!P) select(null);
+ });
+
+ const node = g.node();
+ node[selection] = null;
+ return node;
+ }
+}
+
+function maybeMode(mode = "auto", x, y) {
+ switch (mode = `${mode}`.toLowerCase()) {
+ case "auto": mode = y == null ? "x" : x == null ? "y" : "xy"; break;
+ case "x": case "y": case "xy": break;
+ default: throw new Error(`invalid mode: ${mode}`);
+ }
+ if (/^x/.test(mode) && x == null) throw new Error("missing channel: x");
+ if (/y$/.test(mode) && y == null) throw new Error("missing channel: y");
+ return mode;
+}
+
+function element(name, attrs) {
+ const e = document.createElementNS(namespaces.svg, name);
+ for (const key in attrs) e.setAttribute(key, attrs[key]);
+ return e;
+}
+
+export function pointer(data, {x, y, ...options} = {}) {
+ ([x, y] = maybeTuple(x, y));
+ return new Pointer(data, {...options, x, y});
+}
+
+export function pointerX(data, {mode = "x", x = identity, ...options} = {}) {
+ return new Pointer(data, {...options, mode, x});
+}
+
+export function pointerY(data, {mode = "y", y = identity, ...options} = {}) {
+ return new Pointer(data, {...options, mode, y});
+}
diff --git a/src/plot.js b/src/plot.js
index d619b6bd9d..879aa23725 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -1,11 +1,12 @@
-import {create, cross, difference, groups, InternMap, select} from "d3";
+import {create, cross, difference, groups, InternMap, select, 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, maybeClip, styles} from "./style.js";
import {basic} from "./transforms/basic.js";
import {consumeWarnings} from "./warnings.js";
@@ -96,14 +97,23 @@ 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);
let index = markIndex.get(mark);
if (mark.filter != null) index = mark.filter(index, 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.
let figure = svg;
@@ -121,6 +131,7 @@ export function plot(options = {}) {
figure.scale = exposeScales(scaleDescriptors);
figure.legend = exposeLegends(scaleDescriptors, options);
+ figure.value = initialValue;
const w = consumeWarnings();
if (w > 0) {
@@ -206,6 +217,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();
@@ -281,7 +296,7 @@ class Facet extends Mark {
return {index, channels: [...channels, ...subchannels]};
}
render(I, scales, _, dimensions, axes) {
- const {marks, marksChannels, marksIndexByFacet} = this;
+ const {data, channels, marks, marksChannels, marksIndexByFacet} = this;
const {fx, fy} = scales;
const fyDomain = fy && fy.domain();
const fxDomain = fx && fx.domain();
@@ -289,7 +304,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);
@@ -335,10 +351,25 @@ class Facet extends Mark {
let index = marksFacetIndex[i];
if (mark.filter != null) index = mark.filter(index, marksChannels[i], values);
const node = mark.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;
}
}
@@ -381,6 +412,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);
}
@@ -398,6 +443,9 @@ class FacetMap {
set(key, value) {
return this._.set(key, value), this;
}
+ values() {
+ return this._.values();
+ }
}
// A Map-like interface that supports paired keys.
@@ -416,4 +464,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/test/output/aaplVolumeBrushX.svg b/test/output/aaplVolumeBrushX.svg
new file mode 100644
index 0000000000..ebed1fb95a
--- /dev/null
+++ b/test/output/aaplVolumeBrushX.svg
@@ -0,0 +1,106 @@
+
\ No newline at end of file
diff --git a/test/output/aaplVolumePointerX.svg b/test/output/aaplVolumePointerX.svg
new file mode 100644
index 0000000000..a9c619ca80
--- /dev/null
+++ b/test/output/aaplVolumePointerX.svg
@@ -0,0 +1,104 @@
+
\ No newline at end of file
diff --git a/test/output/gistempAnomalyBrush.html b/test/output/gistempAnomalyBrush.html
new file mode 100644
index 0000000000..9f33ad8da9
--- /dev/null
+++ b/test/output/gistempAnomalyBrush.html
@@ -0,0 +1,1742 @@
+
\ No newline at end of file
diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html
new file mode 100644
index 0000000000..f86c9ef973
--- /dev/null
+++ b/test/output/gistempAnomalyPointer.html
@@ -0,0 +1,1734 @@
+
\ No newline at end of file
diff --git a/test/output/gistempDecadePointer.html b/test/output/gistempDecadePointer.html
new file mode 100644
index 0000000000..b084827ab2
--- /dev/null
+++ b/test/output/gistempDecadePointer.html
@@ -0,0 +1,1755 @@
+
\ No newline at end of file
diff --git a/test/output/letterFrequencyPointer.svg b/test/output/letterFrequencyPointer.svg
new file mode 100644
index 0000000000..481b689005
--- /dev/null
+++ b/test/output/letterFrequencyPointer.svg
@@ -0,0 +1,161 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenBrush.html b/test/output/penguinCulmenBrush.html
new file mode 100644
index 0000000000..59a710f28f
--- /dev/null
+++ b/test/output/penguinCulmenBrush.html
@@ -0,0 +1,3036 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenBrushX.html b/test/output/penguinCulmenBrushX.html
new file mode 100644
index 0000000000..e499d3780b
--- /dev/null
+++ b/test/output/penguinCulmenBrushX.html
@@ -0,0 +1,2988 @@
+
\ No newline at end of file
diff --git a/test/plots/aapl-volume-brush-x.js b/test/plots/aapl-volume-brush-x.js
new file mode 100644
index 0000000000..17c2e9a581
--- /dev/null
+++ b/test/plots/aapl-volume-brush-x.js
@@ -0,0 +1,21 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const data = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {
+ round: true,
+ label: "Trade volume (log₁₀) →"
+ },
+ y: {
+ grid: true,
+ percent: true
+ },
+ marks: [
+ Plot.rectY(data, Plot.binX({y: "proportion"}, {x: d => Math.log10(d.Volume)})),
+ Plot.ruleY([0]),
+ Plot.brushX(data, {x: d => Math.log10(d.Volume)})
+ ]
+ });
+}
diff --git a/test/plots/aapl-volume-pointer-x.js b/test/plots/aapl-volume-pointer-x.js
new file mode 100644
index 0000000000..3db7366eb6
--- /dev/null
+++ b/test/plots/aapl-volume-pointer-x.js
@@ -0,0 +1,21 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const data = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {
+ round: true,
+ label: "Trade volume (log₁₀) →"
+ },
+ y: {
+ grid: true,
+ percent: true
+ },
+ marks: [
+ Plot.rectY(data, Plot.binX({y: "proportion"}, {x: d => Math.log10(d.Volume)})),
+ Plot.ruleY([0]),
+ Plot.pointerX(data, {x: d => Math.log10(d.Volume)})
+ ]
+ });
+}
diff --git a/test/plots/gistemp-anomaly-brush.js b/test/plots/gistemp-anomaly-brush.js
new file mode 100644
index 0000000000..cad771c8a7
--- /dev/null
+++ b/test/plots/gistemp-anomaly-brush.js
@@ -0,0 +1,27 @@
+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/gistemp.csv", d3.autoType);
+ const plot = 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"})
+ ]
+ });
+ const output = html`