Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~24 min· topic 4 of 6

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 t from 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. 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. 2

    Pad with zeros instead of the last point

    In the second example replace Array(6).fill(monthly[5]) by Array(6).fill(0). The last log line now ends padded(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

Draw a line on, and the edge cases of its length New tab

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

Press Run to see the page.

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 0
The wiggle: six points into twelve New tab

The 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

Press Run to see the page.

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: true
An arc that vanishes half-way, and the tween that fixes it New tab

The 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

Press Run to see the page.

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 shape

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

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.

terminal
$ In the preview
── what you'll see ──
The gauge starts as a short arc, then is missing for the whole movement and reappears on the last frame. Counting the frames (a tween that measures the path's bounding box): frames: 26 | frames with something drawn: 1 | first frame: false | last: true. The Console prints no error.

Break #2

Leave the dash on

In the first example delete the line path.attr("stroke-dasharray", null).attr("stroke-dashoffset", null);.

terminal
$ In the preview
── what you'll see ──
After the reveal the line looks the same. Later, when the data changes and the path gets longer, the end of the new line is cut off at the old length.

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 by i(t).

Remember this

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

    Drawing a line on, the dash trick. stroke-dasharray="L L" (with L the line's length, from path.node().getTotalLength()) makes the stroke one dash as long as the whole line followed by a gap as long as the whole line. stroke-dashoffset shifts the pattern. At offset L the dash is pushed off the start, so only the gap shows and the line is invisible. Moving the offset from L to 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 (dasharray back to null) so the finished line is a plain line.

  3. 3

    Edge cases of the dash trick. With no data, d3.line() returns null and the path has no d: the length is 0. With one point it returns M10,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. 4

    **What attrTween is.** transition.attrTween(name, factory) takes a factory that D3 calls once per element when the transition starts. The factory returns the tween function: (t) => value for t from 0 to 1 (already eased). D3 writes that value to the attribute on every frame. attr("d", newPath) is a shortcut for an attrTween that uses d3.interpolateString. When you need other behaviour, you write the function. styleTween and textTween work the same way, and transition.tween(name, factory) writes anything (the examples above used it to spy).

  5. 5

    The wiggle bug. interpolateString pairs 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. 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 flubber or d3-interpolate-path exist; the second example shows the idea they are built on.

  7. 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, interpolateString moves the large-arc flag from 0 to 1 through 0.5, and 0.5 is 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 with d3.interpolateNumber and ask d3.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

01

Explain how stroke-dasharray and stroke-dashoffset draw a line on.

02

Why does a transition between two lines with a different number of points look wrong?

03

Why does an arc disappear in the middle of transition.attr("d", ...), and what is the fix?

Practice

01

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.

02

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 attrTween factory.

  • I can explain the wiggle bug and one fix.

  • I can animate an arc from an angle instead of from a path string.