Topic 9.3
Animating Updates with Object Constancy
In one line
When the data changes, each bar should stay the same element and move to its new place, so the eye can follow it. A key function ties data to elements, join hands out enter, update and exit, and a newer transition always replaces an older one on the same element.
Think of it like this
The name badges at a conference. When people move to a new table, each keeps their own badge, so you can say "Ana moved from table 2 to table 5". If instead the badges stayed on the tables and the names were rewritten, you could never say who moved. Object constancy means the thing on screen that stood for Lantern is still the thing that stands for Lantern after the change.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- object constancy
- A mark that stands for one thing stays the same element when the data changes, so the eye can follow it.
- key function
- The function passed to
.data(rows, key)that says what identifies a row (for stations, its id). - enter / update / exit
- The new rows without an element, the rows that already have one, and the elements whose row is gone.
- interrupt
- Stopping a running transition because a newer one on the same element started, or because you called
selection.interrupt(). - cancel
- The event for a transition that was stopped before it had started (it was still in its delay).
Step by step
01Draw January's stations
The picture on the right is the 21 stations that had rides in January, drawn with a keyed join: one row per station, a bar and a name. The four stations that opened in May are not there yet.
02Switch to July without motion
One change: call update("Jul", { move: false }). The rows are the same elements, so they take their new places and lengths at once: July has 25 rows, and Lantern is first. This is the jump Tomás complained about: the right picture, but the eye lost the stations.
03Interrupt it
The third example fires five updates at once. Each interrupts the one before, and the last wins.
04Move them instead
One change: { move: true }. The same rows, the same final picture, but each row slides to its rank over 500 ms, the bars grow or shrink, and the four new stations fade in at the bottom. The code is the same join, with .transition() on the three groups.
Try it yourself
- 1
Key by position in the second example
In the second example replace
(d) => d.stationin the join by(d, i) => i. The counts in the log do not change ({"enter":4,"update":21,"exit":0}), but the third line now saysLantern kept its element: false | moved from translate(90,56) to translate(90,56): the element that showed Lantern in January is still fifth and now shows another station, and Lantern is on the element that was already first. The counts look right and the animation is wrong, because every bar changes length and name in place. - 2
Give two stations the same key
In the second example's
forMonth, insert.map((d) => (d.station === "S02" ? { ...d, station: "S01" } : d))right before.sort. Two rows now share the keyS01. The log readsJuly: {"enter":5,"update":20,"exit":1}and thenJanuary: {"enter":1,"update":20,"exit":5}: the join matches only one row per key, so the other row enters again and its old element exits, on every update. D3 gives no warning (Topic 5.4). Keys must be unique. - 3
Add a sixth request
In the third example add a sixth width,
[100, 150, 200, 250, 300, 40], to the first line of case A. Case A now ends at width 40 with five interrupts: the last request always wins.
Code & diagrams
Read the 'July, keyed' line from left to right: element 5 (it was fifth in January) is now first and holds Lantern. By index, element 1 starts showing Lantern.
Preview
Console
Nothing logged yet.Expected console output
January, keyed: element 1 = Quay | element 2 = Chapel | element 3 = Clock Tower | element 4 = Library | element 5 = Lantern
July, keyed: element 5 = Lantern | element 1 = Quay | element 3 = Clock Tower | element 2 = Chapel | element 4 = Library
January, by index: element 1 = Quay | element 2 = Chapel | element 3 = Clock Tower | element 4 = Library | element 5 = Lantern
July, by index: element 1 = Lantern | element 2 = Quay | element 3 = Clock Tower | element 4 = Chapel | element 5 = LibraryEvery update starts with `svg.transition()`, one clock shared by the exit, the move and the bars (a transition can be passed to another selection's `.transition(t)`). The exit rows are removed only after they have faded.
Preview
Console
Nothing logged yet.Expected console output
January: {"enter":21,"update":0,"exit":0} -> 21 rows
July: {"enter":4,"update":21,"exit":0} -> 25 rows
Lantern kept its element: true | moved from translate(90,56) to translate(90, 0)
January: {"enter":0,"update":21,"exit":4} -> 21 rows (the four new stations left the page)Every line is logged from an event or after an awaited promise, never after a fixed time. Case A is the one a fast-clicking person makes: four interrupts and one end.
Preview
Console
Nothing logged yet.Expected console output
A: promises: rejected, rejected, rejected, rejected, resolved
A events: A1 start, A1 interrupt, A2 start, A2 interrupt, A3 start, A3 interrupt, A4 start, A4 interrupt, A5 start, A5 end
A: final width 300
B events: waiting cancel, newer start, newer end
C events: first start, first interrupt, second start, second end
C: the bar was between 10 and 300 when interrupted: true | final width 20Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Leave the promise unhandled
Run const t = bar.transition().duration(100).attr("width", 99); t.end(); bar.interrupt(); in a module.
Break #2
Forget .remove() on the exit
In the second example change exit.transition(t).attr("opacity", 0).remove() to exit.transition(t).attr("opacity", 0).
Myth vs fact
Myth
The key function only matters for performance.
Fact
It decides which element is which. Without it, the bars animate from one station's old value to another station's new value, and the picture lies about what moved.
Myth
Reordering the DOM nodes is how you animate a sort.
Fact
Positions are attributes. Transition the transform, and reorder the nodes (with selection.order()) only for reading order.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Transitions on the same element are identified by name. The default name is
null.bar.transition("slide")andbar.transition("fade")run at the same time and never interrupt each other. Use names for independent properties and the default name for everything that should be replaced together. - ▸
transition.remove()only removes the element when it has no other scheduled transition: itsend.removelistener checks every other schedule on the element (removeFunctionin d3-transition'sremove.js). If a newer transition is already waiting on it, the element survives, which is exactly what you want when the user goes back to January while the exit is running. - ▸
While exit rows are still fading, a newer
updatecan bring their keys back: the join then matches them as an update group. They fade back in, and their old exit transition is interrupted, so.remove()never runs. The build topic relies on this.
Remember this
- 1
The problem this solves: when the month changes from January to July, Lantern goes from fifth to first. With no constancy the first bar's text and length are rewritten and nobody sees a station move. With constancy, one bar slides up four places, and Tomás sees which one it was.
- 2
The key function is what ties them.
selection.data(rows, (d) => d.id)matches each row to the element that already holds a row with the same key (the data join of Phase 5; Recall from Topic 5.4: the key is what makes the join follow the thing, not the position). Without a key, D3 matches by position: the first row to the first element. In the first example, switching from January to July by position leaves element 1 showing Lantern, though it showed Quay a moment ago. It looks like Quay's bar turning into Lantern's. With the station id as the key, the element that was fifth is the one that is now first. - 3
Three groups, three jobs.
join(enter, update, exit)gives you three selections. Enter rows are new: create them in a starting state (opacity 0, a width of 0). Update rows exist: transition them to their new place and size. Exit rows are gone: transition them out (fade) and then.remove()them.transition.remove()removes the element at the end, and only if no other transition is still using it. The second example logs the counts: January has 21 stations, July adds the four that opened in May (4 enter, 21 update, 0 exit), and going back to January makes those four exit. - 4
Sorting is a movement. When the order changes, set each row's
transformfrom its new index, and put that in the transition, so every row slides to its new rank. Do not reorder the DOM elements to animate: the position is an attribute. For a screen reader, the DOM order should match the visual order once the move is done (Phase 15 returns to reading order): callselection.order()at the end of the transition. - 5
A newer transition replaces an older one on the same element and with the same name. When a second transition starts on an element, the first is stopped: if it was running it gets an
interruptevent, and if it was still waiting to start (it had a delay) it gets acancelevent. The element keeps whatever value it had at that moment, and the new transition starts from there, so there is no jump. That is the answer to a person who clicks the month picker five times fast: the fifth wins, and the bars never snap. The third example prints the events. - 6
**Interruption rejects
end().** The promise fromtransition.end()resolves only if every element ended. If any element is interrupted or cancelled, it rejects, and the rejection value is the datum of the element that stopped (undefinedfor an element with no data, such as a lone bar). An unhandled rejection printsUncaught (in promise) undefined(or the datum) in the console. Giveend()a second handler in code that can be interrupted.selection.interrupt()stops the running transition on purpose, and the element stays at its half-way value (theinterruptevent fires).
Explain it without notes
What does object constancy mean, and what gives it in D3?
What happens to a running transition when a newer one starts on the same element?
Why does transition.end() reject, and what should you do?
Practice
Write slideTo(g, rank) that transitions a row g to translate(90, rank * 14) over 300 ms and returns a promise that is true if it finished and false if it was interrupted.
Rows with rides of null should not enter the chart. Where in the second example would you filter them out, and why there?
Trade-offs
- ↔
Keyed transitions need a stable id in the data. If rows have none, compute one (name plus month) or accept the by-position animation and say so.
Done when you can
I can say why a key function is needed for an animated update.
I can write the enter, update and exit parts of an animated join.
I can say what the interrupt and cancel events mean, and when each fires.
I can handle a rejected
transition.end().I know that an interrupted element keeps its half-way value.