Topic 10.5
Responsive Charts
In one line
A chart can scale (one drawing, stretched by its viewBox) or redraw (a new drawing at the real width). Scaling is free but shrinks the text. Redrawing keeps text readable and lets you choose fewer ticks and shorter labels, at the cost of watching the box with a ResizeObserver, and of not redrawing on every pixel.
Think of it like this
A poster and a business card. You can shrink the poster to card size with a photocopier, and the small print becomes unreadable. Or you can design a separate card: fewer words, bigger type for the few that remain. Scaling is the photocopier. Redrawing is designing the card.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- ResizeObserver
- A browser object that calls your function when an element's size changes.
- contentRect
- The content size of the observed element (without padding and borders), in CSS pixels.
- redraw
- Building the chart's shapes again with new sizes, instead of stretching the old ones.
- debounce
- Waiting until changes stop for a short time before acting, so a burst causes one action.
- breakpoint
- A width at which the layout changes, such as 480 pixels.
Step by step
01Compare scaling with redrawing
The first example draws the same twelve bars twice, in boxes 600 and 300 pixels wide that the code sets itself. Scaled: the label is 12 pixels at 600 and 6 at 300. Redrawn: 12 pixels in both, with 4 labels at 300.
02Watch a resize storm
The second example changes a box's width ten times, one change per frame. It prints how many times a plain ResizeObserver and onResize would redraw, then after the changes stop, then for a height-only change and after stop().
03Know when to stop watching
A chart that is removed must call the function that onResize returns. The second example calls stop() and shows that later changes cause no redraw.
04Choose the layout by width
The third example draws the daily-rides line at three widths with one function, and prints the size class, the month-label step and the number of y ticks it asks for, for eight widths. In the picture, the 320 wide chart has four month labels (Jan, Apr, Jul, Oct), and the 1000 wide chart has twelve.
Try it yourself
- 1
Remove the wait
In the second example call
onResize(box, () => { steady++; }, { delay: 0 }). The dragging line now readsplain 11 | onResize 11: with no wait, every report redraws, and the saving is gone. - 2
Change a breakpoint
In the third example change
480to300insizeClass. The 320 chart now draws the medium layout, and the edge table shows320 medium,479 medium. In the project you change this number once, inresize.js. - 3
Hide the box
In the second example, before
stop();, hide the box and show it again:box.style.display = "none"; await frame(); await quiet(); console.log("hidden", steady); box.style.display = "block"; await frame(); await quiet(); console.log("shown", steady);. Both print 2: a hidden box reports width 0 and is skipped, and showing it again at the same width needs no redraw.
Code & diagrams
In the picture, the first two rows (600 pixels wide) look the same: twelve bars with twelve month names. The third row is the scaled copy at 300 pixels: the same twelve bars at half size, with month names too small to read. The fourth is the redrawn copy at 300: the same bars with Jan, Apr, Jul and Oct in full-size text.
Console
(nothing was printed)Expected console output
box 600 px | scaled: label 12.0 px, 12 labels | redrawn: label 12.0 px, 12 labels
box 300 px | scaled: label 6.0 px, 12 labels | redrawn: label 12.0 px, 4 labelsThe same measuring code as src/utils/resize.js, pasted in. A plain observer redraws once per change, including for a height-only change that needs no redraw. onResize draws once at the start and once after the drag stops, ignores the height-only change, and does nothing after stop().
Console
(nothing was printed)Expected console output
first report: plain 1 | onResize 1
after 10 changes, while dragging: plain 11 | onResize 1
after the changes stop: plain 11 | onResize 2
after a height-only change: plain 12 | onResize 2
after stop() and one more change: plain 13 | onResize 2In the picture: three charts of the year's rides, 320, 600 and 1000 pixels wide, drawn one above the other with the same data. The widest has a label for every month, the others fewer. A width of 0 (a hidden box) and NaN (unknown) get the small layout. At 479 pixels the layout is small, at 480 medium: the boundary is exact, and the rule `width >= 480` is why NaN falls to small.
Preview
Console
Nothing logged yet.Expected console output
drawn at 320 -> small layout, 9 ticks in all
drawn at 600 -> medium layout, 17 ticks in all
drawn at 1000 -> large layout, 17 ticks in all
--- the decisions at the edges of the classes, and for odd widths
0 small month label every 3 month(s), y ticks 4
320 small month label every 3 month(s), y ticks 4
479 small month label every 3 month(s), y ticks 4
480 medium month label every 1 month(s), y ticks 5
899 medium month label every 1 month(s), y ticks 5
900 large month label every 1 month(s), y ticks 5
1920 large month label every 1 month(s), y ticks 5
NaN small month label every 3 month(s), y ticks 4Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Change the observed box inside the callback
In the second example add this line at the end: new ResizeObserver(() => { box.style.width = box.offsetWidth + 1 + "px"; }).observe(box);
Myth vs fact
Myth
A responsive chart just needs width: 100%.
Fact
That scales the drawing. Text, strokes and ticks scale too, so a chart that was readable at 600 pixels is unreadable at 300. Fixing that needs a redraw with fewer ticks and labels.
Myth
Listening to window.resize is enough.
Fact
A box can change width when the window does not (a sidebar opens, a tab panel shows, a grid reflows). ResizeObserver watches the box itself.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
ResizeObserverfires once when you start observing, and the callback gets entries after layout but before paint, so a redraw inside it paints in the same frame. A chart drawn only onwindow.loadmisses sizes that arrive later. - ▸
A hidden element (
display: none, a closed tab panel) reports width 0, and a chart drawn at 0 has negative or NaN sizes.onResizeskips 0, so the chart draws when the panel opens.
Remember this
- 1
The problem this solves: on the wall screen the dashboard's text was too small, and on a phone the twelve month labels ran into each other. One drawing cannot be right at 320 and at 3,000 pixels wide. Topic 1.4 made a chart scale with its
viewBox. This topic decides when scaling is enough, and when to draw again. - 2
Scaling with the viewBox. An SVG with
viewBox="0 0 600 140"andwidth: 100%always draws the same 600 units, stretched to its box. It costs nothing and needs no JavaScript. But text and strokes scale with it: in a 300-pixel box a 12-unit label is 6 pixels (first example). It suits pictures such as an icon, a map or a sparkline, where nothing needs to stay a fixed pixel size. - 3
Redrawing at the real width. If the chart's viewBox is made the real pixel width (
[0, 0, width, height]), one unit is one pixel, so a 12-unit label stays 12 pixels. The code can then decide: at a small width draw only every third month label (Jan, Apr, Jul, Oct), ask d3 for about 4 gridlines instead of about 5 (the count is a hint: for this data both give the same 0 to 5k scale, so the gain is in the labels), use shorter number labels (5k, not5,000) and shorten the annotation. The first example shows labels falling from 12 to 4 at 300 pixels while keeping a 12-pixel size. - 4
**Know the width with a
ResizeObserver.**new ResizeObserver(callback).observe(element)calls your function when the element's size changes, with the new size inentries[0].contentRect. It is better than listening towindow.resizebecause the box can change when nothing else does (a sidebar opens, a grid reflows). It also reports once when you start observing. - 5
Resize storms. Dragging a window edge changes the width on every frame, and redrawing a 365-point chart ten times a second wastes work and flickers. The second example counts: a plain observer redraws 11 times for 10 width changes, while
onResize(fromsrc/utils/resize.js) draws once at the start and once after the changes stop. It draws the first time without a delay, on the next frame (so there is no blank box), then waits 150 ms of quiet before drawing again (debouncing). - 6
Breakpoints in one place. Decide size classes (
smallbelow 480,mediumbelow 900,largeabove) in one function and let every chart use it, so the dashboard changes layout together. The third example prints the decisions for eight widths, including the edge cases: a hidden box has width 0 (draw nothing, wait), and an unknown width (NaN) falls back to the smallest layout. - 7
What can go wrong. A callback that changes the size of the element it observes makes a loop, and the browser stops it with
ResizeObserver loop completed with undelivered notifications(the Break it below). Draw into a child that does not change the observed box. Also remember to call the stop function when a chart is removed, or the observer keeps the chart's elements alive.
Explain it without notes
When is scaling with the viewBox enough, and when should a chart redraw?
Why use a ResizeObserver instead of window.resize?
How does onResize avoid a redraw on every frame of a drag?
Practice
Write onWidthClass(element, callback) that calls callback(cls) only when the size class changes (small, medium, large), using ResizeObserver and the sizeClass function.
The stations bar chart has fixed 20 pixel rows. At 300 pixels wide, which part of it breaks first, and what would you change in drawStationsBar?
Trade-offs
- ↔
Redrawing gives a better chart at every width but needs more code and testing at each size class. Scaling is free and adequate for decorative drawings.
Done when you can
I can say what happens to a 12-unit label when a viewBox is scaled to half size.
I can use a ResizeObserver, with a first draw and a debounce.
I can choose ticks and labels from one size-class function.
I can avoid and recognise the ResizeObserver loop error.