Use the amazing D3 library to animate a path on a Leaflet map

Updated December 8, 2014

Introduction: Animate a path with D3

Viewing location data that varies through time on a static map is fun, but viewing it on an animated map is a lot more fun. Recently, online map lovers were excited by Chris Whong’s Day in the Life of a NYC Taxi map in which he used D3 to animate taxi paths on a Leaflet map. Fortunately for developers, Chris was generous with his time and code. He described the approach he used in a two-part techblog. Using code snippets from Chris, Mike Bostock (D3’s creator), d3noob as well as others, we break down the process of creating an animated path in Leaflet with D3.

Here is what the finished product looks like:

1) The data: great coffee to great beer

We’re using a GeoJSON file of waypoints in the path from Gimme!, the great (and Ithaca-born) coffee shop to the source for rare and unusual beers, Proletariat.

gimmetoprol

The data was created using a combination of Google Directions, Node.js and QGIS as described in our previous post. But the file is a standard GeoJSON file of points so you can create your own file using simpler options (try, for example, geojson.io). The first bit of data looks like this (this data can be downloaded from GitHub):

{
"type": "FeatureCollection",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },

"features": [
{ "type": "Feature", "properties": { "latitude": 40.722390, "longitude": -73.995170, "time": 1, "id": "route1", "name":"Gimme" }, "geometry": { "type": "Point", "coordinates": [ -73.99517, 40.72239 ] } },
{ "type": "Feature", "properties": { "latitude": 40.721580, "longitude": -73.995480, "time": 2, "id": "route1", "name":"Along route"  }, "geometry": { "type": "Point", "coordinates": [ -73.99548, 40.72158 ] } }}

2) Set the stage: create the basic map

We’re not doing anything fancy here, simply creating a Leaflet map and we’re using MapBox tiles. In the header we’re providing CDN links to Leaflet, D3 and MapBox. The code for creating the map looks something like this:

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7/leaflet.css" />
    <link href='https://api.tiles.mapbox.com/mapbox.js/v1.6.4/mapbox.css' rel='stylesheet' />

    <script src="http://d3js.org/d3.v3.min.js" type="text/javascript"></script>
    <script src="http://cdn.leafletjs.com/leaflet-0.7/leaflet.js"></script>
    <script src='https://api.tiles.mapbox.com/mapbox.js/v1.6.4/mapbox.js'></script>

    <style>
    html,
    body {
        height: 100%;
        width: 100%;
    }
    body {
        margin: 0;
    }
    #map {
        width: 100%;
        height: 100%;
    }
    </style>

</head>

<body>
    <div id="map"></div>

    <script type="text/javascript">
    var mapboxTiles = L.tileLayer('https://{s}.tiles.mapbox.com/v3/examples.map-zr0njcqy/{z}/{x}/{y}.png', {
        attribution: '<a href="http://www.mapbox.com/about/maps/" target="_blank">Terms &amp; Feedback</a>'
    });

    var map = L.map('map')
        .addLayer(mapboxTiles)
        .setView([40.72332345541449, -73.99], 15);
    </script>
</body>
</html>

3) Prepare the container SVG elements

Normally with D3 we would be appending an SVG container to, say, the body (i.e., d3.select("body").append("svg")). In this case we need to append to the map itself (specifically the overlayPane) and Leaflet has a handy function called getPanes to help us do this. As usual, we append a “grouping” or g element to the SVG. In this case we need to add the class leaflet-zoom-hide otherwise we will see a phantom SVG when we zoom.

var svg = d3.select(map.getPanes().overlayPane).append("svg");
var g = svg.append("g").attr("class", "leaflet-zoom-hide");

4) Use the D3 function d3.json() to read your data

Our data is conveniently in GeoJSON format so we can use d3.json() easily to read in our data. Note that this function is asynchronous so any bits of code that require the data will need to be included within this function.

d3.json("points.geojson", function(collection) {
// Do stuff here
});

5) Set up conversion and projection functions

Since SVG does not use the same coordinate system as a globe, the latitude and longitude coordinates will need to be transformed. There are two pieces of the code where we do this. The function that D3 uses to convert GeoJSON to path codes (d3.geo.path()) can include a projection function. In this case we’re using what is called a “stream transform” in D3 combined with a function (projectPoint) that makes use of a Leaflet function (latLngToLayerPoint). These pieces are used here to create (and project) the line between our points and the bounding box coordinates.

var transform = d3.geo.transform({
    point: projectPoint
});
var d3path = 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);
} 
    });

We also need to a function to convert our points to a line (and project the points in the process). D3 has the function to create the line (d3.svg.line()) and we use a custom function to do the projection.

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))
}

6) Create the points and lines we need

We have several elements we will be adding. These include the path itself (as a line), the yellow traveling circle, the points themselves (which we will use in a future post but for now are transparent), the red origin and destination points and the text. They all get added using a similar approach and one that is described in numerous other posts (here is a simple one from Mike Bostock).

// here is the line between points
var linePath = g.selectAll(".lineConnect")
    .data([featuresdata])
    .enter()
    .append("path")
    .attr("class", "lineConnect");

// This will be our traveling circle
var marker = g.append("circle")
    .attr("r", 10)
    .attr("id", "marker")
    .attr("class", "travelMarker");

