Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~25 min· topic 5 of 6

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.

Choose the layout by widthdiagram
Rendering diagram…

Try it yourself

  1. 1

    Remove the wait

    In the second example call onResize(box, () => { steady++; }, { delay: 0 }). The dragging line now reads plain 11 | onResize 11: with no wait, every report redraws, and the saving is gone.

  2. 2

    Change a breakpoint

    In the third example change 480 to 300 in sizeClass. The 320 chart now draws the medium layout, and the edge table shows 320 medium, 479 medium. In the project you change this number once, in resize.js.

  3. 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

Scaled or redrawn: the same bars in a 600 and a 300 pixel box New tab

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 labels
A resize storm: a plain observer and onResize New tab

The 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 2
One chart, three widths, and the size class decisions New tab

In 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

Press Run to see the page.

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 4

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

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);

terminal
$ Console
── what you'll see ──
Uncaught ResizeObserver loop completed with undelivered notifications.
(repeated while the box keeps growing: 73 to 123 times in the 2.5 seconds the example collects, depending on the computer)

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.

  • ▸

    ResizeObserver fires 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 on window.load misses 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. onResize skips 0, so the chart draws when the panel opens.

Remember this

  1. 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. 2

    Scaling with the viewBox. An SVG with viewBox="0 0 600 140" and width: 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. 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, not 5,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. 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 in entries[0].contentRect. It is better than listening to window.resize because the box can change when nothing else does (a sidebar opens, a grid reflows). It also reports once when you start observing.

  5. 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 (from src/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. 6

    Breakpoints in one place. Decide size classes (small below 480, medium below 900, large above) 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. 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

01

When is scaling with the viewBox enough, and when should a chart redraw?

02

Why use a ResizeObserver instead of window.resize?

03

How does onResize avoid a redraw on every frame of a drag?

Practice

01

Write onWidthClass(element, callback) that calls callback(cls) only when the size class changes (small, medium, large), using ResizeObserver and the sizeClass function.

02

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.