Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

brush interaction #1653

Open
wants to merge 44 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c269a48
brush interaction
Fil May 29, 2023
addf3c5
remove metaKey test
Fil May 29, 2023
7824515
apply suggestions from review
Fil May 29, 2023
a04ce1a
simpler! and no RAF
Fil May 29, 2023
ae143a9
fix tests
Fil May 29, 2023
548c25e
cleaner derived "channels" Xl and Xm
Fil May 30, 2023
5c13eca
put the brushes at the top (z-index) of the svg
Fil May 30, 2023
4b98418
error if multiple brushes have been set on this chart
Fil May 30, 2023
78bda67
This solves quite a few issues:
Fil May 30, 2023
72a4bb4
extent (or logical) selectionMode
Fil May 30, 2023
7e8d5ff
better extent
Fil May 30, 2023
eedbc23
initialize with data
Fil May 30, 2023
0f120d2
datum
Fil May 30, 2023
d3a9c94
cleaner, avoid a crash if the mark does not return a node
Fil May 30, 2023
e153848
document
Fil May 30, 2023
cf36270
fix link
Fil May 30, 2023
20a586c
fix links
Fil Aug 21, 2023
33f0eac
use Promise.resolve to get to the top z-index
Fil Aug 22, 2023
e61d6a9
highlight experiment
mbostock Aug 28, 2023
854a5b6
delegate render to derived mark
mbostock Aug 30, 2023
277709b
long form sketch
mbostock Aug 30, 2023
ec463e2
Adopt creator()
Fil Sep 5, 2023
5999ba6
k
Fil Sep 5, 2023
ac48639
initial value
Fil Sep 5, 2023
e4b6acf
do not mutate data!
Fil Sep 6, 2023
183e084
aria-label brush
Fil Sep 6, 2023
6c1108c
fix: markerEnd with a null channel
Fil Sep 6, 2023
bf3c728
show value on all brush tests
Fil Sep 6, 2023
7acd0ce
show value.done
Fil Sep 6, 2023
155b184
alphabetical order of case statements
Fil Sep 6, 2023
347f085
combine onMounted
Fil Sep 6, 2023
4da6662
more documentation
Fil Sep 6, 2023
19a60ed
add version badge
Fil Sep 13, 2023
428e33a
Merge branch 'main' into fil/brush
Fil Sep 18, 2023
4c29052
don't dispatch an event if not connected — it can only be an initiali…
Fil Sep 18, 2023
820d4d9
promote a value set up during initialization to the figure
Fil Sep 18, 2023
9dd5644
test the initial value
Fil Sep 18, 2023
02710bb
respect an existing render transform
Fil Sep 18, 2023
4b035f5
Merge branch 'main' into fil/brush
Fil Nov 14, 2023
115e2e5
Merge branch 'main' into fil/brush
Fil Dec 21, 2023
a41572d
update tests
Fil Dec 21, 2023
e3fe4fa
Merge branch 'main' into fil/brush
Fil Mar 14, 2024
b2f17c7
Merge branch 'main' into fil/brush
Fil May 27, 2024
db508ce
update tests
Fil May 27, 2024
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export default defineConfig({
text: "Interactions",
collapsed: true,
items: [
{text: "Brush", link: "/interactions/brush"},
{text: "Crosshair", link: "/interactions/crosshair"},
{text: "Pointer", link: "/interactions/pointer"}
]
Expand Down
6 changes: 5 additions & 1 deletion docs/data/api.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ function getHref(name: string, path: string): string {
}
break;
}
case "marks/brush":
return "interactions/brush";
Fil marked this conversation as resolved.
Show resolved Hide resolved
case "marks/crosshair":
return "interactions/crosshair";
case "transforms/basic": {
Expand Down Expand Up @@ -140,7 +142,9 @@ export default {
throw new Error(`anchor not found: ${href}#${name}`);
}
}
for (const {context: {href}} of allOptions) {
for (const {
context: {href}
} of allOptions) {
if (!anchors.has(`/${href}.md`)) {
throw new Error(`file not found: ${href}`);
}
Expand Down
24 changes: 23 additions & 1 deletion docs/features/interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ const olympians = shallowRef([
{weight: 170, height: 2.21, sex: "male"}
]);

const penguins = shallowRef([
{culmen_length_mm: 32.1, culmen_depth_mm: 13.1},
{culmen_length_mm: 59.6, culmen_depth_mm: 21.5}
]);

onMounted(() => {
d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data));
d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data));
});

