Smooth path interpolation with d3-interpolate-path

D3 provides us with many of the basic building blocks needed to make charts in browsers while also making it extremely easy to animate them. One of the most common charts created with D3 is a line chart, often consisting of a series of SVG <path> elements to visualize the data. In this post, I dissect how the animation of paths work in D3 and how they can be improved. The final result has been packed and released as a plugin d3-interpolate-path that you can use in your own line charts.

How do paths work?

To begin, let’s take a quick look at how paths work in SVG.

<svg width="500" height="120">
  <path d="M0,0 L200,100 L400,50 L500,80" />
</svg>

Paths take a d attribute that specifies a series of commands indicating how to draw a path. Breaking down this example we get:

  • M0,0 – move to (x, y) position (0, 0)
  • L200,100 – draw a straight line from (0, 0) to (200, 100)
  • L400,50 – draw a straight line from (200, 100) to (400, 50)
  • L500,80 – draw a straight line from (400, 50) to (500, 80)

By manipulating the value of d we can change how our path looks on screen.

Using paths in D3

Now when using D3, it is very unusual to write out the d attribute manually, since D3 provides utilities like d3.line and d3.area to generate those string representations for you. To generate the same path as shown above using d3.line, we do the following:

<svg width="500" height="120">
  <path id="path1" />
</svg>
var line = d3.line();
var data = [[0, 0], [200, 100], [400, 50], [500, 80]];
d3.select('#path1').attr('d', line(data));

The actual raw output from line(data) is:

M0,0L200,100L400,50L500,80

Animating paths in D3

Not only does D3 make generating the d attribute for paths really easy, it also makes animating between two paths effortless: just add .transition() before updating the d attribute and D3 will know how to transition one path string into another. Let’s look at an example where we want to animate Path A to Path B, shown below:

Path A

Path B

To animate from A to B we do the following:

<svg width="500" height="120">
  <path id="path1" />
</svg>
var line = d3.line();
var data1 = [[0, 0], [200, 100], [400, 50], [500, 80]];
var data2 = [[0, 100], [220, 80], [300, 20], [500, 40]];

d3.select('#path1')
  .attr('d', line(data1))
  .transition()
  .attr('d', line(data2));

The Problem: animating paths with different numbers of points

This all seems to work pretty well, so what’s the problem? Let’s see how the animation does when the paths have different numbers of points. In this example, Path B has more points than Path A.

Path A

Path B

Our animation code remains the same as before, just this time, it uses more data points for the final path.

<svg width="500" height="120">
  <path id="path1" />
</svg>
var line = d3.line();
var data1 = [[0, 0], [200, 100], [400, 50], [500, 80]];
var data2 = [[0, 100], [100, 50], [220, 80], [250, 0], 
  [300, 20], [350, 30], [400, 100], [420, 10], [430, 90], 
  [500, 40]];

d3.select('#path1')
  .attr('d', line(data1))
  .transition()
  .attr('d', line(data2));

As we can see, this animation leaves something to be desired. Since there are a different number of points between the two d values (four in A, ten in B), D3 only interpolates between the first four points and appends the remaining values immediately to the resulting string. This makes it so the first step of our animation produces the following path:

Perhaps not quite what we’d expect.

The Solution: match path length prior to interpolation

There are many ways to solve this problem, but the way I came up with was pretty simple: make the paths have the same number of points represented before beginning interpolation. This way the great interpolation D3 provides for paths of the same length can be used even when the initial paths have different number of points. Okay, so how do we do that?

My first approach was to duplicate the last point in the shorter path until you have the same number of points for both paths. Since we need to extend Path A in our example to have ten points, let’s try it out and call the extended path Path A′.

Path A

Path A′

Path B

Here we see that Path A and Path A′ look the same, which is exactly what we want! Except behind the scenes, Path A′ has ten points. It’s d value is:

M0,0L200,100L400,50L500,80L500,80L500,80L500,80L500,80L500,80L500,80

That’s a whole bunch of L500,80 repeated, which works because drawing a line from (500,80) to (500,80) is essentially a no-op. So now that Path A′ has the same number of points as Path B, D3’s path interpolation should work nicely. Let’s see how it does:

While that’s a big improvement, it is a bit weird that the path seems to grow out of its end point instead of smoothly modifying throughout the entire path.

Polishing the solution

We can do better! Instead of copying the last point in the path to make our paths match lengths, we can extend Path A to have the same points as Path B by seeing which point in Path A is closest to the new point it will become in Path B and duplicating that point.

Path A

Path A′

Path B

Again Path A′ looks the same as Path A, but this time its d value is:

M0,0L0,0L200,100L200,100L200,100L400,50L400,50L400,50L400,50L500,80

Instead of just copying the end points, we copied the points closest to the matching point in B. How does it look during animation?

Nice! The paths animate in a natural way, much improved from the default D3 interpolation.

There’s a plugin for that

Extending the paths to match manually every time you have paths of different lengths animating can be a chore, but the good news is I created a plugin to do it for you: d3-interpolate-path. This plugin conforms to D3 v4 plugin standards, so using it is the same as using any other part of D3– just include the script in your tool and access it via d3.interpolatePath().

To get it to work, instead of calling attr to update d, use attrTween. In our example, we could do the following:

var line = d3.line();
var data1 = [[0, 0], [200, 100], [400, 50], [500, 80]];
var data2 = [[0, 100], [100, 50], [220, 80], [250, 0], 
  [300, 20], [350, 30], [400, 100], [420, 10], [430, 90], 
  [500, 40]];

d3.select('#path1')
  .attr('d', line(data1))
  .transition()
  .attrTween('d', function () { 
    return d3.interpolatePath(line(data1), line(data2)); 
  });

In typical use cases, you have data bound to your path element available via the d argument to attrTween (different from the path d attribute). In those cases, the more generic way of using the plugin is as follows:

d3.select('path')
  .transition()
  .attrTween('d', function (d) {
    var previous = d3.select(this).attr('d');
    var current = line(d);
    return d3.interpolatePath(previous, current);
  });

Conclusion

We’ve seen that D3 is great at animating paths of the same length, but doesn’t perform as expected when the paths differ in length. To overcome this issue, we can first extend our paths to have the same length before getting D3 to animate them, which is exactly the functionality that d3-interpolate-path provides. Check it out and share with us what you’ve made! Please let me know if you run into any issues by opening a ticket on Github.

Thanks for reading! May all your paths interpolate smoothly.

Comments