Brushing & Linked Views in D3
A brush is a rectangular selection tool that turns a drag gesture over one chart into a data query that every other chart in the view answers together.
This topic sits inside the D3.js data binding and layout architecture guide, because a coordinated multi-chart view is only as sound as the data joins and scales underneath it. d3-brush gives you the pixel geometry of a selection; the scales you configured in D3 scales and axes configuration turn those pixels back into data values through scale.invert, and the keyed join decides which marks light up. Get those three layers talking to one shared piece of state and you have linked views: brush the timeline, and the scatter, the bar chart, and the map all filter to the same records in the same frame.
When to use what
The first decision is whether you even want a brush. A brush is for selecting a range or region and holding it; if the interaction is momentary or single-target, a simpler primitive is cheaper and clearer. The second decision is dimensionality: a time filter is one-dimensional, a lasso over an XY cloud is two-dimensional, and choosing brushX when you meant brush bakes a wrong assumption into every downstream handler.
| Interaction | Constructor | Emits | Reach for it when |
|---|---|---|---|
| 1D range along x | brushX() |
[x0, x1] pixels |
filtering a time series or a single numeric axis |
| 1D range along y | brushY() |
[y0, y1] pixels |
selecting a value band on a vertical axis |
| 2D region | brush() |
[[x0, y0], [x1, y1]] |
lasso-style selection over a scatter cloud |
| Continuous rescale | d3-zoom |
a transform k, tx, ty |
pan/zoom navigation, no persistent selection |
| Single mark pick | click / pointerdown |
one datum | select or drill into exactly one element |
The rule of thumb: use a brush when the output is a set of records defined by a region, use zoom when the output is a viewport the user navigates, and use a click when the output is a single datum. These are not mutually exclusive — a common production pattern is a small overview chart with a brushX that controls the visible window of a large detail chart, the classic overview-plus-detail arrangement, while the detail chart itself uses d3-zoom for fine navigation. The section on zoom-and-brush interplay below covers how to keep the two from fighting over the same pointer.
Core concepts
A brush is a behavior you attach to a container <g>, exactly like d3-zoom or d3-drag. Calling the brush on a selection installs an overlay rectangle that captures pointer events, a movable selection rectangle, and eight resize handles. The brush maintains its state in pixel space and reports it through three event types — start, brush, and end — each carrying event.selection, the current pixel extent (or null when the brush is cleared).
import { brushX, D3BrushEvent } from 'd3-brush';
import { scaleLinear, ScaleLinear } from 'd3-scale';
import { select, Selection } from 'd3-selection';
interface Row { id: string; t: number; value: number; }
const x: ScaleLinear<number, number> = scaleLinear().domain([0, 100]).range([0, 800]);
// The brush owns pixel geometry; the scale owns the data mapping.
const brush = brushX<undefined>()
.extent([[0, 0], [800, 120]]) // brushable area in pixels
.on('brush end', (event: D3BrushEvent<undefined>) => {
const sel = event.selection as [number, number] | null;
if (sel === null) { clearFilter(); return; } // brush cleared
// A11Y: announce the resolved data range, not raw pixels.
const [d0, d1]: [number, number] = [x.invert(sel[0]), x.invert(sel[1])];
applyFilter(d0, d1); // PERF: keep this handler O(n) or cheaper
});
function attach(g: Selection<SVGGElement, undefined, null, undefined>): void {
g.call(brush);
}
declare function applyFilter(lo: number, hi: number): void;
declare function clearFilter(): void;
The single most important idea is that the brush never knows about your data. It reports pixels; x.invert(px) is the bridge back to the domain. Because the same x scale both positions the marks and decodes the selection, a resize or a domain change that updates x keeps the selection meaningful — the input and output coordinate systems stay welded together, the same discipline that governs every interaction built on a scale. For a 2D brush(), event.selection is [[x0, y0], [x1, y1]], so you invert the x endpoints through xScale and the y endpoints through yScale independently, remembering that SVG’s y axis grows downward while most value scales grow upward, so y0 (top pixel) maps to the larger data value.
Linked highlighting and crossfilter-style filtering are two ends of one spectrum. Highlighting leaves every mark in place and only changes its appearance — the non-selected points fade, the selected ones saturate — which keeps object constancy and is cheap because it is a pure attribute update over the existing join. Filtering actually removes the excluded records from the linked charts, re-running their joins against a smaller array, which is more expensive but is what you want when a downstream chart aggregates (a bar chart of counts must recompute its bins). Most dashboards do both: highlight in charts that share the same marks, filter in charts that aggregate.
Implementation pattern
The reliable architecture is a single source of truth for the current selection plus a broadcast step. Every chart registers as a listener; the brushed chart writes the resolved data range into shared state; the broadcast tells each listener to re-read that state and update. Keeping the state in data units rather than pixels is what lets charts with different scales all consume the same selection.
type Range = readonly [number, number];
type Listener = (range: Range | null) => void;
class LinkBus {
private listeners = new Set<Listener>();
private current: Range | null = null;
subscribe(fn: Listener): () => void {
this.listeners.add(fn);
fn(this.current); // sync new charts to current state
return () => { this.listeners.delete(fn); }; // PERF: unsubscribe on teardown
}
broadcast(range: Range | null): void {
this.current = range;
for (const fn of this.listeners) fn(range);
}
}
// A chart that highlights rather than filters keeps object constancy:
function highlightListener(marks: SVGCircleElement[], accessor: (el: SVGCircleElement) => number) {
return (range: Range | null): void => {
for (const el of marks) {
const v = accessor(el);
const inside = range === null || (v >= range[0] && v <= range[1]);
el.setAttribute('fill-opacity', inside ? '1' : '0.15'); // A11Y: pair with a text summary
}
};
}
Performance & memory
A brush drag fires brush events at pointer-move frequency — potentially hundreds per second over a large drag. If the handler re-joins a 100k-row chart on every event you drop frames immediately. The guiding budget is the familiar 16.67ms per frame, and the brush handler shares it with every listener it wakes.
- Handler cost is O(listeners × work). Each
brushevent runs every subscriber. Keep per-subscriber work sublinear where possible, and defer expensive re-aggregation to theendevent. - Cheap preview, then commit. During
brush, do the cheap thing (fade opacity, move a range rectangle); onend, do the expensive thing (re-bin, re-join). Users read the preview as instant feedback and never notice the commit is deferred. - Debounce or throttle high-frequency handlers. Over large selections, coalesce
brushevents withrequestAnimationFrameso at most one update runs per frame — see debouncing brush events on large selections. - Index for range queries. Filtering a scatter by a numeric range is
O(n)naively; pre-sort or build a lightweight index so the query isO(log n + k)forkmatches. - Reuse selections. Cache the
d3.selectAllof the linked marks once; re-selecting the DOM inside the handler is a per-event allocation and a layout read. - Unsubscribe on teardown. A listener left in the bus after its chart unmounts pins the chart’s closure and its DOM, a classic detached-node leak.
Accessibility checklist
Troubleshooting
- Symptom: the brush selection jumps or disappears on window resize. Cause: the brush’s
.extent()and the scale’s.range()fell out of sync after the container changed size. Fix: recompute both from the new width and callg.call(brush.move, null)or reproject the stored data range back to pixels. - Symptom: linked charts filter to the wrong records. Cause: the selection was stored in pixels and consumed by a chart with a different scale. Fix: store the range in data units and invert once, at the source — see filtering a scatterplot with a D3 brush.
- Symptom: dragging the brush also pans the chart. Cause: a
d3-zoombehavior and the brush are both bound to the same element and competing for the gesture. Fix: separate their pointer filters or gate zoom behind a modifier key, as covered under zoom-and-brush interplay below. - Symptom: the shared crosshair lags behind the pointer. Cause: the nearest-datum lookup is a linear scan over every chart on each move. Fix: bisect a sorted array and throttle the move handler — see synchronizing a crosshair across linked charts.
- Symptom: frames drop while brushing a huge dataset. Cause: every
brushevent re-aggregates the full dataset. Fix: preview cheaply duringbrush, commit onend, and coalesce withrequestAnimationFrame.
Zoom and brush interplay
Zoom and brush both want the pointer, and by default binding both to one element makes the drag ambiguous. There are three clean resolutions. The first is spatial separation: put the brush on an overview strip and the zoom on the detail chart, so no single element carries both gestures. The second is a modifier gate: configure d3-zoom’s .filter() to ignore plain drags and only zoom when a key is held, leaving bare drags for the brush. The third is mode switching: a toolbar toggle that binds one behavior at a time. Whichever you choose, keep the two synchronized through the same shared scale — when a zoom rescales x, the brush’s stored data range must be re-projected to new pixels with x(range[0]) so the visible selection rectangle tracks the zoom instead of drifting. This is exactly why storing the selection in data units rather than pixels pays off: a pixel selection is meaningless after a rescale, but a data range survives it.
Frequently Asked Questions
What is the difference between brushing and zooming in D3?
Brushing selects a range or region and keeps it as a persistent query that other charts answer, emitting a pixel extent through d3-brush. Zooming changes which part of the data you are viewing by applying a transform through d3-zoom, with no persistent selection. Use a brush when the output is a set of records defined by a region, and zoom when the output is a viewport the user navigates.
How do I convert a brush selection back to data values?
The brush reports pixels, so pass its endpoints through the same scale that positioned the marks: x.invert(sel[0]) and x.invert(sel[1]) for a brushX. For a 2D brush, invert the x endpoints through the x scale and the y endpoints through the y scale independently, and remember SVG’s y axis grows downward so the top pixel maps to the larger data value.
How do I keep multiple charts in sync with one brush?
Store the resolved data range in a single shared piece of state and broadcast changes to every chart through a small event bus or a framework store. Each chart subscribes and decides whether to highlight with an attribute update or filter by re-running its join, and a null selection resets all of them to the full dataset.
Related
- D3.js data binding and layout architecture — the overview this topic sits under.
- Filtering a scatterplot with a D3 brush — mapping a 2D brush extent back to data.
- Synchronizing a crosshair across linked charts — a shared pointer across several charts.
- Debouncing brush events on large selections — keeping the handler inside the frame budget.
- D3 scales and axes configuration — the scales whose invert powers every brush.