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).
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.
Try it yourself
- 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 withfrom a person: false, and the dates now begin on 9 Apr. - 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));beforesvg.call(zoom);, and change the firstconsole.logto printwheel(-100, 330, {})andwheel(-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 atk = 4, because d3 multiplies the wheel delta by 10 whenctrlKeyis set. - 3
Limit the zoom further
In the second example change
scaleExtent([1, 8])to[1, 2]. ThescaleTo(50)line now printsk = 2, but thetransformline still printsk = 50.
Code & diagrams
The page sends the mouse events itself. Drag on the real chart in the editor to see the same events.
Preview
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: trueEach gesture ends with a 'zoom ended' line: d3 sends the end event after a wheel gesture has been idle for a moment.
Preview
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 DecBreak 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].
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. Checknode_modules/d3-zoom/src/zoom.js(defaultFilter). - ▸
Wheel zoom ends with a timer:
endfires about 150 ms after the last wheel event (thewheelDelayin the source), so a script that sets a new transform right after a wheel can join the same gesture and seesourceEventof the earlier wheel.
Remember this
- 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
**
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 sendsstart,brushandendevents.event.selectionis[x0, x1]in pixels of the brush's coordinate space, so turn it into dates withx.invert. A click without a drag clears the brush:event.selectionisnull. Always handlenull, usually as "no filter, show everything". - 3
Which event to use.
brushfires on every mouse move: use it only for cheap work such as moving a label.endfires once when the reader lets go: use it for work that is expensive or has side effects (redrawing other charts, changing the URL).event.sourceEventis the real mouse or touch event, ornullwhen your own code moved the brush withbrush.move(g, [a, b]). Move also fires all three events, so checksourceEventbefore you react, or two pieces of code can keep moving each other forever. - 4
Brush and zoom listen to mouse and touch events, not pointer events. Their sources register
mousedown.brush,touchstart.brushandmousedown.zoom,touchstart.zoom, so the examples dispatchMouseEvents at them: a syntheticPointerEventwould do nothing.d3.zoomsets notouch-action(the zoom example printsautofor its SVG).d3.brushsetstouch-action: noneon 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 setspan-yon the brush group. A two-finger pinch is handled byd3.zoom's touch events; a trackpad pinch arrives as a wheel event, see below. - 5
**A zoom is a transform
{ k, x, y }.** A point atqon the unzoomed chart is drawn atk * 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
Limits.
scaleExtent([1, 8])stops zooming out below 1 and in beyond 8;translateExtentandextentkeep the chart on its data. They apply to the wheel, drag, double-click and touch, and tozoom.scaleTo,scaleByandtranslateBy. They do not apply tozoom.transform: asking fork = 50gives 50. UsescaleToandtranslateByin your own code. - 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. Givezoom.filtera rule: wheel only when Ctrl or Command is held (a trackpad pinch arrives as a wheel event withctrlKeyset, 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-actiondecides whether the browser or your code gets the drag (Topic 8.1).
Explain it without notes
What does event.selection contain for a brush, and when is it null?
Why does brush.move need a sourceEvent check in the handler?
How do you show the zoomed part of a time axis without scaling the whole SVG?
Practice
Write a zoom.filter that ignores the wheel completely and only allows drag and double-click.
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.