Skip to content

Instantly share code, notes, and snippets.

@louking
Last active November 19, 2017 19:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save louking/b4605ea009e9171d66dea9346f0fb3d0 to your computer and use it in GitHub Desktop.
Save louking/b4605ea009e9171d66dea9346f0fb3d0 to your computer and use it in GitHub Desktop.
google map - d3 overlay, d3-tip, smooth transition
license: apache-2.0
height: 500
scrolling: no
border: yes
<div id='map'></div>
<link type="text/css" rel="stylesheet" href="routes.css">
<script src="https://d3js.org/d3.v4.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.8.0-alpha.1/d3-tip.min.js"></script>
<script src="//maps.google.com/maps/api/js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.8.0-alpha.1/d3-tip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="routes.js"></script>
body {
margin: 0;
}
#map {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
.theroutes, .theroutes svg {
position: absolute;
}
.theroutes circle {
fill: steelblue;
stroke: black;
stroke-width: 1.5px;
}
.theroutes text {
font-size: 14px;
font-family: helvetica, sans-serif;
font-weight: bold;
}
circle.handle {
fill: red;
}
.d3-tip {
line-height: 1.2;
font-size: small;
font-weight: bold;
font-family: sans-serif;
padding: 6px;
background: white;
border: 1px;
border-style: solid;
border-radius: 6px;
}
// map overlay: https://bl.ocks.org/mbostock/899711
// d3 v3 -> v4: https://amdevblog.wordpress.com/2016/07/20/update-d3-js-scripts-from-v3-to-v4/
// see https://developers.google.com/maps/documentation/javascript/customoverlays
var $ = jQuery;
var fulldata = {"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[39.5132856,-77.4099524],"properties":{"id":5,"name":"12+ From FCF","distance":12.4,"surface":"road","gain":592,"links":"<a href=\"https://www.google.com/maps/search/?api=1&query=39.513019, -77.410483\" target=_blank>start</a> <a href=\"http://www.mapmyrun.com/routes/view/1412836360\" target=_blank>route</a> ","lat":39.5132856,"lng":-77.4099524}}},{"type":"Feature","geometry":{"type":"Point","coordinates":[39.4173126,-77.4157472],"properties":{"id":4,"name":"Another baker park","distance":15,"surface":"road","links":"<a href=\"https://www.google.com/maps/search/?api=1&query=39.416927, -77.415869\" target=_blank>start</a> <a href=\"https://www.runningahead.com/logs/db816ba95fe7403490ba401d30cb44ae/courses/b640b55aeb1f4d96ac38f3e98dd692c5\" target=_blank>route</a> ","lat":39.4173126,"lng":-77.4157472}}},{"type":"Feature","geometry":{"type":"Point","coordinates":[39.4173126,-77.4157472],"properties":{"id":1,"name":"Frederick 12","distance":12,"surface":"road","links":"<a href=\"https://www.google.com/maps/search/?api=1&query=39.416927, -77.415869\" target=_blank>start</a> <a href=\"https://www.runningahead.com/logs/db816ba95fe7403490ba401d30cb44ae/courses/b640b55aeb1f4d96ac38f3e98dd692c5\" target=_blank>route</a> ","lat":39.4173126,"lng":-77.4157472}}},{"type":"Feature","geometry":{"type":"Point","coordinates":[39.515982,-77.4941844],"properties":{"id":3,"name":"Hamburg Parking Lot","distance":11.5,"surface":"trail","gain":1234,"links":"<a href=\"https://www.google.com/maps/search/?api=1&query=39.515874, -77.494219\" target=_blank>start</a> <a href=\"undefined\" target=_blank>route</a> ","lat":39.515982,"lng":-77.4941844}}},{"type":"Feature","geometry":{"type":"Point","coordinates":[39.3967216,-77.3590637],"properties":{"id":2,"name":"test lat/lon","distance":5,"surface":"road","links":"<a href=\"https://www.google.com/maps/search/?api=1&query=39.396662, -77.359486\" target=_blank>start</a> <a href=\"undefined\" target=_blank>route</a> ","lat":39.3967216,"lng":-77.3590637}}},{"type":"Feature","geometry":{"type":"Point","coordinates":[39.3967216,-77.3590637],"properties":{"id":6,"name":"test lat/lon 2","distance":6,"surface":"road","links":"<a href=\"https://www.google.com/maps/search/?api=1&query=39.396662, -77.359486\" target=_blank>start</a> <a href=\"http://www.mapmyrun.com/routes/view/1412836360\" target=_blank>route</a> ","lat":39.3967216,"lng":-77.3590637}}},{"type":"Feature","geometry":{"type":"Point","coordinates":[39.3967216,-77.3590637],"properties":{"id":7,"name":"test lat/lon 3","distance":7,"surface":"road","links":"<a href=\"https://www.google.com/maps/search/?api=1&query=39.396662, -77.359486\" target=_blank>start</a> <a href=\"http://www.mapmyrun.com/routes/view/1412836360\" target=_blank>route</a> ","lat":39.3967216,"lng":-77.3590637}}}]};
var data = fulldata.features;
// for metadata within row
var loc2id = {},
id2loc = {};
// keep tip global
var tip;
// console log control
var debug = false;
// configuration for map display
var rcircle = 10,
rcircleselected = 1.5 * rcircle,
pi = Math.PI,
dexpmin = rcircle * 4, // minimum distance for explosion
maxroutes = 40, // maximum number of routes handled for non-overlapping explosion
separation = 5, // number of pixels to separate individual routes during explosion
dexpmax = maxroutes * (rcircle + separation) / (2*pi),
durt = 500, // transition duration (msec)
textdy = 4, // a bit of a hack, trial and error
// padding is from center of circle
padding = rcircleselected + 2, // +2 adjusts for circle stroke width
t = d3.transition(durt);
// set up map overlay
var overlay,
mapwidth,
mapheight;
SVGOverlay.prototype = new google.maps.OverlayView();
function initMap(width, height) {
// Create the Google Map...
var map = new google.maps.Map(d3.select("#map").node(), {
zoom: 9,
center: new google.maps.LatLng(39.431206, -77.415428),
mapTypeId: google.maps.MapTypeId.TERRAIN
});
overlay = new SVGOverlay(map, width, height);
};
$(document).ready(function() {
// set map div height - see https://stackoverflow.com/questions/1248081/get-the-browser-viewport-dimensions-with-javascript
// 50% of viewport
mapheight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
mapwidth = $("#map").width();
$('#map').height( mapheight + 'px' );
// do all the map stuff
initMap( mapwidth, mapheight );
setlocations();
});
function setlocations() {
// set up loc metadata within data
loc2id = {};
for (var i=0; i<data.length; i++) {
var d = data[i]; // get convenient handle
var thisid = d.geometry.properties.id;
var dlat = d.geometry.properties.lat.toFixed(4);
var dlng = d.geometry.properties.lng.toFixed(4);
var key = dlat + "," + dlng;
if (loc2id[key] === undefined) {
loc2id[key] = [];
};
// convenient to save data index rather than id
loc2id[key].push(thisid);
};
// TODO: sort locations somehow - by distance from Frederick center? from center of map?
var locations = Object.keys(loc2id);
locations.sort().reverse(); // currently north to south because key is lat,lng, northern hemi
id2loc = {};
// loop thru locations
for (var i=0; i<locations.length; i++) {
var thisloc = i+1; // locations are 1-based
// loop thru routes at this location
var key = locations[i];
for (var j=0; j<loc2id[key].length; j++) {
var thisid = loc2id[key][j];
id2loc[thisid] = thisloc;
if (debug) console.log('preDraw: id2loc['+thisid+'] = ' + thisloc);
};
};
// update the data array
for (var i=0; i<data.length; i++) {
var thisid = data[i].geometry.properties.id;
var dloc = id2loc[thisid];
data[i].loc = dloc;
data[i].id = thisid;
};
// tell the map about it
overlay.setdata ( data );
}
// define SVGOverlay class
/** @constructor */
function SVGOverlay(map, width, height) {
// Now initialize all properties.
this.map = map;
this.svg = null;
this.data = [];
this.height = height;
this.width = width;
this.handleboundscheck = false;
this.onIdle = this.onIdle.bind(this);
this.onPanZoom = this.onPanZoom.bind(this);
// Explicitly call setMap on this overlay
this.setMap(map);
}
SVGOverlay.prototype.createsvg_ = function () {
// configuration for d3-tip
tip = d3.tip()
.direction('e')
.offset([0,rcircle+1])
.attr("class", "d3-tip")
// .attr("class", function(d) { "tip-" + d.loc })
.html(function(d) {
var dd = d.geometry.properties;
var thistip = dd.name;
thistip += "<br/>" + dd.distance + " miles (" + dd.surface + ")";
if (dd.gain)
thistip += "<br/>" + dd.gain + " ft elev gain";
thistip += "<br/>" + dd.links;
return thistip;
});
this.svg = this.vis.append("svg")
.style("position", 'absolute')
.style("top", 0)
.style("left", 0)
.style("width", this.width)
.style("height", this.height)
.attr("viewBox","0 0 " + this.width + " " + this.height)
.on("click", function() {
if (debug) console.log('map clicked');
tip.hide();
});
this.svg.call(tip);
}
SVGOverlay.prototype.onAdd = function () {
if (debug) console.log('onAdd()')
// create layer div
// clearly this needs to be adjusted or this.svg should be appended to this layer
var mappane = this.getPanes().overlayMouseTarget;
var layer = d3.select(mappane).append("div")
.attr("id", "layer")
.attr("class", "theroutes");
this.vis = d3.select("#layer");
// create svg
this.createsvg_()
this.map.addListener('idle', this.onIdle);
this.map.addListener('bounds_changed', this.onPanZoom);
this.onPanZoom();
};
SVGOverlay.prototype.setdata = function ( data ) {
if (debug) console.log('setdata()')
this.data = data;
this.draw();
};
SVGOverlay.prototype.sethandleboundscheck = function ( val ) {
this.handleboundscheck = val;
};
SVGOverlay.prototype.onPanZoom = function () {
if (debug) console.log('onPanZoom()')
var proj = this.getProjection();
var svgoverlay = this; // for use within d3 functions
// collapse any exploded locations
d3.selectAll(".handle").each(unexplodeData);
this.svg.selectAll('.route')
// .data(this.data, function(d) { return d.id })
.attr('cx', function(d) { return svgoverlay.transform( d.geometry.coordinates ).x })
.attr('cy', function(d) { return svgoverlay.transform( d.geometry.coordinates ).y })
.attr("class", function(d) { return "g-loc-" + d.loc; }) // overwrites class so must be before classed()
.classed("route", true);
this.svg.selectAll('.route-circle')
// .data(this.data, function(d) { return d.id })
.attr("r", rcircle)
.attr("cx", function(d) { return svgoverlay.transform( d.geometry.coordinates ).x })
.attr("cy", function(d) { return svgoverlay.transform( d.geometry.coordinates ).y })
.attr("id", function(d) { return 'route-circle-' + d.id })
.attr("class", function(d) { return "c-loc-" + d.loc; })
.classed("route-circle", true);
this.svg.selectAll('.route-text')
// .data(this.data, function(d) { return d.id })
.attr("x", function(d) { return svgoverlay.transform( d.geometry.coordinates ).x })
.attr("y", function(d) { return svgoverlay.transform( d.geometry.coordinates ).y })
.attr("text-anchor", "middle")
.attr("dy", function(d) { return textdy })
.attr("class", function(d) { return "t-loc-" + d.loc; })
.classed("route-text", true)
.text(function(d) { return d.loc; });
// reset svg location
this.bounds = this.map.getBounds();
var nebounds = this.bounds.getNorthEast();
var swbounds = this.bounds.getSouthWest();
var svgx = Math.round( this.transform( [nebounds.lat(), swbounds.lng()] ).x );
var svgy = Math.round( this.transform( [nebounds.lat(), swbounds.lng()] ).y );
this.svg
.style("left", svgx )
.style("top", svgy )
// .attr("transform", "translate(" + -svgx + "," + -svgy + ")")
.attr("viewBox",svgx + " " + svgy + " " + this.width + " " + this.height);
};
SVGOverlay.prototype.onIdle = function() {
if (debug) console.log('idle event fired');
// when do we start doing this? After first draw, I think
if (this.handleboundscheck) {
// can add special processing after onPanZoom here
this.bounds = this.map.getBounds();
var nebounds = this.bounds.getNorthEast();
var swbounds = this.bounds.getSouthWest();
var lowlat = Math.min(nebounds.lat(), swbounds.lat());
var lowlng = Math.min(nebounds.lng(), swbounds.lng());
var hilat = Math.max(nebounds.lat(), swbounds.lat());
var hilng = Math.max(nebounds.lng(), swbounds.lng());
if (debug) console.log ('(lowlat, hilat, lowlng, hilng) = ' + lowlat + ', ' + hilat + ', ' + lowlng + ', ' + hilng );
};
}
SVGOverlay.prototype.onRemove = function () {
this.map.removeListener('bounds_changed', this.onPanZoom);
this.svg.remove();
this.svg = null;
};
SVGOverlay.prototype.draw = function () {
if (debug) console.log('draw');
var svgoverlay = this; // for use within d3 functions
// if this.svg has been created
if (this.svg) {
// select all starting points
// Add group containers to hold circle and text
var routes = this.svg.selectAll("g")
.data(this.data, function(d) { return d.id });
routes.enter().append("g")
.classed("route", true)
.attr("transform", "translate(0,0)")
.style("cursor", "pointer")
.on("click", explodeData);
routes.exit()
.remove();
// Add a circle and text to all existing routes, if not there already
d3.selectAll(".route").each(function(d, i) {
var thisroute = d3.select(this);
if (thisroute.select("circle").empty()) {
thisroute.append("circle").classed("route-circle", true);
}
if (thisroute.select("text").empty()) {
thisroute.append("text").classed("route-text", true);
}
})
// update point locations
this.onPanZoom();
};
};
// transform point from [lat, lng] to google.maps.Point
SVGOverlay.prototype.transform = function( p ) {
var latlng = new google.maps.LatLng( p[0], p[1] );
var proj = this.getProjection();
return proj.fromLatLngToDivPixel(latlng)
};
// called with group containing circle, text
// if there are other groups in same location, explode
// else special handling for lone group
function explodeData(d, i) {
// Use D3 to select element and also all at same location
var loc = d.loc;
var thisg = d3.select(this);
var theselocs = d3.selectAll(".g-loc-" + loc)
var numlocs = theselocs.size();
var svg = d3.select(this.parentNode);
// shouldn't happen
if (numlocs == 0) {
throw 'noLocationsFound';
// if only one at location, maybe there is some special processing
} else if (numlocs == 1) {
// handle single selection click
// don't let this through to svg click event
// http://bl.ocks.org/jasondavies/3186840
d3.event.stopPropagation();
tip.show(d);
// multiple at location, explode
} else {
// if not selected yet, explode all in same loc
if (!thisg.attr("exploded")) {
// d3.select(this).raise();
theselocs.attr("exploded", true);
var cx = Number(thisg.attr("cx"));
var cy = Number(thisg.attr("cy"));
// create lines now so they're underneath
// initially x1,y1 = x2,y2 because we'll be transitioning
theselocs.each(function (d,i) {
svg.append('line')
.attr("class", "l-loc-" + loc)
.attr("x1", cx)
.attr("y1", cy)
.attr("x2", cx)
.attr("y2", cy)
.attr("stroke-width", 1.5)
.attr("stroke", "black")
.transition(t)
.attr("x2", cx + dexp(numlocs) * Math.cos((2*pi/numlocs)*i))
.attr("y2", cy + dexp(numlocs) * Math.sin((2*pi/numlocs)*i))
});
// create handle for original location
svg.append("circle")
.attr("id", "exploded-" + loc)
.attr("class", "handle")
.attr("loc", d.loc)
.attr("r", rcircle)
.attr("cx", cx)
.attr("cy", cy)
.style("cursor", "pointer")
.on("click", unexplodeData);
// explode
theselocs
.each(function(d, i){
var thisg = d3.select(this);
// transition to new location
thisg.raise().transition(t)
.attr("transform", "translate("
+ dexp(numlocs) * Math.cos((2*pi/numlocs)*i) + ","
+ dexp(numlocs) * Math.sin((2*pi/numlocs)*i) + ")"
);
});
// if exploded and individual selected, maybe there is some special processing
} else {
// handle single selection click
// don't let this through to svg click event
// http://bl.ocks.org/jasondavies/3186840
d3.event.stopPropagation();
tip.show(d);
}
} // multiple at location
};
// called with handle for an exploded group
function unexplodeData(d, i) {
// Use D3 to select element
var handle = d3.select(this);
var loc = handle.attr("loc");
var x = handle.attr("cx");
var y = handle.attr("cy");
var theselocs = d3.selectAll(".g-loc-" + loc);
// set exploded circles to original state
theselocs.transition(t)
.attr("selected", null)
.attr("transform", "translate(0,0)")
.attr("exploded", null);
// shrink lines
d3.selectAll(".l-loc-" + loc)
.transition(t)
.attr("x2", x)
.attr("y2", y)
.remove()
// remove handle
d3.select("#exploded-" + loc).remove();
};
// some other ancillary functions
function id(d) {
return d.geometry.properties.id;
};
function dexp(numlocs) {
var thisdexp = numlocs * (rcircle + separation) / (2*pi);
if (thisdexp < dexpmin) {
thisdexp = dexpmin;
} else if (thisdexp > dexpmax) {
thisdexp = dexpmax;
}
return thisdexp;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment