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 @@ + + + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + ↑ culmen_depth_mm + + + + 35 + 40 + 45 + 50 + 55 + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ +
+ + + + −2.5 + −2.0 + −1.5 + −1.0 + −0.5 + 0.0 + 0.5 + 1.0 + 1.5 + 2.0 + 2.5 + + + + −2 + −1 + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ 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 @@ +
+ + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ 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`