Skip to content
Draft
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
66 changes: 63 additions & 3 deletions docs/interactions/brush.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Plot.plot({
})
```

The brush mark does not require data. When added to a plot, it renders a [brush](https://d3js.org/d3-brush) overlay covering the frame. The user can click and drag to create a rectangular selection, drag the selection to reposition it, or drag an edge or corner to resize it. Clicking outside the selection clears it.
When added to a plot, the brush mark renders a [brush](https://d3js.org/d3-brush) overlay covering the frame. The user can click and drag to create a rectangular selection, drag the selection to reposition it, or drag an edge or corner to resize it. Clicking outside the selection clears it.

## Input events

Expand Down Expand Up @@ -114,6 +114,65 @@ Plot.plot({
To achieve higher contrast, place the brush below the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't obstruct pointer events.
:::

## Data and options

The brush accepts optional *data* and *options*. When the brush options specify **x**, **y**, **fx**, or **fy** channels, these become defaults for the associated reactive marks.

:::plot defer hidden
```js
Plot.plot({
marks: ((brush) => [
brush,
Plot.dot(penguins, brush.inactive({fill: "species", r: 2})),
Plot.dot(penguins, brush.context({fill: "#ccc", r: 2})),
Plot.dot(penguins, brush.focus({fill: "species", r: 3}))
])(Plot.brush(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}))
})
```
:::

```js
const brush = Plot.brush(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"});
Plot.plot({
marks: [
brush,
Plot.dot(penguins, brush.inactive({fill: "species", r: 2})),
Plot.dot(penguins, brush.context({fill: "#ccc", r: 2})),
Plot.dot(penguins, brush.focus({fill: "species", r: 3}))
]
})
```

If neither **x** nor **y** is specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], …] such that **x** = [*x₀*, *x₁*, …] and **y** = [*y₀*, *y₁*, …].

```js
const brush = Plot.brush(points);
```

### Selection styling

The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, and **strokeOpacity** options style the brush selection rectangle, overriding D3's defaults.

```js
const brush = Plot.brush(penguins, {
x: "culmen_length_mm",
y: "culmen_depth_mm",
stroke: "currentColor",
strokeWidth: 1.5
});
```

### Filtered data

When the brush has *data*, the [BrushValue](#brushvalue) includes a **data** property containing the subset filtered by the selection.

```js
plot.addEventListener("input", () => {
console.log(plot.value?.data); // filtered subset of the brush's data
const selected = otherData.filter((d) => plot.value?.filter(d.x, d.y)); // filter a different dataset
});
```

## 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.
Expand Down Expand Up @@ -197,6 +256,7 @@ The brush value dispatched on [_input_ events](#input-events). When the brush is
- **fx** - the *fx* facet value, if applicable
- **fy** - the *fy* facet value, if applicable
- **filter** - a function to test whether a point is inside the selection
- **data** - when the brush has data, the filtered subset
- **pending** - `true` during interaction; absent when committed

By convention, *x1* < *x2* and *y1* < *y2*.
Expand All @@ -210,13 +270,13 @@ plot.addEventListener("input", () => {
});
```

## brush() {#brush}
## brush(*data*, *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.
Returns a new brush with the given *data* and *options*. Both *data* and *options* are optional. If *data* is specified but the 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₁*, …].

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

Expand Down
48 changes: 45 additions & 3 deletions src/interactions/brush.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {RenderableMark} from "../mark.js";
import type {ChannelValueSpec} from "../channel.js";
import type {Data, MarkOptions, RenderableMark} from "../mark.js";
import type {Rendered} from "../transforms/basic.js";

/**
Expand Down Expand Up @@ -27,10 +28,39 @@ export interface BrushValue {
* facet. For projected plots, *x* and *y* are typically longitude and latitude.
*/
filter: (x: number | Date, y: number | Date, f1?: any, f2?: any) => boolean;
/** When the brush has data, the subset of data matching the selection. */
data?: any[];
/** True during interaction, absent when committed. */
pending?: true;
}

