From 5eae9675928e2b97a3c47dbd7c8e9601b6ddd57d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 12 Feb 2026 17:55:09 +0100 Subject: [PATCH 01/40] brush (again) --- src/index.js | 1 + src/interactions/brush.js | 161 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/interactions/brush.js diff --git a/src/index.js b/src/index.js index a95fdbc035..4461bdfcb7 100644 --- a/src/index.js +++ b/src/index.js @@ -53,6 +53,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, brush} from "./interactions/brush.js"; export {pointer, pointerX, pointerY} from "./interactions/pointer.js"; export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js"; export {scale} from "./scales.js"; diff --git a/src/interactions/brush.js b/src/interactions/brush.js new file mode 100644 index 0000000000..2d5c618e03 --- /dev/null +++ b/src/interactions/brush.js @@ -0,0 +1,161 @@ +import {brush as d3Brush, create, selectAll} from "d3"; +import {Mark} from "../mark.js"; + +export class Brush extends Mark { + constructor() { + super(undefined, {}, {}, {}); + this._brush = d3Brush(); + 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; + let target, currentNode, clearing; + + if (!index?.fi) { + const invertX = (!context.projection && x?.invert) || ((d) => d); + const invertY = (!context.projection && y?.invert) || ((d) => d); + context.dispatchValue(null); + const {_brush, _brushNodes} = this; + _brush + .extent([ + [dimensions.marginLeft - 1, dimensions.marginTop - 1], + [dimensions.width - dimensions.marginRight + 1, dimensions.height - dimensions.marginBottom + 1] + ]) + .on("start brush end", function (event) { + const {selection, type} = event; + if (type === "start" && !clearing) { + target = event.sourceEvent?.currentTarget ?? this; + currentNode = _brushNodes.indexOf(target); + if (!clearing) { + clearing = true; + selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); + clearing = false; + for (let i = 0; i < _brushNodes.length; ++i) { + inactive.update(false, i); + ctx.update(true, i); + focus.update(false, i); + } + } + } + + if (selection === null) { + if (type === "end") { + for (let i = 0; i < _brushNodes.length; ++i) { + inactive.update(true, i); + ctx.update(false, i); + focus.update(false, i); + } + } else { + inactive.update(false, currentNode); + ctx.update(true, currentNode); + focus.update(false, currentNode); + } + context.dispatchValue(null); + } else { + let [[px1, py1], [px2, py2]] = selection; + px1 -= 0.25; + px2 += 0.25; + py1 -= 0.25; + py2 += 0.25; + inactive.update(false, currentNode); + ctx.update((xi, yi) => !(px1 < xi && xi < px2 && py1 < yi && yi < py2), currentNode); + focus.update((xi, yi) => px1 < xi && xi < px2 && py1 < yi && yi < py2, currentNode); + + let x1 = invertX(px1), + x2 = invertX(px2); + let y1 = invertY(py1), + y2 = invertY(py2); + if (x1 > x2) [x2, x1] = [x1, x2]; + if (y1 > y2) [y2, y1] = [y1, y2]; + + const facet = target?.__data__; + const filter = filterFromBrush(x, y, facet, context.projection, px1, px2, py1, py2); + context.dispatchValue({ + x1, + x2, + y1, + y2, + ...(fx && facet && {fx: facet.x}), + ...(fy && facet && {fy: facet.y}), + filter + }); + } + }); + } + + const g = create("svg:g"); + g.call(this._brush); + const node = g.node(); + this._brushNodes.push(node); + return node; + } +} + +export function brush() { + return new Brush(); +} + +function filterFromBrush(xScale, yScale, facet, projection, px1, px2, py1, py2) { + let px, py; + const stream = projection?.stream({ + point(x, y) { + px = x; + py = y; + } + }) ?? { + point: (x, y) => { + px = xScale(x); + py = yScale(y); + } + }; + return filterSignature( + (dx, dy) => { + stream.point(dx, dy); + return px1 < px && px < px2 && py1 < py && py < py2; + }, + facet?.x, + facet?.y + ); +} + +function filterSignature(test, currentFx, currentFy) { + return currentFx === undefined + ? currentFy === undefined + ? (x, y) => test(x, y) + : (x, y, fy) => fy === currentFy && test(x, y) + : currentFy === undefined + ? (x, y, fx) => fx === currentFx && test(x, y) + : (x, y, fx, fy) => fx === currentFx && fy === currentFy && test(x, y); +} + +function renderFilter(initialTest) { + const updatePerFacet = []; + return Object.assign( + function (options = {}) { + return { + ...options, + render(index, scales, values, dimensions, context, next) { + const {x: X, y: Y} = values; + const filter = (test) => + typeof test === "function" ? index.filter((i) => test(X[i], Y[i])) : test ? index : []; + let g = next(filter(initialTest), scales, values, dimensions, context); + updatePerFacet.push((test) => { + const transform = g.getAttribute("transform"); + g.replaceWith((g = next(filter(test), scales, values, dimensions, context))); + if (transform) g.setAttribute("transform", transform); + }); + return g; + } + }; + }, + { + update(test, i) { + return updatePerFacet[i]?.(test); + } + } + ); +} From 57e1ae302839f57af1ea4dfd05fccf7aae7857f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 12 Feb 2026 18:30:25 +0100 Subject: [PATCH 02/40] d.ts --- src/index.d.ts | 1 + src/interactions/brush.d.ts | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/interactions/brush.d.ts diff --git a/src/index.d.ts b/src/index.d.ts index a83f0f3715..2937287f11 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/interactions/brush.d.ts b/src/interactions/brush.d.ts new file mode 100644 index 0000000000..75effc5f9c --- /dev/null +++ b/src/interactions/brush.d.ts @@ -0,0 +1,65 @@ +import type {RenderableMark} from "../mark.js"; +import type {Rendered} from "../transforms/basic.js"; + +/** + * The brush value dispatched on input events. When the brush is cleared, the + * value is null; otherwise it contains the selection bounds (in data space, + * or pixels if projected) and a filter function to test whether a data point + * is inside the brush. By convention *x1* < *x2* and *y1* < *y2*. + */ +export interface BrushValue { + /** The lower *x* value of the brushed region. */ + x1: number | Date; + /** The upper *x* value of the brushed region. */ + x2: number | Date; + /** The lower *y* value of the brushed region. */ + y1: number | Date; + /** The upper *y* value of the brushed region. */ + y2: number | Date; + /** The *fx* facet value, if applicable. */ + fx?: any; + /** The *fy* facet value, if applicable. */ + fy?: any; + /** + * A function to test whether a point falls inside the brush selection. + * The signature depends on active facets: *(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: (x: number | Date, y: number | Date, f1?: any, f2?: any) => boolean; +} + +/** + * A brush mark that renders a two-dimensional [brush](https://d3js.org/d3-brush) + * allowing the user to select a rectangular region. The brush coordinates across + * facets, clearing previous selections when a new brush starts. + * + * The brush dispatches an input event when the selection changes. The selection + * is available as plot.value as a **BrushValue**, or null when the selection is + * cleared. Use the **inactive**, **context**, and **focus** methods to create + * companion marks that respond to the brush state. + */ +export class Brush extends RenderableMark { + /** + * Returns mark options that show the mark when no brush selection is active, + * and hide it during brushing. Use this for the default appearance. + */ + inactive(options?: T): Rendered; + + /** + * Returns mark options that hide the mark by default and, during brushing, + * show only the points *outside* the selection. Use this for a dimmed + * background layer. + */ + context(options?: T): Rendered; + + /** + * Returns mark options that hide the mark by default and, during brushing, + * show only the points *inside* the selection. Use this to highlight the + * selected data. + */ + focus(options?: T): Rendered; +} + +/** Creates a new brush mark. */ +export function brush(): Brush; From 985cff32eed2c122978eed48fa851d9a3111fd50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 12 Feb 2026 20:54:06 +0100 Subject: [PATCH 03/40] reactive --- src/interactions/brush.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index 75effc5f9c..6a6d1cd726 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -37,7 +37,7 @@ export interface BrushValue { * The brush dispatches an input event when the selection changes. The selection * is available as plot.value as a **BrushValue**, or null when the selection is * cleared. Use the **inactive**, **context**, and **focus** methods to create - * companion marks that respond to the brush state. + * reactive marks that respond to the brush state. */ export class Brush extends RenderableMark { /** From 3dc3fa760be169805b53136978d26109d1b6a75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 12 Feb 2026 21:24:44 +0100 Subject: [PATCH 04/40] documentation --- docs/.vitepress/config.ts | 1 + docs/features/interactions.md | 23 +++- docs/interactions/brush.md | 226 ++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 docs/interactions/brush.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f1a0381ac8..eca1e9a30c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -145,6 +145,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/features/interactions.md b/docs/features/interactions.md index 4cd5a27b3a..fea45bee55 100644 --- a/docs/features/interactions.md +++ b/docs/features/interactions.md @@ -3,6 +3,7 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {shallowRef, onMounted} from "vue"; +import penguins from "../data/penguins.ts"; const olympians = shallowRef([ {weight: 31, height: 1.21, sex: "female"}, @@ -54,7 +55,27 @@ 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 mark](../interactions/brush.md) lets the reader select a rectangular region by clicking and dragging. The selected region is then exposed as the plot’s `value` and can be used to filter data. Optionally, when combined with reactive marks — **inactive**, **context**, and **focus** — the brush highlights the selected data while dimming the rest. + +:::plot defer hidden +```js +Plot.plot({ + marks: ((brush) => (d3.timeout(() => d3.select(brush._brushNodes[0]).call(brush._brush.move, [[100, 60], [300, 200]])), [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}), + brush + ]))(Plot.brush()) +}) +``` +::: + +```js +Plot.plot({ + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}), + Plot.brush() + ] +}) +``` ## Zooming diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md new file mode 100644 index 0000000000..3d66d8d6e8 --- /dev/null +++ b/docs/interactions/brush.md @@ -0,0 +1,226 @@ + + +# 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. + +:::plot hidden +```js +Plot.plot({ + marks: ((brush) => (d3.timeout(() => d3.select(brush._brushNodes[0]).call(brush._brush.move, [[100, 60], [300, 200]])), [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}), + brush + ]))(Plot.brush()) +}) +``` +::: + +```js +Plot.plot({ + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}), + Plot.brush() + ] +}) +``` + +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. + +## Input events + +The brush dispatches an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes. The plot’s value (`plot.value`) is set to a [BrushValue](#brushvalue) object when a selection is active, or null when the selection is cleared. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views), or to register an *input* event listener to react to the brush. + +```js +const plot = Plot.plot(options); + +plot.addEventListener("input", (event) => { + console.log(plot.value); +}); +``` + +The **filter** function on the brush value tests whether a data point falls inside the selection. Its signature depends on whether the plot uses faceting: + +| Facets | Signature | +|-------------|--------------------------------| +| none | *filter*(*x*, *y*) | +| **fx** only | *filter*(*x*, *y*, *fx*) | +| **fy** only | *filter*(*x*, *y*, *fy*) | +| both | *filter*(*x*, *y*, *fx*, *fy*) | + +When faceted, the filter returns true only for points in the brushed facet. + + +## Reactive marks + +The brush can be paired with reactive marks that respond to the brush state. Create a brush mark, then call its **inactive**, **context**, and **focus** methods to derive options that reflect the selection. + +- **inactive** — shows the mark when no selection is active; hides it during brushing. +- **context** — hidden when no selection is active; during brushing, shows points *outside* the selection. +- **focus** — hidden when no selection is active; during brushing, shows points *inside* the selection. + +A typical pattern is to layer three reactive marks: the inactive mark provides a default view, while the context and focus marks replace it during brushing, the context dimming unselected points and the focus highlighting selected ones. + +:::plot defer hidden +```js +Plot.plot({ + marks: ((brush) => [ + Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 2})), + Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#ccc", r: 2})), + brush, + Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 3, pointerEvents: "none"})) + ])(Plot.brush()) +}) +``` +::: + +```js +const brush = Plot.brush(); +Plot.plot({ + marks: [ + Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 2})), + Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#ccc", r: 2})), + brush, + Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 3, pointerEvents: "none"})) + ] +}) +``` + +:::tip +To achieve higher contrast, place the brush mark before the focus mark in the marks array; then set **pointerEvents** to *none* on any mark rendered above the brush to ensure it doesn't intercept pointer events. +::: + +## 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. + +:::plot defer hidden +```js +Plot.plot({ + height: 270, + grid: true, + marks: ((brush) => [ + Plot.frame(), + Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 2})), + Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "#ccc", r: 2})), + brush, + Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 3, pointerEvents: "none"})) + ])(Plot.brush()) +}) +``` +::: + +```js +const brush = Plot.brush(); +Plot.plot({ + marks: [ + Plot.frame(), + Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 2})), + Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "#ccc", r: 2})), + brush, + Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 3, pointerEvents: "none"})) + ] +}) +``` + +## Projections + +For plots with a [geographic projection](../features/projections.md), the brush operates in screen space. The brush value’s **x1**, **y1**, **x2**, **y2** bounds are expressed in pixels from the top-left corner of the frame, and the **filter** function takes the data's coordinates (typically longitude and latitude) and projects them to test against the brush extent. + +
+ +:::plot defer hidden +```js +Plot.plot({ + projection: "equal-earth", + marks: ((brush) => [ + Plot.geo(land, {strokeWidth: 0.5}), + Plot.sphere(), + Plot.dot(cities, brush.inactive({x: "longitude", y: "latitude", r: 2, fill: "#999"})), + Plot.dot(cities, brush.context({x: "longitude", y: "latitude", r: 1, fill: "#999"})), + brush, + Plot.dot(cities, brush.focus({x: "longitude", y: "latitude", r: 3, fill: "red", pointerEvents: "none"})) + ])(Plot.brush()) +}) +``` +::: + +
+ +```js +const brush = Plot.brush(); +Plot.plot({ + projection: "equal-earth", + marks: [ + Plot.geo(land, {strokeWidth: 0.5}), + Plot.sphere(), + Plot.dot(cities, brush.inactive({x: "longitude", y: "latitude", r: 2, fill: "#999"})), + Plot.dot(cities, brush.context({x: "longitude", y: "latitude", r: 1, fill: "#999"})), + brush, + Plot.dot(cities, brush.focus({x: "longitude", y: "latitude", r: 3, fill: "red", pointerEvents: "none"})) + ] +}) +``` + +## BrushValue {#brushvalue} + +The brush value dispatched on [_input_ events](#input-events). When the brush is cleared, the value is null; otherwise it’s an object with the following properties: + +- **x1** - the lower *x* bound of the selection (in data space, or pixels if projected) +- **x2** - the upper *x* bound of the selection +- **y1** - the lower *y* bound of the selection +- **y2** - the upper *y* bound of the selection +- **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 + +By convention, *x1* < *x2* and *y1* < *y2*. + +## brush() {#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. + +## *brush*.inactive(*options*) {#brush.inactive} + +```js +Plot.dot(data, brush.inactive({x: "weight", y: "height", fill: "species"})) +``` + +Returns mark options that show the mark when no brush selection is active, and hide it during brushing. Use this for the default appearance of data before any selection is made. + +## *brush*.context(*options*) {#brush.context} + +```js +Plot.dot(data, brush.context({x: "weight", y: "height", fill: "#ccc"})) +``` + +Returns mark options that hide the mark by default and, during brushing, show only the points *outside* the selection. Use this for a dimmed background layer. + +## *brush*.focus(*options*) {#brush.focus} + +```js +Plot.dot(data, brush.focus({x: "weight", y: "height", fill: "species"})) +``` + +Returns mark options that hide the mark by default and, during brushing, show only the points *inside* the selection. Use this to highlight the selected data. From ce8061246542572e81473920387ec1968496d8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 12 Feb 2026 21:26:15 +0100 Subject: [PATCH 05/40] snapshot tests --- test/output/brushDot.html | 417 ++++++++++ test/output/brushFaceted.html | 494 +++++++++++ test/output/brushFacetedFxFy.html | 618 ++++++++++++++ test/output/brushFacetedFy.html | 500 ++++++++++++ test/output/brushGeoUS.html | 91 +++ test/output/brushGeoWorld.html | 1044 +++++++++++++++++++++++ test/output/brushGeoWorldFaceted.html | 1090 +++++++++++++++++++++++++ test/output/brushRandomNormal.html | 1089 ++++++++++++++++++++++++ test/output/brushSimple.html | 414 ++++++++++ test/plots/brush.ts | 261 ++++++ test/plots/index.ts | 1 + 11 files changed, 6019 insertions(+) create mode 100644 test/output/brushDot.html create mode 100644 test/output/brushFaceted.html create mode 100644 test/output/brushFacetedFxFy.html create mode 100644 test/output/brushFacetedFy.html create mode 100644 test/output/brushGeoUS.html create mode 100644 test/output/brushGeoWorld.html create mode 100644 test/output/brushGeoWorldFaceted.html create mode 100644 test/output/brushRandomNormal.html create mode 100644 test/output/brushSimple.html create mode 100644 test/plots/brush.ts diff --git a/test/output/brushDot.html b/test/output/brushDot.html new file mode 100644 index 0000000000..07b95411f6 --- /dev/null +++ b/test/output/brushDot.html @@ -0,0 +1,417 @@ +
+ + + + 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/brushFaceted.html b/test/output/brushFaceted.html new file mode 100644 index 0000000000..115c574ad8 --- /dev/null +++ b/test/output/brushFaceted.html @@ -0,0 +1,494 @@ +
+ + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + + ↑ culmen_depth_mm + + + + + 40 + 50 + + + 40 + 50 + + + 40 + 50 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushFacetedFxFy.html b/test/output/brushFacetedFxFy.html new file mode 100644 index 0000000000..11694eec5a --- /dev/null +++ b/test/output/brushFacetedFxFy.html @@ -0,0 +1,618 @@ +
+ + + + FEMALE + + + MALE + + + + sex + + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + + ↑ culmen_depth_mm + + + + + 40 + 50 + + + 40 + 50 + + + 40 + 50 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushFacetedFy.html b/test/output/brushFacetedFy.html new file mode 100644 index 0000000000..0164252177 --- /dev/null +++ b/test/output/brushFacetedFy.html @@ -0,0 +1,500 @@ +
+ + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + + ↑ culmen_depth_mm + + + + + 35 + 40 + 45 + 50 + 55 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushGeoUS.html b/test/output/brushGeoUS.html new file mode 100644 index 0000000000..28d79812bd --- /dev/null +++ b/test/output/brushGeoUS.html @@ -0,0 +1,91 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushGeoWorld.html b/test/output/brushGeoWorld.html new file mode 100644 index 0000000000..d345358969 --- /dev/null +++ b/test/output/brushGeoWorld.html @@ -0,0 +1,1044 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushGeoWorldFaceted.html b/test/output/brushGeoWorldFaceted.html new file mode 100644 index 0000000000..df854a6ba6 --- /dev/null +++ b/test/output/brushGeoWorldFaceted.html @@ -0,0 +1,1090 @@ +
+ + + + < median + + + ≥ median + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushRandomNormal.html b/test/output/brushRandomNormal.html new file mode 100644 index 0000000000..192e142434 --- /dev/null +++ b/test/output/brushRandomNormal.html @@ -0,0 +1,1089 @@ +
+ + + + −2.5 + −2.0 + −1.5 + −1.0 + −0.5 + 0.0 + 0.5 + 1.0 + 1.5 + 2.0 + 2.5 + 3.0 + 3.5 + + + ↑ 1 + + + + −3 + −2 + −1 + 0 + 1 + 2 + 3 + + + 0 → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushSimple.html b/test/output/brushSimple.html new file mode 100644 index 0000000000..d0b0114dd1 --- /dev/null +++ b/test/output/brushSimple.html @@ -0,0 +1,414 @@ +
+ + + + 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/plots/brush.ts b/test/plots/brush.ts new file mode 100644 index 0000000000..6b8566a9bf --- /dev/null +++ b/test/plots/brush.ts @@ -0,0 +1,261 @@ +// The programmatic brush.move calls below use the private _brush and +// _brushNodes API with pixel coordinates. Replace with the public setter +// (in data space) when available. +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {geoProject} from "d3-geo-projection"; +import {feature, mesh} from "topojson-client"; +import {html} from "htl"; + +function formatValue(v: any) { + if (v == null) return JSON.stringify(v); + const o: any = {}; + for (const [k, val] of Object.entries(v)) { + o[k] = typeof val === "function" ? `${k}(${paramNames(val)})` : val; + } + return JSON.stringify(o, null, 2); +} + +function paramNames(fn: (...args: any[]) => any) { + const s = fn.toString(); + const m = s.match(/^\(([^)]*)\)|^([^=]+?)(?=\s*=>)/); + return m ? (m[1] ?? m[2]).trim() : "…"; +} + +export async function brushDot() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const brush = new Plot.Brush(); + const xy = {x: "culmen_length_mm" as const, y: "culmen_depth_mm" as const}; + const plot = Plot.plot({ + marks: [ + brush, + Plot.dot(penguins, brush.inactive({...xy, fill: "species", r: 2, pointerEvents: "none"})), + Plot.dot(penguins, brush.context({...xy, fill: "#ccc", r: 2, pointerEvents: "none"})), + Plot.dot(penguins, brush.focus({...xy, fill: "species", r: 3, pointerEvents: "none"})) + ] + }); + const textarea = html` diff --git a/test/output/brushFaceted.html b/test/output/brushFaceted.html index 82764d4afc..f491c7710a 100644 --- a/test/output/brushFaceted.html +++ b/test/output/brushFaceted.html @@ -105,15 +105,15 @@ - - - - - - - - - + + + + + + + + + @@ -288,9 +288,16 @@ + + + + + + + @@ -426,16 +433,13 @@ - - - @@ -446,11 +450,9 @@ - - @@ -458,7 +460,6 @@ - @@ -474,7 +475,6 @@ - diff --git a/test/output/brushFacetedFxFy.html b/test/output/brushFacetedFxFy.html index add67e468e..709feceb0a 100644 --- a/test/output/brushFacetedFxFy.html +++ b/test/output/brushFacetedFxFy.html @@ -160,18 +160,6 @@ - - - - - - - - - - - - @@ -183,7 +171,7 @@ - + @@ -195,7 +183,7 @@ - + @@ -207,7 +195,7 @@ - + @@ -219,6 +207,18 @@ + + + + + + + + + + + + @@ -395,6 +395,10 @@ + + + + @@ -531,65 +535,30 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -601,16 +570,47 @@ - - - - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/brushFacetedFy.html b/test/output/brushFacetedFy.html index a9b59f161c..2e422e8c28 100644 --- a/test/output/brushFacetedFy.html +++ b/test/output/brushFacetedFy.html @@ -111,15 +111,15 @@ - - - - - - - - - + + + + + + + + + @@ -297,7 +297,6 @@ - @@ -306,26 +305,19 @@ - - + - - - - - - + - + + - - @@ -460,6 +452,7 @@ + @@ -470,28 +463,35 @@ + + - - + + + + + + - - + + + diff --git a/test/output/brushGeoWorldFaceted.html b/test/output/brushGeoWorldFaceted.html index 9889c75dc4..223c41579a 100644 --- a/test/output/brushGeoWorldFaceted.html +++ b/test/output/brushGeoWorldFaceted.html @@ -43,18 +43,6 @@ - - - - - - - - - - - - @@ -66,6 +54,18 @@ + + + + + + + + + + + + @@ -76,36 +76,26 @@ - - - - - - - - - - @@ -117,7 +107,6 @@ - @@ -127,22 +116,15 @@ - - - - - - - @@ -156,7 +138,6 @@ - @@ -165,30 +146,21 @@ - - - - - - - - - @@ -196,10 +168,8 @@ - - @@ -219,91 +189,62 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -312,28 +253,21 @@ - - - - - - - @@ -343,14 +277,10 @@ - - - - @@ -358,12 +288,8 @@ - - - - @@ -371,14 +297,11 @@ - - - @@ -394,40 +317,25 @@ - - - - - - - - - - - - - - - @@ -435,36 +343,28 @@ - - - - - - - - @@ -481,9 +381,7 @@ - - @@ -496,7 +394,6 @@ - @@ -505,19 +402,13 @@ - - - - - - @@ -530,19 +421,13 @@ - - - - - - @@ -556,21 +441,16 @@ - - - - - @@ -585,6 +465,7 @@ + @@ -600,6 +481,8 @@ + + @@ -645,8 +528,10 @@ + + @@ -656,13 +541,17 @@ + + + + @@ -670,6 +559,7 @@ + @@ -682,8 +572,10 @@ + + @@ -691,6 +583,7 @@ + @@ -698,6 +591,7 @@ + @@ -706,17 +600,24 @@ + + + + + + + @@ -728,6 +629,7 @@ + @@ -741,20 +643,26 @@ + + + + + + @@ -766,20 +674,27 @@ + + + + + + + @@ -787,8 +702,10 @@ + + @@ -806,9 +723,11 @@ + + @@ -832,6 +751,7 @@ + @@ -845,14 +765,17 @@ + + + @@ -865,11 +788,15 @@ + + + + @@ -887,6 +814,7 @@ + @@ -899,6 +827,9 @@ + + + @@ -911,7 +842,10 @@ + + + @@ -920,19 +854,26 @@ + + + + + + + @@ -941,14 +882,17 @@ + + + @@ -964,6 +908,7 @@ + @@ -971,14 +916,19 @@ + + + + + @@ -990,6 +940,7 @@ + @@ -1000,90 +951,139 @@ + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/brushRandomNormal.html b/test/output/brushRandomNormal.html index 90481cb76c..1ac27dfa67 100644 --- a/test/output/brushRandomNormal.html +++ b/test/output/brushRandomNormal.html @@ -69,164 +69,168 @@ - - - - - - - - - + + + + + + + + + + + - - - - + + + - + - - + + + + - - + - + + + - - - + - + + - - + - - - + - - + - - - - - + + + - - + + + - - + + - + - - + - - + + - - - + + + - + + + - + - + + - + + - + + - + + + + - + + + - + + - - - + + - - - - + @@ -236,93 +240,85 @@ + + - - - - - + + - - + + - - - - - - + + + + - - + + - + + + - + + - - - + - - - + + + - + - - + - - - - + - + - + + - + - - + + - - + + - - - @@ -330,51 +326,44 @@ - - - + + - - + - - - - - + - - - + + @@ -385,704 +374,715 @@ - - + - - - + - + + + - - + + - + - + - + - - + - - + + - + + + + - + + - + - - + + - - - + + + + - + + + - + + + - + + - - + + - + + - + - - - - - + + + - - + + + - - - - - - - - - + + + - - - + - - + + + - - - - + + + + + + - - - + - - - - + + + - - + + - - - - + - - - - - - + + - + + - - + + - - + + + - + - - + - - - + + + + - - + + - + + - - - - - + - - + - - - - + + + - - + + - - - - - - + - - - + + - - + - + - - - - + - - - + - + - - + - - - - + - - - + + - + + - + - + - + + - - - + - + + - - - + + - + + - + - - + - - + - - + - - - - - + + + + + - - + + - - - - - - - + + + + + + + - + + + + - + + + - - + + + - - - - + + - - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - + - + - - - - - - - - + + + + + - - + + + - + - - + - - - - - - - - - - + + + + + + + + + + + + + + - - - - - + + + + + - + - - - - + + + + - - - + + + + + + - - - - - - - + + + + - - + + + + + + + + + + - + + + + - + + + - - + + - + + + + - - - - - - + + + - + + - - - + + - - + + + - - + - - - - - + + - - - - + + + + + - - - - + - + - - - - - + + - + - - - - + + + + + - + - + - - - - - - - + + + + + + + + + + + - - - - + + + + + + + - + - + - - - - - - + + + + + + + - - - - + + + + - + + + - - - + + + + + + - + - - - + + + - - - - + + + + - - + - - - - - + + + + - + + + + - + + - + + + + - - + - - - + + + + + + + + - - - - - + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + - - - - - - + + + + + + + - - - - + + + - - - - - + + + + + diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 4238ca1aa7..2419f4b2ed 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -1,6 +1,3 @@ -// The programmatic brush.move calls below use the private _brush and -// _brushNodes API with pixel coordinates. Replace with the public setter -// (in data space) when available. import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {geoProject} from "d3-geo-projection"; @@ -41,10 +38,7 @@ export async function brushDot() { textarea.value = formatValue(v) + `\nfiltered: ${filtered.length} of ${penguins.length}`; }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[0]).call((brush as any)._brush.move, [ - [80, 40], - [300, 200] - ]); + brush.move({x1: 36, x2: 48, y1: 15, y2: 20}); oninput(); return html`
${plot}${textarea}
`; } @@ -71,10 +65,7 @@ export async function brushFaceted() { textarea.value = formatValue(v) + `\nfiltered: ${filtered.length} of ${penguins.length}`; }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[1]).call((brush as any)._brush.move, [ - [60, 80], - [180, 280] - ]); + brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"}); oninput(); return html`
${plot}${textarea}
`; } @@ -101,10 +92,7 @@ export async function brushFacetedFy() { textarea.value = formatValue(v) + `\nfiltered: ${filtered.length} of ${penguins.length}`; }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[1]).call((brush as any)._brush.move, [ - [100, 40], - [400, 120] - ]); + brush.move({x1: 38, x2: 52, y1: 15, y2: 19, fy: "Chinstrap"}); oninput(); return html`
${plot}${textarea}
`; } @@ -136,10 +124,7 @@ export async function brushFacetedFxFy() { textarea.value = formatValue(v) + `\nfiltered: ${filtered.length} of ${penguins.length}`; }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[2]).call((brush as any)._brush.move, [ - [60, 50], - [170, 140] - ]); + brush.move({x1: 40, x2: 50, y1: 14, y2: 17, fx: "Gentoo", fy: "MALE"}); oninput(); return html`
${plot}${textarea}
`; } @@ -188,10 +173,7 @@ export async function brushGeoUS() { (filtered.length ? `\n${filtered.map((d: any) => d.capital).join(", ")}` : ""); }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[0]).call((brush as any)._brush.move, [ - [80, 60], - [300, 220] - ]); + brush.move({x1: 80, x2: 300, y1: 60, y2: 220}); oninput(); return html`
${plot}${textarea}
`; } @@ -222,10 +204,7 @@ export async function brushGeoWorld() { (filtered.length ? `\n${filtered.map((d: any) => d.name).join(", ")}` : ""); }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[0]).call((brush as any)._brush.move, [ - [80, 60], - [300, 200] - ]); + brush.move({x1: 80, x2: 300, y1: 60, y2: 200}); oninput(); return html`
${plot}${textarea}
`; } @@ -266,10 +245,7 @@ export async function brushGeoWorldFaceted() { (filtered.length ? `\n${filtered.map((d: any) => d.name).join(", ")}` : ""); }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[1]).call((brush as any)._brush.move, [ - [80, 30], - [300, 150] - ]); + brush.move({x1: 80, x2: 300, y1: 30, y2: 150, fy: "< median"}); oninput(); return html`
${plot}${textarea}
`; } @@ -294,10 +270,7 @@ export async function brushRandomNormal() { textarea.value = formatValue(v) + `\nfiltered: ${filtered.length} of ${data.length}`; }; plot.oninput = oninput; - d3.select((brush as any)._brushNodes[0]).call((brush as any)._brush.move, [ - [100, 60], - [350, 250] - ]); + brush.move({x1: -1, x2: 1, y1: -1, y2: 0.5}); oninput(); return html`
${plot}${textarea}
`; } From 64039a7c9437213c70f942438eb6e1ab96f00cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 13 Feb 2026 14:28:15 +0100 Subject: [PATCH 17/40] obstruct --- docs/interactions/brush.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index ff40437253..93ad4a1f54 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -111,7 +111,7 @@ Plot.plot({ ``` :::tip -To achieve higher contrast, place the brush below the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't intercept pointer events. +To achieve higher contrast, place the brush below the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't obstruct pointer events. ::: ## Faceting From ba8432516094c97a63b521fbc27b6395eae00eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 13 Feb 2026 16:41:25 +0100 Subject: [PATCH 18/40] be clearer about projecting coordinates for brush_move on a map --- docs/interactions/brush.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index 93ad4a1f54..109e1542a7 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -258,4 +258,4 @@ brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"}) brush.move(null) ``` -For projected plots, the coordinates are in pixels (consistent with the [BrushValue](#brushvalue)). +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. From 312b4775db1b11da33ae0e2226fa139d9ac68629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 13 Feb 2026 21:00:12 +0100 Subject: [PATCH 19/40] fix comment --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 16976c2585..84b6f14ea8 100644 --- a/src/plot.js +++ b/src/plot.js @@ -176,7 +176,7 @@ export function plot(options = {}) { return {...state, channels: {...state.channels, ...facetState?.channels}}; }; - // Allows e.g. the pointer transform to support viewof. + // Allows e.g. the pointer transform and brush to support viewof. context.dispatchValue = (value) => { if (figure.value === value) return; figure.value = value; From 2cc9c1c396ac916d64ef1f1fc6c9bc31fca5230b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 20 Feb 2026 11:02:49 +0100 Subject: [PATCH 20/40] simpler --- src/interactions/brush.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index b1292bb3db..ead2d3961e 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -117,14 +117,11 @@ export class Brush extends Mark { return; } const {x1, x2, y1, y2, fx, fy} = value; - const node = - this._brushNodes.length === 1 - ? this._brushNodes[0] - : this._brushNodes.find((n) => { - const d = n.__data__; - return d && (fx === undefined || d.x === fx) && (fy === undefined || d.y === fy); - }); - if (!node) throw new Error("No brush node found for the specified facet"); + const node = this._brushNodes.find((n) => { + const d = n.__data__; + return (fx === undefined || d?.x === fx) && (fy === undefined || d?.y === fy); + }); + if (!node) throw new Error(fx === undefined && fy === undefined ? "No brush node found" : "No brush node found for the specified facet"); const px1 = this._applyX(x1); const px2 = this._applyX(x2); const py1 = this._applyY(y1); From 5dc545d0032176f50526234c65b91751678d2ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 20 Feb 2026 11:20:39 +0100 Subject: [PATCH 21/40] prettier --- src/interactions/brush.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index ead2d3961e..7069c22c86 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -121,7 +121,10 @@ export class Brush extends Mark { const d = n.__data__; return (fx === undefined || d?.x === fx) && (fy === undefined || d?.y === fy); }); - if (!node) throw new Error(fx === undefined && fy === undefined ? "No brush node found" : "No brush node found for the specified facet"); + if (!node) + throw new Error( + fx === undefined && fy === undefined ? "No brush node found" : "No brush node found for the specified facet" + ); const px1 = this._applyX(x1); const px2 = this._applyX(x2); const py1 = this._applyY(y1); From b228cd16807afb4ed71e893ec59008255532aa21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 20 Feb 2026 11:55:39 +0100 Subject: [PATCH 22/40] initial brush selection on all examples --- docs/interactions/brush.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index 109e1542a7..d9ef32e908 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -85,15 +85,15 @@ The brush can be paired with reactive marks that respond to the brush state. Cre A typical pattern is to layer three reactive marks: the inactive mark provides a default view, while the context and focus marks replace it during brushing, the context dimming unselected points and the focus highlighting selected ones. -:::plot defer hidden +:::plot hidden ```js Plot.plot({ - marks: ((brush) => [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 36, x2: 48, y1: 15, y2: 20})), [ brush, Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 2})), Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#ccc", r: 2})), Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 3})) - ])(Plot.brush()) + ]))(Plot.brush()) }) ``` ::: @@ -118,18 +118,18 @@ To achieve higher contrast, place the brush below the reactive marks; reactive m 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. -:::plot defer hidden +:::plot hidden ```js Plot.plot({ height: 270, grid: true, - marks: ((brush) => [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 43, x2: 50, y1: 17, y2: 19, fx: "Adelie"})), [ Plot.frame(), brush, Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 2})), Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "#ccc", r: 2})), Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 3})) - ])(Plot.brush()) + ]))(Plot.brush()) }) ``` ::: @@ -153,18 +153,18 @@ For plots with a [geographic projection](../features/projections.md), the brush
-:::plot defer hidden +:::plot hidden ```js Plot.plot({ projection: "equal-earth", - marks: ((brush) => [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 80, x2: 300, y1: 60, y2: 200})), [ Plot.geo(land, {strokeWidth: 0.5}), Plot.sphere(), brush, Plot.dot(cities, brush.inactive({x: "longitude", y: "latitude", r: 2, fill: "#999"})), Plot.dot(cities, brush.context({x: "longitude", y: "latitude", r: 1, fill: "#999"})), Plot.dot(cities, brush.focus({x: "longitude", y: "latitude", r: 3, fill: "red"})) - ])(Plot.brush()) + ]))(Plot.brush()) }) ``` ::: From 7c710e9f03d3a65c7a5f59a460b2df4881d84855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 16 Feb 2026 15:01:56 +0100 Subject: [PATCH 23/40] `brushX` and `brushY` Since we want to support brushing histograms, we needed two additional features: * an **interval** option for snapping the brush on gesture end. * support for X1/X2 channels in renderFilter, for rect marks. (This will require some work to merge with #2363) --- docs/components/links.js | 2 +- docs/interactions/brush.md | 109 ++++++- src/index.js | 2 +- src/interactions/brush.d.ts | 52 ++- src/interactions/brush.js | 245 +++++++++----- test/brush-test.ts | 76 +++++ test/output/brushXDot.html | 392 ++++++++++++++++++++++ test/output/brushXHistogram.html | 119 +++++++ test/output/brushXHistogramFaceted.html | 295 +++++++++++++++++ test/output/brushXTemporal.html | 146 +++++++++ test/output/brushXTemporalReversed.html | 146 +++++++++ test/output/brushYDot.html | 411 ++++++++++++++++++++++++ test/output/brushYHistogram.html | 113 +++++++ test/plots/brush.ts | 241 ++++++++++++++ 14 files changed, 2244 insertions(+), 105 deletions(-) create mode 100644 test/output/brushXDot.html create mode 100644 test/output/brushXHistogram.html create mode 100644 test/output/brushXHistogramFaceted.html create mode 100644 test/output/brushXTemporal.html create mode 100644 test/output/brushXTemporalReversed.html create mode 100644 test/output/brushYDot.html create mode 100644 test/output/brushYHistogram.html diff --git a/docs/components/links.js b/docs/components/links.js index ed46b5fdfe..251a2f5e25 100644 --- a/docs/components/links.js +++ b/docs/components/links.js @@ -13,7 +13,7 @@ export function getAnchors(text) { .toLowerCase() ); } - for (const [, anchor] of text.matchAll(/ \{#([\w\d-]+)\}/g)) { + for (const [, anchor] of text.matchAll(/ \{#([\w\d.-]+)\}/g)) { anchors.push(anchor); } return anchors; diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index d9ef32e908..af4d1514e6 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -44,6 +44,40 @@ 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. + +## 1-D brushing + +The **brushX** mark operates on the *x* axis. + +:::plot defer hidden +```js +Plot.plot({ + height: 200, + marks: ((brush) => (d3.timeout(() => brush.move({x1: 3200, x2: 4800})), [ + brush, + Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))), + Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))), + Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"}))) + ]))(Plot.brushX()) +}) +``` +::: + +```js +const brush = Plot.brushX(); +Plot.plot({ + height: 200, + marks: [ + brush, + Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))), + Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))), + Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"}))) + ] +}) +``` + +Similarly, the **brushY** mark operates on the *y* axis. + ## Input events The brush dispatches an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes. The plot’s value (`plot.value`) is set to a [BrushValue](#brushvalue) object when a selection is active, or null when the selection is cleared. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views), or to register an *input* event listener to react to the brush. @@ -56,14 +90,14 @@ plot.addEventListener("input", (event) => { }); ``` -The **filter** function on the brush value tests whether a data point falls inside the selection. Its signature depends on whether the plot uses faceting: +The **filter** function on the brush value tests whether a data point falls inside the selection. Its signature depends on whether the plot uses faceting, and on the brush’s dimension: -| Facets | Signature | -|-------------|--------------------------------| -| none | *filter*(*x*, *y*) | -| **fx** only | *filter*(*x*, *y*, *fx*) | -| **fy** only | *filter*(*x*, *y*, *fy*) | -| both | *filter*(*x*, *y*, *fx*, *fy*) | +| Facets | 1-D brush | 2-D brush | +|-------------------|-------------------------------|--------------------------------| +| *none* | *filter*(*value*) | *filter*(*x*, *y*) | +| **fx** only | *filter*(*value*, *fx*) | *filter*(*x*, *y*, *fx*) | +| **fy** only | *filter*(*value*, *fy*) | *filter*(*x*, *y*, *fy*) | +| **fx** and **fy** | *filter*(*value*, *fx*, *fy*) | *filter*(*x*, *y*, *fx*, *fy*) | When faceted, the filter returns true only for points in the brushed facet. For example: @@ -88,7 +122,7 @@ A typical pattern is to layer three reactive marks: the inactive mark provides a :::plot hidden ```js Plot.plot({ - marks: ((brush) => (d3.timeout(() => brush.move({x1: 36, x2: 48, y1: 15, y2: 20})), [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 38, x2: 48, y1: 15, y2: 19})), [ brush, Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 2})), Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#ccc", r: 2})), @@ -123,7 +157,7 @@ The brush mark supports [faceting](../features/facets.md). When the plot uses ** Plot.plot({ height: 270, grid: true, - marks: ((brush) => (d3.timeout(() => brush.move({x1: 43, x2: 50, y1: 17, y2: 19, fx: "Adelie"})), [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 45, x2: 55, y1: 15, y2: 20, fx: "Gentoo"})), [ Plot.frame(), brush, Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 2})), @@ -157,7 +191,7 @@ For plots with a [geographic projection](../features/projections.md), the brush ```js Plot.plot({ projection: "equal-earth", - marks: ((brush) => (d3.timeout(() => brush.move({x1: 80, x2: 300, y1: 60, y2: 200})), [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 300, x2: 500, y1: 50, y2: 200})), [ Plot.geo(land, {strokeWidth: 0.5}), Plot.sphere(), brush, @@ -199,7 +233,7 @@ The brush value dispatched on [_input_ events](#input-events). When the brush is - **filter** - a function to test whether a point is inside the selection - **pending** - `true` during interaction; absent when committed -By convention, *x1* < *x2* and *y1* < *y2*. +By convention, *x1* < *x2* and *y1* < *y2*. The brushX mark does not dispatch *y1* and *y2*; similarly, the brushY mark does not dispatch *x1* and *x2*. The **pending** property indicates the user is still interacting with the brush. To skip intermediate values and react only to committed selections: @@ -248,7 +282,11 @@ Returns mark options that hide the mark by default and, during brushing, show on brush.move({x1: 36, x2: 48, y1: 15, y2: 20}) ``` -Programmatically sets the brush selection in data space. The *value* must have **x1**, **x2**, **y1**, and **y2** properties. For faceted plots, include **fx** or **fy** to target a specific facet. Pass null to clear the selection. +Programmatically sets the brush selection in data space. For a 2D brush, the *value* must have **x1**, **x2**, **y1**, and **y2** properties; for brushX, **x1** and **x2**; for brushY, **y1** and **y2**. For faceted plots, include **fx** or **fy** to target a specific facet. Pass null to clear the selection. + +```js +brush.move({x1: 3500, x2: 5000}) // brushX +``` ```js brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"}) @@ -259,3 +297,50 @@ 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} + +```js +const brush = Plot.brushX() +``` + +Returns a new horizontal brush mark that selects along the *x* axis. The available *options* are: + +- **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 + +When an **interval** is set, the selection snaps to interval boundaries on release, and the filter rounds values before testing, for consistency with binned marks using the same interval. (Use the same interval in the bin transform so the brush aligns with bin edges.) + +:::plot defer hidden +```js +Plot.plot({ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 3500, x2: 5000})), [ + Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})), + brush, + Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))), + Plot.ruleY([0]) + ]))(Plot.brushX({interval: 100})) +}) +``` +::: + +```js +const brush = Plot.brushX({interval: 100}); +Plot.plot({ + marks: [ + Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})), + brush, + Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))), + Plot.ruleY([0]) + ] +}) +``` + +The brushX mark does not support projections. + +## brushY(*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). diff --git a/src/index.js b/src/index.js index 4461bdfcb7..cd2a458f2b 100644 --- a/src/index.js +++ b/src/index.js @@ -53,7 +53,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, brush} from "./interactions/brush.js"; +export {Brush, brush, brushX, brushY} from "./interactions/brush.js"; export {pointer, pointerX, pointerY} from "./interactions/pointer.js"; export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js"; export {scale} from "./scales.js"; diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index f63781e5aa..a4fa018fde 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -1,3 +1,4 @@ +import type {Interval} from "../interval.js"; import type {RenderableMark} from "../mark.js"; import type {Rendered} from "../transforms/basic.js"; @@ -9,32 +10,35 @@ import type {Rendered} from "../transforms/basic.js"; */ export interface BrushValue { /** The lower *x* value of the brushed region. */ - x1: number | Date; + x1?: number | Date; /** The upper *x* value of the brushed region. */ - x2: number | Date; + x2?: number | Date; /** The lower *y* value of the brushed region. */ - y1: number | Date; + y1?: number | Date; /** The upper *y* value of the brushed region. */ - y2: number | Date; + y2?: number | Date; /** The *fx* facet value, if applicable. */ fx?: any; /** The *fy* facet value, if applicable. */ fy?: any; /** * A function to test whether a point falls inside the brush selection. - * The signature depends on active facets: *(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. + * 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)*, + * *(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: (x: number | Date, y: number | Date, f1?: any, f2?: any) => boolean; + filter: (...args: any[]) => boolean; /** True during interaction, absent when committed. */ pending?: true; } /** - * A brush mark that renders a two-dimensional [brush](https://d3js.org/d3-brush) - * allowing the user to select a rectangular region. The brush coordinates across - * facets, clearing previous selections when a new brush starts. + * 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 + * selections when a new brush starts. * * The brush dispatches an input event when the selection changes. The selection * is available as plot.value as a **BrushValue**, or null when the selection is @@ -64,13 +68,31 @@ export class Brush extends RenderableMark { /** * Programmatically sets the brush selection in data space. Pass an object - * with **x1**, **x2**, **y1**, **y2** (and optionally **fx**, **fy** for - * faceted plots) to set the selection, or null to clear it. + * with the relevant bounds (**x1** and **x2**, **y1** and **y2**, and + * **fx**, **fy** for faceted plots) to set the selection, or null to clear it. */ move( - value: {x1: number | Date; x2: number | Date; y1: number | Date; y2: number | Date; fx?: any; fy?: any} | null + value: {x1?: number | Date; x2?: number | Date; y1?: number | Date; y2?: number | Date; fx?: any; fy?: any} | null ): void; } -/** Creates a new brush mark. */ +/** Creates a new two-dimensional brush mark. */ export function brush(): Brush; + +/** Options for brush marks. */ +export interface 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. + */ + 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 *y* axis. Not supported with projections. */ +export function brushY(options?: BrushOptions): Brush; diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 7069c22c86..9fd6254f16 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,11 +1,24 @@ -import {brush as d3Brush, create, pointer, select, selectAll} from "d3"; +import { + brush as d3Brush, + brushX as d3BrushX, + brushY as d3BrushY, + create, + pointer, + select, + selectAll, + ascending +} from "d3"; import {composeRender, Mark} from "../mark.js"; +import {keyword, maybeInterval} from "../options.js"; export class Brush extends Mark { - constructor() { + constructor({dimension = "xy", interval, sync = false} = {}) { super(undefined, {}, {}, {}); - this._brush = d3Brush(); + 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); this._brushNodes = []; + this._sync = sync; this.inactive = renderFilter(true); this.context = renderFilter(false); this.focus = renderFilter(false); @@ -13,39 +26,50 @@ export class Brush extends Mark { render(index, scales, values, dimensions, context) { const {x, y, fx, fy} = scales; const {inactive, context: ctx, focus} = this; - let target, currentNode, clearing; + let target, currentNode, syncing; if (!index?.fi) { + const dim = this._dimension; + const interval = this._interval; + if (context.projection && dim !== "xy") throw new Error(`brush${dim.toUpperCase()} does not support projections`); const invertX = (!context.projection && x?.invert) || ((d) => d); const invertY = (!context.projection && y?.invert) || ((d) => d); - this._applyX = (!context.projection && x) || ((d) => d); - this._applyY = (!context.projection && y) || ((d) => d); + 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; + let snapping; _brush .extent([ [dimensions.marginLeft - 1, dimensions.marginTop - 1], [dimensions.width - dimensions.marginRight + 1, dimensions.height - dimensions.marginBottom + 1] ]) .on("start brush end", function (event) { + if (syncing) return; const {selection, type} = event; - if (type === "start" && !clearing) { + if (type === "start" && !snapping) { target = event.sourceEvent?.currentTarget ?? this; currentNode = _brushNodes.indexOf(target); - if (!clearing) { - clearing = true; + if (!sync) { + syncing = true; selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); - clearing = false; - for (let i = 0; i < _brushNodes.length; ++i) { - inactive.update(false, i); - ctx.update(true, i); - focus.update(false, i); - } + syncing = false; + } + for (let i = 0; i < _brushNodes.length; ++i) { + inactive.update(false, i); + ctx.update(true, i); + focus.update(false, i); } } if (selection === null) { if (type === "end") { + if (sync) { + syncing = true; + selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); + syncing = false; + } for (let i = 0; i < _brushNodes.length; ++i) { inactive.update(true, i); ctx.update(false, i); @@ -53,21 +77,19 @@ export class Brush extends Mark { } context.dispatchValue(null); } else { - inactive.update(false, currentNode); - ctx.update(true, currentNode); - focus.update(false, currentNode); + for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) { + inactive.update(false, i); + ctx.update(true, i); + focus.update(false, i); + } let value = null; if (event.sourceEvent) { const [px, py] = pointer(event, this); - const x1 = invertX(px); - const y1 = invertY(py); const facet = target?.__data__; - const filter = filterFromBrush(x, y, facet, context.projection, px, px, py, py); + const filter = filterFromBrush(dim, interval, x, y, facet, context.projection, px, px, py, py); value = { - x1, - x2: x1, - y1, - y2: y1, + ...(dim !== "y" && {x1: invertX(px), x2: invertX(px)}), + ...(dim !== "x" && {y1: invertY(py), y2: invertY(py)}), ...(fx && facet && {fx: facet.x}), ...(fy && facet && {fy: facet.y}), filter, @@ -77,25 +99,54 @@ export class Brush extends Mark { context.dispatchValue(value); } } else { - const [[px1, py1], [px2, py2]] = selection; - inactive.update(false, currentNode); - ctx.update((xi, yi) => !(px1 <= xi && xi < px2 && py1 <= yi && yi < py2), currentNode); - focus.update((xi, yi) => px1 <= xi && xi < px2 && py1 <= yi && yi < py2, currentNode); - - let x1 = invertX(px1), - x2 = invertX(px2); - let y1 = invertY(py1), - y2 = invertY(py2); - if (x1 > x2) [x2, x1] = [x1, x2]; - if (y1 > y2) [y2, y1] = [y1, y2]; + const [[px1, py1], [px2, py2]] = + dim === "xy" + ? selection + : dim === "x" + ? [ + [selection[0], NaN], + [selection[1], NaN] + ] + : [ + [NaN, selection[0]], + [NaN, selection[1]] + ]; + + const inX = isNaN(px1) ? () => true : (xi) => px1 <= xi && xi < px2; + const inY = isNaN(py1) ? () => true : (yi) => py1 <= yi && yi < py2; + + if (sync) { + syncing = true; + selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, selection); + syncing = false; + } + for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) { + inactive.update(false, i); + ctx.update((xi, yi) => !(inX(xi) && inY(yi)), i); + focus.update((xi, yi) => inX(xi) && inY(yi), i); + } + + const [x1, x2] = invertX && [invertX(px1), invertX(px2)].sort(ascending); + const [y1, y2] = invertY && [invertY(py1), invertY(py2)].sort(ascending); + + // Snap to interval on end + if (type === "end" && interval && !snapping) { + const s1 = dim === "x" ? x1 : y1; + const s2 = dim === "x" ? x2 : y2; + const r1 = intervalRound(interval, s1); + let r2 = intervalRound(interval, s2); + if (+r1 === +r2) r2 = interval.offset(r1); + snapping = true; + select(this).call(_brush.move, [r1, r2].map(dim === "x" ? applyX : applyY).sort(ascending)); + snapping = false; + return; + } const facet = target?.__data__; - const filter = filterFromBrush(x, y, facet, context.projection, px1, px2, py1, py2); + const filter = filterFromBrush(dim, interval, x, y, facet, context.projection, px1, px2, py1, py2); context.dispatchValue({ - x1, - x2, - y1, - y2, + ...(dim !== "y" && {x1, x2}), + ...(dim !== "x" && {y1, y2}), ...(fx && facet && {fx: facet.x}), ...(fy && facet && {fy: facet.y}), filter, @@ -125,52 +176,92 @@ export class Brush extends Mark { throw new Error( fx === undefined && fy === undefined ? "No brush node found" : "No brush node found for the specified facet" ); - const px1 = this._applyX(x1); - const px2 = this._applyX(x2); - const py1 = this._applyY(y1); - const py2 = this._applyY(y2); - select(node).call(this._brush.move, [ - [Math.min(px1, px2), Math.min(py1, py2)], - [Math.max(px1, px2), Math.max(py1, py2)] - ]); + const [px1, px2] = [x1, x2].map(this._applyX).sort(ascending); + const [py1, py2] = [y1, y2].map(this._applyY).sort(ascending); + select(node).call( + this._brush.move, + this._dimension === "xy" + ? [ + [px1, py1], + [px2, py2] + ] + : this._dimension === "x" + ? [px1, px2] + : [py1, py2] + ); } } -export function brush() { - return new Brush(); +export function brush(options) { + return new Brush(options); +} + +export function brushX({interval} = {}) { + return new Brush({dimension: "x", interval}); +} + +export function brushY({interval} = {}) { + return new Brush({dimension: "y", interval}); } -function filterFromBrush(xScale, yScale, facet, projection, px1, px2, py1, py2) { - let px, py; - const stream = projection?.stream({ - point(x, y) { - px = x; - py = y; +function filterFromBrush(dim, interval, xScale, yScale, facet, projection, px1, px2, py1, py2) { + switch (dim) { + case "x": + case "y": { + const floor = interval ? (d) => interval.floor(d) : (d) => d; + const [scale, pv1, pv2] = dim === "x" ? [xScale, px1, px2] : [yScale, py1, py2]; + let p; + return filterSignature1D((d) => ((p = scale(floor(d))), pv1 <= p && p < pv2), facet?.x, facet?.y); } - }) ?? { - point: (x, y) => { - px = xScale(x); - py = yScale(y); + case "xy": { + let px, py; + const stream = projection?.stream({ + point(x, y) { + px = x; + py = y; + } + }) ?? { + point: (x, y) => { + px = xScale(x); + py = yScale(y); + } + }; + return filterSignature2D( + (dx, dy) => (stream.point(dx, dy), px1 <= px && px < px2 && py1 <= py && py < py2), + facet?.x, + facet?.y + ); } - }; - return filterSignature( - (dx, dy) => { - stream.point(dx, dy); - return px1 <= px && px < px2 && py1 <= py && py < py2; - }, - facet?.x, - facet?.y - ); + } } -function filterSignature(test, currentFx, currentFy) { +function filterSignature2D(test, currentFx, currentFy) { return currentFx === undefined ? currentFy === undefined ? (x, y) => test(x, y) - : (x, y, fy) => fy === currentFy && test(x, y) + : (x, y, fy) => (fy === undefined || fy === currentFy) && test(x, y) + : currentFy === undefined + ? (x, y, fx) => (fx === undefined || fx === currentFx) && test(x, y) + : (x, y, fx, fy) => + (fx === undefined || fx === currentFx) && (fy === undefined || fy === currentFy) && test(x, y); +} + +function filterSignature1D(test, currentFx, currentFy) { + return currentFx === undefined + ? currentFy === undefined + ? (v) => test(v) + : (v, fy) => (fy === undefined || fy === currentFy) && test(v) : currentFy === undefined - ? (x, y, fx) => fx === currentFx && test(x, y) - : (x, y, fx, fy) => fx === currentFx && fy === currentFy && test(x, y); + ? (v, fx) => (fx === undefined || fx === currentFx) && test(v) + : (v, fx, fy) => + (fx === undefined || fx === currentFx) && (fy === undefined || fy === currentFy) && test(v); +} + +function intervalRound(interval, v) { + const lo = interval.floor(v); + const hi = interval.offset(lo); + v = +v; + return v - +lo < +hi - v ? lo : hi; } function renderFilter(initialTest) { @@ -181,9 +272,11 @@ function renderFilter(initialTest) { pointerEvents: "none", ...options, render: composeRender(function (index, scales, values, dimensions, context, next) { - const {x: X, y: Y} = values; + const {x: X, y: Y, x1: X1, x2: X2, y1: Y1, y2: Y2} = values; + const MX = X ?? (X1 && X2 ? Float64Array.from(X1, (v, i) => (v + X2[i]) / 2) : undefined); + const MY = Y ?? (Y1 && Y2 ? Float64Array.from(Y1, (v, i) => (v + Y2[i]) / 2) : undefined); const filter = (test) => - typeof test === "function" ? index.filter((i) => test(X[i], Y[i])) : test ? index : []; + typeof test === "function" ? index.filter((i) => test(MX?.[i], MY?.[i])) : test ? index : []; let g = next(filter(initialTest), scales, values, dimensions, context); updatePerFacet.push((test) => { const transform = g.getAttribute("transform"); diff --git a/test/brush-test.ts b/test/brush-test.ts index 136460c93e..7dcdf6ed9c 100644 --- a/test/brush-test.ts +++ b/test/brush-test.ts @@ -185,3 +185,79 @@ it("brush reactive marks compose with user render transforms", () => { }); assert.equal(rendered.length, 3, "user render should have been called for each reactive mark"); }); + +it("brushX value has x1/x2 but no y1/y2", async () => { + 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.brushX(); + const plot = Plot.plot({ + x: {domain: [0, 60]}, + y: {domain: [0, 60]}, + marks: [ + Plot.dot(data, brush.inactive({x: "x", y: "y"})), + Plot.dot(data, brush.context({x: "x", y: "y", fill: "#ccc"})), + Plot.dot(data, brush.focus({x: "x", y: "y", fill: "red"})), + brush + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + + brush.move({x1: 15, x2: 45}); + + assert.ok(lastValue, "should have a value"); + assert.ok("x1" in lastValue, "value should have x1"); + assert.ok("x2" in lastValue, "value should have x2"); + assert.ok(!("y1" in lastValue), "value should not have y1"); + assert.ok(!("y2" in lastValue), "value should not have y2"); + assert.ok(typeof lastValue.filter === "function", "value should have a filter function"); + + // 1D filter takes a single argument + const filtered = data.filter((d) => lastValue.filter(d.x)); + assert.ok(filtered.length > 0, "should select some points"); + assert.ok(filtered.length < data.length, "should not include all points"); +}); + +it("brushY value has y1/y2 but no x1/x2", async () => { + 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.brushY(); + const plot = Plot.plot({ + x: {domain: [0, 60]}, + y: {domain: [0, 60]}, + marks: [ + Plot.dot(data, brush.inactive({x: "x", y: "y"})), + Plot.dot(data, brush.context({x: "x", y: "y", fill: "#ccc"})), + Plot.dot(data, brush.focus({x: "x", y: "y", fill: "red"})), + brush + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + + brush.move({y1: 15, y2: 45}); + + assert.ok(lastValue, "should have a value"); + assert.ok("y1" in lastValue, "value should have y1"); + assert.ok("y2" in lastValue, "value should have y2"); + assert.ok(!("x1" in lastValue), "value should not have x1"); + assert.ok(!("x2" in lastValue), "value should not have x2"); + assert.ok(typeof lastValue.filter === "function", "value should have a filter function"); + + // 1D filter takes a single argument + const filtered = data.filter((d) => lastValue.filter(d.y)); + assert.ok(filtered.length > 0, "should select some points"); + assert.ok(filtered.length < data.length, "should not include all points"); +}); diff --git a/test/output/brushXDot.html b/test/output/brushXDot.html new file mode 100644 index 0000000000..a4165c7710 --- /dev/null +++ b/test/output/brushXDot.html @@ -0,0 +1,392 @@ +
+ + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXHistogram.html b/test/output/brushXHistogram.html new file mode 100644 index 0000000000..ee3fefcdca --- /dev/null +++ b/test/output/brushXHistogram.html @@ -0,0 +1,119 @@ +
+ + + + 0 + 2 + 4 + 6 + 8 + 10 + 12 + 14 + 16 + 18 + 20 + 22 + + + ↑ Frequency + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXHistogramFaceted.html b/test/output/brushXHistogramFaceted.html new file mode 100644 index 0000000000..5a2bed0f21 --- /dev/null +++ b/test/output/brushXHistogramFaceted.html @@ -0,0 +1,295 @@ +
+
+
+ + + Adelie + + Chinstrap + + Gentoo +
+ + + + Biscoe + + + Dream + + + Torgersen + + + + + + + 0 + 5 + 10 + 15 + + + 0 + 5 + 10 + 15 + + + 0 + 5 + 10 + 15 + + + + ↑ Frequency + + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
\ No newline at end of file diff --git a/test/output/brushXTemporal.html b/test/output/brushXTemporal.html new file mode 100644 index 0000000000..0ecb3ded14 --- /dev/null +++ b/test/output/brushXTemporal.html @@ -0,0 +1,146 @@ +
+ + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushXTemporalReversed.html b/test/output/brushXTemporalReversed.html new file mode 100644 index 0000000000..47734d9a1e --- /dev/null +++ b/test/output/brushXTemporalReversed.html @@ -0,0 +1,146 @@ +
+ + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + 2018 + 2017 + 2016 + 2015 + 2014 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/brushYDot.html b/test/output/brushYDot.html new file mode 100644 index 0000000000..bb9d682681 --- /dev/null +++ b/test/output/brushYDot.html @@ -0,0 +1,411 @@ +
+ + + + 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/brushYHistogram.html b/test/output/brushYHistogram.html new file mode 100644 index 0000000000..c83f2bf5e6 --- /dev/null +++ b/test/output/brushYHistogram.html @@ -0,0 +1,113 @@ +
+ + + + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + + + ↑ culmen_depth_mm + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + + + Frequency → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 2419f4b2ed..b1746a922b 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -275,6 +275,247 @@ export async function brushRandomNormal() { return html`
${plot}${textarea}
`; } +export async function brushXHistogram() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const brush = Plot.brushX(); + const plot = Plot.plot({ + marks: [ + brush, + Plot.rectY( + penguins, + Plot.binX( + {y: "count"}, + brush.inactive({x: "body_mass_g", thresholds: 40, fill: "currentColor", fillOpacity: 0.8}) + ) + ), + Plot.rectY( + penguins, + Plot.binX( + {y: "count"}, + brush.context({x: "body_mass_g", thresholds: 40, fill: "currentColor", fillOpacity: 0.3}) + ) + ), + Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", thresholds: 40}))), + Plot.ruleY([0]) + ] + }); + const textarea = html` + \ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index b1746a922b..8f518aa1c5 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -531,3 +531,30 @@ export async function brushSimple() { plot.oninput = oninput; return html`
${plot}${textarea}
`; } + +export async function brushXLine() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + const brush = Plot.brushX(); + const plot = Plot.plot({ + marks: [ + brush, + Plot.lineY(aapl, brush.inactive({x: "Date", y: "Close"})), + Plot.lineY(aapl, brush.context({x: "Date", y: "Close", stroke: "#ccc", strokeWidth: 0.5})), + Plot.lineY(aapl, brush.focus({x: "Date", y: "Close", strokeWidth: 2})) + ] + }); + const textarea = html` + \ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 8f518aa1c5..811d45b7b7 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -558,3 +558,30 @@ export async function brushXLine() { oninput(); return html`
${plot}${textarea}
`; } + +export async function brushYLine() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + const brush = Plot.brushY(); + const plot = Plot.plot({ + marks: [ + brush, + Plot.lineX(aapl, brush.inactive({y: "Date", x: "Close"})), + Plot.lineX(aapl, brush.context({y: "Date", x: "Close", stroke: "#ccc", strokeWidth: 0.5})), + Plot.lineX(aapl, brush.focus({y: "Date", x: "Close", strokeWidth: 2})) + ] + }); + const textarea = html` + \ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 811d45b7b7..fcde24bcce 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -559,6 +559,48 @@ export async function brushXLine() { return html`
${plot}${textarea}
`; } +export async function brushLineZ() { + const data = await d3.csv("data/bls-metro-unemployment.csv", d3.autoType); + const brush = Plot.brush(); + const zxy = {z: "division", x: "date", y: "unemployment"} as const; + const render = (index: number[], scales: any, values: any, dimensions: any, context: any, next: any) => { + const Z = values.channels.z?.value; + if (!Z) return next(index, scales, values, dimensions, context); + const groups = new Set(); + for (const i of index) groups.add(Z[i]); + const expanded: number[] = []; + for (let i = 0; i < Z.length; ++i) { + if (groups.has(Z[i])) { + expanded.push(i); + values.z[i] = Z[i]; + } + } + return next(expanded, scales, values, dimensions, context); + }; + const plot = Plot.plot({ + marks: [ + Plot.lineY(data, {...zxy, stroke: "#ccc", strokeWidth: 0.5}), + brush, + Plot.lineY(data, brush.focus({...zxy, strokeWidth: 2, render})) + ] + }); + const textarea = html` + \ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 4ce8ae68b7..4e25c26672 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -595,6 +595,54 @@ export async function brushLineZ() { return html`
${plot}${textarea}
`; } +export async function brushSyncedCharts() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + const brush = Plot.brushX(); + const xy = {x: "Date", y: "Close"} as const; + const sign = (d: any) => d.Close < d.Open; + const plot1 = Plot.plot({ + height: 280, + y: {grid: true}, + color: {range: ["#2ecc71", "#e74c3c"]}, + marks: [ + Plot.ruleY([0]), + brush, + Plot.lineY(aapl, brush.inactive({...xy, z: null, stroke: sign})), + Plot.lineY(aapl, brush.context({...xy, stroke: "#ccc", strokeWidth: 0.5})), + Plot.lineY(aapl, brush.focus({...xy, z: null, stroke: sign, strokeWidth: 2})) + ] + }); + const ohlc = {x: "Date", y1: "Low", y2: "High", inset: -0.5, stroke: sign} as const; + const aapl2016 = aapl.filter((d: any) => d.Date.getUTCFullYear() === 2016); + const plot2 = Plot.plot({ + height: 280, + clip: true, + x: {domain: [new Date("2016-01-01"), new Date("2017-01-01")]}, + y: { + domain: d3.nice(...(d3.extent(aapl2016.flatMap((d: any) => [d.Low, d.High])) as [number, number]), 10), + grid: true, + label: "Price ($)" + }, + color: {range: ["#2ecc71", "#e74c3c"]}, + marks: [ + Plot.ruleX(aapl, brush.inactive({...ohlc})), + Plot.ruleX(aapl, brush.context({...ohlc, stroke: "#ccc"})), + Plot.ruleX(aapl, brush.focus({...ohlc})), + brush + ] + }); + const textarea = html`