Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~30 min· topic 4 of 6

Topic 17.4

Step-Driven Charts

In one line

One chart, many states: each step of the story is a named state, and the chart is a function of the active step. That is why scrolling back up, jumping with a link, resizing and opening the page half-way down all work without special cases.

Think of it like this

A TV weather map. The presenter says "yesterday", "today", "tomorrow", and the same map changes to each day. Nobody builds a new map for each word. There is one map and a list of days, and the map can show any day when asked, in any order, even backwards.

Words you'll meet

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

state
The complete description of one picture: measure, bands, marks. Not a change from the previous picture.
step
One block of story text that names a state.
interrupt
A newer transition on the same element stops the older one, which then rejects end().
morph
Changing a path into another by interpolating its numbers. Needs the same number of points in both.
deep link
An address that opens the page at one step, such as story.html#step-3.
scroll-behavior
CSS: auto jumps at once, smooth animates the scroll and passes the steps in between.

Step by step

01Step 1: the whole year

show("year"): the line of 365 days, the axis, nothing else. This is the setting-up move of Topic 17.2. The chart is one SVG with a line, two axes and a layer for marks.

02Step 2: turn on the seasons

One change: show("seasons") turns on five background bands (winter appears twice: January and February, and December). The line is the same line. It is highest inside the yellow summer band.

03Step 3: mark the busiest day

One change: a dot and a label on 18 July, 4,380 rides, found with d3.greatest(daily, d => d.rides). This label has room on the right. Step 5's "Last week" label sits near the right edge and flips to the left, the placement rule of Topic 17.2.

04Step 4: the outage

The bands go away so the eye is not busy, and the dot moves to 14 August, 439 rides. This is the reveal. The label says "app down", the caveat about rain goes in the step's own text.

05Step 5: e-bikes

The same line now shows the 7-day share of rides that were e-bikes, on an axis from 0 to 50 %, with two labels: 28 % in the first week, 38 % in the last. Shown without a transition here; on the real page the line morphs over half a second.

06Step 6: scroll back up

The reader scrolls back to the seasons. show("seasons") is called again and draws the same picture as step 2, whatever came before. That is the whole point of a state: the route does not matter.

Try it yourself

  1. 1

    Change one state

    In the first example's library, find outage: { metric: "rides", bands: false, notes: ["outage"] } and change bands: false to bands: true. Every route to outage now shows the season bands too, because the state is the only place that decides it. You changed one line and no step changed.

  2. 2

    Take the interruption away

    In the first example, change const first = show("outage", 400); to const first = await show("outage", 400);. Now the first change finishes before the second starts and the log reads interrupted change finished: true | the newer one finished: true. A reader who scrolls slowly gets that; one who scrolls fast gets the interruption.

Code & diagrams

Forward, back, and interrupted: the picture is the state New tab

The second chart is only there to compare against. It is drawn straight in the `seasons` state with no transition.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

year -> seasons, finished: true
seasons -> ebikes, finished: true
interrupted change finished: false | the newer one finished: true
same line as a fresh chart: true | same bands: true
Jumping to a step with a link New tab

Each reported step is one chart change. An instant jump skips the steps in between; a smooth scroll passes through them. The wait loop polls for the observer's report, it does not measure time.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

page opened, first report: 0
instant jump to step 4  -> steps reported: 4
instant jump to step 1  -> steps reported: 1
smooth jump to step 3  -> steps reported: 2, 3
Several steps in the band: pick the nearest to the middle New tab

A wrong answer that prints no error: "the last step reported" is wrong at 0, 100 and is only right by luck at 160 and 330. At 280 there is no earlier scroll, so the first report is the answer.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

scrollTop   0 | in the band: [1,2] | last reported: 2 | closest to middle: 1 | really in the middle: 1
scrollTop 160 | in the band: [3,4] | last reported: 4 | closest to middle: 4 | really in the middle: 4
scrollTop 100 | in the band: [2,3] | last reported: 2 | closest to middle: 3 | really in the middle: 3
scrollTop 330 | in the band: [5,6] | last reported: 6 | closest to middle: 6 | really in the middle: 6
opened at scrollTop 280, first report: 5

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 a transition can be interrupted

In the first example's library, change tr.end().then(() => true, () => false) to tr.end().then(() => true).

terminal
$ Console
── what you'll see ──
year -> seasons, finished: true
seasons -> ebikes, finished: true
[error] Uncaught (in promise) undefined

Myth vs fact

Myth

Each step should add something to the previous picture.

Fact

Then going back, jumping or reloading half-way shows the wrong picture. Each step names a full state; transitions do the adding.

Myth

The last step the observer reported is the active one.

Fact

With a thick band or a quick scroll, callbacks can name several steps. At scrollTop 0 in the third example the last one reported is 2, and the step in the middle is 1. Choose by distance to the middle.

Myth

Smooth scrolling and instant jumps need different code.