// if you want the actual points change opacity
var ptFeatures = g.selectAll("circle")
    .data(featuresdata)
    .enter()
    .append("circle")
    .attr("r", 3)
    .attr("class", function(d){
        return "waypoints " + "c" + d.properties.time
    })      
    .style("opacity", 0);

// I want the origin and destination to look different
var originANDdestination = [featuresdata[0], featuresdata[17]]

var begend = g.selectAll(".drinks")
    .data(originANDdestination)
    .enter()
    .append("circle", ".drinks")
    .attr("r", 5)
    .style("fill", "red")
    .style("opacity", "1");

    // I want names for my coffee and beer
var text = g.selectAll("text")
    .data(originANDdestination)
    .enter()
    .append("text")
    .text(function(d) {
        return d.properties.name
    })
    .attr("class", "locnames")
    .attr("y", function(d) {
        return -10 //I'm moving the text UP 10px
    })

7) Add our items to the actual map (and account for zooming)

Using Leaflet’s viewreset method and our reset function we can tell our app to re-compute the SVG coordinates and the coordinates of our map elements when the user repositions the map. We also run the reset function initially to put our SVG elements on the map.

map.on("viewreset", reset);

// this puts stuff on the map! 
reset();

8) The function to reset the SVG elements if the user repositions the map

For the point-related elements of our SVG we can use the applyLatLngToLayer function combined with the CSS transform property to convert latitude and longitude to the current map view coordinates. You’ll note that for the SVG were adding 120 px to the width and height. This is because the bounds would otherwise perfectly fit our features and, as a result, your circles that represent points will get cut off.

function reset() {
    var bounds = d3path.bounds(collection),
        topLeft = bounds[0],
        bottomRight = bounds[1];


    begend.attr("transform",
        function(d) {
            return "translate(" +
                applyLatLngToLayer(d).x + "," +
                applyLatLngToLayer(d).y + ")";
        });

    //...do same thing to text, ptFeatures and marker...
    
        });

    svg.attr("width", bottomRight[0] - topLeft[0] + 120)
        .attr("height", bottomRight[1] - topLeft[1] + 120)
        .style("left", topLeft[0] - 50 + "px")
        .style("top", topLeft[1] - 50 + "px");


    linePath.attr("d", toLine)
    g.attr("transform", "translate(" + (-topLeft[0] + 50) + "," + (-topLeft[1] + 50) + ")");


} // end reset

9) The animation special sauce: two functions that do the D3 magic

We are using a D3 transition to create the effect of a smooth line between points and the transition makes use of a function called tweenDash. This is a very clever approach to animating the path and I think Mike Bostock was the one to first show the example. The basic idea is this: SVG has a style property called stroke-dasharray which can be used to specify lengths of the alternating dashes and gaps that make up a line. So if you specify “5,5” you would have a line 5px long and then a gap 5px long and this pattern would be repeated. But what if you had an overall line/path that is 100px long and you specify “0,100”? You would have no line and a gap of 100px meaning — no line! How about if you specified an array of “1,100”? This would give you a line 1px long and a gap of 100px — since your line is only 100px long, though, in practice this would yield a gap of 99px. Then you can use “2,100”, “3,100” and so on to smoothly fill in the line. This is the idea behind these functions.

The transition function adds a transition to our path (linePath) and the transition is being applied to the stroke-dasharray style. The stoke-gap numbers (e.g, “3,100”) are being fed in from our tweenDash function.

function transition(path) {
    linePath.transition()
        .duration(7500)
        .attrTween("stroke-dasharray", tweenDash)
        .each("end", function() {
            d3.select(this).call(transition);// infinite loop
            ptFeatures.style("opacity", 0)
        }); 


} 
function tweenDash() {

    return function(t) {
        // In original version of this post the next two lines of JS were
        // outside this return which led to odd behavior on zoom
        // Thanks to Martin Raifer for the suggested fix.

        //total length of path (single value)
        var l = linePath.node().getTotalLength(); 
        interpolate = d3.interpolateString("0," + l, l + "," + l); 

        //t is fraction of time 0-1 since transition began
        var marker = d3.select("#marker");
        
        // p is the point on the line (coordinates) at a given length
        // along the line. In this case if l=50 and we're midway through
        // the time then this would 25.
        var p = linePath.node().getPointAtLength(t * l);

        //Move the marker to that point
        marker.attr("transform", "translate(" + p.x + "," + p.y + ")"); //move marker
        return interpolate(t);
    }
}

And you’re done. One small issue.

In the sample code I run the transition function from within the reset function. As a result, each time the user zooms the path resets. I would prefer to have the path continue if the user zooms and I thought that taking transition out of the reset function would work. But if you try zooming mysterious things happen. See the maps below. If you have a solution to the zooming issue, please let me know.

It is lovely when someone, out of the blue, suggests a code fix. I want to thank Martin Raifer for his fix to this issue. The fix involved simply taking two lines of JS code in the tweenDash() function and moving them into the return as noted in the code snippet above.

Final map:
The Gist for this one is here.

This is the final code from GitHub.

6 responses

  1. Can the yellow circle travel in linear line from origin point (Gimme) to destination point(Proletariat) without turning left /right / passing through other points (Along route)?

Leave a Reply

Your email address will not be published. Required fields are marked *