diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index 7c3788b88e..faa9f99fab 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -106,6 +106,15 @@ plot.addEventListener("input", () => { }); ``` +The facet argument is optional: if omitted, contains skips the facet check. For example, if the selected region is [44, 46] × [17, 19] over the "Adelie" facet: + +```js +const {contains} = plot.value; +contains(45, 18) // true +contains(45, 18, {fx: "Adelie"}) // true +contains(45, 18, {fx: "Gentoo"}) // false +``` + ## 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. @@ -206,7 +215,7 @@ plot.addEventListener("input", () => { ## Faceting -The brush mark supports [faceting](../features/facets.md). When the plot uses **fx** or **fy** facets, each facet gets its own brush. Starting a brush in one facet clears any selection in other facets. The dispatched value includes the **fx** and **fy** facet values of the brushed facet; optionally pass `{fx, fy}` as the last argument to **contains** to restrict matching to the brushed facet. +The brush mark supports [faceting](../features/facets.md). When the plot uses **fx** or **fy** facets, each facet gets its own brush. The dispatched value includes the **fx** and **fy** facet values of the brushed facet; optionally pass `{fx, fy}` as the last argument to **contains** to restrict matching to the brushed facet. :::plot hidden ```js @@ -237,6 +246,39 @@ Plot.plot({ }) ``` +By default, starting a brush in one facet clears any selection in other facets. Set **sync** to true to brush across all facet panes simultaneously. When the user brushes in one facet, the same selection rectangle appears in all panes, and the reactive marks update across all facets. + +:::plot hidden +```js +Plot.plot({ + height: 270, + grid: true, + marks: ((brush) => (d3.timeout(() => brush.move({x1: 43, x2: 50, y1: 17, y2: 19})), [ + 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({sync: true})) +}) +``` +::: + +```js +const brush = Plot.brush({sync: true}); +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})) + ] +}) +``` + +The dispatched value still includes **fx** (and **fy**), indicating the facet where the interaction originated. + ## 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. Use **contains** with pixel coordinates to test against the brush extent. @@ -299,6 +341,10 @@ const brush = Plot.brush() Returns a new brush with the given *data* and *options*. Both *data* and *options* are optional. If *data* is specified but neither **x** nor **y** is specified in the *options*, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], …] such that **x** = [*x₀*, *x₁*, …] and **y** = [*y₀*, *y₁*, …]. +The following *options* are supported: + +- **sync** - if true, the brush spans all facet panes simultaneously; defaults to false + ## *brush*.inactive(*options*) {#brush-inactive} ```js diff --git a/src/interactions/brush.d.ts b/src/interactions/brush.d.ts index 0858b6c1a5..458773cf35 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -62,6 +62,11 @@ export interface BrushOptions extends MarkOptions { * inherited by reactive marks as a default. */ fy?: MarkOptions["fy"]; + + /** + * If true, the brush spans all facet panes simultaneously; defaults to false. + */ + sync?: boolean; } /** @@ -75,6 +80,7 @@ export interface BrushOptions extends MarkOptions { * reactive marks that respond to the brush state. */ export class Brush extends RenderableMark { + constructor(options?: BrushOptions); /** * Creates a new brush mark with the given *data* and *options*. If *data* and * *options* specify **x** and **y** channels, these become defaults for diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 874f15db26..d484cebb21 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -48,7 +48,10 @@ export class Region { } export class Brush extends Mark { - constructor(data, {dimension = "xy", interval, sync = false, ...options} = {}) { + constructor(data, options = {}) { + if (data != null && !isIterable(data)) (options = data), (data = undefined); + const {dimension = "xy", interval, sync = false, ...rest} = options; + options = rest; const {x, y, z} = options; super( dataify(data), @@ -142,6 +145,7 @@ export class Brush extends Mark { if (selection === null) { if (type === "end") { + context.interaction.brushing = false; if (sync) { self._syncing = true; selectAll(nodes.filter((_, i) => i !== currentNode)).call(brush.move, null); @@ -188,6 +192,7 @@ export class Brush extends Mark { context.dispatchValue(value); } } else { + if (event.sourceEvent) context.interaction.brushing = true; const [[px1, py1], [px2, py2]] = dim === "xy" ? selection : dim === "x" ? [[selection[0]], [selection[1]]] : [[, selection[0]], [, selection[1]]]; // prettier-ignore diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index f0c0f765b0..a198bebc5e 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -140,7 +140,9 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // squashed, selecting primarily on the dominant dimension. Across facets, // use unsquashed distance to determine the winner. function pointermove(event) { - if (state.sticky || (event.pointerType === "mouse" && event.buttons === 1)) return; // dragging + if (context.interaction?.brushing) { if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); return; } // prettier-ignore + if (state.sticky) return; + if (event.pointerType === "mouse" && event.buttons === 1) return void update(null); // hide tip during drag let [xp, yp] = pointof(event); (xp -= tx), (yp -= ty); // correct for facets and band scales const kpx = xp < dimensions.marginLeft || xp > dimensions.width - dimensions.marginRight ? 1 : kx; @@ -166,6 +168,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op if (i == null) return; // not pointing if (state.sticky && state.roots.some((r) => r?.contains(event.target))) return; // stay sticky if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); // clear all pointers + else if (context.interaction?.brushing) return void update(null); // cancel tip on brush start else (state.sticky = true), render(i); event.stopImmediatePropagation(); // suppress other pointers } diff --git a/test/brush-test.ts b/test/brush-test.ts index 902f5288e9..6001beebde 100644 --- a/test/brush-test.ts +++ b/test/brush-test.ts @@ -168,6 +168,111 @@ it("brush programmatic move on second facet selects the correct facet", async () assert.equal([...species][0], "Chinstrap", "filtered species should be Chinstrap"); }); +it("brush faceted filter without fx selects across all facets", 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})), b] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + b.move({x1: 35, x2: 50, y1: 14, y2: 20, fx: "Adelie"}); + + // With fx: restricts to Adelie + const withFx = penguins.filter((d: any) => lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm, {fx: d.species})); + assert.ok( + withFx.every((d: any) => d.species === "Adelie"), + "with fx should only select Adelie" + ); + + // Without fx: selects across all facets + const withoutFx = penguins.filter((d: any) => lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm)); + const species = new Set(withoutFx.map((d: any) => d.species)); + assert.ok(species.size > 1, `without fx should select multiple species, got: ${[...species]}`); + assert.ok(withoutFx.length > withFx.length, "without fx should select more points"); +}); + +it("brush cross-facet filter selects across all facets", async () => { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const b = new Plot.Brush({sync: true}); + 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})), b] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + 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 (origin facet)"); + + // Without fx: selects across all facets + const withoutFx = penguins.filter((d: any) => lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm)); + const species = new Set(withoutFx.map((d: any) => d.species)); + assert.ok(species.size > 1, `should select multiple species, got: ${[...species]}`); + + // With fx: restricts to the origin facet + const withFx = penguins.filter((d: any) => lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm, {fx: d.species})); + const fxSpecies = new Set(withFx.map((d: any) => d.species)); + assert.equal(fxSpecies.size, 1, "with fx should restrict to origin facet"); + assert.ok(withFx.length < withoutFx.length, "with fx should select fewer points"); +}); + +it("brush faceted filter with fx and fy supports partial facet args", 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", fy: "sex"}; + const plot = Plot.plot({ + marks: [Plot.dot(penguins, b.inactive({...xy, r: 2})), b] + }); + + let lastValue: any; + plot.addEventListener("input", () => (lastValue = plot.value)); + b.move({x1: 35, x2: 50, y1: 14, y2: 20, fx: "Adelie", fy: "MALE"}); + + // Both fx and fy: restricts to Adelie MALE + const withBoth = penguins.filter((d: any) => + lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm, {fx: d.species, fy: d.sex}) + ); + assert.ok(withBoth.length > 0, "should select some points"); + assert.ok( + withBoth.every((d: any) => d.species === "Adelie" && d.sex === "MALE"), + "should only select Adelie MALE" + ); + + // Only fx (fy undefined): restricts to Adelie, any sex + const withFx = penguins.filter((d: any) => lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm, {fx: d.species})); + assert.ok(withFx.length >= withBoth.length, "with fx only should select at least as many"); + assert.ok( + withFx.every((d: any) => d.species === "Adelie"), + "with fx only should restrict to Adelie" + ); + const sexes = new Set(withFx.map((d: any) => d.sex)); + assert.ok(sexes.size > 1, `with fx only should include multiple sexes, got: ${[...sexes]}`); + + // Only fy (fx undefined): restricts to MALE, any species + const withFy = penguins.filter((d: any) => lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm, {fy: d.sex})); + assert.ok(withFy.length >= withBoth.length, "with fy only should select at least as many"); + assert.ok( + withFy.every((d: any) => d.sex === "MALE"), + "with fy only should restrict to MALE" + ); + const spp = new Set(withFy.map((d: any) => d.species)); + assert.ok(spp.size > 1, `with fy only should include multiple species, got: ${[...spp]}`); + + // Neither fx nor fy: selects across all facets + const withNeither = penguins.filter((d: any) => lastValue.contains(d.culmen_length_mm, d.culmen_depth_mm)); + assert.ok(withNeither.length >= withFx.length, "without facets should select at least as many as fx only"); + assert.ok(withNeither.length >= withFy.length, "without facets should select at least as many as fy only"); + const allSpecies = new Set(withNeither.map((d: any) => d.species)); + const allSexes = new Set(withNeither.map((d: any) => d.sex)); + assert.ok(allSpecies.size > 1, "without facets should include multiple species"); + assert.ok(allSexes.size > 1, "without facets should include multiple sexes"); +}); + it("brush with data includes filtered data in value", () => { const data = [ {x: 10, y: 10}, @@ -235,6 +340,7 @@ it("brush with generator data includes filtered data in value", () => { {x: 20, y: 20}, {x: 30, y: 30} ]); + }); it("brush reactive marks compose with user render transforms", () => { diff --git a/test/output/brushCrossFacet.html b/test/output/brushCrossFacet.html new file mode 100644 index 0000000000..d7fe1101df --- /dev/null +++ b/test/output/brushCrossFacet.html @@ -0,0 +1,496 @@ +
+ + + + 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/brushDotTip.svg b/test/output/brushDotTip.svg new file mode 100644 index 0000000000..abfb11e928 --- /dev/null +++ b/test/output/brushDotTip.svg @@ -0,0 +1,416 @@ + + + + + 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 index a8eb42d2f9..2602e0480b 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -272,6 +272,31 @@ export async function brushRandomNormal() { return html`
${plot}${textarea}
`; } +export async function brushCrossFacet() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const brush = new Plot.Brush({sync: true}); + const xy = {x: "culmen_length_mm" as const, y: "culmen_depth_mm" as const, fx: "species" as const}; + const plot = Plot.plot({ + marks: [ + Plot.frame(), + brush, + Plot.dot(penguins, brush.inactive({...xy, fill: "sex", r: 2})), + Plot.dot(penguins, brush.context({...xy, fill: "#ccc", r: 2})), + Plot.dot(penguins, brush.focus({...xy, fill: "sex", r: 3})) + ] + }); + const textarea = html`