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
2 changes: 1 addition & 1 deletion docs/components/links.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function getAnchors(text) {
.toLowerCase()
);
}
for (const [, anchor] of text.matchAll(/ \{#([\w\d-]+)\}/g)) {
for (const [, anchor] of text.matchAll(/ \{#([\w\d.-]+)\}/g)) {
anchors.push(anchor);
}
return anchors;
Expand Down
117 changes: 101 additions & 16 deletions docs/interactions/brush.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,40 @@ 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.


## 1-D brushing

The **brushX** mark operates on the *x* axis.

:::plot defer hidden
```js
Plot.plot({
height: 200,
marks: ((brush) => (d3.timeout(() => brush.move({x1: 3200, x2: 4800})), [
brush,
Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))),
Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))),
Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"})))
]))(Plot.brushX())
})
```
:::

```js
const brush = Plot.brushX();
Plot.plot({
height: 200,
marks: [
brush,
Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))),
Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))),
Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"})))
]
})
```

Similarly, the **brushY** mark operates on the *y* axis.

## Input events

The brush dispatches an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes. The plot’s value (`plot.value`) is set to a [BrushValue](#brushvalue) object when a selection is active, or null when the selection is cleared. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views), or to register an *input* event listener to react to the brush.
Expand All @@ -56,14 +90,14 @@ plot.addEventListener("input", (event) => {
});
```

The **filter** function on the brush value tests whether a data point falls inside the selection. Its signature depends on whether the plot uses faceting:
The **filter** function on the brush value tests whether a data point falls inside the selection. Its signature depends on whether the plot uses faceting, and on the brush’s dimension:

| Facets | Signature |
|-------------|--------------------------------|
| none | *filter*(*x*, *y*) |
| **fx** only | *filter*(*x*, *y*, *fx*) |
| **fy** only | *filter*(*x*, *y*, *fy*) |
| both | *filter*(*x*, *y*, *fx*, *fy*) |
| Facets | 1-D brush | 2-D brush |
|-------------------|-------------------------------|--------------------------------|
| *none* | *filter*(*value*) | *filter*(*x*, *y*) |
| **fx** only | *filter*(*value*, *fx*) | *filter*(*x*, *y*, *fx*) |
| **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:

Expand All @@ -88,12 +122,12 @@ A typical pattern is to layer three reactive marks: the inactive mark provides a
:::plot defer hidden
```js
Plot.plot({
marks: ((brush) => [
marks: ((brush) => (d3.timeout(() => brush.move({x1: 38, x2: 48, y1: 15, y2: 19})), [
brush,
Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 2})),
Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#ccc", r: 2})),
Plot.dot(penguins, brush.focus({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 3}))
])(Plot.brush())
]))(Plot.brush())
})
```
:::
Expand All @@ -111,7 +145,7 @@ Plot.plot({
```

:::tip
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.
To achieve higher contrast, you can place the brush before the reactive marks; reactive marks default to using **pointerEvents** *none* to ensure they don't obstruct pointer events.
:::

## Faceting
Expand All @@ -123,13 +157,13 @@ The brush mark supports [faceting](../features/facets.md). When the plot uses **
Plot.plot({
height: 270,
grid: true,
marks: ((brush) => [
marks: ((brush) => (d3.timeout(() => brush.move({x1: 45, x2: 55, y1: 15, y2: 20, fx: "Gentoo"})), [
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())
]))(Plot.brush())
})
```
:::
Expand Down Expand Up @@ -157,14 +191,14 @@ For plots with a [geographic projection](../features/projections.md), the brush
```js
Plot.plot({
projection: "equal-earth",
marks: ((brush) => [
marks: ((brush) => (d3.timeout(() => brush.move({x1: 300, x2: 500, y1: 50, y2: 200})), [
Plot.geo(land, {strokeWidth: 0.5}),
Plot.sphere(),
brush,
Plot.dot(cities, brush.inactive({x: "longitude", y: "latitude", r: 2, fill: "#999"})),
Plot.dot(cities, brush.context({x: "longitude", y: "latitude", r: 1, fill: "#999"})),
Plot.dot(cities, brush.focus({x: "longitude", y: "latitude", r: 3, fill: "red"}))
])(Plot.brush())
]))(Plot.brush())
})
```
:::
Expand Down Expand Up @@ -199,7 +233,7 @@ The brush value dispatched on [_input_ events](#input-events). When the brush is
- **filter** - a function to test whether a point is inside the selection
- **pending** - `true` during interaction; absent when committed

By convention, *x1* < *x2* and *y1* < *y2*.
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*.

The **pending** property indicates the user is still interacting with the brush. To skip intermediate values and react only to committed selections:

Expand Down Expand Up @@ -248,7 +282,11 @@ Returns mark options that hide the mark by default and, during brushing, show on
brush.move({x1: 36, x2: 48, y1: 15, y2: 20})
```

Programmatically sets the brush selection in data space. The *value* must have **x1**, **x2**, **y1**, and **y2** properties. For faceted plots, include **fx** or **fy** to target a specific facet. Pass null to clear the selection.
Programmatically sets the brush selection in data space. For a 2D brush, the *value* must have **x1**, **x2**, **y1**, and **y2** properties; for brushX, **x1** and **x2**; for brushY, **y1** and **y2**. For faceted plots, include **fx** or **fy** to target a specific facet. Pass null to clear the selection.

```js
brush.move({x1: 3500, x2: 5000}) // brushX
```

```js
brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"})
Expand All @@ -259,3 +297,50 @@ brush.move(null)
```

For projected plots, the coordinates are in pixels (consistent with the [BrushValue](#brushvalue)), so you need to project the two corners of the brush beforehand. In the future Plot might expose its *projection* to facilitate this. Please upvote [this issue](https://github.com/observablehq/plot/issues/1191) to help prioritize this feature.

## brushX(*options*) {#brushX}

```js
const brush = Plot.brushX()
```

Returns a new horizontal brush mark that selects along the *x* axis. The available *options* are:

- **interval** - an interval to snap the brush to on release; a number for quantitative scales (_e.g._, `100`), a time interval name for temporal scales (_e.g._, `"month"`), or an object with *floor* and *offset* methods

When an **interval** is set, the selection snaps to interval boundaries on release, and the filter rounds values before testing, for consistency with binned marks using the same interval. (Use the same interval in the bin transform so the brush aligns with bin edges.)

:::plot defer hidden
```js
Plot.plot({
marks: ((brush) => (d3.timeout(() => brush.move({x1: 3500, x2: 5000})), [
Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})),
brush,
Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))),
Plot.ruleY([0])
]))(Plot.brushX({interval: 100}))
})
```
:::

```js
const brush = Plot.brushX({interval: 100});
Plot.plot({
marks: [
Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})),
brush,
Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))),
Plot.ruleY([0])
]
})
```

The brushX mark does not support projections.

## brushY(*options*) {#brushY}

```js
const brush = Plot.brushY()
```

Returns a new vertical brush mark that selects along the *y* axis. Accepts the same *options* as [brushX](#brushX).
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export {window, windowX, windowY} from "./transforms/window.js";
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
export {treeNode, treeLink} from "./transforms/tree.js";
export {Brush, brush} from "./interactions/brush.js";
export {Brush, brush, brushX, brushY} from "./interactions/brush.js";
export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js";
export {scale} from "./scales.js";
Expand Down
52 changes: 37 additions & 15 deletions src/interactions/brush.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {Interval} from "../interval.js";
import type {RenderableMark} from "../mark.js";
import type {Rendered} from "../transforms/basic.js";

Expand All @@ -9,32 +10,35 @@ import type {Rendered} from "../transforms/basic.js";
*/
export interface BrushValue {
/** The lower *x* value of the brushed region. */
x1: number | Date;
x1?: number | Date;
/** The upper *x* value of the brushed region. */
x2: number | Date;
x2?: number | Date;
/** The lower *y* value of the brushed region. */
y1: number | Date;
y1?: number | Date;
/** The upper *y* value of the brushed region. */
y2: number | Date;
y2?: number | Date;
/** The *fx* facet value, if applicable. */
fx?: any;
/** The *fy* facet value, if applicable. */
fy?: any;
/**
* A function to test whether a point falls inside the brush selection.
* The signature depends on active facets: *(x, y)*, *(x, y, fx)*, *(x, y, fy)*,
* or *(x, y, fx, fy)*. When faceted, returns true only for points in the brushed
* facet. For projected plots, *x* and *y* are typically longitude and latitude.
* The signature depends on the dimensions and active facets: for brushX
* and brushY, filter on the value *v* with *(v)*, *(v, fx)*, *(v, fy)*,
* or *(v, fx, fy)* *(x, y)*; for a 2D brush, use *(x, y)*, *(x, y, fx)*,
* *(x, y, fy)*, or *(x, y, fx, fy)*. When faceted, returns true only for
* points in the brushed facet. For projected plots, *x* and *y* are
* typically longitude and latitude.
*/
filter: (x: number | Date, y: number | Date, f1?: any, f2?: any) => boolean;
filter: (...args: any[]) => boolean;
/** True during interaction, absent when committed. */
pending?: true;
}

/**
* 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
* facets, clearing previous selections when a new brush starts.
* 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
* selections when a new brush starts.
*
* The brush dispatches an input event when the selection changes. The selection
* is available as plot.value as a **BrushValue**, or null when the selection is
Expand Down Expand Up @@ -64,13 +68,31 @@ export class Brush extends RenderableMark {

/**
* Programmatically sets the brush selection in data space. Pass an object
* with **x1**, **x2**, **y1**, **y2** (and optionally **fx**, **fy** for
* faceted plots) to set the selection, or null to clear it.
* with the relevant bounds (**x1** and **x2**, **y1** and **y2**, and
* **fx**, **fy** for faceted plots) to set the selection, or null to clear it.
*/
move(
value: {x1: number | Date; x2: number | Date; y1: number | Date; y2: number | Date; fx?: any; fy?: any} | null
value: {x1?: number | Date; x2?: number | Date; y1?: number | Date; y2?: number | Date; fx?: any; fy?: any} | null
): void;
}

/** Creates a new brush mark. */
/** 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;
}

/** Creates a one-dimensional brush mark along the *x* axis. Not supported with projections. */
export function brushX(options?: BrushOptions): Brush;

/** Creates a one-dimensional brush mark along the *y* axis. Not supported with projections. */
export function brushY(options?: BrushOptions): Brush;
Loading