Fact

They need the same code. The chart is a function of the active step, so the instant jump shows one change and the smooth scroll shows the changes on the way.

Pro corner

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

  • ▸

    Animating between states that have a different number of marks is where morphs fail. The line works because rides and e-bike share both have 365 points. For bars that change count, key the data join (Topic 9.3) so enter and exit are real, and do not interpolate paths with different point counts: D3 will give you a broken in-between shape.

  • ▸

    scroll-behavior: smooth and scrollIntoView({ behavior: "smooth" }) are cancelled by the next scroll input. Users on trackpads fling past many steps. Debounce nothing: the state function is cheap, and a transition that starts at 0 ms because a newer one replaced it is exactly what you want.

  • ▸

    For a shareable address, set location.hash or call history.replaceState when the active step changes (not pushState, which fills the back button with every step), and read the hash on load to set the first scroll position before observing. Opening story.html#step-3 then reports step 3 on the first callback, as the last line of the third example shows for a scroll offset.

  • ▸

    In the live editor the page runs inside a sandboxed frame, so the examples scroll their own container. On a real page the scroller is the window: use root: null and window.scrollTo, and the same band works.

Remember this

  1. 1

    The problem this solves: a first version of a scrolly chart often says "when step 3 arrives, add the outage mark", which is a change from step 2's picture. It works when the reader goes down one step at a time. It breaks when they scroll back up, jump with a link, resize, or open the page half-way down: the chart is now showing the wrong picture and nothing says so.

  2. 2

    A step is a state, not an action. Give each step a name and describe the whole picture it should show: which measure, whether the season bands are on, which marks are visible. The code is one function, show(name), that draws that state from nothing. The state list in the first stage step below has five entries: year, seasons, peak, outage, ebikes. Going to seasons from anywhere gives the same picture.

  3. 3

    Build the chart one change per step. The studio view beside this text uses the technique: step 1 draws the year, step 2 turns on the season bands, step 3 marks the busiest day, step 4 shows only the outage, step 5 morphs the same line to the e-bike share, and step 6 jumps back to the seasons. Each stage is complete code that runs on its own, which is the Red Blob way: one change per step.

  4. 4

    Transitions fill the gaps between states. show(name, 500) moves the line, the axis, the bands and the marks over 500 ms. When the measure changes (rides to e-bike share), the same line morphs: D3 interpolates the two d strings, which works because both have the same number of points (365). A newer show interrupts the transition that is still running, and the element carries on from where it was, so a fast scroll never snaps. The first example prints it: the interrupted change reports false, the newer one true.

  5. 5

    Going back must give the same picture. The first example goes year, seasons, ebikes, then back to seasons, and compares the line's d string and the bands' opacities with a second chart that was set to seasons directly: identical. That is the test that a state is really a function of the step and does not depend on the route.

  6. 6

    Jumps skip steps, smooth scrolling does not. A link to a step does an instant jump: the observer reports only the target (4, not 1, 2, 3). With scroll-behavior: smooth the page scrolls through the steps in between and the observer reports 2, then 3, so the chart plays two transitions. Both are right. Decide which you want, and make sure each transition can be interrupted. Let each step have an id and a link (#step-3) so a reader can share it.

  7. 7

    Pick one active step when several are in the band. A thick band can hold two short steps. The callback's last entry is not the reader's step: at scrollTop 0 the last reported step is 2 while the step in the middle is 1. The third example keeps the set of in-band steps and chooses the one whose middle is nearest the middle of the scroller. It also shows that a page opened half-way down reports the right step on its first callback, so a reloaded tab or a shared link starts correct.

Explain it without notes

01

Why define each step as a full state instead of a change from the previous step?

02

What happens to a running transition when a newer one starts on the same element, and what must your code do about end()?

03

What does the observer report when a link jumps from step 0 to step 4, and what with smooth scrolling?

04

Why is the last reported step not always the active one, and how do you choose?

Practice

01

Add a state weekend to the library that shows the season bands and marks nothing, and call show("weekend", 0) in a scratch copy of the first example. Say what you had to change besides the STATES entry.

02

Write closestStep(centres, mid) that returns the index of the centre nearest mid and the lowest index on a tie. Test it with an empty list, one centre and a tie.

Trade-offs

  • ↔

    Drawing a whole state each time is simple and safe, and costs a little more work than patching one mark. For a chart of 365 points this is nothing; for tens of thousands of marks, draw once and change attributes.

  • ↔

    A thick band is forgiving of short steps and needs a nearest-to-middle rule. A thin band reports one step at a time and can flicker on a very fast scroll.

Done when you can

  • I can describe each step as a complete named state.

  • I can show that going back gives the same picture as going straight there.

  • I can handle an interrupted transition without an uncaught promise.

  • I can say what the observer reports for an instant jump and for a smooth scroll.

  • I can choose the active step when several are in the band, and start a page half-way down.