Animated Strava Paths

I recently purchased a neat product, the Garmin vívosport. It’s a slim fitness band with real waterproofness and GPS tracking, perfect for water sports. Garmin devices are usually fairly hackable, with simple filesystem access to well-defined FIT files.

I was also recently introduced to Strava, a social network that lets you post “activities” (e.g., bike rides) generated by fitness devices like my vívosport. Right now, my Strava profile consists of nothing but short and clumsy windsurfing activities at the Berkeley Marina.

For a weekend project, I wrote a Python script that gathers all my data from Strava and produces a single JSON file. Then, I created a Javascript client that uses no libraries other than D3 and Google Maps.

D3 made it easy to smoothly animate the paths when changing the selected activity. You can play with the web app here. It looks like this:

These GPS tracks look like child’s scribble because I’m definitely a beginner at windsurfing. They are surprisingly accurate; I can even tell which of the three docks that I launched from.

Strava and Python

To fetch data from Strava, I used the popular requests package for Python 3, leveraging only the following API endpoints:

https://www.strava.com/oauth/authorize
https://www.strava.com/oauth/token
https://www.strava.com/api/v3/athlete/activities
https://www.strava.com/api/v3/activities/<ID>/streams/latlng,time

In addition to requests, I also used flask because my Python script is actually a miniature web server. The script is used only for one-time generation of a JSON file, but it needs to act like a web server because it receives an auth token from Strava after the user logs in.

D3 and Google Maps

Google Maps lets you draw arbitrary graphics over a map using OverlayView, which corresponds to an absolutely-positioned <div> that floats over your map.

Overlay views require you to override two methods: onAdd (used for initialization) and draw (called after every pan / zoom). Here’s roughly how I create the map and the overlay:

const bounds = new google.maps.LatLngBounds(
  new google.maps.LatLng(minLatLong[0], minLatLong[1]),
  new google.maps.LatLng(maxLatLong[0], maxLatLong[1]));
const map = new google.maps.Map(d3.select("#map").node());
map.fitBounds(bounds);
overlay = new google.maps.OverlayView();
overlay.onAdd = function() { ... };
overlay.draw = function() { ... };
overlay.setMap(map);

In onAdd, I create a single <svg> that auto-expands inside the div. I also specify the viewBox attribute, which lets me avoid updating an overall SVG transform every time the div changes size (i.e. when zooming).

mapOverlay.onAdd = function() {
  const proj = this.getProjection();
  const ne = proj.fromLatLngToDivPixel(bounds.getNorthEast());
  const sw = proj.fromLatLngToDivPixel(bounds.getSouthWest());
  const span = bounds.toSpan(), aabb = bounds.toJSON();
  const width = ne.x - sw.x, height = sw.y - ne.y;
  const scalex = width / span.lng(), scaley = height / span.lat();

  pathShape = d3.line()
    .x(d => (d[1] - aabb.west) * scalex)
    .y(d => height - (d[0] - aabb.south) * scaley);

  d3.select(this.getPanes().overlayLayer)
    .append("svg")
    .attr("width", "100%").attr("height", "100%")
    .attr("viewBox", "0 0 " + width + " " + height)
    .append("path")
    .attr("fill", "none").attr("stroke", "#2d699e");
    .datum(activities[activityId].lines)
    .attr("d", pathShape);
};

I found that it was best to keep my draw implementation as simple as possible:

  • Update the left/top/width/height attributes of the overlay div.
  • Update the stroke width of the path.

Here’s the implementation:

mapOverlay.draw = function() {
  const proj = this.getProjection();
  const ne = proj.fromLatLngToDivPixel(bounds.getNorthEast());
  const sw = proj.fromLatLngToDivPixel(bounds.getSouthWest());
  const width = ne.x - sw.x, height = sw.y - ne.y;
  d3.select(this.getPanes().overlayLayer)
    .style("left", sw.x + "px").style("top", ne.y + "px")
    .style("width", width + "px").style("height", height + "px")
    .select("path").attr("stroke-width", originalHeight / height);
};

Path Interpolation

Since I’m using D3, it was easy to throw in a transition animation. However each Strava path has a different number of points, which complicates things. I stumbled across Peter Beshai’s implementation of interpolatePath, which handles this quite nicely.

function updateSvgPath(activityId) {
  let selection = .select("svg path")
    .datum(activities[activityId].lines);

  // If this is the first time drawing the path, no need to animate.
  if (!d3.select("svg path").attr("d")) {
    selection.attr("d", pathShape);
    return;
  }

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

The complete source for this project is on GitHub, feel free to steal from it:

https://github.com/prideout/sfpaths

Philip Rideout
December 2017