Skip to content
Draft
50 changes: 48 additions & 2 deletions docs/interactions/brush.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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.
Expand Down Expand Up @@ -146,7 +155,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; 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
Expand Down Expand Up @@ -177,6 +186,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.
Expand Down Expand Up @@ -230,14 +272,18 @@ The brush value dispatched on [_input_ events](#input-events). When the brush is

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*. Values are automatically rounded to the optimal precision that distinguishes neighboring pixels.

## brush() {#brush}
## brush(*options*) {#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.

The following *options* are supported:

- **sync** - if true, the brush spans all facet panes simultaneously; defaults to false

## *brush*.inactive(*options*) {#brush-inactive}

```js
Expand Down
30 changes: 18 additions & 12 deletions src/interactions/brush.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ export class Region {
contains(x: any, y?: any, facets?: {fx?: any; fy?: any}): boolean;
}

/** 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
Expand All @@ -44,6 +60,7 @@ export class Region {
* 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.
Expand Down Expand Up @@ -75,18 +92,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. 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;
Expand Down
105 changes: 105 additions & 0 deletions test/brush-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>("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<any>("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<any>("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},
Expand Down
Loading