Skip to content

brush, pointer #721

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Observable Plot - Changelog

## 0.4.2

*Not yet released. These are forthcoming changes in the main branch.*

Plot now supports [interaction marks](./README.md#interactions)! An interaction mark defines an interactive selection represented as a subset of the mark’s data. For example, the [brush mark](./README.md#brush) allows rectangular selection by clicking and dragging; you can use a brush to select points of interest from a scatterplot and show them in a table. The interactive selection is exposed as *plot*.value. When the selection changes during interaction, the plot emits *input* events. This allows plots to be [Observable views](https://observablehq.com/@observablehq/introduction-to-views), but you can also [listen to *input* events](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) directly.

## 0.4.1

Released February 17, 2022.
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,38 @@ Plot.vector(wind, {x: "longitude", y: "latitude", length: "speed", rotate: "dire

Returns a new vector with the given *data* and *options*. If neither the **x** nor **y** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …].

## Interactions

Interactions are special marks that handle user input and define interactive selections. When a plot has an interaction mark, the returned *plot*.value represents the current selection as an array subset of the interaction mark’s data. As the user modifies the selection through interaction with the plot, *input* events are emitted. This design is compatible with [Observable’s viewof operator](https://observablehq.com/@observablehq/introduction-to-views), but you can also listen to *input* events directly via the [EventTarget interface](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget).

### Brush

[Source](./src/marks/brush.js) · [Examples](https://observablehq.com/@observablehq/plot-brush) · Selects points within a single contiguous rectangular region, such as nearby dots in a scatterplot.

#### Plot.brush(*data*, *options*)

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

Returns a new brush with the given *data* and *options*. If neither the **x** nor **y** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …].

#### Plot.brushX(*data*, *options*)

```js
Plot.brushX(penguins, {x: "culmen_depth_mm"})
```

Equivalent to [Plot.brush](#plotbrushdata-options) except that if the **x** option is not specified, it defaults to the identity function and assumes that *data* = [*x₀*, *x₁*, *x₂*, …].

#### Plot.brushY(*data*, *options*)

```js
Plot.brushY(penguins, {y: "culmen_length_mm"})
```

Equivalent to [Plot.brush](#plotbrushdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …].

## Decorations

Decorations are static marks that do not represent data. Currently this includes only [Plot.frame](#frame), although internally Plot’s axes are implemented as decorations and may in the future be exposed here for more flexible configuration.
Expand Down
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@ export {Area, area, areaX, areaY} from "./marks/area.js";
export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {boxX, boxY} from "./marks/box.js";
export {Brush, brush, brushX, brushY} from "./marks/brush.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
export {Image, image} from "./marks/image.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
export {Link, link} from "./marks/link.js";
export {Pointer, pointer, pointerX, pointerY} from "./marks/pointer.js";
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
export {Text, text, textX, textY} from "./marks/text.js";
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {Vector, vector} from "./marks/vector.js";
export {selection} from "./selection.js";
export {valueof} from "./options.js";
export {filter, reverse, sort, shuffle} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
Expand Down
87 changes: 87 additions & 0 deletions src/marks/brush.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {brush as brusher, brushX as brusherX, brushY as brusherY, create, select} from "d3";
import {identity, maybeTuple} from "../options.js";
import {Mark} from "../plot.js";
import {selection, selectionEquals} from "../selection.js";
import {applyDirectStyles, applyIndirectStyles} from "../style.js";

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

export class Brush extends Mark {
constructor(data, {x, y, ...options} = {}) {
super(
data,
[
{name: "x", value: x, scale: "x", optional: true},
{name: "y", value: y, scale: "y", optional: true}
],
options,
defaults
);
this.activeElement = null;
}
render(index, {x, y}, {x: X, y: Y}, dimensions) {
const {ariaLabel, ariaDescription, ariaHidden, ...options} = this;
const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
const brush = this;
const g = create("svg:g")
.call(applyIndirectStyles, {ariaLabel, ariaDescription, ariaHidden}, dimensions)
.call((X && Y ? brusher : X ? brusherX : brusherY)()
.extent([[marginLeft, marginTop], [width - marginRight, height - marginBottom]])
.on("start brush end", function(event) {
const {type, selection: extent} = event;
// For faceting, when starting a brush in a new facet, clear the
// brush and selection on the old facet. In the future, we might
// allow independent brushes across facets by disabling this?
if (type === "start" && brush.activeElement !== this) {
if (brush.activeElement !== null) {
select(brush.activeElement).call(event.target.clear, event);
brush.activeElement[selection] = null;
}
brush.activeElement = this;
}
let S = null;
if (extent) {
S = index;
if (X) {
let [x0, x1] = Y ? [extent[0][0], extent[1][0]] : extent;
if (x.bandwidth) x0 -= x.bandwidth();
S = S.filter(i => x0 <= X[i] && X[i] <= x1);
}
if (Y) {
let [y0, y1] = X ? [extent[0][1], extent[1][1]] : extent;
if (y.bandwidth) y0 -= y.bandwidth();
S = S.filter(i => y0 <= Y[i] && Y[i] <= y1);
}
}
if (!selectionEquals(this[selection], S)) {
this[selection] = S;
this.dispatchEvent(new Event("input", {bubbles: true}));
Copy link

@enjoylife enjoylife Jul 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how much of a "public" api your wanting to expose in these events... but access to the selected data indexes or the range of the data is needed. e.g.

this.dispatchEvent(new CustomEvent("input", {bubbles: true, detail: {selected: S, range}}));

where range is something like if(X && x.invert) range = extent.map(x.invert);

Then in the listener, you can use the indexes for filtering right away or show the textual range to to the user.

Without access to the indexes used, callers only can view the plot.value. This only has what was selected. It does not provide what criteria was used for the selection, nor the data left out.

}
}))
.call(g => g.selectAll(".selection")
.attr("shape-rendering", null) // reset d3-brush
.call(applyIndirectStyles, options, dimensions)
.call(applyDirectStyles, options))
.node();
g[selection] = null;
return g;
}
}

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

export function brushX(data, {x = identity, ...options} = {}) {
return new Brush(data, {...options, x});
}

export function brushY(data, {y = identity, ...options} = {}) {
return new Brush(data, {...options, y});
}
Loading