From 4e8afad3a4a0f197e9749c5d76f56b57a506fddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 17 Feb 2026 00:48:02 +0100 Subject: [PATCH 1/3] brush(data, options) --- docs/interactions/brush.md | 79 +++++- src/interactions/brush.d.ts | 74 +++++- src/interactions/brush.js | 97 +++++-- test/brush-test.ts | 69 +++++ test/output/brushBrutalist.svg | 415 ++++++++++++++++++++++++++++++ test/output/brushCoordinates.html | 275 ++++++++++++++++++++ test/output/brushXData.html | 389 ++++++++++++++++++++++++++++ test/plots/brush.ts | 79 +++++- 8 files changed, 1428 insertions(+), 49 deletions(-) create mode 100644 test/output/brushBrutalist.svg create mode 100644 test/output/brushCoordinates.html create mode 100644 test/output/brushXData.html diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index 74562c5e32..cbf55d4058 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,6 +287,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 - **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 - **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*. Values are automatically rounded to the optimal precision that distinguishes neighboring pixels. @@ -240,13 +301,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} @@ -294,13 +355,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 @@ -333,10 +394,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 5416abb28b..168e2d99de 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,8 @@ 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[]; /** True during interaction, absent when committed. */ pending?: true; @@ -36,6 +39,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 @@ -47,6 +77,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. @@ -77,22 +115,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 d6e91766d0..4e9f8ef8e3 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,21 +48,35 @@ 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._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._sync = sync; - this.inactive = renderFilter(true); - this.context = renderFilter(false); - this.focus = renderFilter(false); } render(index, scales, values, dimensions, context) { if (typeof document === "undefined") return null; 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, syncing; if (!index?.fi) { @@ -70,8 +88,20 @@ 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 sync = this._sync; + 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 + ) + ) + )); let snapping; _brush .extent([ @@ -129,6 +159,7 @@ export class Brush extends Mark { ...(fy && facet && {fy: facet.y}), pending: true }); + if (filterData) value.data = filterData(value); } context.dispatchValue(value); } @@ -165,21 +196,27 @@ export class Brush extends Mark { } const facet = target?.__data__; - context.dispatchValue( - new Region({ - ...(dim !== "y" && {x1, x2}), - ...(dim !== "x" && {y1, y2}), - ...(fx && facet && {fx: facet.x}), - ...(fy && facet && {fy: facet.y}), - ...(type !== "end" && {pending: true}) - }) - ); + const region = new Region({ + ...(dim !== "y" && {x1, x2}), + ...(dim !== "x" && {y1, y2}), + ...(fx && facet && {fx: facet.x}), + ...(fy && facet && {fy: facet.y}), + ...(type !== "end" && {pending: true}) + }); + if (filterData) region.data = filterData(region); + context.dispatchValue(region); } }); } const g = create("svg:g").attr("aria-label", this._dimension === "xy" ? "brush" : `brush-${this._dimension}`); 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; @@ -210,16 +247,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; + [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) { @@ -229,12 +275,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((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 6e78c66c69..2ac89dc193 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 @@ + + + + + 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..bbae674ef5 --- /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 9825df76ee..9ffa93d247 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", "pending"]) { - if (k in v) o[k] = v[k]; + const lines: string[] = []; + for (const k of ["x1", "x2", "y1", "y2", "fx", "fy", "data", "pending"]) { + 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`