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 109e1542a7..c44c4f5424 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,12 +122,12 @@ A typical pattern is to layer three reactive marks: the inactive mark provides a :::plot defer hidden ```js Plot.plot({ - marks: ((brush) => [ + 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})), Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 3})) - ])(Plot.brush()) + ]))(Plot.brush()) }) ``` ::: @@ -111,7 +145,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 obstruct pointer events. +To achieve higher contrast, you can place the brush before the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't obstruct pointer events. ::: ## Faceting @@ -123,13 +157,13 @@ The brush mark supports [faceting](../features/facets.md). When the plot uses ** Plot.plot({ height: 270, grid: true, - marks: ((brush) => [ + 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})), 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()) }) ``` ::: @@ -157,14 +191,14 @@ For plots with a [geographic projection](../features/projections.md), the brush ```js Plot.plot({ projection: "equal-earth", - marks: ((brush) => [ + marks: ((brush) => (d3.timeout(() => brush.move({x1: 300, x2: 500, y1: 50, 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()) }) ``` ::: @@ -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 value does not include *y1* and *y2*; similarly, the brushY value does not include *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 b1292bb3db..0b8194b760 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -1,10 +1,22 @@ -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} = {}) { 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.inactive = renderFilter(true); this.context = renderFilter(false); @@ -16,12 +28,16 @@ export class Brush extends Mark { let target, currentNode, clearing; 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; + let snapping; _brush .extent([ [dimensions.marginLeft - 1, dimensions.marginTop - 1], @@ -29,7 +45,7 @@ export class Brush extends Mark { ]) .on("start brush end", function (event) { const {selection, type} = event; - if (type === "start" && !clearing) { + if (type === "start" && !clearing && !snapping) { target = event.sourceEvent?.currentTarget ?? this; currentNode = _brushNodes.indexOf(target); if (!clearing) { @@ -59,15 +75,11 @@ export class Brush extends Mark { 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 +89,47 @@ export class Brush extends Mark { context.dispatchValue(value); } } else { - const [[px1, py1], [px2, py2]] = selection; + 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; + 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); + ctx.update((xi, yi) => !(inX(xi) && inY(yi)), currentNode); + focus.update((xi, yi) => inX(xi) && inY(yi), 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 [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, @@ -117,22 +151,24 @@ 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); - }); + const node = this._brushNodes.find((n) => { + const d = n.__data__; + return d === undefined || ((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)] - ]); + 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] + ); } } @@ -140,30 +176,46 @@ 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; +export function brushX({interval} = {}) { + return new Brush({dimension: "x", interval}); +} + +export function brushY({interval} = {}) { + return new Brush({dimension: "y", interval}); +} + +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) @@ -173,6 +225,23 @@ function filterSignature(test, currentFx, currentFy) { : (x, y, fx, fy) => fx === currentFx && fy === currentFy && test(x, y); } +function filterSignature1D(test, currentFx, currentFy) { + return currentFx === undefined + ? currentFy === undefined + ? (v) => test(v) + : (v, fy) => fy === currentFy && test(v) + : currentFy === undefined + ? (v, fx) => fx === currentFx && test(v) + : (v, fx, fy) => fx === currentFx && 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) { const updatePerFacet = []; return Object.assign( @@ -181,9 +250,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`