diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 5aaf69e194..5bd7b4755e 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -133,6 +133,7 @@ export default defineConfig({
text: "Interactions",
collapsed: true,
items: [
+ {text: "Brush", link: "/interactions/brush"},
{text: "Crosshair", link: "/interactions/crosshair"},
{text: "Pointer", link: "/interactions/pointer"}
]
diff --git a/docs/data/api.data.ts b/docs/data/api.data.ts
index 3efbb7c156..22ee311514 100644
--- a/docs/data/api.data.ts
+++ b/docs/data/api.data.ts
@@ -62,6 +62,8 @@ function getHref(name: string, path: string): string {
}
break;
}
+ case "marks/brush":
+ return "interactions/brush";
case "marks/crosshair":
return "interactions/crosshair";
case "transforms/basic": {
@@ -140,7 +142,9 @@ export default {
throw new Error(`anchor not found: ${href}#${name}`);
}
}
- for (const {context: {href}} of allOptions) {
+ for (const {
+ context: {href}
+ } of allOptions) {
if (!anchors.has(`/${href}.md`)) {
throw new Error(`file not found: ${href}`);
}
diff --git a/docs/features/interactions.md b/docs/features/interactions.md
index bf1dae5741..b36e4e4e83 100644
--- a/docs/features/interactions.md
+++ b/docs/features/interactions.md
@@ -9,8 +9,14 @@ const olympians = shallowRef([
{weight: 170, height: 2.21, sex: "male"}
]);
+const penguins = shallowRef([
+ {culmen_length_mm: 32.1, culmen_depth_mm: 13.1},
+ {culmen_length_mm: 59.6, culmen_depth_mm: 21.5}
+]);
+
onMounted(() => {
d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data));
+ d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data));
});
@@ -54,7 +60,23 @@ These values are displayed atop the axes on the edge of the frame; unlike the ti
## Selecting
-Support for selecting points within a plot through direct manipulation is under development. If you are interested in this feature, please upvote [#5](https://github.com/observablehq/plot/issues/5). See [#721](https://github.com/observablehq/plot/pull/721) for some early work on brushing.
+The [brush transform](../interactions/brush.md) allows the interactive selection of discrete elements by direct manipulation of the chart.
+
+:::plot defer https://observablehq.com/@observablehq/brushing-plot--1653
+```js
+Plot.dot(
+ penguins,
+ Plot.brush({
+ x: "culmen_length_mm",
+ y: "culmen_depth_mm",
+ stroke: "currentColor",
+ fill: "#fff",
+ unselected: {strokeOpacity: 0.5},
+ selected: {fill: "species"}
+ })
+).plot()
+```
+:::
## Zooming
diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md
new file mode 100644
index 0000000000..bc156ab972
--- /dev/null
+++ b/docs/interactions/brush.md
@@ -0,0 +1,106 @@
+
+
+# Brush transform
+
+The **brush transform** allows the interactive selection of discrete elements, such as dots in a scatterplot, by direct manipulation of the chart. A brush listens to mouse and touch events on the chart, allowing the user to define a rectangular region. All the data points that fall within the region are included in the selection.
+
+:::plot defer
+```js
+Plot.dot(
+ penguins,
+ Plot.brush({
+ x: "culmen_length_mm",
+ y: "culmen_depth_mm",
+ stroke: "currentColor",
+ fill: "#fff",
+ unselected: {strokeOpacity: 0.5},
+ selected: {fill: "species"}
+ })
+).plot()
+```
+:::
+
+When the chart has a dominant axis, an horizontal or vertical brush is recommended; for example, to select bars in a histogram:
+
+:::plot defer
+```js
+Plot.rectY(
+ penguins,
+ Plot.brushX(
+ Plot.binX(
+ {y: "count"},
+ {
+ x: "body_mass_g",
+ thresholds: 40,
+ unselected: {opacity: 0.1},
+ }
+ )
+ )
+).plot()
+```
+:::
+
+The brush transform interactively partitions the mark’s index in two: the unselected subset — for points outside the region —, and the selected subset for points inside. As the selection changes, the mark is replaced by two derived marks: below, a mark for the unselected data, with the mark options combined with the **unselected** option; above, a mark for the selected data, with the mark options combined with the **selected** option. All the channel values are incorporated into default scale domains, allowing *e.g.* a color scale to include the fill channel of the selected mark.
+
+The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush), is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushX) and [brushY](#brushY), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart.
+
+The brush transform emits an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes, and sets the value of the plot element to the selected data. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views) (viewof), or to register an *input* event listener to react to brushing.
+
+## Brush options
+
+The following options control the brush transform:
+
+- **x1** - the starting horizontal↔︎ target position; bound to the *x* scale
+- **y1** - the starting vertical↕︎ target position; bound to the *y* scale
+- **x2** - the ending horizontal↔︎ target position; bound to the *x* scale
+- **y2** - the ending vertical↕︎ target position; bound to the *y* scale
+- **x** - the fallback horizontal↔︎ target position; bound to the *x* scale
+- **y** - the fallback vertical↕︎ target position; bound to the *y* scale
+- **selected** - additional options for the derived mark representing the selection
+- **unselected** - additional options for the derived mark representing non-selected data
+
+The positional options define a sensitive surface for each data point, defined on the horizontal axis as the extent between *x1* and *x2* if specified, between *x* and *x + bandwidth* if *x* is a band scale, or the value *x* otherwise. The sensitive surface’s vertical extent likewise spans from *y1* to *y2* if specified, from *y* to *y + bandwidth* if *y* is a band scale, or is equal to the *y* value otherwise.
+
+When the user interacts with the plot by clicking and dragging the brush to define a rectangular region, all the elements whose sensitive surface intersect with the brushed region are selected, and the derived marks are re-rendered.
+
+The selected data exposed as the value of the plot is an array of the (possibly transformed) data rendered by the *selected* derived mark. For example, in the case of the histogram above, the selected data is an array of bins, each containing the penguins whose body mass is between the bin’s lower and upper bounds.
+
+The value is decorated with the brush’s coordinates (in data space) as its **x1** and **x2** properties for a quantitative scale *x*, and its **x** property if *x* is ordinal — and likewise for *y*. The value is also decorated with a **done** property set to false while brushing, true when the user releases the pointer, and undefined when the brush is canceled. Additionally, when faceting, it exposes the brushed facet’s *fx* and *fy* properties.
+
+For details on the user interface (including touch events, pointer events and modifier keys), see [d3-brush](https://github.com/d3/d3-brush).
+
+## brush(*options*) {#brush}
+
+```js
+Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm"}))
+```
+
+Applies the brush render transform to the specified *options* to filter the mark index such that the points whose sensitive surface intersect with the brushed region the point closest to the pointer is rendered.
+
+## brushX(*options*) {#brushX}
+
+```js
+Plot.tip(aapl, Plot.pointerX({x: "Date", y: "Close"}))
+```
+
+Like [brush](#brush), except the determination of the intersection exclusively considers the *x* (horizontal↔︎) position; this should be used for plots where *x* is the dominant dimension, such as the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart.
+
+## brushY(*options*) {#brushY}
+
+```js
+Plot.tip(alphabet, Plot.pointerY({x: "frequency", y: "letter"}))
+```
+
+Like [brush](#brush), except the determination of the intersection exclusively considers the *y* (vertical↕) position; this should be used for plots where *y* is the dominant dimension.
diff --git a/src/channel.js b/src/channel.js
index 4fb46c3448..ac699f1f6a 100644
--- a/src/channel.js
+++ b/src/channel.js
@@ -41,6 +41,8 @@ export function valueObject(channels, scales) {
// promote symbol names (e.g., "plus") to symbol implementations (symbolPlus).
// Note: mutates channel!
export function inferChannelScale(name, channel) {
+ if (name === undefined) name = channel.scale; // TODO fixme
+ else name = name.replace(/^\w+:/, ""); // XXX
const {scale, value} = channel;
if (scale === true || scale === "auto") {
switch (name) {
diff --git a/src/index.d.ts b/src/index.d.ts
index dcaa949da8..62aa22b0fe 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -4,6 +4,7 @@ export * from "./curve.js";
export * from "./dimensions.js";
export * from "./format.js";
export * from "./inset.js";
+export * from "./interactions/brush.js";
export * from "./interactions/pointer.js";
export * from "./interval.js";
export * from "./legends.js";
diff --git a/src/index.js b/src/index.js
index 9fde7ce2d5..8cf99acf98 100644
--- a/src/index.js
+++ b/src/index.js
@@ -44,6 +44,7 @@ export {window, windowX, windowY} from "./transforms/window.js";
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
export {treeNode, treeLink} from "./transforms/tree.js";
+export {brush, brushX, brushY} from "./interactions/brush.js";
export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
export {scale} from "./scales.js";
diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts
new file mode 100644
index 0000000000..40a6bef04b
--- /dev/null
+++ b/src/interactions/brush.d.ts
@@ -0,0 +1,42 @@
+import type {Rendered} from "../transforms/basic.js";
+
+/** Options for the brush transform. */
+type BrushOptions = {
+ /**
+ * How to display the selected mark when the user manipulates the brush.
+ */
+ selected?: null; // TODO
+ /**
+ * How to display the unselected mark when the user manipulates the brush.
+ */
+ unselected?: null;
+ /**
+ * The brush’s padding, defaults to 1.
+ */
+ padding?: number;
+};
+
+/**
+ * Applies a render transform to the specified *options* to filter the mark
+ * index such that only the point closest to the pointer is rendered; the mark
+ * will re-render interactively in response to pointer events.
+ */
+export function brush(options: T & BrushOptions): Rendered;
+
+/**
+ * Like the pointer transform, except the determination of the closest point
+ * considers mostly the *x* (horizontal↔︎) position; this should be used for
+ * plots where *x* is the dominant dimension, such as time in a time-series
+ * chart, the binned quantitative dimension in a histogram, or the categorical
+ * dimension of a bar chart.
+ */
+export function brushX(options: T & BrushOptions): Rendered;
+
+/**
+ * Like the pointer transform, except the determination of the closest point
+ * considers mostly the *y* (vertical↕︎) position; this should be used for plots
+ * where *y* is the dominant dimension, such as time in a time-series chart, the
+ * binned quantitative dimension in a histogram, or the categorical dimension of
+ * a bar chart.
+ */
+export function brushY(options: T & BrushOptions): Rendered;
diff --git a/src/interactions/brush.js b/src/interactions/brush.js
new file mode 100644
index 0000000000..9dc1e6fdde
--- /dev/null
+++ b/src/interactions/brush.js
@@ -0,0 +1,172 @@
+import {ascending, transpose, select} from "d3";
+import {brush as brusher, brushX as brusherX, brushY as brusherY} from "d3";
+import {take} from "../options.js";
+
+const states = new WeakMap();
+
+function brushTransform(mode, {selected = {}, unselected = {}, padding = 1, ...options}) {
+ if (typeof padding !== "number") throw new Error(`invalid brush padding: ${padding}`);
+ return {
+ ...options,
+ creator() {
+ const s = (this.selected = new this.constructor(this.data, {...options, ...selected}));
+ const u = (this.unselected = new this.constructor(this.data, {...options, ...unselected}));
+ for (const c in selected) if (c in s.channels) this.channels[`selected:${c}`] = s.channels[c];
+ for (const c in unselected) if (c in u.channels) this.channels[`unselected:${c}`] = u.channels[c];
+ },
+ render: function (index, scales, values, dimensions, context, next) {
+ const s = this.selected;
+ const u = this.unselected;
+ const svalues = {...values};
+ const uvalues = {...values};
+ for (const c in selected) svalues[c] = c in s.channels ? values[`selected:${c}`] : undefined;
+ for (const c in unselected) uvalues[c] = c in u.channels ? values[`unselected:${c}`] : undefined;
+ const g = options.render
+ ? options.render(index, scales, values, dimensions, context, next)
+ : next(index, scales, values, dimensions, context);
+ const {data} = context.getMarkState(this); // TODO: data might be attached to values in the future(?)
+
+ const svg = context.ownerSVGElement;
+ function createBrush() {
+ // Isolate state per-plot.
+ let state = states.get(svg);
+ const transform = g.getAttribute("transform");
+ if (state) {
+ if (!index.fi) throw new Error("The brush interaction currently supports only one brush per plot.");
+ } else {
+ // Derive intersection bounds.
+ const {x, y} = scales.scales;
+ const bx = x?.bandwidth ?? 0;
+ const by = y?.bandwidth ?? 0;
+ const {x: X, x1: X1, x2: X2, y: Y, y1: Y1, y2: Y2} = values;
+ const Xl = X1 && X2 ? X1.map((d, i) => Math.min(d, X2[i])) : X;
+ const Xm = X1 && X2 ? X1.map((d, i) => Math.max(d, X2[i]) + bx) : bx ? X.map((d) => d + bx) : X;
+ const Yl = Y1 && Y2 ? Y1.map((d, i) => Math.min(d, Y2[i])) : Y;
+ const Ym = Y1 && Y2 ? Y1.map((d, i) => Math.max(d, Y2[i]) + by) : by ? Y.map((d) => d + by) : Y;
+
+ // This brush is shared by all the facets.
+ const {width, height, marginLeft, marginTop, marginRight, marginBottom} = dimensions;
+ const extent = [
+ [marginLeft - padding, marginTop - padding],
+ [width - marginRight + padding, height - marginBottom + padding]
+ ];
+ const brush = (mode === "xy" ? brusher : mode === "x" ? brusherX : brusherY)()
+ .extent(extent)
+ .on("start brush end", function ({type, selection, sourceEvent}, fi) {
+ const b = state.brushState[fi];
+ const {index, transform, g} = b;
+ const [X, Y] = !selection
+ ? []
+ : mode === "xy"
+ ? transpose(selection)
+ : mode === "x"
+ ? [selection]
+ : [, selection];
+
+ const S = [];
+ const U = [];
+ for (const i of index) {
+ [U, S][+((!X || (X[0] < Xm[i] && Xl[i] < X[1])) && (!Y || (Y[0] < Ym[i] && Yl[i] < Y[1])))].push(i);
+ }
+ if (!b.ug) {
+ g.replaceWith((b.sg = document.createComment("selected")));
+ b.sg.parentNode.insertBefore((b.ug = document.createComment("unselected")), b.sg);
+ }
+ b.ug.replaceWith((b.ug = u.render(U, scales, uvalues, dimensions, context)));
+ b.sg.replaceWith((b.sg = s.render(S, scales, svalues, dimensions, context)));
+ if (transform) {
+ b.ug.setAttribute("transform", transform);
+ b.sg.setAttribute("transform", transform);
+ }
+
+ switch (type) {
+ // Only one facet can be active at a time; clear and unselect the others.
+ case "start":
+ if (sourceEvent && sourceEvent.type !== "empty")
+ for (const {i, empty} of state.brushState) if (i !== fi) empty();
+ break;
+ case "end":
+ if (selection === null) {
+ b.sg.replaceWith(g);
+ b.ug.remove();
+ b.sg = null;
+ b.ug = null;
+ if (sourceEvent) for (const {i, reset} of state.brushState) if (i !== fi) reset();
+ }
+ break;
+ }
+
+ // Update the plot’s value.
+ const value = selection === null ? data : take(data, S);
+ if (selection !== null) {
+ if (X) addBrushDomain("x", x, X, value);
+ if (Y) addBrushDomain("y", y, Y, value);
+ if ("fx" in scales) value.fx = index.fx;
+ if ("fy" in scales) value.fy = index.fy;
+ value.done = type === "end";
+ }
+ context.dispatchValue(value);
+ });
+ states.set(svg, (state = {brush, brushState: [], selection: null}));
+ }
+ const {brush, brushState} = state;
+ const fi = index.fi ?? 0;
+ const target = select(g.parentElement)
+ .append("g")
+ .attr("aria-label", "brush")
+ .attr("transform", transform)
+ .datum(fi)
+ .call(brush);
+ brushState[fi] = {
+ i: fi,
+ index,
+ transform,
+ g,
+ reset() {
+ target.call(brush.move, null);
+ },
+ empty() {
+ target.call(
+ brush.move,
+ mode === "xy"
+ ? [
+ [0, 0],
+ [0, 0]
+ ]
+ : [0, 0]
+ );
+ }
+ };
+ svg.removeEventListener("pointerenter", createBrush);
+ }
+ svg.addEventListener("pointerenter", createBrush);
+ context.dispatchValue(data);
+ return g;
+ }
+ };
+}
+
+export function brush(options = {}) {
+ return brushTransform("xy", options);
+}
+
+export function brushX(options = {}) {
+ return brushTransform("x", options);
+}
+
+export function brushY(options = {}) {
+ return brushTransform("y", options);
+}
+
+// Note: mutates value!
+function addBrushDomain(k, x, X, value) {
+ if (x.type === "band" || x.type === "point") {
+ const b = x.bandwidth ?? 0;
+ value[k] = x.domain.filter((d) => {
+ const v = x.apply(d);
+ return X[0] < v + b && v < X[1];
+ });
+ } else {
+ [value[`${k}1`], value[`${k}2`]] = x.invert ? X.map(x.invert).sort(ascending) : X;
+ }
+}
diff --git a/src/mark.js b/src/mark.js
index 0e867689b5..5611f89bf2 100644
--- a/src/mark.js
+++ b/src/mark.js
@@ -22,6 +22,7 @@ export class Mark {
marginRight = margin,
marginBottom = margin,
marginLeft = margin,
+ creator,
clip = defaults?.clip,
channels: extraChannels,
tip,
@@ -85,6 +86,7 @@ export class Mark {
if (render != null) {
this.render = composeRender(render, this.render);
}
+ creator?.call(this, options); // XXX
}
initialize(facets, facetChannels, plotOptions) {
let data = arrayify(this.data);
diff --git a/src/plot.js b/src/plot.js
index 628108bd65..9a6757c455 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -175,7 +175,7 @@ export function plot(options = {}) {
context.dispatchValue = (value) => {
if (figure.value === value) return;
figure.value = value;
- figure.dispatchEvent(new Event("input", {bubbles: true}));
+ if (figure.isConnected) figure.dispatchEvent(new Event("input", {bubbles: true}));
};
// Reinitialize; for deriving channels dependent on other channels.
diff --git a/test/output/brushBand.html b/test/output/brushBand.html
new file mode 100644
index 0000000000..59ce036d7f
--- /dev/null
+++ b/test/output/brushBand.html
@@ -0,0 +1,53 @@
+
\ No newline at end of file
diff --git a/test/output/brushFacets.html b/test/output/brushFacets.html
new file mode 100644
index 0000000000..38a364aa3e
--- /dev/null
+++ b/test/output/brushFacets.html
@@ -0,0 +1,475 @@
+
\ No newline at end of file
diff --git a/test/output/brushMetroInequalityChange.html b/test/output/brushMetroInequalityChange.html
new file mode 100644
index 0000000000..0881b66a41
--- /dev/null
+++ b/test/output/brushMetroInequalityChange.html
@@ -0,0 +1,435 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/brushRectX.html b/test/output/brushRectX.html
new file mode 100644
index 0000000000..64c7c7c142
--- /dev/null
+++ b/test/output/brushRectX.html
@@ -0,0 +1,85 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/brushRectY.html b/test/output/brushRectY.html
new file mode 100644
index 0000000000..f7b80bb45f
--- /dev/null
+++ b/test/output/brushRectY.html
@@ -0,0 +1,85 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/brushScatterplot.html b/test/output/brushScatterplot.html
new file mode 100644
index 0000000000..89a51b310f
--- /dev/null
+++ b/test/output/brushScatterplot.html
@@ -0,0 +1,402 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/highlightDot.html b/test/output/highlightDot.html
new file mode 100644
index 0000000000..6202863d1e
--- /dev/null
+++ b/test/output/highlightDot.html
@@ -0,0 +1,434 @@
+
+