diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md
index c44c4f5424..d4b20ca11b 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
@@ -148,6 +149,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 [BrushValue](#brushvalue) 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, and the **filter** function also filters on the relevant facet values.
@@ -231,6 +291,7 @@ 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
- **filter** - a function to test whether a point is inside the selection
+- **data** - when the brush has data, the filtered subset
- **pending** - `true` during interaction; absent when committed
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*.
@@ -244,13 +305,13 @@ plot.addEventListener("input", () => {
});
```
-## 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 the 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}
@@ -298,13 +359,13 @@ brush.move(null)
For projected plots, the coordinates are in pixels (consistent with the [BrushValue](#brushvalue)), 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
@@ -337,10 +398,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 a4fa018fde..cf634807fe 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";
/**
@@ -25,16 +26,45 @@ export interface BrushValue {
* A function to test whether a point falls inside the brush selection.
* The signature depends on the dimensions and active facets: for brushX
* and brushY, filter on the value *v* with *(v)*, *(v, fx)*, *(v, fy)*,
- * or *(v, fx, fy)* *(x, y)*; for a 2D brush, use *(x, y)*, *(x, y, fx)*,
+ * or *(v, fx, fy)*; for a 2D brush, use *(x, y)*, *(x, y, fx)*,
* *(x, y, fy)*, or *(x, y, fx, fy)*. When faceted, returns true only for
* points in the brushed facet. For projected plots, *x* and *y* are
* typically longitude and latitude.
*/
filter: (...args: any[]) => boolean;
+ /** When the brush has data, the subset of data matching the selection. */
+ data?: any[];
/** True during interaction, absent when committed. */
pending?: true;
}
+/** 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
@@ -46,6 +76,14 @@ export interface BrushValue {
* 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.
@@ -76,23 +114,38 @@ 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; the dispatched
* filter function floors values before testing, for consistency with binned
- * marks. Supported by the 1-dimensional marks brushX and brushY.
+ * marks.
*/
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 0b8194b760..5f52221de1 100644
--- a/src/interactions/brush.js
+++ b/src/interactions/brush.js
@@ -9,22 +9,39 @@ import {
ascending
} from "d3";
import {composeRender, Mark} from "../mark.js";
-import {keyword, maybeInterval} from "../options.js";
+import {dataify, identity, isIterable, keyword, maybeInterval, maybeTuple, take} from "../options.js";
+import {applyAttr} from "../style.js";
+
+const defaults = {ariaLabel: "brush", fill: "#777", fillOpacity: 0.3, stroke: "#fff"};
export class Brush extends Mark {
- constructor({dimension = "xy", interval} = {}) {
- super(undefined, {}, {}, {});
+ constructor(data, {dimension = "xy", interval, ...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._brush = this._dimension === "x" ? d3BrushX() : this._dimension === "y" ? d3BrushY() : d3Brush();
this._interval = interval == null ? null : maybeInterval(interval);
+ 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);
this._brushNodes = [];
- this.inactive = renderFilter(true);
- this.context = renderFilter(false);
- this.focus = renderFilter(false);
}
render(index, scales, values, dimensions, context) {
const {x, y, fx, fy} = scales;
- const {inactive, context: ctx, focus} = this;
+ 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 {data, _brush, _brushNodes, inactive, context: ctx, focus} = this;
let target, currentNode, clearing;
if (!index?.fi) {
@@ -36,7 +53,19 @@ export class Brush extends Mark {
const applyX = (this._applyX = (!context.projection && x) || ((d) => d));
const applyY = (this._applyY = (!context.projection && y) || ((d) => d));
context.dispatchValue(null);
- const {_brush, _brushNodes} = this;
+ const filterIndex =
+ dim !== "xy"
+ ? fx && fy ? (f) => (_, i) => f((dim === "x" ? X : Y)[i], FX[i], FY[i])
+ : fx ? (f) => (_, i) => f((dim === "x" ? X : Y)[i], FX[i])
+ : fy ? (f) => (_, i) => f((dim === "x" ? X : Y)[i], FY[i])
+ : (f) => (_, i) => f((dim === "x" ? X : Y)[i])
+ : fx && fy ? (f) => (_, i) => f(X[i], Y[i], FX[i], FY[i])
+ : fx ? (f) => (_, i) => f(X[i], Y[i], FX[i])
+ : fy ? (f) => (_, i) => f(X[i], Y[i], FY[i])
+ : (f) => (_, i) => f(X[i], Y[i]); // prettier-ignore
+ const filterData =
+ data != null &&
+ ((filter) => (filter === true ? data : filter === false ? [] : take(data, index.filter(filterIndex(filter)))));
let snapping;
_brush
.extent([
@@ -83,6 +112,7 @@ export class Brush extends Mark {
...(fx && facet && {fx: facet.x}),
...(fy && facet && {fy: facet.y}),
filter,
+ ...(filterData && {data: filterData(filter)}),
pending: true
};
}
@@ -133,6 +163,7 @@ export class Brush extends Mark {
...(fx && facet && {fx: facet.x}),
...(fy && facet && {fy: facet.y}),
filter,
+ ...(filterData && {data: filterData(filter)}),
...(type !== "end" && {pending: true})
});
}
@@ -141,6 +172,12 @@ export class Brush extends Mark {
const g = create("svg:g").attr("aria-label", "brush");
g.call(this._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();
this._brushNodes.push(node);
return node;
@@ -172,16 +209,25 @@ export class Brush extends Mark {
}
}
-export function brush() {
- return new Brush();
+export function brush(data, options = {}) {
+ if (arguments.length === 1 && !isIterable(data)) (options = data), (data = undefined);
+ let {x, y, ...rest} = options;
+ [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 filterFromBrush(dim, interval, xScale, yScale, facet, projection, px1, px2, py1, py2) {
@@ -242,12 +288,13 @@ function intervalRound(interval, v) {
return v - +lo < +hi - v ? lo : hi;
}
-function renderFilter(initialTest) {
+function renderFilter(initialTest, channelDefaults = {}) {
const updatePerFacet = [];
return Object.assign(
function ({render, ...options} = {}) {
return {
pointerEvents: "none",
+ ...channelDefaults,
...options,
render: composeRender(function (index, scales, values, dimensions, context, next) {
const {x: X, y: Y, x1: X1, x2: X2, y1: Y1, y2: Y2} = values;
diff --git a/test/brush-test.ts b/test/brush-test.ts
index 7dcdf6ed9c..6468c0e4ce 100644
--- a/test/brush-test.ts
+++ b/test/brush-test.ts
@@ -160,6 +160,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..6113631742
--- /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 b1746a922b..39955bc83b 100644
--- a/test/plots/brush.ts
+++ b/test/plots/brush.ts
@@ -6,11 +6,17 @@ import {html} from "htl";
function formatValue(v: any) {
if (v == null) return JSON.stringify(v);
- const o: any = {};
+ const lines: string[] = [];
for (const [k, val] of Object.entries(v)) {
- o[k] = typeof val === "function" ? `${k}(${paramNames(val as (...args: any[]) => any)})` : val;
+ const formatted =
+ typeof val === "function"
+ ? `${k}(${paramNames(val as (...args: any[]) => any)})`
+ : 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}`;
}
function paramNames(fn: (...args: any[]) => any) {
@@ -455,6 +461,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`