Filtering a Scatterplot with a D3 Brush
You drag a rectangle across a scatterplot expecting the enclosed points to select, but the wrong points highlight — or none do — because the brush reports pixels and your test compares them against data.
This is a companion to brushing and linked views in D3, narrowed to the two-dimensional case: a brush() over an XY point cloud. The fix hinges on the same bridge every brush interaction needs — inverting the pixel extent through the scales you built in D3 scales and axes configuration — but the 2D geometry adds two traps that the 1D brushX case never exposes: the inverted y axis and the two-corner selection shape.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: pixel selection compared straight against data values.
import { brush, D3BrushEvent } from 'd3-brush';
interface Pt { id: string; x: number; y: number; }
const points: Pt[] = loadPoints();
const b = brush<undefined>().on('brush', (event: D3BrushEvent<undefined>) => {
const s = event.selection as [[number, number], [number, number]] | null;
if (!s) return;
const [[x0, y0], [x1, y1]] = s;
// Wrong: x0..x1 are PIXELS, pt.x is a DATA value — the comparison is meaningless.
const hit = points.filter((p) => p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1);
highlight(hit);
});
// ✅ FIXED: invert each axis, swap y, cache the selection, handle the empty brush.
import { brush, D3BrushEvent } from 'd3-brush';
import { ScaleLinear } from 'd3-scale';
import { selectAll, Selection } from 'd3-selection';
interface Pt { id: string; x: number; y: number; }
function makeScatterBrush(
xScale: ScaleLinear<number, number>,
yScale: ScaleLinear<number, number>,
points: Pt[],
): void {
// PERF: select the circles ONCE, not inside the handler.
const dots: Selection<SVGCircleElement, Pt, null, undefined> =
selectAll<SVGCircleElement, Pt>('circle.pt');
const b = brush<undefined>()
.extent([[0, 0], [xScale.range()[1], yScale.range()[0]]])
.on('brush end', (event: D3BrushEvent<undefined>) => {
const s = event.selection as [[number, number], [number, number]] | null;
if (s === null) { dots.classed('selected', true); announce(points.length); return; }
const [[x0, y0], [x1, y1]] = s;
// Invert per axis; y swaps because the top pixel is the LARGER value.
const dx0: number = xScale.invert(x0), dx1: number = xScale.invert(x1);
const dyHi: number = yScale.invert(y0), dyLo: number = yScale.invert(y1);
let count = 0;
dots.classed('selected', (p: Pt) => {
const inside = p.x >= dx0 && p.x <= dx1 && p.y >= dyLo && p.y <= dyHi;
if (inside) count++;
return inside;
});
announce(count); // A11Y: report how many points the selection contains
});
selectAll<SVGGElement, undefined>('g.brush').call(b);
}
declare function announce(n: number): void;
The decisive line is the y inversion. Because yScale.range() is typically [height, 0] — pixel height at the bottom for data value 0, pixel 0 at the top for the maximum — the brush’s y0 (the smaller pixel, nearer the top) inverts to the larger data value. Compare against p.y >= dyLo && p.y <= dyHi where dyLo = yScale.invert(y1) and dyHi = yScale.invert(y0). Swap those and every selection silently misses, because you are asking for points whose value is both above the max and below the min.
Step-by-step fix
import { brushSelection } from 'd3-brush';
// A reusable pure predicate keeps the handler and any unit test in agreement.
function inBrush(
p: { x: number; y: number },
data: [[number, number], [number, number]],
xScale: (v: number) => number, // forward map, for round-trip checks
invX: (px: number) => number,
invY: (px: number) => number,
): boolean {
const [[x0, y0], [x1, y1]] = data;
const dx0 = invX(x0), dx1 = invX(x1);
const dyLo = invY(y1), dyHi = invY(y0); // swap: top pixel = larger value
return p.x >= dx0 && p.x <= dx1 && p.y >= dyLo && p.y <= dyHi;
}
Verification
// Round-trip: a point at a known data coordinate must fall inside a brush
// whose pixel corners were produced by the forward scale of that coordinate.
const xScale = makeLinear([0, 10], [0, 500]);
const yScale = makeLinear([0, 10], [400, 0]); // note inverted range
const p = { x: 5, y: 5 };
const px = { x: xScale(5), y: yScale(5) }; // forward → pixels
const rect: [[number, number], [number, number]] =
[[px.x - 20, px.y - 20], [px.x + 20, px.y + 20]];
console.assert(
inBrush(p, rect, xScale, xScale.invert, yScale.invert),
'point at its own pixel location must be inside a box around it',
);
In DevTools, drag a brush over a dense group of points you can see, then log the resolved data rectangle and eyeball that its numeric bounds bracket the highlighted points. A quick sanity check: brush the top-left corner of the plot and confirm the reported y range is high values and low x values — if it reports low y, your y swap is missing.
Why the y axis inverts
The inverted-y trap is worth understanding rather than memorizing, because it recurs in every pointer interaction on a value axis. Screen coordinates put the origin at the top-left and grow downward, so a taller bar or a higher data value sits at a smaller pixel y. Conventionally you encode this in the scale itself by giving yScale a range of [height, 0] — data 0 maps to pixel height (bottom), data max maps to pixel 0 (top). That keeps every forward call yScale(value) correct without per-call arithmetic. The consequence surfaces only when you invert: yScale.invert(smallPixel) returns a large value. The brush hands you y0 < y1 in pixel order (top corner first), so yScale.invert(y0) is your upper data bound and yScale.invert(y1) is your lower one. Naming the inverted results dyHi and dyLo at the point of inversion, rather than reusing y0/y1, is the cheapest way to stop the bug from reappearing when someone edits the handler later.
Filter versus highlight for scatter selection
For a scatterplot specifically, prefer highlighting over filtering as the response to a brush. Removing the unselected points from the DOM destroys the spatial context that makes the selection meaningful — the user selected that cluster over there relative to the rest of the cloud, and deleting the rest erases the “over there”. Fading unselected points to a low opacity while saturating the selected ones preserves the reference frame and is a pure attribute update over the existing join, so it stays cheap even at tens of thousands of points. Reserve actual filtering for linked charts that aggregate — a companion bar chart of category counts, say — where the point cloud’s context does not apply. This split, highlight-in-place and filter-downstream, is the same division of labor described across coordinated views generally.
Edge cases & gotchas
- Empty brush is not an error. Clicking once without dragging, or clearing with Escape, yields
event.selection === null. Treat it as “everything selected” (reset) or “nothing selected” per your product, but never let it throw. brushversusendsemantics. Listen tobrushfor live highlighting during the drag andendfor committing an expensive downstream filter; binding onlyendmakes the interaction feel dead, binding onlybrushruns the expensive path continuously.- Points exactly on the boundary. Use inclusive comparisons; a strict
>drops points a user clearly dragged over, which reads as a flaky selection. - Non-linear scales.
invertexists on continuous scales includingscaleLogandscaleSymlog, so the same pattern works for skewed data, butscaleBandhas noinvertand needs a manual reconstruction. - Zoomed scatter. If
d3-zoomhas rescaled the axes, invert through the rescaled scale (transform.rescaleX(xScale)), not the original, or the selection lands on pre-zoom coordinates. - High-cardinality clouds. At 100k+ points, the
O(n)predicate scan on everybrushevent is the bottleneck; move the marks to Canvas and defer the full scan to theendevent.
Frequently Asked Questions
Why does my D3 brush select the wrong points on the y axis?
Because SVG’s y axis grows downward and your yScale range is usually [height, 0], so the brush’s top pixel y0 inverts to the larger data value, not the smaller one. Set your lower bound from yScale.invert(y1) and your upper bound from yScale.invert(y0) — the y endpoints swap during inversion. Comparing without swapping asks for points above the max and below the min, so nothing matches.
Should a brush filter or just highlight scatterplot points?
For the scatterplot itself, highlight: fade unselected points and saturate selected ones so the spatial context of the cloud survives, which is also cheaper because it is a pure attribute update. Filter only the linked charts that aggregate, like a companion bar chart, where the point cloud’s layout is irrelevant. Removing points from the scatter destroys the very reference frame that made the selection meaningful.
How do I brush a scatterplot that has already been zoomed?
Invert through the rescaled scale, not the original. When d3-zoom applies a transform, derive the current scale with transform.rescaleX(xScale) and transform.rescaleY(yScale), then call invert on those. Using the pre-zoom scale maps the pixel selection to the wrong data coordinates, so the highlighted points drift away from the drawn rectangle.
Related
- Brushing and linked views in D3 — the parent topic on coordinated selection.
- D3 scales and axes configuration — the invert operation this fix depends on.
- Debouncing brush events on large selections — keeping the scan cheap at high cardinality.
- Synchronizing a crosshair across linked charts — a related pointer-driven coordination pattern.