Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions docs/interactions/brush.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,25 @@ The **filter** function on the brush value tests whether a data point falls insi
| **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;
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.
Expand Down Expand Up @@ -116,7 +125,7 @@ To achieve higher contrast, place the brush below the reactive marks; reactive m

## 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
Expand Down Expand Up @@ -147,6 +156,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.
Expand Down Expand Up @@ -210,14 +252,18 @@ plot.addEventListener("input", () => {
});
```

## 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
11 changes: 10 additions & 1 deletion src/interactions/brush.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ 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;
}

/**
* 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
Expand All @@ -42,6 +50,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.
Expand Down Expand Up @@ -73,4 +82,4 @@ export class Brush extends RenderableMark {
}

/** Creates a new brush mark. */
export function brush(): Brush;
export function brush(options?: BrushOptions): Brush;
61 changes: 39 additions & 22 deletions src/interactions/brush.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ import {brush as d3Brush, create, pointer, select, selectAll} from "d3";
import {composeRender, Mark} from "../mark.js";

export class Brush extends Mark {
constructor() {
constructor({sync = false} = {}) {
super(undefined, {}, {}, {});
this._brush = d3Brush();
this._brushNodes = [];
this._sync = sync;
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;
let target, currentNode, syncing;

if (!index?.fi) {
const invertX = (!context.projection && x?.invert) || ((d) => d);
Expand All @@ -22,40 +23,49 @@ export class Brush extends Mark {
this._applyY = (!context.projection && y) || ((d) => d);
context.dispatchValue(null);
const {_brush, _brushNodes} = this;
const sync = this._sync;
_brush
.extent([
[dimensions.marginLeft - 1, dimensions.marginTop - 1],
[dimensions.width - dimensions.marginRight + 1, dimensions.height - dimensions.marginBottom + 1]
])
.on("start brush end", function (event) {
if (syncing) return;
const {selection, type} = event;
if (type === "start" && !clearing) {
if (type === "start") {
target = event.sourceEvent?.currentTarget ?? this;
currentNode = _brushNodes.indexOf(target);
if (!clearing) {
clearing = true;
if (!sync) {
syncing = 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);
}
syncing = 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") {
if (sync) {
syncing = true;
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null);
syncing = false;
}
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);
for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) {
inactive.update(false, i);
ctx.update(true, i);
focus.update(false, i);
}
let value = null;
if (event.sourceEvent) {
const [px, py] = pointer(event, this);
Expand All @@ -78,9 +88,16 @@ export class Brush extends Mark {
}
} 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);
if (sync) {
syncing = true;
selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, selection);
syncing = false;
}
for (let i = sync ? 0 : currentNode, n = sync ? _brushNodes.length : currentNode + 1; i < n; ++i) {
inactive.update(false, i);
ctx.update((xi, yi) => !(px1 <= xi && xi < px2 && py1 <= yi && yi < py2), i);
focus.update((xi, yi) => px1 <= xi && xi < px2 && py1 <= yi && yi < py2, i);
}

let x1 = invertX(px1),
x2 = invertX(px2);
Expand Down Expand Up @@ -133,8 +150,8 @@ export class Brush extends Mark {
}
}

export function brush() {
return new Brush();
export function brush(options) {
return new Brush(options);
}

function filterFromBrush(xScale, yScale, facet, projection, px1, px2, py1, py2) {
Expand Down Expand Up @@ -164,10 +181,10 @@ function filterSignature(test, currentFx, currentFy) {
return currentFx === undefined
? currentFy === undefined
? (x, y) => test(x, y)
: (x, y, fy) => fy === currentFy && test(x, y)
: (x, y, fy) => (fy === undefined || 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);
? (x, y, fx) => (fx === undefined || fx === currentFx) && test(x, y)
: (x, y, fx, fy) => (fx === undefined || fx === currentFx) && (fy === undefined || fy === currentFy) && test(x, y);
}

function renderFilter(initialTest) {
Expand Down
105 changes: 105 additions & 0 deletions test/brush-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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