Last active
November 19, 2017 19:17
-
-
Save louking/b4605ea009e9171d66dea9346f0fb3d0 to your computer and use it in GitHub Desktop.
google map - d3 overlay, d3-tip, smooth transition
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
license: apache-2.0 | |
height: 500 | |
scrolling: no | |
border: yes |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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