Codementor Events

D3-v3 animate paths and points

Published Apr 10, 2019

1562726

Let's explore some ways to animate SVG in D3 with geodata. I'll tackle the hard ones first, path animation, or along a path. I'll cover pre-v4 and the latest version, since the syntax is nearly the same post v4.

There are two general methods for path animation in D3, this is whether you animate a line, point, or both. The first called Stroke-Dash interpolation and the second named point-along-path interpolation, the differences are granular. Often you can use either method to achieve the same type of animation overall. Briefly, the difference is in point-along-path, you calculate the overall length of the path to animate, and in stroke-dash, you animate in the spaces (dash) taken out of the stroke (line-path). So, you could think of stroke-dash, as akin to the sides of a film strip, with holes punctured throughout, except you can vary the length of holes (or dashes) to increase or decrease precision. You animate by plotting from one dash to the next.
Point in path, is focused on the overall length of the path, as a result, you do a little bit more math. Point in path has always been more of a fall-back for me, since it can animate in a few situations that stroke-dash cannot, such as with gradients. But the differences really are minute. Some feel one is better at canvas, webgl, etc. In practice, I have seen no large performant difference in either technique.
For D3.V3 you load your wrangled data, (bypassing D3's loading mechanism is very doable, but you have to get your data into a nice Geo-JSON format previous the injecting), since D3 gives a lot of benefits to data effeciencies it recognizes. Stuff that happens in the backend, that generally makes your life easier down the road. I will cover how to bypass the loader and inject your own rolled data in another article. For now...

var Tiles = L.tileLayer('https://{s}.mapprovider/map/{z}/{x}/{y}', {
  attribution: '<a href="http://maps.com" target="_blank">Terms &amp; Feedback</a>'
    });
 var map = L.map('map')
 	.addLayer(Tiles)
    .setView([33.215125313, -110.523415], 14);
 var svg = d3.select(map.getPanes().overlayPane).append("svg");
 var g = svg.append("g").attr("class", "leaflet-zoom-hide");

The above, sets our tilelayer, or map tiles, map adds the layer, and setview focuses the map on an area, the added number, sets the zoom level. Svg appends a d3 layer for rendering svg to the map, and g handles a shadow bug, if you don't include g, you will see persistent path trails when zooming in or out.
Once loaded, it is common practice to either map or filter your features collection, by some method to best serve some subset. A good rule of thumb, use the filter to ensure data for every point, or precision of points (1e7 or how many 0.000001, etc), or if you data has it, distance error, travel-method, etc.

d3.json("points.geojson", function(features) {
    //from this point forward, your entire program
    //should be within this function
var data = features.collection.filter(function(d) {
  if ( d.id === points ){
    return d.id;
    };
})

})

We loaded the data, and filtered it. All upcoming code is located within the d3.json load function.

var transform = d3.geo.transform({ point: projectPoint });
//these are changed if using V4, to d3.geoPath, incase you're using 
//a newer version and wonder why yours doesn't work
var path = d3.geo.path().projection(transform);
function projectPoint(x, y) {
  var point = map.latLngToLayerPoint(new L.LatLng(y, x));
    this.stream.point(point.x, point.y);
}
var toLine = d3.svg.line()
  .interpolate('linear')
    .x(function(d) { return applyLatLngToLayer(d).x })
    .y(function(d) { return applyLatLngToLayer(d).y });
    
function applyLatLngToLayer(d) {
  var y = d.geometry.coordinates[1]
    var x = d.geometry.coordinates[0]
    return map.latLngToLayerPoint(new L.LatLng(y, x))
}

The path and transform take regular coordinates and turn them into svg coordinates. Project, applies those coordinates back to the leaflet map layer, using the current stream. Line, interpolates our coordinates between points, and the last function is the method we employ to apply geometry coordinates to the maplayer.

//Line to animate, between coordinates
var linePath = g.selectAll(".linePaths")
  .data([data])
    .enter()
    .append("path")
    .attr("class", "linePaths");
    
//This is the point to animate
var marker = g.append('circle')
  .attr('r', 6)
    .attr('id', 'marker')
    .attr('class', 'animatedMarker');
    
//now we add all the points, so we can animate to them
var allPoints = g.selectAll('circle')
  .data(data)
    .enter()
    .append('circle')
    .attr('r', 2)
    .attr('class', d => "waypoints " + "c" + d.properties.time) //e6 shorthand
    .style('opacity', 0);

So a lot going on here, but also a lot of repetition. You'll notice the marker does not have a data decleration, this is because we are creating the marker from scratch, so we do not need to attach it to data. Furthermore, it will "merge" with the other points data, thanks to it's shared selection of circle. But do notice, to create, is to append in this case, and in the other case, we select all points, we do not have an actual linepath, so it is in essense the same data, but we will create the linePaths on the go, as you will see.

//this just adjusts the paths and points whenever the view resets, so everything
//stays in relation
map.on("viewreset", reset);
//this draws on the map
reset();

//here is the actual reset function for user reposition of the map
function reset() {
  var bounds = path.bounds(features),
    	topLeft = bounds[0],
        bottomRight = bounds[1];
        
    svg.attr("width", bottomRight[0] - topLeft[0] + 120)
    	.attr("height", bottomRight[1] - topLeft[1] + 120)
        .style("left", topLeft[0] - 60 + "px")
        .style("top", topLeft[1] - 60 + "px");
        
    linePath.attr("d", toLine) //here is where we create the linePath
    g.attr("transform", "translate("+(-topLeft[0] + 60) + "," + (-topLeft[1] + 60) + ")");
}

If your marker/points/paths don't appear to line up well with your map, this is the first place to look for a culprit. Adjusting the larger numbers, will increase the viewable "box" around the animation, while the smaller numbers, will move the centering point on the maplayer. I have found, that generally keeping the numbers divisible by each other, is a good general starting place. I.E. divisible by 30,40,50, etc. So, respectively, that would be 60,80,100 and 120,160,200. And that should get you close enough to do any minor adjustments you may need.

function transition(path) {
  linePath.transition()
    	.duration(d => d.dur)
        .attrTween("stroke-dasharray", tweenDash) //there it is, the method for stroke-dasharray interpolation
}
function tweenDash() {
  return function(t) {
    	var l = linePath.node().getTotalLength(); //and here is the second method, getting length
        interpolate = d3.interpolateString("0," + l, l + "," + l); //this is the interpolate for the stroke-dash
        var marker = d3.select('#marker');
        var p = linePath.node().getPointAtLength(t * l); //and the other measurement, to locate the point in path
        marker.attr("transform", "translate(" + p.x + "," + p.y + ")");
        return interpolate(t);
    }
}

And that's it, you get both a line animation and a point animation. Shortly, I will cover v4+, and highlight the code changes. For whatever reason, v3 is still widely used. Personally, I don't have a preference. They both have some tradeoff's for bumping up the version, or remaining in v3. By the way, we did not exit out of the json load, so make sure all your code, is within that function. Next post, I'll show how we can move away from having everything isolated within that call.

Discover and read more posts from Robert Nicely
get started
post commentsBe the first to share your opinion
Show more replies