d3 Animation Along a Path

Updated Feb 5, 2018

With d3 it’s possible to animate an element’s position along a <path> element, as seen in this example. The example makes use of the SVGPathElement methods getTotalLength(), which returns the total length of the path, and getPointAtLength(), which returns the x-y position of the path at a specified length.

But the method only works if you want to transition along the entire length of the path. If you want to instead transition to a specific point on the path you need additional tools. Fortunately d3 line generators include a defined() method, which lets you specify which data points are included in the rendered path. Using this, you can create a path segment that spans only the range of your transition.

The source for the examples in this post is available here; alternatively, the relevant files are included inline for you to copy or download.

Example with Linear Interpolation

The first example uses a line generator with linear interpolation:

By default the line segment is transparent; use the “Show segment” checkbox to make it red so you can see the animation path. Clicking the “update” button will generate a new random x value to transition to. The code for the above example is as follows:

line.js Download Copy
import {
  axisBottom  as d3_axisBottom,
  axisLeft    as d3_axisLeft,
  curveLinear as d3_curveLinear,
  line        as d3_line,
  max         as d3_max,
  scaleLinear as d3_scaleLinear,
  select      as d3_select,
  transition  as d3_transition
from 'd3';
const margin      = { top: 10, right: 10, bottom: 20, left: 30 };
const outerWidth  = 800;
const outerHeight = 300;
const innerWidth  = outerWidth - margin.left - margin.right;
const innerHeight = outerHeight - margin.top - margin.bottom;
const parentNode  = d3_select('#example-line');
let data          = [];
let lastY         = 5;
let point         = 0;
// Create data set.
for (let i = 0; i < 20; i++) {
  const nextY = lastY * (1 + 0.5*(Math.random() - 0.5));
  data.push(nextY);
  lastY = nextY;
}
// Create checkbox to show segment.
parentNode.append('input')
  .attr('type''checkbox')
  .on('change'function () {
    d3_select('#path-seg').attr('stroke'this.checked ? '#b44' : 'none');
  });
parentNode.append('span').text('Show segment');
// Create button to update on click.
parentNode.append('button')
  .text('Update')
  .on('click', update);
// Text to show x position.
const text = parentNode.append('span');
// Create main svg element.
const svg = parentNode.append('svg')
  .attr('width', outerWidth)
  .attr('height', outerHeight)
  .append('g')
    .attr('transform''translate(' + margin.left + ',' + margin.top + ')');
// Create scales and axes.
const x     = d3_scaleLinear().domain([0data.length - 1]).range([0, innerWidth]).nice();
const y     = d3_scaleLinear().domain([0d3_max(data)]).range([innerHeight, 0]).nice();
const xAxis = d3_axisBottom(x);
const yAxis = d3_axisLeft(y);
// Render axes.
svg.append('g')
  .attr('class''x axis')
  .attr('transform''translate(0,' + innerHeight + ')')
  .call(xAxis);
svg.append('g')
  .attr('class''y axis')
  .call(yAxis);
// Render line.
const line = d3_line()
  .curve(d3_curveLinear)
  .x( (d, i) => x(i))
  .y( d => y(d));
svg.append('path')
  .datum(data)
  .attr('id''line')
  .attr('fill''none')
  .attr('stroke-width'2)
  .attr('stroke''steelblue')
  .attr('d', line);
// Render line segment that we'll animate along.
const lineSeg = d3_line()
  .curve(d3_curveLinear)
  .x( (d, i) => x(i))
  .y( d => y(d))
const pathSeg = svg.append('path')
  .datum(data)
  .attr('id''path-seg')
  .attr('fill''none')
  .attr('stroke-width'2)
  .attr('stroke''none');
// Render marker.
const marker = svg.append('circle')
  .attr('id''marker')
  .attr('r'5)
  .attr('cx'0)
  .attr('cy'0)
  .attr('fill''#fff')
  .attr('stroke-width'2)
  .attr('stroke''steelblue')
  .attr('transform''translate(' + x(point) + ',' + y(data[point]) + ')');
update();
// Updates position of marker.
function update () {
  // Get the next point.
  const nextPoint = Math.floor(Math.random() * (data.length - 1));
  // Only include points between existing and new point.
  lineSeg.defined( (d, i) =>
    i <= nextPoint && i >= point || i <= point && i >= nextPoint
  );
  // Update path.
  pathSeg.attr('d', lineSeg);
  // Transition marker from point to nextPoint.
  marker.transition().duration(1500)
    .attrTween('transform', nextPoint > point ? translateRight(pathSeg.node()) : translateLeft(pathSeg.node()))
    .on('end', () => { point = nextPoint; });
  text.text('x = ' + nextPoint);
}
// Tween function for moving to right.
function translateRight (node) {
  const l = node.getTotalLength();
  return () => {
    return (t) => {
      const p = node.getPointAtLength(t * l);
      return 'translate(' + p.x + ',' + p.y + ')';
    };
  };
}
// Tween function for moving to left.
function translateLeft (node) {
  const l = node.getTotalLength();
  return () => {
    return (t) => {
      const p = node.getPointAtLength((1 - t) * l);
      return 'translate(' + p.x + ',' + p.y + ')';
    };
  };
}

Note that on line 113 we generate a new x value that exists as an index in the data set. If nextPoint did not exist as an index—say we set it to 5.5—then the marker would undershoot or overshoot the desired end point. If you’re always transitioning to points that exist in your data set then this is not an issue. The next example shows how to handle cases where you need to transition to arbitrary points.

Example with Non-linear Interpolation and Arbitrary Data Points

In this example we use a curveBasis interpolator with the line generator to create a sine curve:

Clicking the “update” button generates a new random [x, y] pair and transitions the circle marker to that point. Unlike the previous example, where the x value always existed as an element in the data set, here x can be any value in the x domain. The trick is found in the update() function, where we add a new entry to our data that corresponds to the generated value. This way our transition will start and stop at the exact desired points.

If you click the checkbox to “Show segment” and click the “Update” button a few times you’ll notice that the red line segment diverges from the blue line in some cases. This is because we’re adding the new data point, which the line interpolator includes in rendering a new line.

While in this case the divergence is tolerable, other cases may demand additional tweaks. There are at least two options:

Here is the code for the second example:

sine.js Download Copy
import {
  axisBottom  as d3_axisBottom,
  axisLeft    as d3_axisLeft,
  curveBasis  as d3_curveBasis,
  extent      as d3_extent,
  line        as d3_line,
  scaleLinear as d3_scaleLinear,
  select      as d3_select,
  transition  as d3_transition
from 'd3';
const margin      = { top: 10, right: 10, bottom: 20, left: 30 };
const outerWidth  = 800;
const outerHeight = 300;
const innerHeight = outerHeight - margin.top - margin.bottom;
const innerWidth  = outerWidth - margin.left - margin.right;
const parentNode  = d3_select('#example-sine');
// Generates x value with specified increment.
function xValues (incr) {
  let values = [];
  let i = -2;
  while (i <= 2) {
    values.push(i * Math.PI);
    i = Number((i + incr).toFixed(3));
  }
  return values;
}
// Create data set and initial x point for marker.
const data = xValues(0.1).map( d => [d, Math.sin(d)] );
let point  = -2*Math.PI;
// Create checkbox to show or hide line segment path.
parentNode.append('input')
  .attr('type''checkbox')
  .on('change'function () {
    d3_select('#path-seg-sine').attr('stroke'this.checked ? '#b44' : 'none');
  });
parentNode.append('span').text('Show segment');
// Create button to update on click.
parentNode.append('button')
  .text('Update')
  .on('click', update);
// Text to show current x value.
const text = parentNode.append('span');
// Main svg element.
const svg = parentNode.append('svg')
  .attr('width', outerWidth)
  .attr('height', outerHeight)
  .append('g')
    .attr('transform''translate(' + margin.left + ',' + margin.top + ')');
// Scales and axes.
const x     = d3_scaleLinear().domain(d3_extent(data, d => d[0])).range([0, innerWidth]);
const y     = d3_scaleLinear().domain([-1.51.5]).range([innerHeight, 0]).nice();
const xAxis = d3_axisBottom(x).tickFormat(d => (d / Math.PI+ 'π').tickValues(xValues(0.5));;
const yAxis = d3_axisLeft(y);
svg.append('g')
  .attr('class''x axis')
  .attr('transform''translate(0,' + innerHeight + ')')
  .call(xAxis);
svg.append('g')
  .attr('class''y axis')
  .call(yAxis);
const line = d3_line()
  .curve(d3_curveBasis)
  .x(d => x(d[0]))
  .y(d => y(d[1]));
svg.append('path')
  .datum(data)
  .attr('fill''none')
  .attr('stroke-width'2)
  .attr('stroke''steelblue')
  .attr('d', line);
const lineSeg = d3_line()
  .curve(d3_curveBasis)
  .x(d => x(d[0]))
  .y(d => y(d[1]));
const pathSeg = svg.append('path')
  .datum(data)
  .attr('id''path-seg-sine')
  .attr('fill''none')
  .attr('stroke-width'2)
  .attr('stroke''none');
const marker = svg.append('circle')
  .attr('r'5)
  .attr('cx'0)
  .attr('cy'0)
  .attr('fill''#fff')
  .attr('stroke-width'2)
  .attr('stroke''steelblue')
  .attr('transform''translate(' + x(point) + ',' + y(Math.sin(point)) + ')');
update();
// Updates marker position.
function update () {
  // Get new point between -2*PI and 2*PI.
  const nextPoint = (-2 + 4*Math.random()) * Math.PI;
  // Insert [nextPoint, Math.sin(nextPoint)] into data set.
  for (let i = 0; i < data.length - 1; i++) {
    if (nextPoint >= data[i][0&& nextPoint <= data[i + 1][0]) {
      data.splice(i + 10, [nextPoint, Math.sin(nextPoint)]);
      break;
    }
  }
  // Update line segment and path.
  lineSeg.defined( d =>
    d[0<= nextPoint && d[0>= point || d[0<= point && d[0>= nextPoint
  );
  pathSeg.attr('d', lineSeg);
  // Transition marker from point to nextPoint.
  marker.transition().duration(1500)
    .attrTween('transform', nextPoint > point ? translateRight(pathSeg.node()) : translateLeft(pathSeg.node()))
    .on('end', () => { point = nextPoint; });
  text.text('x = ' + nextPoint.toFixed(3));
}
// Tween function for moving to right.
function translateRight (node) {
  const l = node.getTotalLength();
  return () => {
    return (t) => {
      const p = node.getPointAtLength(t * l);
      return 'translate(' + p.x + ',' + p.y + ')';
    };
  };
}
// Tween function for moving to left.
function translateLeft (node) {
  const l = node.getTotalLength();
  return () => {
    return (t) => {
      const p = node.getPointAtLength((1 - t) * l);
      return 'translate(' + p.x + ',' + p.y + ')';
    };
  };
}

Comments Leave a comment

  • z_h_z_h
    It's a good idea to make a hidden path. I was thinking how to solve this problem, but I did not guess. You save me.

Leave a comment