</script>
Expand Down Expand Up @@ -54,7 +60,23 @@ These values are displayed atop the axes on the edge of the frame; unlike the ti

## Selecting

Support for selecting points within a plot through direct manipulation is under development. If you are interested in this feature, please upvote [#5](https://github.com/observablehq/plot/issues/5). See [#721](https://github.com/observablehq/plot/pull/721) for some early work on brushing.
The [brush transform](../interactions/brush.md) <VersionBadge version="0.6.11" pr="1653" /> allows the interactive selection of discrete elements by direct manipulation of the chart.

:::plot defer https://observablehq.com/@observablehq/brushing-plot--1653
```js
Plot.dot(
penguins,
Plot.brush({
x: "culmen_length_mm",
y: "culmen_depth_mm",
stroke: "currentColor",
fill: "#fff",
unselected: {strokeOpacity: 0.5},
selected: {fill: "species"}
})
).plot()
```
:::

## Zooming

Expand Down
106 changes: 106 additions & 0 deletions docs/interactions/brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script setup>

import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {ref, shallowRef, onMounted} from "vue";

const penguins = shallowRef([]);

onMounted(() => {
d3.csv("../data/penguins.csv", d3.autoType).then((data) => (penguins.value = data));
});

</script>

# Brush transform

The **brush transform** allows the interactive selection of discrete elements, such as dots in a scatterplot, by direct manipulation of the chart. A brush listens to mouse and touch events on the chart, allowing the user to define a rectangular region. All the data points that fall within the region are included in the selection.

:::plot defer
```js
Plot.dot(
penguins,
Plot.brush({
x: "culmen_length_mm",
y: "culmen_depth_mm",
stroke: "currentColor",
fill: "#fff",
unselected: {strokeOpacity: 0.5},
selected: {fill: "species"}
})
).plot()
```
:::

When the chart has a dominant axis, an horizontal or vertical brush is recommended; for example, to select bars in a histogram:

:::plot defer
```js
Plot.rectY(
penguins,
Plot.brushX(
Plot.binX(
{y: "count"},
{
x: "body_mass_g",
thresholds: 40,
unselected: {opacity: 0.1},
}
)
)
).plot()
```
:::

The brush transform interactively partitions the mark’s index in two: the unselected subset — for points outside the region —, and the selected subset for points inside. As the selection changes, the mark is replaced by two derived marks: below, a mark for the unselected data, with the mark options combined with the **unselected** option; above, a mark for the selected data, with the mark options combined with the **selected** option. All the channel values are incorporated into default scale domains, allowing *e.g.* a color scale to include the fill channel of the selected mark.

The brush transform supports both one- and two-dimensional brushing modes. The two-dimensional mode, [brush](#brush), is suitable for scatterplots and the general case: it allows the user to define a rectangular region by clicking on a corner (_e.g._ the top-left corner) and dragging the pointer to the bottom-right corner. The one-dimensional modes, [brushX](#brushX) and [brushY](#brushY), in contrast only consider one dimension; this is desirable when a chart has a “dominant” dimension, such as time in a time-series chart, the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart.

The brush transform emits an [*input* event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) whenever the selection changes, and sets the value of the plot element to the selected data. This allows you to use a plot as an [Observable view](https://observablehq.com/@observablehq/views) (viewof), or to register an *input* event listener to react to brushing.

## Brush options

The following options control the brush transform:

- **x1** - the starting horizontal↔︎ target position; bound to the *x* scale
- **y1** - the starting vertical↕︎ target position; bound to the *y* scale
- **x2** - the ending horizontal↔︎ target position; bound to the *x* scale
- **y2** - the ending vertical↕︎ target position; bound to the *y* scale
- **x** - the fallback horizontal↔︎ target position; bound to the *x* scale
- **y** - the fallback vertical↕︎ target position; bound to the *y* scale
- **selected** - additional options for the derived mark representing the selection
- **unselected** - additional options for the derived mark representing non-selected data

The positional options define a sensitive surface for each data point, defined on the horizontal axis as the extent between *x1* and *x2* if specified, between *x* and *x + bandwidth* if *x* is a band scale, or the value *x* otherwise. The sensitive surface’s vertical extent likewise spans from *y1* to *y2* if specified, from *y* to *y + bandwidth* if *y* is a band scale, or is equal to the *y* value otherwise.

When the user interacts with the plot by clicking and dragging the brush to define a rectangular region, all the elements whose sensitive surface intersect with the brushed region are selected, and the derived marks are re-rendered.

The selected data exposed as the value of the plot is an array of the (possibly transformed) data rendered by the *selected* derived mark. For example, in the case of the histogram above, the selected data is an array of bins, each containing the penguins whose body mass is between the bin’s lower and upper bounds.

The value is decorated with the brush’s coordinates (in data space) as its **x1** and **x2** properties for a quantitative scale *x*, and its **x** property if *x* is ordinal — and likewise for *y*. The value is also decorated with a **done** property set to false while brushing, true when the user releases the pointer, and undefined when the brush is canceled. Additionally, when faceting, it exposes the brushed facet’s *fx* and *fy* properties.

For details on the user interface (including touch events, pointer events and modifier keys), see [d3-brush](https://github.com/d3/d3-brush).

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

```js
Plot.dot(penguins, Plot.brush({x: "culmen_length_mm", y: "culmen_depth_mm"}))
```

Applies the brush render transform to the specified *options* to filter the mark index such that the points whose sensitive surface intersect with the brushed region the point closest to the pointer is rendered.

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

```js
Plot.tip(aapl, Plot.pointerX({x: "Date", y: "Close"}))
```

Like [brush](#brush), except the determination of the intersection exclusively considers the *x* (horizontal↔︎) position; this should be used for plots where *x* is the dominant dimension, such as the binned quantitative dimension in a histogram, or the categorical dimension of a bar chart.

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

```js
Plot.tip(alphabet, Plot.pointerY({x: "frequency", y: "letter"}))
```

Like [brush](#brush), except the determination of the intersection exclusively considers the *y* (vertical↕) position; this should be used for plots where *y* is the dominant dimension.
2 changes: 2 additions & 0 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export function valueObject(channels, scales) {
// promote symbol names (e.g., "plus") to symbol implementations (symbolPlus).
// Note: mutates channel!
export function inferChannelScale(name, channel) {
if (name === undefined) name = channel.scale; // TODO fixme
else name = name.replace(/^\w+:/, ""); // XXX
const {scale, value} = channel;
if (scale === true || scale === "auto") {
switch (name) {
Expand Down
1 change: 1 addition & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./curve.js";
export * from "./dimensions.js";
export * from "./format.js";
export * from "./inset.js";
export * from "./interactions/brush.js";
export * from "./interactions/pointer.js";
export * from "./interval.js";
export * from "./legends.js";
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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, brushX, brushY} from "./interactions/brush.js";
export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
export {scale} from "./scales.js";
Expand Down
42 changes: 42 additions & 0 deletions src/interactions/brush.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type {Rendered} from "../transforms/basic.js";

/** Options for the brush transform. */
type BrushOptions = {
/**
* How to display the selected mark when the user manipulates the brush.
*/
selected?: null; // TODO
/**
* How to display the unselected mark when the user manipulates the brush.
*/
unselected?: null;
/**
* The brush’s padding, defaults to 1.
*/
padding?: number;
};

/**
* Applies a render transform to the specified *options* to filter the mark
* index such that only the point closest to the pointer is rendered; the mark
* will re-render interactively in response to pointer events.
*/
export function brush<T>(options: T & BrushOptions): Rendered<T>;

/**
* Like the pointer transform, except the determination of the closest point
* considers mostly the *x* (horizontal↔︎) position; this should be used for
* plots where *x* is the dominant dimension, such as time in a time-series
* chart, the binned quantitative dimension in a histogram, or the categorical
* dimension of a bar chart.
*/
export function brushX<T>(options: T & BrushOptions): Rendered<T>;

/**
* Like the pointer transform, except the determination of the closest point
* considers mostly the *y* (vertical↕︎) position; this should be used for plots
* where *y* is the dominant dimension, such as time in a time-series chart, the
* binned quantitative dimension in a histogram, or the categorical dimension of
* a bar chart.
*/
export function brushY<T>(options: T & BrushOptions): Rendered<T>;
Loading
Loading