Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate ~23 min· topic 5 of 7

Topic 8.5

Brushing and Zooming

In one line

A brush selects a range by dragging, and a zoom changes what part of the chart you see. This topic shows the event shapes d3.brush and d3.zoom really send, how to turn them into dates, what the limits do and do not stop, and how to avoid a chart that steals the page's scroll.

Think of it like this

A highlighter pen and a magnifying glass over a long paper timeline. The pen marks a stretch (a brush). The magnifying glass shows part of it larger, and you can slide the paper under it (a zoom). They answer different questions: "which part do I mean?" and "let me see this part closer".

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

brush
A draggable range on a chart that the reader uses to select part of it.
selection
The brush's current range, as pixel positions, or null when nothing is selected.
zoom transform
Three numbers k, x and y: the scale factor and the shift that the reader has applied.
scaleExtent
The smallest and largest zoom factors the reader may reach.
sourceEvent
The real mouse or touch event behind a brush or zoom event. Null when code caused it.

Step by step

01Drag a brush and read its events

The first example adds d3.brushX() to the year's line. It sends a real drag (mouse events dispatched by the page) and prints every event: the selection in pixels, the same range as dates, and whether a person caused it. Then code moves the brush to June, and a click clears it.

02Zoom, rescale the axis, and test the limits

The second example zooms the line with the wheel and rescales the axis and the path from transform.rescaleX(x). It then asks for impossible zooms in two ways and prints what comes back. A plain wheel event is cancelled: the page cannot scroll.

03Give the scroll back

Add a filter: the wheel zooms only with Ctrl or Command held. In a test run, a plain wheel event was no longer cancelled, so the page scrolls, and a Ctrl + wheel event zoomed (k = 4 for one notch). Reset with svg.call(zoom.transform, d3.zoomIdentity).

give the scroll backwhole filejs
zoom.filter((event) => (event.type === "wheel" ? event.ctrlKey || event.metaKey : !event.button && !event.ctrlKey));

04From gesture to dates

Both behaviours end in numbers you convert: a brush gives pixels, x.invert turns them into dates. A zoom gives { k, x, y }, and rescaleX turns it into a scale.

From gesture to datesdiagram
Rendering diagram…

Try it yourself

  1. 1

    Snap the brush to whole days

    In the first example, add this line before const overlay: brush.on("end.snap", (event) => { if (event.sourceEvent && event.selection) g.call(brush.move, event.selection.map((px) => x(d3.utcDay.round(x.invert(px))))); });. After the drag, the brush jumps to day boundaries: a second start, brush and end follow with from a person: false, and the dates now begin on 9 Apr.

  2. 2

    Give the scroll back to the page

    In the second example, add zoom.filter((event) => (event.type === "wheel" ? event.ctrlKey || event.metaKey : !event.button && !event.ctrlKey)); before svg.call(zoom);, and change the first console.log to print wheel(-100, 330, {}) and wheel(-100, 330, { ctrlKey: true }) on two lines. A plain wheel is no longer cancelled (false, so the page scrolls) and a Ctrl + wheel is (true). That Ctrl wheel gesture ends at k = 4, because d3 multiplies the wheel delta by 10 when ctrlKey is set.

  3. 3

    Limit the zoom further

    In the second example change scaleExtent([1, 8]) to [1, 2]. The scaleTo(50) line now prints k = 2, but the transform line still prints k = 50.

Code & diagrams

A brush: selection, dates, null and moving it from code New tab

The page sends the mouse events itself. Drag on the real chart in the editor to see the same events.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

start | selection: [ 200, 200 ] | dates: 8 Apr to 8 Apr | rides in range: 0 | from a person: true
brush | selection: [ 200, 260 ] | dates: 8 Apr to 16 May | rides in range: 108,477 | from a person: true
brush | selection: [ 200, 320 ] | dates: 8 Apr to 23 Jun | rides in range: 244,826 | from a person: true
end   | selection: [ 200, 320 ] | dates: 8 Apr to 23 Jun | rides in range: 244,826 | from a person: true
--- code moves the brush to June
start | selection: [ 285, 332 ] | dates: 1 Jun to 1 Jul | rides in range: 107,712 | from a person: false
brush | selection: [ 285, 332 ] | dates: 1 Jun to 1 Jul | rides in range: 107,712 | from a person: false
end   | selection: [ 285, 332 ] | dates: 1 Jun to 1 Jul | rides in range: 107,712 | from a person: false
--- a click without dragging clears it
start | selection: [ 100, 100 ] | dates: 5 Feb to 5 Feb | rides in range: 0 | from a person: true
end   | selection: null | dates: none | rides in range: 0 | from a person: true
Zoom: rescaled axis, limits, and the wheel taking over New tab

Each gesture ends with a 'zoom ended' line: d3 sends the end event after a wheel gesture has been idle for a moment.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

plain wheel zooms, and the page cannot scroll (default prevented): true
zoom ended: k = 1.15 | x = -49 | visible: 24 Jan to 7 Dec
zoom ended: k = 8 | x = -2334 | visible: 8 Jun to 24 Jul
scaleTo(50)   gave k = 8
zoom ended: k = 50 | x = 0 | visible: 4 Dec to 12 Dec
transform(k=50) gave k = 50
zoom ended: k = 1 | x = 0 | visible: 1 Jan to 31 Dec

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget that the selection can be null

