diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md
index 6413afd287..7c3788b88e 100644
--- a/docs/interactions/brush.md
+++ b/docs/interactions/brush.md
@@ -20,7 +20,9 @@ onMounted(() => {
# Brush mark
-The **brush mark** renders a two-dimensional [brush](https://d3js.org/d3-brush) that allows the user to select a rectangular region by clicking and dragging. It is typically used to highlight a subset of data, or to filter data for display in a linked view.
+The **brush mark** renders a [brush](https://d3js.org/d3-brush) that allows the user to select a region by clicking and dragging. It is typically used to highlight a subset of data, or to filter data for display in a linked view.
+
+## 2-D brushing
:::plot hidden
```js
@@ -42,8 +44,7 @@ Plot.plot({
})
```
-The brush mark does not require data. When added to a plot, it renders a [brush](https://d3js.org/d3-brush) overlay covering the frame. The user can click and drag to create a rectangular selection, drag the selection to reposition it, or drag an edge or corner to resize it. Clicking outside the selection clears it.
-
+The user can click and drag to create a rectangular selection, drag the selection to reposition it, or drag an edge or corner to resize it. Clicking outside the selection clears it.
## 1-D brushing
@@ -144,6 +145,65 @@ Plot.plot({
To achieve higher contrast, you can place the brush before the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't obstruct pointer events.
:::
+## Data and options
+
+The brush accepts optional *data* and *options*. When the options specify **x**, **y**, **fx**, or **fy** channels, these become defaults for the associated reactive marks.
+
+:::plot defer hidden
+```js
+Plot.plot({
+ marks: ((brush) => [
+ brush,
+ Plot.dot(penguins, brush.inactive({fill: "species", r: 2})),
+ Plot.dot(penguins, brush.context({fill: "#ccc", r: 2})),
+ Plot.dot(penguins, brush.focus({fill: "species", r: 3}))
+ ])(Plot.brush(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}))
+})
+```
+:::
+
+```js
+const brush = Plot.brush(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"});
+Plot.plot({
+ marks: [
+ brush,
+ Plot.dot(penguins, brush.inactive({fill: "species", r: 2})),
+ Plot.dot(penguins, brush.context({fill: "#ccc", r: 2})),
+ Plot.dot(penguins, brush.focus({fill: "species", r: 3}))
+ ]
+})
+```
+
+If neither **x** nor **y** is specified, *data* is assumed to be an array of values, such as [*x₀*, *x₁*, …] for 1-dimensional brushes, or an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], …] for 2-dimensional brushes.
+
+```js
+const brush = Plot.brush(points);
+```
+
+### Selection styling
+
+The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, and **strokeOpacity** options style the brush selection rectangle, overriding D3's defaults.
+
+```js
+const brush = Plot.brush(penguins, {
+ x: "culmen_length_mm",
+ y: "culmen_depth_mm",
+ stroke: "currentColor",
+ strokeWidth: 1.5
+});
+```
+
+### Filtered data
+
+When the brush has *data*, the [Region](#region) includes a **data** property containing the subset filtered by the selection.
+
+```js
+plot.addEventListener("input", () => {
+ console.log(plot.value?.data); // filtered subset of the brush's data
+ const selected = otherData.filter((d) => plot.value?.filter(d.x, d.y)); // filter a different dataset
+});
+```
+
## Faceting
The brush mark supports [faceting](../features/facets.md). When the plot uses **fx** or **fy** facets, each facet gets its own brush. Starting a brush in one facet clears any selection in other facets. The dispatched value includes the **fx** and **fy** facet values of the brushed facet; optionally pass `{fx, fy}` as the last argument to **contains** to restrict matching to the brushed facet.
@@ -227,16 +287,17 @@ The brush value dispatched on [_input_ events](#input-events). When the brush is
- **fx** - the *fx* facet value, if applicable
- **fy** - the *fy* facet value, if applicable
- **contains** - a method to test whether a point is inside the selection; see [input events](#input-events)
+- **data** - when the brush has data, the filtered subset
By convention, *x1* < *x2* and *y1* < *y2*. The brushX value does not include *y1* and *y2*; similarly, the brushY value does not include *x1* and *x2*. Values are automatically rounded to the optimal precision that distinguishes neighboring pixels.
-## brush() {#brush}
+## brush(*data*, *options*) {#brush}
```js
const brush = Plot.brush()
```
-Returns a new brush. The mark exposes the **inactive**, **context**, and **focus** methods for creating reactive marks that respond to the brush state.
+Returns a new brush with the given *data* and *options*. Both *data* and *options* are optional. If *data* is specified but neither **x** nor **y** is specified in the *options*, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], …] such that **x** = [*x₀*, *x₁*, …] and **y** = [*y₀*, *y₁*, …].
## *brush*.inactive(*options*) {#brush-inactive}
@@ -284,13 +345,13 @@ brush.move(null)
For projected plots, the coordinates are in pixels (consistent with the [Region](#region)), so you need to project the two corners of the brush beforehand. In the future Plot might expose its *projection* to facilitate this. Please upvote [this issue](https://github.com/observablehq/plot/issues/1191) to help prioritize this feature.
-## brushX(*options*) {#brushX}
+## brushX(*data*, *options*) {#brushX}
```js
const brush = Plot.brushX()
```
-Returns a new horizontal brush mark that selects along the *x* axis. The available *options* are:
+Returns a new horizontal brush mark that selects along the *x* axis. If *data* is specified without an **x** channel, each datum is used as the *x* value directly. In addition to the [brush options](#data-and-options), the *interval* option is supported:
- **interval** - an interval to snap the brush to on release; a number for quantitative scales (_e.g._, `100`), a time interval name for temporal scales (_e.g._, `"month"`), or an object with *floor* and *offset* methods
@@ -323,10 +384,10 @@ Plot.plot({
The brushX mark does not support projections.
-## brushY(*options*) {#brushY}
+## brushY(*data*, *options*) {#brushY}
```js
const brush = Plot.brushY()
```
-Returns a new vertical brush mark that selects along the *y* axis. Accepts the same *options* as [brushX](#brushX).
+Returns a new vertical brush mark that selects along the *y* axis. If *data* is specified without a **y** channel, each datum is used as the *y* value directly. For the other options, see [brushX](#brushX).
diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts
index c3d2ecde31..0858b6c1a5 100644
--- a/src/interactions/brush.d.ts
+++ b/src/interactions/brush.d.ts
@@ -1,5 +1,6 @@
+import type {ChannelValueSpec} from "../channel.js";
import type {Interval} from "../interval.js";
-import type {RenderableMark} from "../mark.js";
+import type {Data, MarkOptions, RenderableMark} from "../mark.js";
import type {Rendered} from "../transforms/basic.js";
/**
@@ -22,6 +23,9 @@ export class Region {
fx?: any;
/** The *fy* facet value, if applicable. */
fy?: any;
+ /** When the brush has data, the subset of data matching the selection. */
+ data?: any[];
+
/**
* Tests whether a point falls inside the brush selection.
*
@@ -33,6 +37,33 @@ export class Region {
contains(x: any, y?: any, facets?: {fx?: any; fy?: any}): boolean;
}
+/** Options for the brush mark. */
+export interface BrushOptions extends MarkOptions {
+ /**
+ * The horizontal position channel, typically bound to the *x* scale. When
+ * specified, inherited by reactive marks as a default.
+ */
+ x?: ChannelValueSpec;
+
+ /**
+ * The vertical position channel, typically bound to the *y* scale. When
+ * specified, inherited by reactive marks as a default.
+ */
+ y?: ChannelValueSpec;
+
+ /**
+ * The horizontal facet channel, bound to the *fx* scale. When specified,
+ * inherited by reactive marks as a default.
+ */
+ fx?: MarkOptions["fx"];
+
+ /**
+ * The vertical facet channel, bound to the *fy* scale. When specified,
+ * inherited by reactive marks as a default.
+ */
+ fy?: MarkOptions["fy"];
+}
+
/**
* A mark that renders a [brush](https://d3js.org/d3-brush) allowing the user to
* select a region. The brush coordinates across facets, clearing previous
@@ -44,6 +75,14 @@ export class Region {
* reactive marks that respond to the brush state.
*/
export class Brush extends RenderableMark {
+ /**
+ * Creates a new brush mark with the given *data* and *options*. If *data* and
+ * *options* specify **x** and **y** channels, these become defaults for
+ * reactive marks (**inactive**, **context**, **focus**). The **fill**,
+ * **fillOpacity**, **stroke**, **strokeWidth**, and **strokeOpacity** options
+ * style the brush selection rectangle.
+ */
+ constructor(data?: Data, options?: BrushOptions);
/**
* Returns mark options that show the mark when no brush selection is active,
* and hide it during brushing. Use this for the default appearance.
@@ -74,22 +113,36 @@ export class Brush extends RenderableMark {
): void;
}
-/** Creates a new two-dimensional brush mark. */
-export function brush(): Brush;
+/**
+ * Creates a new brush mark with the given *data* and *options*. If neither
+ * **x** nor **y** is specified, they default to the first and second
+ * element of each datum, assuming [*x*, *y*] pairs.
+ */
+export function brush(options?: BrushOptions): Brush;
+export function brush(data?: Data, options?: BrushOptions): Brush;
-/** Options for brush marks. */
-export interface BrushOptions {
+/** Options for 1-dimensional brush marks. */
+export interface Brush1DOptions extends BrushOptions {
/**
* An interval to snap the brush to, such as a number for quantitative scales
* or a time interval name like *month* for temporal scales. On brush end, the
- * selection is rounded to the nearest interval boundaries. Supported by the
- * 1-dimensional marks brushX and brushY.
+ * selection is rounded to the nearest interval boundaries.
*/
interval?: Interval;
}
-/** Creates a one-dimensional brush mark along the *x* axis. Not supported with projections. */
-export function brushX(options?: BrushOptions): Brush;
+/**
+ * Creates a one-dimensional brush mark along the *x* axis. If *data* is
+ * specified without an **x** channel, each datum is used as the *x* value
+ * directly. Not supported with projections.
+ */
+export function brushX(options?: Brush1DOptions): Brush;
+export function brushX(data?: Data, options?: Brush1DOptions): Brush;
-/** Creates a one-dimensional brush mark along the *y* axis. Not supported with projections. */
-export function brushY(options?: BrushOptions): Brush;
+/**
+ * Creates a one-dimensional brush mark along the *y* axis. If *data* is
+ * specified without a **y** channel, each datum is used as the *y* value
+ * directly. Not supported with projections.
+ */
+export function brushY(options?: Brush1DOptions): Brush;
+export function brushY(data?: Data, options?: Brush1DOptions): Brush;
diff --git a/src/interactions/brush.js b/src/interactions/brush.js
index 2b2e54eda1..874f15db26 100644
--- a/src/interactions/brush.js
+++ b/src/interactions/brush.js
@@ -9,17 +9,21 @@ import {
ascending
} from "d3";
import {composeRender, Mark} from "../mark.js";
-import {constant, keyword, maybeInterval} from "../options.js";
+import {constant, dataify, identity, isIterable, keyword, maybeInterval, maybeTuple, take} from "../options.js";
import {pixelRound} from "../precision.js";
+import {applyAttr} from "../style.js";
+
+const defaults = {ariaLabel: "brush", fill: "#777", fillOpacity: 0.3, stroke: "#fff"};
export class Region {
- constructor({x1, x2, y1, y2, fx, fy, pending} = {}) {
+ constructor({x1, x2, y1, y2, fx, fy, data, pending} = {}) {
if (x1 !== undefined) this.x1 = x1;
if (x2 !== undefined) this.x2 = x2;
if (y1 !== undefined) this.y1 = y1;
if (y2 !== undefined) this.y2 = y2;
if (fx !== undefined) this.fx = fx;
if (fy !== undefined) this.fy = fy;
+ if (data !== undefined) this.data = data;
if (pending !== undefined) this.pending = pending;
}
contains(x, y, facets) {
@@ -44,20 +48,34 @@ export class Region {
}
export class Brush extends Mark {
- constructor({dimension = "xy", interval, sync = false} = {}) {
- super(undefined, {}, {}, {});
+ constructor(data, {dimension = "xy", interval, sync = false, ...options} = {}) {
+ const {x, y, z} = options;
+ super(
+ dataify(data),
+ {
+ x: {value: x, scale: "x", optional: true},
+ y: {value: y, scale: "y", optional: true}
+ },
+ options,
+ defaults
+ );
this._dimension = keyword(dimension, "dimension", ["x", "y", "xy"]);
this._interval = interval == null ? null : maybeInterval(interval);
this._sync = sync;
- this._states = []; // per-plot state: {brush, nodes, applyX, applyY}
+ this._states = []; // per-plot state: {brush, nodes, applyX, applyY, svg}
this._syncing = false;
- this.inactive = renderFilter(true);
- this.context = renderFilter(false);
- this.focus = renderFilter(false);
+ const channelDefaults = {x, y, z, fx: this.fx, fy: this.fy};
+ this.inactive = renderFilter(true, channelDefaults);
+ this.context = renderFilter(false, channelDefaults);
+ this.focus = renderFilter(false, channelDefaults);
}
render(index, scales, values, dimensions, context) {
if (typeof document === "undefined") return null;
const {x, y, fx, fy} = scales;
+ const X = values.channels?.x?.value;
+ const Y = values.channels?.y?.value;
+ const FX = values.channels?.fx?.value;
+ const FY = values.channels?.fy?.value;
const {inactive, context: ctx, focus, _states} = this;
// Per-plot state; context.interaction is fresh for each plot.
@@ -78,6 +96,20 @@ export class Brush extends Mark {
_states.push(state);
context.dispatchValue(null);
const sync = this._sync;
+ const {data} = this;
+ const filterData =
+ data != null &&
+ ((region) =>
+ take(
+ data,
+ index.filter((_, i) =>
+ region.contains(
+ dim !== "y" ? X[i] : undefined,
+ dim !== "x" ? Y[i] : undefined,
+ FX || FY ? {fx: FX?.[i], fy: FY?.[i]} : undefined
+ )
+ )
+ ));
const self = this;
let target, currentNode, snapping;
@@ -151,6 +183,7 @@ export class Brush extends Mark {
...(fy && facet && {fy: facet.y}),
pending: true
});
+ if (filterData) value.data = filterData(value);
}
context.dispatchValue(value);
}
@@ -200,6 +233,7 @@ export class Brush extends Mark {
...(fy && facet && {fy: facet.y}),
...(type !== "end" && {pending: true})
});
+ if (filterData) region.data = filterData(region);
context.dispatchValue(region);
// Sync other plots in data space
@@ -236,6 +270,12 @@ export class Brush extends Mark {
const g = create("svg:g").attr("aria-label", this._dimension === "xy" ? "brush" : `brush-${this._dimension}`);
g.call(state.brush);
+ const sel = g.select(".selection");
+ applyAttr(sel, "fill", this.fill);
+ applyAttr(sel, "fill-opacity", this.fillOpacity);
+ applyAttr(sel, "stroke", this.stroke);
+ applyAttr(sel, "stroke-width", this.strokeWidth);
+ applyAttr(sel, "stroke-opacity", this.strokeOpacity);
const node = g.node();
state.nodes.push(node);
return node;
@@ -270,16 +310,25 @@ export class Brush extends Mark {
}
}
-export function brush(options) {
- return new Brush(options);
+export function brush(data, options = {}) {
+ if (arguments.length === 1 && !isIterable(data)) (options = data), (data = undefined);
+ let {x, y, ...rest} = options;
+ if (data != null) [x, y] = maybeTuple(x, y);
+ return new Brush(data, {...rest, x, y});
}
-export function brushX({interval} = {}) {
- return new Brush({dimension: "x", interval});
+export function brushX(data, options = {}) {
+ if (arguments.length === 1 && !isIterable(data)) (options = data), (data = undefined);
+ let {x, interval, ...rest} = options;
+ if (x === undefined && data != null) x = identity;
+ return new Brush(data, {...rest, dimension: "x", interval, x});
}
-export function brushY({interval} = {}) {
- return new Brush({dimension: "y", interval});
+export function brushY(data, options = {}) {
+ if (arguments.length === 1 && !isIterable(data)) (options = data), (data = undefined);
+ let {y, interval, ...rest} = options;
+ if (y === undefined && data != null) y = identity;
+ return new Brush(data, {...rest, dimension: "y", interval, y});
}
function intervalRound(interval, v) {
@@ -289,12 +338,13 @@ function intervalRound(interval, v) {
return v - +lo < +hi - v ? lo : hi;
}
-function renderFilter(initialTest) {
+function renderFilter(initialTest, channelDefaults = {}) {
const updates = new WeakMap();
return Object.assign(
function ({render, ...options} = {}) {
return {
pointerEvents: "none",
+ ...channelDefaults,
...options,
render: composeRender((index, scales, values, dimensions, context, next) => {
const {x: X, y: Y, x1: X1, x2: X2, y1: Y1, y2: Y2, z: Z} = values;
diff --git a/test/brush-test.ts b/test/brush-test.ts
index aad5af6b93..902f5288e9 100644
--- a/test/brush-test.ts
+++ b/test/brush-test.ts
@@ -168,6 +168,75 @@ it("brush programmatic move on second facet selects the correct facet", async ()
assert.equal([...species][0], "Chinstrap", "filtered species should be Chinstrap");
});
+it("brush with data includes filtered data in value", () => {
+ const data = [
+ {x: 10, y: 10},
+ {x: 20, y: 20},
+ {x: 30, y: 30},
+ {x: 40, y: 40},
+ {x: 50, y: 50}
+ ];
+ const brush = Plot.brush(data, {x: "x", y: "y"});
+ const plot = Plot.plot({
+ x: {domain: [0, 60]},
+ y: {domain: [0, 60]},
+ marks: [
+ brush,
+ Plot.dot(data, brush.inactive()),
+ Plot.dot(data, brush.context({fill: "#ccc"})),
+ Plot.dot(data, brush.focus({fill: "red"}))
+ ]
+ });
+
+ let lastValue: any;
+ plot.addEventListener("input", () => (lastValue = plot.value));
+ brush.move({x1: 15, x2: 35, y1: 15, y2: 35});
+
+ assert.ok(lastValue, "should have a value");
+ assert.ok(Array.isArray(lastValue.data), "value should have a data array");
+ assert.equal(lastValue.data.length, 2, "filtered data should contain 2 points");
+ assert.deepEqual(lastValue.data, [
+ {x: 20, y: 20},
+ {x: 30, y: 30}
+ ]);
+});
+
+it("brush with generator data includes filtered data in value", () => {
+ const data = [
+ {x: 10, y: 10},
+ {x: 20, y: 20},
+ {x: 30, y: 30},
+ {x: 40, y: 40},
+ {x: 50, y: 50}
+ ];
+ function* generate() {
+ yield* data;
+ }
+ const brush = Plot.brush(generate(), {x: "x", y: "y"});
+ const plot = Plot.plot({
+ x: {domain: [0, 60]},
+ y: {domain: [0, 60]},
+ marks: [
+ brush,
+ Plot.dot(data, brush.inactive()),
+ Plot.dot(data, brush.context({fill: "#ccc"})),
+ Plot.dot(data, brush.focus({fill: "red"}))
+ ]
+ });
+
+ let lastValue: any;
+ plot.addEventListener("input", () => (lastValue = plot.value));
+ brush.move({x1: 15, x2: 35, y1: 15, y2: 35});
+
+ assert.ok(lastValue, "should have a value");
+ assert.ok(Array.isArray(lastValue.data), "value should have a data array");
+ assert.equal(lastValue.data.length, 2, "filtered data should contain 2 points");
+ assert.deepEqual(lastValue.data, [
+ {x: 20, y: 20},
+ {x: 30, y: 30}
+ ]);
+});
+
it("brush reactive marks compose with user render transforms", () => {
const data = [
{x: 10, y: 10},
diff --git a/test/output/brushBrutalist.svg b/test/output/brushBrutalist.svg
new file mode 100644
index 0000000000..0fa4df4291
--- /dev/null
+++ b/test/output/brushBrutalist.svg
@@ -0,0 +1,415 @@
+
\ No newline at end of file
diff --git a/test/output/brushCoordinates.html b/test/output/brushCoordinates.html
new file mode 100644
index 0000000000..8da067dfed
--- /dev/null
+++ b/test/output/brushCoordinates.html
@@ -0,0 +1,275 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/brushXData.html b/test/output/brushXData.html
new file mode 100644
index 0000000000..bbae674ef5
--- /dev/null
+++ b/test/output/brushXData.html
@@ -0,0 +1,389 @@
+
+
+
\ No newline at end of file
diff --git a/test/plots/brush.ts b/test/plots/brush.ts
index 4e25c26672..a8eb42d2f9 100644
--- a/test/plots/brush.ts
+++ b/test/plots/brush.ts
@@ -6,11 +6,14 @@ import {html} from "htl";
function formatValue(v: any) {
if (v == null) return JSON.stringify(v);
- const o: any = {};
- for (const k of ["x1", "x2", "y1", "y2", "fx", "fy"]) {
- if (k in v) o[k] = v[k];
+ const lines: string[] = [];
+ for (const k of ["x1", "x2", "y1", "y2", "fx", "fy", "data"]) {
+ if (!(k in v)) continue;
+ const val = v[k];
+ const formatted = Array.isArray(val) ? `Array(${val.length})` : JSON.stringify(val);
+ lines.push(` ${k}: ${formatted}`);
}
- return JSON.stringify(o, null, 2);
+ return `{\n${lines.join(",\n")}\n}`;
}
export async function brushDot() {
@@ -449,6 +452,30 @@ export async function brushXDot() {
return html`${plot}${textarea}`;
}
+export async function brushXData() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ const values = Plot.valueof(penguins, "body_mass_g");
+ const brush = Plot.brushX(values);
+ const plot = Plot.plot({
+ height: 170,
+ marginTop: 10,
+ marks: [
+ brush,
+ Plot.dot(values, Plot.dodgeY(brush.inactive({fill: "currentColor"}))),
+ Plot.dot(values, Plot.dodgeY(brush.context({fill: "currentColor", fillOpacity: 0.3}))),
+ Plot.dot(values, Plot.dodgeY(brush.focus({fill: "currentColor"})))
+ ]
+ });
+ const textarea = html`