Skip to content

Latest commit

 

History

History
378 lines (296 loc) · 14.4 KB

File metadata and controls

378 lines (296 loc) · 14.4 KB
<script setup> import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import * as topojson from "topojson-client"; import {shallowRef, computed, onMounted} from "vue"; import penguins from "../data/penguins.ts"; const world = shallowRef(null); const land = computed(() => world.value && topojson.feature(world.value, world.value.objects.land)); const allCities = shallowRef([]); const cities = computed(() => allCities.value.filter((d) => d.population > 500000)); onMounted(() => { d3.json("../data/countries-110m.json").then((data) => (world.value = data)); d3.csv("../data/cities-10k.csv", d3.autoType).then((data) => (allCities.value = data)); }); </script>

Brush mark

The brush mark renders a two-dimensional brush that allows the user to select a rectangular region by clicking and dragging. It is typically used to highlight a subset of data, or to filter data for display in a linked view.

:::plot hidden

Plot.plot({
  marks: ((brush) => (d3.timeout(() => brush.move({x1: 36, x2: 48, y1: 15, y2: 20})), [
    Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}),
    brush
  ]))(Plot.brush())
})

:::

Plot.plot({
  marks: [
    Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}),
    Plot.brush()
  ]
})

The brush mark does not require data. When added to a plot, it renders a 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 hidden

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())
})

:::

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 whenever the selection changes. The plot’s value (plot.value) is set to a Region instance when a selection is active, or null when the selection is cleared. This allows you to use a plot as an Observable view, or to register an input event listener to react to the brush.

const plot = Plot.plot(options);

plot.addEventListener("input", (event) => {
  console.log(plot.value);
});

The contains method on the region tests whether a data point falls inside the selection:

  • 2-D brush: region.contains(x, y) or region.contains(x, y, {fx, fy})
  • 1-D brush: region.contains(value) or region.contains(value, {fx}) / region.contains(value, {fy})

For faceted plots, you can optionally pass the facet values as an object; contains then returns true only for points in the brushed facet. For example:

plot.addEventListener("input", () => {
  const region = plot.value;
  const selected = region ? penguins.filter((d) => region.contains(d.culmen_length_mm, d.culmen_depth_mm)) : penguins;
  console.log(selected);
});

The facet argument is optional: if omitted, contains skips the facet check. For example, if the selected region is [44, 46] × [17, 19] over the "Adelie" facet:

const {contains} = plot.value;
contains(45, 18) // true
contains(45, 18, {fx: "Adelie"}) // true
contains(45, 18, {fx: "Gentoo"}) // false

Reactive marks

The brush can be paired with reactive marks that respond to the brush state. Create a brush mark, then call its inactive, context, and focus methods to derive options that reflect the selection.

  • inactive — shows the mark when no selection is active; hides it during brushing.
  • context — hidden when no selection is active; during brushing, shows points outside the selection.
  • focus — hidden when no selection is active; during brushing, shows points inside the selection.

A typical pattern is to layer three reactive marks: the inactive mark provides a default view, while the context and focus marks replace it during brushing, the context dimming unselected points and the focus highlighting selected ones.

:::plot hidden

Plot.plot({
  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())
})

:::

const brush = Plot.brush();
Plot.plot({
  marks: [
    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}))
  ]
})

:::tip 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

The brush mark supports faceting. When the plot uses fx or fy facets, each facet gets its own brush. The dispatched value includes the fx and fy facet values of the brushed facet; optionally pass {fx, fy} as the last argument to contains to restrict matching to the brushed facet.

:::plot hidden

Plot.plot({
  height: 270,
  grid: true,
  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())
})

:::

const brush = Plot.brush();
Plot.plot({
  marks: [
    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}))
  ]
})

By default, starting a brush in one facet clears any selection in other facets. Set sync to true to brush across all facet panes simultaneously. When the user brushes in one facet, the same selection rectangle appears in all panes, and the reactive marks update across all facets.

:::plot hidden

Plot.plot({
  height: 270,
  grid: true,
  marks: ((brush) => (d3.timeout(() => brush.move({x1: 43, x2: 50, y1: 17, y2: 19})), [
    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({sync: true}))
})

:::

const brush = Plot.brush({sync: true});
Plot.plot({
  marks: [
    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}))
  ]
})

The dispatched value still includes fx (and fy), indicating the facet where the interaction originated.

Projections

For plots with a geographic projection, the brush operates in screen space. The brush value’s x1, y1, x2, y2 bounds are expressed in pixels from the top-left corner of the frame. Use contains with pixel coordinates to test against the brush extent.

:::plot hidden

Plot.plot({
  projection: "equal-earth",
  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())
})

:::

const brush = Plot.brush();
Plot.plot({
  projection: "equal-earth",
  marks: [
    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"}))
  ]
})

Region {#region}

The brush value dispatched on input events. When the brush is cleared, the value is null; otherwise it's a Region instance with the following properties:

  • x1 - the lower x bound of the selection (in data space, or pixels if projected)
  • x2 - the upper x bound of the selection
  • y1 - the lower y bound of the selection
  • y2 - the upper y bound of the selection
  • fx - the fx facet value, if applicable
  • fy - the fy facet value, if applicable
  • contains - a method to test whether a point is inside the selection; see input events

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. Values are automatically rounded to the optimal precision that distinguishes neighboring pixels.

brush(options) {#brush}

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.

The following options are supported:

  • sync - if true, the brush spans all facet panes simultaneously; defaults to false

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

Plot.dot(data, brush.inactive({x: "weight", y: "height", fill: "species"}))

Returns mark options that show the mark when no brush selection is active, and hide it during brushing. Use this for the default appearance of data before any selection is made.

brush.context(options) {#brush-context}

Plot.dot(data, brush.context({x: "weight", y: "height", fill: "#ccc"}))

Returns mark options that hide the mark by default and, during brushing, show only the points outside the selection. Use this for a dimmed background layer.

brush.focus(options) {#brush-focus}

Plot.dot(data, brush.focus({x: "weight", y: "height", fill: "species"}))

Returns mark options that hide the mark by default and, during brushing, show only the points inside the selection. Use this to highlight the selected data.

brush.move(value) {#brush-move}

brush.move({x1: 36, x2: 48, y1: 15, y2: 20})

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.

brush.move({x1: 3500, x2: 5000}) // brushX
brush.move({x1: 40, x2: 52, y1: 15, y2: 20, fx: "Chinstrap"})
brush.move(null)

For projected plots, the coordinates are in pixels (consistent with the Region), 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 to help prioritize this feature.

brushX(options) {#brushX}

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. (Use the same interval in the bin transform so the brush aligns with bin edges.)

:::plot defer hidden

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}))
})

:::

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}

const brush = Plot.brushY()

Returns a new vertical brush mark that selects along the y axis. Accepts the same options as brushX.