Topic 9.4
Animating Paths and Custom Tweens
In one line
A line can be drawn on with one dash trick. A path whose number of points changes needs a tween that you write, or it wiggles. And a shape such as an arc must be rebuilt from an interpolated angle at every frame, never interpolated as text.
Think of it like this
A flip-book. Each page is a drawing, and flipping fast makes it move. A tween is the person who draws the pages between the first and the last drawing. D3 can draw simple in-between pages by itself (a longer bar, a nearer colour). For a curved shape you must say how to draw each page, or the in-betweens come out as nonsense.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- tween
- The function a transition calls on every frame, with
tfrom 0 to 1, to write one value. - attrTween
- A transition method that takes a factory returning your own tween function for an attribute.
- dasharray
- The pattern of dashes and gaps along a stroke, as a list of lengths.
- dashoffset
- How far the dash pattern is shifted along the stroke. Moving it is what draws a line on.
- wiggle
- The tangled in-between shapes when two paths with a different number of points are interpolated as text.
Step by step
01Measure the line, then hide it
The code asks the path for its length (4,226 units here) and sets a dash and a gap, each that long. With the offset equal to the length the line is invisible, though it is in the DOM.
02Slide the offset to zero
A normal transition on stroke-dashoffset: from 4,226 to 0 over 600 ms. The line draws itself from left to right. At the end the code clears both attributes, so a resize or a later d change does not meet a stale dash.
03Meet the wiggle
The second example interpolates a 6-point line into a 12-point line and draws the halfway shape. It crosses itself. Padding the short line to 12 points, with the last point repeated, gives a clean in-between.
04Rebuild shapes from numbers
The third example animates a gauge arc. Interpolating the two path strings loses the arc at the half-way moment. Interpolating the angle and calling d3.arc() per frame always draws a valid shape.
Try it yourself
- 1
Reveal faster or in two parts
In the first example change
.duration(600)to.duration(200). The log lines are the same: only the time changes. Then try.ease(d3.easeLinear): a linear reveal draws at a constant speed, which suits a line that is read from left to right. - 2
Pad with zeros instead of the last point
In the second example replace
Array(6).fill(monthly[5])byArray(6).fill(0). The last log line now endspadded(0) ends at it: false: the line starts out dropping to zero on the right, which is not where the old line ended, so the in-between shows a dip that is in neither dataset. Pad with the last real value, so that the first frame looks like the old chart.
Code & diagrams
The reveal is a plain transition on one attribute, so it can be waited for with `await ... end()`. Both edge cases have a length of 0, so a reveal function should return at once for them.
Preview
Console
Nothing logged yet.Expected console output
the line is 4226 units long, from 365 points
before the reveal the line is hidden: dashoffset equals length: true
after the reveal, dashoffset: 0
cleaned up: null null
empty data: d = null | length 0
one point: d = M10,104.752Z | length 0The example evaluates the interpolator at t = 0.5, so the pictures are the same on every run. The naive halfway line crosses itself in a small tangle just right of the middle.
Preview
Console
Nothing logged yet.Expected console output
A has 6 points, B has 12
points at t=0 naive: 12 padded: 12
A's last point: 414,11.939 | naive(0) still has it: true | padded(0) ends at it: trueThe left gauge shows the string interpolation frozen at t = 0.5: nothing is drawn, and the browser prints no error. The right one is the finished tween: 315 of 320 bikes docked, 98.4%, a nearly full ring.
Preview
Console
Nothing logged yet.Expected console output
docked: 315 of 320 = 98.4%
start : M0,-70A70,70,0,0,1,6.988,-69.65L4.992,-49.75A50,50,0,0,0,0,-50Z
end : M0,-70A70,70,0,1,1,-6.861,-69.663L-4.901,-49.759A50,50,0,1,0,0,-50Z
naive t=0.5: M0,-70A70,70,0,0.5,1,0.06350000000000033,-69.6565L0.045500000000000096,-49.7545A50,50,0,0.5,0,0,-50Z
naive halfway draws something: false
tween t=0.5: M0,-70A70,70,0,1,1,-0.064,70L-0.046,50A50,50,0,1,0,0,-50Z
tween halfway draws something: true
after the transition the arc is exactly the final shapeBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Interpolate the arc as text
In the third example give the right gauge a starting shape, d3.select(".tween").attr("d", arc({ endAngle: from })), and replace the attrTween line by .attr("d", arc({ endAngle: to })) (a plain transition to the end path). Without a starting shape D3 has nothing to pair the numbers with, and it writes the end path at once.
Break #2
Leave the dash on
In the first example delete the line path.attr("stroke-dasharray", null).attr("stroke-dashoffset", null);.
Myth vs fact
Myth
transition.attr("d", path) always animates a path correctly.
Fact
Only when old and new path have the same commands in the same order. Different point counts wiggle, and arcs lose their flags.
Myth
A tween is only for fancy effects.
Fact
It is the general tool. attr, style and text are shortcuts for tweens with a built-in interpolator.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The factory is called when the transition starts, once per element, and returns the function that runs every frame. Put the expensive work (a copy of the old data, an interpolator built from the current attribute) in the factory, and keep the per-frame function small. It is the same split as
d3.interpolateString(a, b)followed byi(t).
Remember this
- 1
The problem this solves:
transition.attr("d", newPath)works for the paths of Phase 3, and it fails in two quiet ways. When the old and new path have a different number of points the line wiggles through a tangle on the way. And for shapes built from flags and angles, such as an arc, the in-between text is not a valid path, and the shape vanishes mid-way, with no error. - 2
Drawing a line on, the dash trick.
stroke-dasharray="L L"(withLthe line's length, frompath.node().getTotalLength()) makes the stroke one dash as long as the whole line followed by a gap as long as the whole line.stroke-dashoffsetshifts the pattern. At offsetLthe dash is pushed off the start, so only the gap shows and the line is invisible. Moving the offset fromLto 0 slides the dash in, and the line draws from its start to its end. The first example does it with the 365-day line (4,226 units long here) and cleans up (dasharrayback tonull) so the finished line is a plain line. - 3
Edge cases of the dash trick. With no data,
d3.line()returnsnulland the path has nod: the length is 0. With one point it returnsM10,104.752Z, which draws nothing and also has length 0. A reveal of nothing is no reveal: skip it when the length is 0. A line with gaps (defined) is several sub-paths, and the dash pattern restarts at every sub-path. So the pieces draw on at the same time, each from its own start, and each is complete before the offset reaches 0. - 4
**What
attrTweenis.**transition.attrTween(name, factory)takes a factory that D3 calls once per element when the transition starts. The factory returns the tween function:(t) => valuefortfrom 0 to 1 (already eased). D3 writes that value to the attribute on every frame.attr("d", newPath)is a shortcut for anattrTweenthat usesd3.interpolateString. When you need other behaviour, you write the function.styleTweenandtextTweenwork the same way, andtransition.tween(name, factory)writes anything (the examples above used it to spy). - 5
The wiggle bug.
interpolateStringpairs the numbers of the old path with the numbers of the new path in order. When the old path has 6 points and the new one has 12, the extra numbers have no partner: they are written as they are in the new path, and the numbers that do pair are mixed with the wrong points. The second example draws the half-year line and the whole-year line, and the halfway shape between them: it crosses itself. A line chart that gets new data of a different length does this on every update. - 6
Two fixes. Make both paths the same number of points: repeat the last point of the shorter line until the counts agree, so that the extra points start piled up at the end and then move out. Or, for a time series, do not animate the shape: keep the same path and animate the scale's domain, or fade one chart out and the other in. Libraries such as
flubberord3-interpolate-pathexist; the second example shows the idea they are built on. - 7
Arcs need their own tween. An arc path contains flags (
0,0,1) that are 0 or 1. Between two arcs of different size,interpolateStringmoves the large-arc flag from 0 to 1 through 0.5, and0.5is not a valid flag, so the browser discards the whole path: the arc is invisible for part of the animation (the third example measures it). The fix is to interpolate the angle withd3.interpolateNumberand askd3.arc()for a new path at every frame. The same rule holds for pie slices, sunbursts and every shape made by a generator: interpolate the inputs, not the output.
Explain it without notes
Explain how stroke-dasharray and stroke-dashoffset draw a line on.
Why does a transition between two lines with a different number of points look wrong?
Why does an arc disappear in the middle of transition.attr("d", ...), and what is the fix?
Practice
Write reveal(path, ms) that draws a path on, returns a promise, does nothing when the length is 0, and clears the dash even when the transition is interrupted.
Write padTo(values, n) that repeats the last value until the array has n items, and use it so that a 6-month line can animate into a 12-month line.
Trade-offs
- ↔
Drawing a line on shows the direction of time, and it delays the moment the reader can see the whole line. Do it once on load, never on every update.
Done when you can
I can draw a line on with a dash and an offset, and clean up afterwards.
I can say what the length is for empty data and for one point.
I can write an
attrTweenfactory.I can explain the wiggle bug and one fix.
I can animate an arc from an angle instead of from a path string.