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:
autojumps at once,smoothanimates 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
Change one state
In the first example's library, find
outage: { metric: "rides", bands: false, notes: ["outage"] }and changebands: falsetobands: true. Every route tooutagenow shows the season bands too, because the state is the only place that decides it. You changed one line and no step changed. - 2
Take the interruption away
In the first example, change
const first = show("outage", 400);toconst first = await show("outage", 400);. Now the first change finishes before the second starts and the log readsinterrupted change finished: true | the newer one finished: true. A reader who scrolls slowly gets that; one who scrolls fast gets the interruption.
Code & diagrams
The second chart is only there to compare against. It is drawn straight in the `seasons` state with no transition.
Preview
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: trueEach 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
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, 3A 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
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: 5Break 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).
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: smoothandscrollIntoView({ 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.hashor callhistory.replaceStatewhen the active step changes (notpushState, which fills the back button with every step), and read the hash on load to set the first scroll position before observing. Openingstory.html#step-3then 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: nullandwindow.scrollTo, and the same band works.
Remember this
- 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
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 toseasonsfrom anywhere gives the same picture. - 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
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 twodstrings, which works because both have the same number of points (365). A newershowinterrupts 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 reportsfalse, the newer onetrue. - 5
Going back must give the same picture. The first example goes
year,seasons,ebikes, then back toseasons, and compares the line'sdstring and the bands' opacities with a second chart that was set toseasonsdirectly: identical. That is the test that a state is really a function of the step and does not depend on the route. - 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: smooththe 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 anidand a link (#step-3) so a reader can share it. - 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
scrollTop0 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
Why define each step as a full state instead of a change from the previous step?
What happens to a running transition when a newer one starts on the same element, and what must your code do about end()?
What does the observer report when a link jumps from step 0 to step 4, and what with smooth scrolling?
Why is the last reported step not always the active one, and how do you choose?
Practice
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.
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.