diff --git a/docs/interactions/brush.md b/docs/interactions/brush.md index bed27272b9..bde3ccebcd 100644 --- a/docs/interactions/brush.md +++ b/docs/interactions/brush.md @@ -99,16 +99,25 @@ The **filter** function on the brush value tests whether a data point falls insi | **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: - ```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; + const selected = filter + ? penguins.filter((d) => filter(d.culmen_length_mm, d.culmen_depth_mm)) + : penguins; console.log(selected); }); ``` +The facet arguments are optional: if *fx* or *fy* is undefined, the filter skips the facet check for that dimension. For example, if the selected region is [44, 46] × [17, 19] over the "Adelie" facet: + +```js +const filter = plot.value?.filter; // f(x, y, fx) +filter(45, 18) // true +filter(45, 18, "Adelie") // true +filter(45, 18, "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. @@ -150,7 +159,7 @@ To achieve higher contrast, you can place the brush before the reactive marks; r ## 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. +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, and the **filter** function also filters on the relevant facet values. :::plot hidden ```js @@ -181,6 +190,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, and the **filter** function takes the data's coordinates (typically longitude and latitude) and projects them to test against the brush extent. @@ -244,7 +286,7 @@ plot.addEventListener("input", () => { }); ``` -## brush() {#brush} +## brush(*options*) {#brush} ```js const brush = Plot.brush() @@ -252,6 +294,10 @@ 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. +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 a4fa018fde..6f0fcb712a 100644 --- a/src/interactions/brush.d.ts +++ b/src/interactions/brush.d.ts @@ -35,6 +35,22 @@ export interface BrushValue { pending?: true; } +/** Options for the brush mark. */ +export interface BrushOptions { + /** + * If true, the brush spans all facet panes simultaneously; defaults to false. + */ + sync?: boolean; + /** + * 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; +} + /** * 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 @@ -46,6 +62,7 @@ export interface BrushValue { * reactive marks that respond to the brush state. */ export class Brush extends RenderableMark { + constructor(options?: BrushOptions); /** * Returns mark options that show the mark when no brush selection is active, * and hide it during brushing. Use this for the default appearance. @@ -77,19 +94,7 @@ export class Brush extends RenderableMark { } /** 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; -} +export function brush(options?: BrushOptions): Brush; /** Creates a one-dimensional brush mark along the *x* axis. Not supported with projections. */ export function brushX(options?: BrushOptions): Brush; diff --git a/test/brush-test.ts b/test/brush-test.ts index 7dcdf6ed9c..b2f2b7aaa3 100644 --- a/test/brush-test.ts +++ b/test/brush-test.ts @@ -160,6 +160,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.filter(d.culmen_length_mm, d.culmen_depth_mm, 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.filter(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.filter(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.filter(d.culmen_length_mm, d.culmen_depth_mm, 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.filter(d.culmen_length_mm, d.culmen_depth_mm, d.species, 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.filter(d.culmen_length_mm, d.culmen_depth_mm, 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.filter(d.culmen_length_mm, d.culmen_depth_mm, undefined, 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.filter(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 reactive marks compose with user render transforms", () => { const data = [ {x: 10, y: 10}, 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/plots/brush.ts b/test/plots/brush.ts index 811d45b7b7..19ec7986c6 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -275,6 +275,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`