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..109e1542a7 --- /dev/null +++ b/docs/interactions/brush.md @@ -0,0 +1,261 @@ + + +# 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. For example: + +```js +plot.addEventListener("input", () => { + const filter = plot.value?.filter; + const selected = filter ? penguins.filter((d) => filter(d.culmen_length_mm, d.culmen_depth_mm)) : penguins; + console.log(selected); +}); +``` + +## 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) => [ + 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()) +}) +``` +::: + +```js +const brush = Plot.brush(); +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})), + Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 3})) + ] +}) +``` + +:::tip +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 + +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(), + 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()) +}) +``` +::: + +```js +const brush = Plot.brush(); +Plot.plot({ + marks: [ + 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})) + ] +}) +``` + +## 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(), + 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()) +}) +``` +::: + +
+ +```js +const brush = Plot.brush(); +Plot.plot({ + projection: "equal-earth", + marks: [ + 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"})) + ] +}) +``` + +## 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 +- **pending** - `true` during interaction; absent when committed + +By convention, *x1* < *x2* and *y1* < *y2*. + +The **pending** property indicates the user is still interacting with the brush. To skip intermediate values and react only to committed selections: + +```js +plot.addEventListener("input", () => { + if (plot.value?.pending) return; + // handle committed value (null if cleared) +}); +``` + +## 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. + +## *brush*.move(*value*) {#brush.move} + +```js +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. + +```js +brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"}) +``` + +```js +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. 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/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.d.ts b/src/interactions/brush.d.ts new file mode 100644 index 0000000000..f63781e5aa --- /dev/null +++ b/src/interactions/brush.d.ts @@ -0,0 +1,76 @@ +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; + /** 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. + * + * 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 + * reactive 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; + + /** + * 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. + */ + move( + value: {x1: number | Date; x2: number | Date; y1: number | Date; y2: number | Date; fx?: any; fy?: any} | null + ): void; +} + +/** Creates a new brush mark. */ +export function brush(): Brush; diff --git a/src/interactions/brush.js b/src/interactions/brush.js new file mode 100644 index 0000000000..b1292bb3db --- /dev/null +++ b/src/interactions/brush.js @@ -0,0 +1,203 @@ +import {brush as d3Brush, create, pointer, select, selectAll} from "d3"; +import {composeRender, 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); + this._applyX = (!context.projection && x) || ((d) => d); + this._applyY = (!context.projection && y) || ((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); + } + context.dispatchValue(null); + } else { + inactive.update(false, currentNode); + ctx.update(true, currentNode); + focus.update(false, currentNode); + 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); + value = { + x1, + x2: x1, + y1, + y2: y1, + ...(fx && facet && {fx: facet.x}), + ...(fy && facet && {fy: facet.y}), + filter, + pending: true + }; + } + 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 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, + ...(type !== "end" && {pending: true}) + }); + } + }); + } + + const g = create("svg:g").attr("aria-label", "brush"); + g.call(this._brush); + const node = g.node(); + this._brushNodes.push(node); + return node; + } + move(value) { + if (value == null) { + selectAll(this._brushNodes).call(this._brush.move, null); + 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 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)] + ]); + } +} + +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 ({render, ...options} = {}) { + return { + pointerEvents: "none", + ...options, + render: composeRender(function (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; + }, render) + }; + }, + { + update(test, i) { + return updatePerFacet[i]?.(test); + } + } + ); +} 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; diff --git a/test/brush-test.ts b/test/brush-test.ts new file mode 100644 index 0000000000..136460c93e --- /dev/null +++ b/test/brush-test.ts @@ -0,0 +1,187 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import assert from "assert"; +import it from "./jsdom.js"; + +it("brush() renders without error", async () => { + const data = await d3.csv("data/penguins.csv", d3.autoType); + const plot = Plot.plot({ + marks: [Plot.dot(data, {x: "culmen_length_mm", y: "culmen_depth_mm"}), Plot.brush()] + }); + assert.ok(plot.querySelector("[aria-label=brush]"), "brush mark should exist"); + assert.ok(plot.querySelector(".selection"), "brush selection rect should exist"); +}); + +it("brush with inactive/context/focus marks renders correctly", async () => { + const data = await d3.csv("data/penguins.csv", d3.autoType); + const brush = new Plot.Brush(); + const xy = {x: "culmen_length_mm", y: "culmen_depth_mm"}; + const plot = Plot.plot({ + marks: [ + Plot.dot(data, brush.inactive({...xy, fill: "species", r: 2})), + Plot.dot(data, brush.context({...xy, fill: "#ccc", r: 2})), + Plot.dot(data, brush.focus({...xy, fill: "species", r: 3})), + brush + ] + }); + assert.ok(plot.querySelector("[aria-label=brush]"), "brush mark should exist"); + + const dotGroups = plot.querySelectorAll("[aria-label=dot]"); + // inactive dots should be visible initially + assert.ok(dotGroups[0].childNodes.length > 0, "inactive dots should be visible initially"); + // context dots should be hidden initially + assert.equal(dotGroups[1].childNodes.length, 0, "context dots should be hidden initially"); + // focus dots should be hidden initially + assert.equal(dotGroups[2].childNodes.length, 0, "focus dots should be hidden initially"); +}); + +it("brush dispatches value on programmatic brush move", 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 = new Plot.Brush(); + 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 + ] + }); + + const values: any[] = []; + plot.addEventListener("input", () => values.push(plot.value)); + + // Programmatically move the brush in data space + brush.move({x1: 10, x2: 45, y1: 10, y2: 45}); + + // Programmatic brush.move fires start, brush, end events + assert.ok(values.length >= 3, "should have dispatched at least three values"); + + // Intermediate values (start, brush) should be pending + const intermediates = values.slice(0, -1); + for (const v of intermediates) { + assert.equal(v.pending, true, "intermediate value should be pending"); + } + + // The committed value (end) should not be pending + const lastValue = values[values.length - 1]; + assert.ok(lastValue, "last value should not be null"); + assert.ok(!("pending" in lastValue), "committed value should not be pending"); + assert.ok(typeof lastValue.filter === "function", "value should have a filter function"); + + // Check filtered elements + const filtered = data.filter((d) => lastValue.filter(d.x, d.y)); + assert.ok(filtered.length > 0, "should have filtered some elements"); + assert.ok(filtered.length < data.length, "should not include all elements"); +}); + +it("brush faceted filter restricts to the brushed facet", async () => { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const b = new Plot.Brush(); + const xy = {x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species"}; + const plot = Plot.plot({ + marks: [ + Plot.dot(penguins, b.inactive({...xy, r: 2})), + Plot.dot(penguins, b.context({...xy, fill: "#ccc", r: 2})), + Plot.dot(penguins, b.focus({...xy, r: 3})), + b + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + + // Brush a region in the first facet (data coordinates) + b.move({x1: 35, x2: 50, y1: 14, y2: 20, fx: "Adelie"}); + + assert.ok(lastValue, "should have a value"); + assert.ok(lastValue.fx !== undefined, "value should include fx"); + + // Filter WITH facet (correct) + const filteredWithFacet = penguins.filter((d: any) => + lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm, d.species) + ); + + // Filter mistakenly WITHOUT facet + const filteredWithoutFacet = penguins.filter( + (d: any) => + d.culmen_length_mm >= lastValue.x1 && + d.culmen_length_mm <= lastValue.x2 && + d.culmen_depth_mm >= lastValue.y1 && + d.culmen_depth_mm <= lastValue.y2 + ); + + assert.ok(filteredWithFacet.length > 0, "should select some points"); + assert.ok( + filteredWithFacet.length < filteredWithoutFacet.length, + `facet filter should be stricter (${filteredWithFacet.length} < ${filteredWithoutFacet.length})` + ); + + // All filtered points should belong to a single species + const species = new Set(filteredWithFacet.map((d: any) => d.species)); + assert.equal(species.size, 1, `all filtered points should be one species, got: ${[...species]}`); +}); + +it("brush programmatic move on second facet selects the correct facet", async () => { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const b = new Plot.Brush(); + const xy = {x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species"}; + const plot = Plot.plot({ + marks: [ + Plot.dot(penguins, b.inactive({...xy, r: 2})), + Plot.dot(penguins, b.context({...xy, fill: "#ccc", r: 2})), + Plot.dot(penguins, b.focus({...xy, r: 3})), + b + ] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + + // Brush the second facet (Chinstrap) using data coordinates + b.move({x1: 35, x2: 50, y1: 14, y2: 20, fx: "Chinstrap"}); + + assert.ok(lastValue, "should have a value"); + assert.ok(lastValue.fx !== undefined, "value should include fx"); + assert.equal(lastValue.fx, "Chinstrap", "fx should be Chinstrap (the second facet)"); + + const filtered = penguins.filter((d: any) => lastValue.filter(d.culmen_length_mm, d.culmen_depth_mm, d.species)); + assert.ok(filtered.length > 0, "should select some points"); + + const species = new Set(filtered.map((d: any) => d.species)); + assert.equal(species.size, 1, "all filtered points should be one species"); + assert.equal([...species][0], "Chinstrap", "filtered species should be Chinstrap"); +}); + +it("brush reactive marks compose with user render transforms", () => { + const data = [ + {x: 10, y: 10}, + {x: 20, y: 20}, + {x: 30, y: 30} + ]; + const brush = new Plot.Brush(); + const rendered: string[] = []; + const render = (index: number[], scales: any, values: any, dimensions: any, context: any, next: any) => { + const g = next(index, scales, values, dimensions, context); + rendered.push("custom"); + return g; + }; + Plot.plot({ + x: {domain: [0, 40]}, + y: {domain: [0, 40]}, + marks: [ + brush, + Plot.dot(data, brush.inactive({x: "x", y: "y", render})), + Plot.dot(data, brush.context({x: "x", y: "y", render})), + Plot.dot(data, brush.focus({x: "x", y: "y", render})) + ] + }); + assert.equal(rendered.length, 3, "user render should have been called for each reactive mark"); +}); diff --git a/test/output/brushDot.html b/test/output/brushDot.html new file mode 100644 index 0000000000..5b323855d1 --- /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..f491c7710a --- /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..709feceb0a --- /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..2e422e8c28 --- /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..5a95efbf29 --- /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..c732ca6e8e --- /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..223c41579a --- /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..1ac27dfa67 --- /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..3be75eac52 --- /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..2419f4b2ed --- /dev/null +++ b/test/plots/brush.ts @@ -0,0 +1,292 @@ +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 as (...args: any[]) => any)})` : 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})), + Plot.dot(penguins, brush.context({...xy, fill: "#ccc", r: 2})), + Plot.dot(penguins, brush.focus({...xy, fill: "species", r: 3})) + ] + }); + const textarea = html`