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

Mapping a 2D brush extent back to a data rectangle The two-corner pixel selection inverts through the x and y scales into a data-space rectangle, with the top pixel mapping to the larger y value. y hi y lo x lo x hi [x0,y0] [x1,y1] invert to data rect x: [xScale.invert(x0), xScale.invert(x1)] y: [yScale.invert(y1), yScale.invert(y0)] ← swap
A 2D brush is two corners in pixel space; inverting x and y separately yields a data rectangle, and the y endpoints swap because the top pixel is the larger value.
Clustered scatter sample Three seeded gaussian clusters of points on a shared pair of axes. xy
A representative point cloud of the kind a scatterplot with a d3 brush operates on — three clusters that a renderer must place, pick, or pan without dropping frames.

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.
  • brush versus end semantics. Listen to brush for live highlighting during the drag and end for committing an expensive downstream filter; binding only end makes the interaction feel dead, binding only brush runs 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. invert exists on continuous scales including scaleLog and scaleSymlog, so the same pattern works for skewed data, but scaleBand has no invert and needs a manual reconstruction.
  • Zoomed scatter. If d3-zoom has 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 every brush event is the bottleneck; move the marks to Canvas and defer the full scan to the end event.
Linear, log and sqrt scale mappings The same input domain mapped to pixels three ways: linear, logarithmic and square-root. input (domain)pixels (range)linearlogsqrt
How linear, log and sqrt scales each map one domain onto the pixel range — the choice at the heart of a scatterplot with a d3 brush.

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.