In the first example, in the const total = … line, delete s ? and : 0, so it always reads s[0] and s[1].

terminal
$ Console
── what you'll see ──
--- a click without dragging clears it
start | selection: [ 100, 100 ] | dates: 5 Feb to 5 Feb | rides in range: 0 | from a person: true
Uncaught TypeError: Cannot read properties of null (reading '0')

Myth vs fact

Myth

Zoom works by scaling the SVG.

Fact

Scaling the SVG also scales strokes and text. Rescaling the scales and redrawing keeps lines and labels the same size.

Myth

scaleExtent always stops the zoom.

Fact

It limits user gestures and the helper methods, but zoom.transform sets exactly what you give it.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The default zoom filter is (!event.ctrlKey || event.type === "wheel") && !event.button: it ignores Ctrl-click and the right mouse button but accepts every wheel event. Check node_modules/d3-zoom/src/zoom.js (defaultFilter).

  • ▸

    Wheel zoom ends with a timer: end fires about 150 ms after the last wheel event (the wheelDelay in the source), so a script that sets a new transform right after a wheel can join the same gesture and see sourceEvent of the earlier wheel.

Remember this

  1. 1

    The problem this solves: 365 days on 580 pixels hide the details of one month, and a reader who wants "June only" has no way to say it. Brushing lets them mark June; zooming lets them look at it.

  2. 2

    **d3.brushX() draws a draggable range.** g.call(d3.brushX().extent([[x0, y0], [x1, y1]])) adds an overlay (catches the drag), a selection rectangle and two handles. It sends start, brush and end events. event.selection is [x0, x1] in pixels of the brush's coordinate space, so turn it into dates with x.invert. A click without a drag clears the brush: event.selection is null. Always handle null, usually as "no filter, show everything".

  3. 3

    Which event to use. brush fires on every mouse move: use it only for cheap work such as moving a label. end fires once when the reader lets go: use it for work that is expensive or has side effects (redrawing other charts, changing the URL). event.sourceEvent is the real mouse or touch event, or null when your own code moved the brush with brush.move(g, [a, b]). Move also fires all three events, so check sourceEvent before you react, or two pieces of code can keep moving each other forever.

  4. 4

    Brush and zoom listen to mouse and touch events, not pointer events. Their sources register mousedown.brush, touchstart.brush and mousedown.zoom, touchstart.zoom, so the examples dispatch MouseEvents at them: a synthetic PointerEvent would do nothing. d3.zoom sets no touch-action (the zoom example prints auto for its SVG). d3.brush sets touch-action: none on its group when the device has a touch screen (checked in its source and in Chromium with touch enabled). So on a phone a finger drag over a brushed chart does not scroll the page unless you override it: the build sets pan-y on the brush group. A two-finger pinch is handled by d3.zoom's touch events; a trackpad pinch arrives as a wheel event, see below.

  5. 5

    **A zoom is a transform { k, x, y }.** A point at q on the unzoomed chart is drawn at k * q + x. Do not scale the whole SVG: strokes and text would grow too. Ask the transform for new scales instead: event.transform.rescaleX(x) returns a copy of the scale with a new domain, and you redraw the axis and line with it. The second example prints the visible dates after each step.

  6. 6

    Limits. scaleExtent([1, 8]) stops zooming out below 1 and in beyond 8; translateExtent and extent keep the chart on its data. They apply to the wheel, drag, double-click and touch, and to zoom.scaleTo, scaleBy and translateBy. They do not apply to zoom.transform: asking for k = 50 gives 50. Use scaleTo and translateBy in your own code.

  7. 7

    The wheel steals the page's scroll. By default a plain wheel turn over the chart zooms it and calls preventDefault, so a reader scrolling down a long dashboard gets stuck zooming. Give zoom.filter a rule: wheel only when Ctrl or Command is held (a trackpad pinch arrives as a wheel event with ctrlKey set, so it still works). Show a hint, and add buttons for zoom in, zoom out and reset: they serve keyboard users and phones. For touch, touch-action decides whether the browser or your code gets the drag (Topic 8.1).

Explain it without notes

01

What does event.selection contain for a brush, and when is it null?

02

Why does brush.move need a sourceEvent check in the handler?

03

How do you show the zoomed part of a time axis without scaling the whole SVG?

Practice

01

Write a zoom.filter that ignores the wheel completely and only allows drag and double-click.

02

After a brush end, write the code that returns the rows inside the selection, handling null.

Trade-offs

  • ↔

    A brush selects a range precisely but is hard for a fingertip. Add presets (last 7 days, June, whole year) as buttons for phones.

Done when you can

  • I can read a brush's selection, convert it to dates and handle null.

  • I can say why brush.move needs a sourceEvent check.

  • I can zoom by rescaling an axis, and say which limits apply to which calls.

  • I can stop a chart from stealing the page's scroll, and give touch and keyboard users a way to zoom.