/** Options for the brush mark. */
export interface BrushOptions extends MarkOptions {
/**
* The horizontal position channel, typically bound to the *x* scale. When
* specified, inherited by reactive marks as a default.
*/
x?: ChannelValueSpec;

/**
* The vertical position channel, typically bound to the *y* scale. When
* specified, inherited by reactive marks as a default.
*/
y?: ChannelValueSpec;

/**
* The horizontal facet channel, bound to the *fx* scale. When specified,
* inherited by reactive marks as a default.
*/
fx?: MarkOptions["fx"];

/**
* The vertical facet channel, bound to the *fy* scale. When specified,
* inherited by reactive marks as a default.
*/
fy?: MarkOptions["fy"];
}

/**
* 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 +72,14 @@ export interface BrushValue {
* reactive marks that respond to the brush state.
*/
export class Brush extends RenderableMark {
/**
* Creates a new brush mark with the given *data* and *options*. If *data* and
* *options* specify **x** and **y** channels, these become defaults for
* reactive marks (**inactive**, **context**, **focus**). The **fill**,
* **fillOpacity**, **stroke**, **strokeWidth**, and **strokeOpacity** options
* style the brush selection rectangle.
*/
constructor(data?: Data, 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 @@ -72,5 +110,9 @@ export class Brush extends RenderableMark {
): void;
}

/** Creates a new brush mark. */
export function brush(): Brush;
/**
* Creates a new brush mark with the given *data* and *options*. If neither
* **x** nor **y** is specified, and the data is an array of [x, y] pairs,
* they default to the first and second element respectively.
*/
export function brush(data?: Data, options?: BrushOptions): Brush;
54 changes: 44 additions & 10 deletions src/interactions/brush.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import {brush as d3Brush, create, pointer, select, selectAll} from "d3";
import {composeRender, Mark} from "../mark.js";
import {dataify, maybeTuple, take} from "../options.js";
import {applyAttr} from "../style.js";

const defaults = {ariaLabel: "brush", fill: "#777", fillOpacity: 0.3, stroke: "#fff"};

export class Brush extends Mark {
constructor() {
super(undefined, {}, {}, {});
constructor(data, options = {}) {
const {x, y, z} = options;
super(
dataify(data),
{
x: {value: x, scale: "x", optional: true},
y: {value: y, scale: "y", optional: true}
},
options,
defaults
);
const channelDefaults = {x, y, z, fx: this.fx, fy: this.fy};
this.inactive = renderFilter(true, channelDefaults);
this.context = renderFilter(false, channelDefaults);
this.focus = renderFilter(false, channelDefaults);
this._brush = d3Brush();
this._brushNodes = [];
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;
const X = values.channels?.x?.value;
const Y = values.channels?.y?.value;
const FX = values.channels?.fx?.value;
const FY = values.channels?.fy?.value;
const {data, _brush, _brushNodes, inactive, context: ctx, focus} = this;
let target, currentNode, clearing;

if (!index?.fi) {
Expand All @@ -21,7 +39,13 @@ export class Brush extends Mark {
this._applyX = (!context.projection && x) || ((d) => d);
this._applyY = (!context.projection && y) || ((d) => d);
context.dispatchValue(null);
const {_brush, _brushNodes} = this;
const filterIndex = fx && fy ? (f) => (_, i) => f(X[i], Y[i], FX[i], FY[i])
: fx ? (f) => (_, i) => f(X[i], Y[i], FX[i])
: fy ? (f) => (_, i) => f(X[i], Y[i], FY[i])
: (f) => (_, i) => f(X[i], Y[i]); // prettier-ignore
const filterData =
data != null &&
((filter) => (filter === true ? data : filter === false ? [] : take(data, index.filter(filterIndex(filter)))));
_brush
.extent([
[dimensions.marginLeft - 1, dimensions.marginTop - 1],
Expand Down Expand Up @@ -71,6 +95,7 @@ export class Brush extends Mark {
...(fx && facet && {fx: facet.x}),
...(fy && facet && {fy: facet.y}),
filter,
...(filterData && {data: filterData(filter)}),
pending: true
};
}
Expand Down Expand Up @@ -99,6 +124,7 @@ export class Brush extends Mark {
...(fx && facet && {fx: facet.x}),
...(fy && facet && {fy: facet.y}),
filter,
...(filterData && {data: filterData(filter)}),
...(type !== "end" && {pending: true})
});
}
Expand All @@ -107,6 +133,12 @@ export class Brush extends Mark {

const g = create("svg:g").attr("aria-label", "brush");
g.call(this._brush);
const sel = g.select(".selection");
applyAttr(sel, "fill", this.fill);
applyAttr(sel, "fill-opacity", this.fillOpacity);
applyAttr(sel, "stroke", this.stroke);
applyAttr(sel, "stroke-width", this.strokeWidth);
applyAttr(sel, "stroke-opacity", this.strokeOpacity);
const node = g.node();
this._brushNodes.push(node);
return node;
Expand Down Expand Up @@ -136,8 +168,9 @@ export class Brush extends Mark {
}
}

export function brush() {
return new Brush();
export function brush(data, {x, y, ...options} = {}) {
[x, y] = maybeTuple(x, y);
return new Brush(data, {...options, x, y});
}

function filterFromBrush(xScale, yScale, facet, projection, px1, px2, py1, py2) {
Expand Down Expand Up @@ -173,12 +206,13 @@ function filterSignature(test, currentFx, currentFy) {
: (x, y, fx, fy) => fx === currentFx && fy === currentFy && test(x, y);
}

function renderFilter(initialTest) {
function renderFilter(initialTest, channelDefaults = {}) {
const updatePerFacet = [];
return Object.assign(
function ({render, ...options} = {}) {
return {
pointerEvents: "none",
...channelDefaults,
...options,
render: composeRender(function (index, scales, values, dimensions, context, next) {
const {x: X, y: Y} = values;
Expand Down
69 changes: 69 additions & 0 deletions test/brush-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,75 @@ it("brush programmatic move on second facet selects the correct facet", async ()
assert.equal([...species][0], "Chinstrap", "filtered species should be Chinstrap");
});

it("brush with data includes filtered data in value", () => {
const data = [
{x: 10, y: 10},
{x: 20, y: 20},
{x: 30, y: 30},
{x: 40, y: 40},
{x: 50, y: 50}
];
const brush = Plot.brush(data, {x: "x", y: "y"});
const plot = Plot.plot({
x: {domain: [0, 60]},
y: {domain: [0, 60]},
marks: [
brush,
Plot.dot(data, brush.inactive()),
Plot.dot(data, brush.context({fill: "#ccc"})),
Plot.dot(data, brush.focus({fill: "red"}))
]
});

let lastValue: any;
plot.addEventListener("input", () => (lastValue = plot.value));
brush.move({x1: 15, x2: 35, y1: 15, y2: 35});

assert.ok(lastValue, "should have a value");
assert.ok(Array.isArray(lastValue.data), "value should have a data array");
assert.equal(lastValue.data.length, 2, "filtered data should contain 2 points");
assert.deepEqual(lastValue.data, [
{x: 20, y: 20},
{x: 30, y: 30}
]);
});

it("brush with generator data includes filtered data in value", () => {
const data = [
{x: 10, y: 10},
{x: 20, y: 20},
{x: 30, y: 30},
{x: 40, y: 40},
{x: 50, y: 50}
];
function* generate() {
yield* data;
}
const brush = Plot.brush(generate(), {x: "x", y: "y"});
const plot = Plot.plot({
x: {domain: [0, 60]},
y: {domain: [0, 60]},
marks: [
brush,
Plot.dot(data, brush.inactive()),
Plot.dot(data, brush.context({fill: "#ccc"})),
Plot.dot(data, brush.focus({fill: "red"}))
]
});

let lastValue: any;
plot.addEventListener("input", () => (lastValue = plot.value));
brush.move({x1: 15, x2: 35, y1: 15, y2: 35});

assert.ok(lastValue, "should have a value");
assert.ok(Array.isArray(lastValue.data), "value should have a data array");
assert.equal(lastValue.data.length, 2, "filtered data should contain 2 points");
assert.deepEqual(lastValue.data, [
{x: 20, y: 20},
{x: 30, y: 30}
]);
});

it("brush reactive marks compose with user render transforms", () => {
const data = [
{x: 10, y: 10},
Expand Down
Loading