Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
27319ef
sync option for cross-facet brushing; make fx, fy optional in the fil…
Fil Feb 20, 2026
704a11e
tip+brush combination
Fil Feb 14, 2026
a49628f
no-tip
Fil Feb 14, 2026
84b0976
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Feb 20, 2026
6495cab
Merge branch 'fil/brush-dataless' into fil/brush+tip
Fil Feb 20, 2026
6701a3d
prettier
Fil Feb 20, 2026
be97c6d
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Feb 24, 2026
6da729d
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Feb 24, 2026
51469a2
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Feb 24, 2026
df857c5
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Feb 24, 2026
effae15
Merge branch 'fil/brush-dataless' into fil/brush+tip
Fil Feb 24, 2026
c0e9e5f
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Feb 25, 2026
caa4673
Merge branch 'fil/brush-dataless' into fil/brush+tip
Fil Feb 25, 2026
4e670f8
fix review issue
Fil Feb 25, 2026
fbb66e0
replace .no-tip with a mutable context.interaction = {}
Fil Feb 27, 2026
1ae2608
Merge branch 'fil/brush-dataless' into fil/brush+tip
Fil Mar 2, 2026
08f39d1
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Mar 2, 2026
a71f466
Merge branch 'fil/brush-dataless' into fil/brush+tip
Fil Mar 2, 2026
7326016
Merge branch 'fil/brush-dataless' into fil/brush-across-facets
Fil Mar 2, 2026
80b7de2
Merge branch 'fil/brush-across-facets' into fil/brush-merge
Fil Mar 2, 2026
1f84fd2
Merge branch 'fil/brush-data-options' into fil/brush-merge
Fil Mar 2, 2026
9d7131f
Merge branch 'fil/brush-dataless' into fil/brush-data-options
Fil Mar 3, 2026
77fb872
Merge branch 'fil/brush-dataless' into fil/brush-merge
Fil Mar 3, 2026
3f57c47
fix: don't set default x/y channels when brush has no data
Fil Mar 3, 2026
5a1bae0
Merge branch 'fil/brush-data-options' into fil/brush-merge
Fil Mar 3, 2026
cadfa8b
Merge branch 'fil/brush-data-options' into fil/brush-merge
Fil Mar 3, 2026
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
48 changes: 47 additions & 1 deletion docs/interactions/brush.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/interactions/brush.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/interactions/brush.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/interactions/pointer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
}
Expand Down
106 changes: 106 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.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<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.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<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.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},
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading