Skip to content

Instantly share code, notes, and snippets.

@brianally
Last active September 25, 2015 01:07
Show Gist options
  • Save brianally/ad340bf0037215b11920 to your computer and use it in GitHub Desktop.
Save brianally/ad340bf0037215b11920 to your computer and use it in GitHub Desktop.
Interactive Path Animation

This is a simple game to amuse small children. Click anywhere on the map to see an animated line drawn between the last location and the next courtesy of Google's Directions API. We begin with the first location centred on the map. Change cities using the select list at top-left.

The lines are animated with CSS, a questionable alternative to using d3's transition() that i've outlined here.

UPDATE

I've thrown in the towel. That CSS transition technique seems to be unreliable for complicated paths. Occassionally, the path will appear to begin drawing somewhere close to the end, reach the end, then continue drawing from the beginning until it reaches the point where it had started. Meanwhile, that portion that had first been drawn will have "erased" itself, so the path stops before it reaches the end. That technique, whatever its (questionable) merits probably should not be used for complicated paths.

Regardless, i'll leave this up should someone need to amuse their small children.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anyone wanna waste some time?</title>
<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7/leaflet.css" />
<link rel="stylesheet" href="https://api.tiles.mapbox.com/mapbox.js/v1.6.4/mapbox.css" />
<style>
html,
body {
height: 100%;
width: 100%;
}
body {
margin: 0;
}
#map {
position: absolute;
top : 0;
bottom : 0;
width : 100%;
}
svg {
xxxoutline: 1px solid red;
}
path {
fill : none;
stroke-width : 4px;
stroke : red;
stroke-opacity: 0.5;
transition: stroke-dashoffset 2s linear;
}
path.computed {
stroke-dashoffset: 0;
}
.marker {
position: relative;
z-index:3000;
r : 6;
fill : red;
stroke : none;
fill-opacity: 0.5;
}
.marker.current {
r : 8;
fill-opacity: 1;
}
#menu {
padding: 10px;
}
.debug .ui-coords {
background : #fff;
position : absolute;
top : 10px;
right : 10px;
min-width : 300px;
padding : 10px;
z-index : 100;
border-radius: 3px;
}
</style>
</head>
<body>
<div id="output" class="ui-coords">
<h6>Click: <code id="ui-click"></code></h6>
<h6>Mousemove: <code id="ui-mousemove"></code></h6>
</div>
<div id="map"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js"></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>
<script src="http://maps.google.com/maps/api/js?sensor=true"></script>
<script>
(function(window, undefined) {
var width = 960;
var height = 500;
// Using keys for lat & lng rather than simple array because
// we're mixing Leaflet and Google with d3, whose ordering differs.
// Being explicit saves headaches.
var regions = [
{ name : "Montreal", coords: {lat: 45.515940, lng: -73.583661}, zoom : 12 },
{ name : "Paris", coords: {lat: 48.860282, lng: 2.340350}, zoom : 13 },
{ name : "Amsterdam", coords: {lat: 52.371656, lng: 4.892070}, zoom : 14 },
{ name : "Chicago", coords: {lat: 41.861528, lng: -87.717861}, zoom : 11 },
{ name : "Barcelona", coords: {lat: 41.406897, lng: 2.171516}, zoom : 12 },
{ name : "Singapore", coords: {lat: 1.338881, lng: 103.850066}, zoom : 12 }
];
var map, menu, svg, g, pathGenerator, directionsService, dispatch, debug;
// I've no plans to support other query params so ... simplify all the things!
debug = (location.search && location.search.substr(1) == "debug");
map = initLeaflet(map);
// point transform and path generator
var transform = d3.geo.transform({
point: streamPoint
});
pathGenerator = d3.geo.path().projection(transform);
// event handling
dispatch = d3.dispatch("clicked", "directions", "zoom");
dispatch.on("clicked", destinationRequest);
dispatch.on("directions", handleDirections);
//dispatch.on("zoom", update);
directionsService = new google.maps.DirectionsService();
run(map);
function run(map) {
// tear down previous svg if any
d3.select("svg").remove();
var containerNode = d3.select(".leaflet-container").node();
var dims = getElementDimensions(containerNode);
// set Leaflet view based on selected coords
var initialCoords = setMapRegion(map);
svg = d3.select(map.getPanes().overlayPane)
.append("svg")
.attr("width", dims.width)
.attr("height", dims.height);
var g = svg.append("g")
.attr("class", "leaflet-zoom-hide");
data = initData(initialCoords);
map.on("viewreset dragend resize", resetOverlay);
map.on("click", handleClick);
update(data, g);
}
function resetOverlay() {
if ( data.paths.geometries.length ) {
var g = d3.select("svg g");
var bounds = pathGenerator.bounds(data.paths);
var topLeft = bounds[0];
var bottomRight = bounds[1];
svg
.attr("width", bottomRight[0] - topLeft[0])
.attr("height", bottomRight[1] - topLeft[1])
.style("left", topLeft[0] + "px")
.style("top", topLeft[1] + "px");
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");
g.attr("transform", "translate(" + (-topLeft[0] + 50) + "," + (-topLeft[1] + 50) + ")");
}
update(data, g);
}
// click event handler fires "clicked", passing destination coords
//
function handleClick(e) {
var dest = e.latlng;
dispatch.clicked(dest);
}
// a destination request event triggers ... guess what?
//
function destinationRequest(dest) {
// current marker is the last one placed on map
var origin = currentMarkerCoords(data);
// async: dispatch will prompt to continue when google responds
getDirections(origin, dest);
}
// handle the response from google directions api
//
function handleDirections(leg) {
data = extractPath(leg, data);
resetOverlay();
//update(data);
}
// draw markers & paths
//
function update(data, g) {
g = g || d3.select("svg g");
var paths = g.selectAll("path")
.data(data.paths.geometries);
paths.enter()
.append("path")
.call(transition);
paths.attr("d", pathGenerator);
var markers = g.selectAll(".marker")
.classed("current", false)
.data(data.markers.geometries);
markers.enter()
.append("circle")
.attr("class", "marker");
markers
.attr("cx", function(d) { return projectPoint( d.coordinates[0], d.coordinates[1] ).x; })
.attr("cy", function(d) { return projectPoint( d.coordinates[0], d.coordinates[1] ).y; });
g.select(".marker:last-of-type")
.classed("current", true);
}
// Have CSS handle line drawing transition trickery
// see: http://bl.ocks.org/brianally/b477ac1e7b4cabc8eb0b
//
// UPDATE: unused
//
function animateStroke(path) {
var node = path.node();
if( node ) {
var l = node.getTotalLength();
path
.attr("stroke-dasharray", l + " " + l)
.attr("stroke-dashoffset", l);
getComputed(node).getPropertyValue("stroke-dashoffset");
path.classed("computed", true);
}
}
function transition(path) {
path.transition()
.duration(2000)
.attrTween("stroke-dasharray", tweenDash);
}
function tweenDash() {
var l = this.getTotalLength(),
i = d3.interpolateString("0," + l, l + "," + l);
return function(t) { return i(t); };
}
// make request to google directions service
//
function getDirections(origin, dest) {
var req = {
origin : new google.maps.LatLng(origin.lat, origin.lng),
destination: new google.maps.LatLng(dest.lat, dest.lng),
travelMode : google.maps.DirectionsTravelMode.DRIVING,
unitSystem : google.maps.UnitSystem.METRIC
};
directionsService.route(req, function(result, status) {
if (status == google.maps.DirectionsStatus.OK) {
dispatch.directions(result.routes[0].legs[0]);
} else {
console.error("directions request failed: %s", status);
return null;
}
});
}
// unpack google's response and add to data
//
function extractPath(leg, data) {
var ls = {
"type" : "LineString",
"coordinates": []
};
var pnt = {
"type" : "Point",
"coordinates": []
};
leg.steps.forEach(function(step) {
step.path.forEach(function(position) {
ls.coordinates.push( [position.L, position.H] );
});
});
// We want to use the last point for the new marker, not the clicked
// coords, because google may not send back precisely the same point.
pnt.coordinates = ls.coordinates[ ls.coordinates.length - 1 ];
data.paths.geometries.push(ls);
data.markers.geometries.push(pnt);
if (debug) {
console.log("lineString: %O", ls);
console.log("point: %O", pnt);
}
return data;
}
// Get the coordinates of the most recently added marker
//
function currentMarkerCoords(data) {
var c = data.markers.geometries[ data.markers.geometries.length - 1 ].coordinates;
return { lng: c[0], lat: c[1] };
}
// Set the map view according to the currently selected city and add a first
// marker centred on the region.
//
function setMapRegion(map) {
var selected = getSelectedRegion();
map.setView([selected.coords.lat, selected.coords.lng], selected.zoom);
return selected.coords;
}
// get the currently selected region from dropdown menu
//
function getSelectedRegion() {
var city = menu.property("value");
return regions.filter(function(d) {
return d.name == city;
})[0];
}
// Projects a lng/lat position onto the canvas
//
function projectPoint(x, y) {
//return map.latLngToLayerPoint(new L.LatLng(y, x));
// http://stackoverflow.com/questions/19660153/d3-leaflet-d3-geo-path-resampling
return map.project(new L.LatLng(y, x))._subtract(map.getPixelOrigin());
}
// Stream to add points to a path
//
function streamPoint(lng, lat) {
var point = projectPoint(lng, lat);
this.stream.point(point.x, point.y);
}
// creates the map object and city select menu
//
function initLeaflet(map) {
var tileProvider = {
url : "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
options: {
attribution: '&copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Directions data: Google'
}
};
// set up Leaflet
var mapboxTiles = L.tileLayer(tileProvider.url, tileProvider.options);
map = L.map('map')
.addLayer(mapboxTiles);
// menu to change cities
L.Control.CitySelectMenu = L.Control.extend({
options: {
position: "topleft"
},
onAdd: function (map) {
var container = L.DomUtil.create("div", "menu");
var stop = L.DomEvent.stopPropagation;
container.innerHTML = "<select></select>";
L.DomEvent
.on(container, 'click', stop)
.on(container, 'mousedown', stop)
return container;
}
});
new L.Control.CitySelectMenu().addTo(map);
menu = d3.select(".menu select");
menu.selectAll("option")
.data(regions).enter()
.append("option")
.text(function(d) { return d.name; });
// allow to pass map to run()
var mapRun = (function(map) { return function() { run(map); }; })(map);
menu.on("change", mapRun);
// display mouse coords in debug mode
if (debug) {
d3.select("body").classed("debug", true);
map.on("mousemove click", function(e) {
d3.select("#ui-" + e.type)
.html( e.containerPoint.toString() + ", " + e.latlng.toString() );
});
}
return map;
}
// create a new data object initialised with start location
//
function initData(coords) {
return {
paths: {
"type": "GeometryCollection",
"geometries": []
},
markers: {
"type": "GeometryCollection",
"geometries": [
{
"type" : "Point",
"coordinates": [ coords.lng, coords.lat ]
}
]
}
};
}
function getComputed(node) {
return window.getComputedStyle(node);
}
function getElementDimensions(node) {
var computed = getComputed(node);
return {
width: parseInt(computed.getPropertyValue("width"), 10),
height: parseInt(computed.getPropertyValue("height"), 10)
};
}
})(this);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment