Skip to content

Commit 7c710e9

Browse files
committed
brushX and brushY
Since we want to support brushing histograms, we needed two additional features: * an **interval** option for snapping the brush on gesture end. * support for X1/X2 channels in renderFilter, for rect marks. (This will require some work to merge with #2363)
1 parent b228cd1 commit 7c710e9

File tree

14 files changed

+2244
-105
lines changed

14 files changed

+2244
-105
lines changed

docs/components/links.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function getAnchors(text) {
1313
.toLowerCase()
1414
);
1515
}
16-
for (const [, anchor] of text.matchAll(/ \{#([\w\d-]+)\}/g)) {
16+
for (const [, anchor] of text.matchAll(/ \{#([\w\d.-]+)\}/g)) {
1717
anchors.push(anchor);
1818
}
1919
return anchors;

docs/interactions/brush.md

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,40 @@ Plot.plot({
4444

4545
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.
4646

47+
48+
## 1-D brushing
49+
50+
The **brushX** mark operates on the *x* axis.
51+
52+
:::plot defer hidden
53+
```js
54+
Plot.plot({
55+
height: 200,
56+
marks: ((brush) => (d3.timeout(() => brush.move({x1: 3200, x2: 4800})), [
57+
brush,
58+
Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))),
59+
Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))),
60+
Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"})))
61+
]))(Plot.brushX())
62+
})
63+
```
64+
:::
65+
66+
```js
67+
const brush = Plot.brushX();
68+
Plot.plot({
69+
height: 200,
70+
marks: [
71+
brush,
72+
Plot.dot(penguins, Plot.dodgeY(brush.inactive({x: "body_mass_g", fill: "species"}))),
73+
Plot.dot(penguins, Plot.dodgeY(brush.context({x: "body_mass_g", fill: "#ddd"}))),
74+
Plot.dot(penguins, Plot.dodgeY(brush.focus({x: "body_mass_g", fill: "species"})))
75+
]
76+
})
77+
```
78+
79+
Similarly, the **brushY** mark operates on the *y* axis.
80+
4781
## Input events
4882

4983
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.
@@ -56,14 +90,14 @@ plot.addEventListener("input", (event) => {
5690
});
5791
```
5892

59-
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:
93+
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:
6094

61-
| Facets | Signature |
62-
|-------------|--------------------------------|
63-
| none | *filter*(*x*, *y*) |
64-
| **fx** only | *filter*(*x*, *y*, *fx*) |
65-
| **fy** only | *filter*(*x*, *y*, *fy*) |
66-
| both | *filter*(*x*, *y*, *fx*, *fy*) |
95+
| Facets | 1-D brush | 2-D brush |
96+
|-------------------|-------------------------------|--------------------------------|
97+
| *none* | *filter*(*value*) | *filter*(*x*, *y*) |
98+
| **fx** only | *filter*(*value*, *fx*) | *filter*(*x*, *y*, *fx*) |
99+
| **fy** only | *filter*(*value*, *fy*) | *filter*(*x*, *y*, *fy*) |
100+
| **fx** and **fy** | *filter*(*value*, *fx*, *fy*) | *filter*(*x*, *y*, *fx*, *fy*) |
67101

68102
When faceted, the filter returns true only for points in the brushed facet. For example:
69103

@@ -88,7 +122,7 @@ A typical pattern is to layer three reactive marks: the inactive mark provides a
88122
:::plot hidden
89123
```js
90124
Plot.plot({
91-
marks: ((brush) => (d3.timeout(() => brush.move({x1: 36, x2: 48, y1: 15, y2: 20})), [
125+
marks: ((brush) => (d3.timeout(() => brush.move({x1: 38, x2: 48, y1: 15, y2: 19})), [
92126
brush,
93127
Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species", r: 2})),
94128
Plot.dot(penguins, brush.context({x: "culmen_length_mm", y: "culmen_depth_mm", fill: "#ccc", r: 2})),
@@ -123,7 +157,7 @@ The brush mark supports [faceting](../features/facets.md). When the plot uses **
123157
Plot.plot({
124158
height: 270,
125159
grid: true,
126-
marks: ((brush) => (d3.timeout(() => brush.move({x1: 43, x2: 50, y1: 17, y2: 19, fx: "Adelie"})), [
160+
marks: ((brush) => (d3.timeout(() => brush.move({x1: 45, x2: 55, y1: 15, y2: 20, fx: "Gentoo"})), [
127161
Plot.frame(),
128162
brush,
129163
Plot.dot(penguins, brush.inactive({x: "culmen_length_mm", y: "culmen_depth_mm", fx: "species", fill: "sex", r: 2})),
@@ -157,7 +191,7 @@ For plots with a [geographic projection](../features/projections.md), the brush
157191
```js
158192
Plot.plot({
159193
projection: "equal-earth",
160-
marks: ((brush) => (d3.timeout(() => brush.move({x1: 80, x2: 300, y1: 60, y2: 200})), [
194+
marks: ((brush) => (d3.timeout(() => brush.move({x1: 300, x2: 500, y1: 50, y2: 200})), [
161195
Plot.geo(land, {strokeWidth: 0.5}),
162196
Plot.sphere(),
163197
brush,
@@ -199,7 +233,7 @@ The brush value dispatched on [_input_ events](#input-events). When the brush is
199233
- **filter** - a function to test whether a point is inside the selection
200234
- **pending** - `true` during interaction; absent when committed
201235
202-
By convention, *x1* < *x2* and *y1* < *y2*.
236+
By convention, *x1* < *x2* and *y1* < *y2*. The brushX mark does not dispatch *y1* and *y2*; similarly, the brushY mark does not dispatch *x1* and *x2*.
203237
204238
The **pending** property indicates the user is still interacting with the brush. To skip intermediate values and react only to committed selections:
205239
@@ -248,7 +282,11 @@ Returns mark options that hide the mark by default and, during brushing, show on
248282
brush.move({x1: 36, x2: 48, y1: 15, y2: 20})
249283
```
250284
251-
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.
285+
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.
286+
287+
```js
288+
brush.move({x1: 3500, x2: 5000}) // brushX
289+
```
252290
253291
```js
254292
brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"})
@@ -259,3 +297,50 @@ brush.move(null)
259297
```
260298
261299
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.
300+
301+
## brushX(*options*) {#brushX}
302+
303+
```js
304+
const brush = Plot.brushX()
305+
```
306+
307+
Returns a new horizontal brush mark that selects along the *x* axis. The available *options* are:
308+
309+
- **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
310+
311+
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.)
312+
313+
:::plot defer hidden
314+
```js
315+
Plot.plot({
316+
marks: ((brush) => (d3.timeout(() => brush.move({x1: 3500, x2: 5000})), [
317+
Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})),
318+
brush,
319+
Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))),
320+
Plot.ruleY([0])
321+
]))(Plot.brushX({interval: 100}))
322+
})
323+
```
324+
:::
325+
326+
```js
327+
const brush = Plot.brushX({interval: 100});
328+
Plot.plot({
329+
marks: [
330+
Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", interval: 100, fill: "currentColor", fillOpacity: 0.3})),
331+
brush,
332+
Plot.rectY(penguins, Plot.binX({y: "count"}, brush.focus({x: "body_mass_g", interval: 100}))),
333+
Plot.ruleY([0])
334+
]
335+
})
336+
```
337+
338+
The brushX mark does not support projections.
339+
340+
## brushY(*options*) {#brushY}
341+
342+
```js
343+
const brush = Plot.brushY()
344+
```
345+
346+
Returns a new vertical brush mark that selects along the *y* axis. Accepts the same *options* as [brushX](#brushX).

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export {window, windowX, windowY} from "./transforms/window.js";
5353
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
5454
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
5555
export {treeNode, treeLink} from "./transforms/tree.js";
56-
export {Brush, brush} from "./interactions/brush.js";
56+
export {Brush, brush, brushX, brushY} from "./interactions/brush.js";
5757
export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
5858
export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js";
5959
export {scale} from "./scales.js";

src/interactions/brush.d.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type {Interval} from "../interval.js";
12
import type {RenderableMark} from "../mark.js";
23
import type {Rendered} from "../transforms/basic.js";
34

@@ -9,32 +10,35 @@ import type {Rendered} from "../transforms/basic.js";
910
*/
1011
export interface BrushValue {
1112
/** The lower *x* value of the brushed region. */
12-
x1: number | Date;
13+
x1?: number | Date;
1314
/** The upper *x* value of the brushed region. */
14-
x2: number | Date;
15+
x2?: number | Date;
1516
/** The lower *y* value of the brushed region. */
16-
y1: number | Date;
17+
y1?: number | Date;
1718
/** The upper *y* value of the brushed region. */
18-
y2: number | Date;
19+
y2?: number | Date;
1920
/** The *fx* facet value, if applicable. */
2021
fx?: any;
2122
/** The *fy* facet value, if applicable. */
2223
fy?: any;
2324
/**
2425
* A function to test whether a point falls inside the brush selection.
25-
* The signature depends on active facets: *(x, y)*, *(x, y, fx)*, *(x, y, fy)*,
26-
* or *(x, y, fx, fy)*. When faceted, returns true only for points in the brushed
27-
* facet. For projected plots, *x* and *y* are typically longitude and latitude.
26+
* The signature depends on the dimensions and active facets: for brushX
27+
* and brushY, filter on the value *v* with *(v)*, *(v, fx)*, *(v, fy)*,
28+
* or *(v, fx, fy)* *(x, y)*; for a 2D brush, use *(x, y)*, *(x, y, fx)*,
29+
* *(x, y, fy)*, or *(x, y, fx, fy)*. When faceted, returns true only for
30+
* points in the brushed facet. For projected plots, *x* and *y* are
31+
* typically longitude and latitude.
2832
*/
29-
filter: (x: number | Date, y: number | Date, f1?: any, f2?: any) => boolean;
33+
filter: (...args: any[]) => boolean;
3034
/** True during interaction, absent when committed. */
3135
pending?: true;
3236
}
3337

3438
/**
35-
* A brush mark that renders a two-dimensional [brush](https://d3js.org/d3-brush)
36-
* allowing the user to select a rectangular region. The brush coordinates across
37-
* facets, clearing previous selections when a new brush starts.
39+
* A mark that renders a [brush](https://d3js.org/d3-brush) allowing the user to
40+
* select a region. The brush coordinates across facets, clearing previous
41+
* selections when a new brush starts.
3842
*
3943
* The brush dispatches an input event when the selection changes. The selection
4044
* is available as plot.value as a **BrushValue**, or null when the selection is
@@ -64,13 +68,31 @@ export class Brush extends RenderableMark {
6468

6569
/**
6670
* Programmatically sets the brush selection in data space. Pass an object
67-
* with **x1**, **x2**, **y1**, **y2** (and optionally **fx**, **fy** for
68-
* faceted plots) to set the selection, or null to clear it.
71+
* with the relevant bounds (**x1** and **x2**, **y1** and **y2**, and
72+
* **fx**, **fy** for faceted plots) to set the selection, or null to clear it.
6973
*/
7074
move(
71-
value: {x1: number | Date; x2: number | Date; y1: number | Date; y2: number | Date; fx?: any; fy?: any} | null
75+
value: {x1?: number | Date; x2?: number | Date; y1?: number | Date; y2?: number | Date; fx?: any; fy?: any} | null
7276
): void;
7377
}
7478

75-
/** Creates a new brush mark. */
79+
/** Creates a new two-dimensional brush mark. */
7680
export function brush(): Brush;
81+
82+
/** Options for brush marks. */
83+
export interface BrushOptions {
84+
/**
85+
* An interval to snap the brush to, such as a number for quantitative scales
86+
* or a time interval name like *month* for temporal scales. On brush end, the
87+
* selection is rounded to the nearest interval boundaries; the dispatched
88+
* filter function floors values before testing, for consistency with binned
89+
* marks. Supported by the 1-dimensional marks brushX and brushY.
90+
*/
91+
interval?: Interval;
92+
}
93+
94+
/** Creates a one-dimensional brush mark along the *x* axis. Not supported with projections. */
95+
export function brushX(options?: BrushOptions): Brush;
96+
97+
/** Creates a one-dimensional brush mark along the *y* axis. Not supported with projections. */
98+
export function brushY(options?: BrushOptions): Brush;

0 commit comments

Comments
 (0)