An exercise in learning D3 / a proof of concept / my hat-in-the-ring for what comes next after Polymaps and Modest Maps. See the github project page for more information.
Created
September 22, 2011 02:41
-
-
Save RandomEtc/1233904 to your computer and use it in GitHub Desktop.
Map Tiles in D3
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
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>Map Tiles in D3</title> | |
<!-- An exercise in learning D3 for DOM manipulation and transitions. | |
Uses CSS (3D) transforms where available, falls back to normal CSS if not. | |
Coordinate and tile positioning logic cribbed from Modest Maps. | |
A two day hack... Doubtless still glitches to be ironed out! | |
No projections, no layers, no double click, no touching, no overlays. | |
Queueing img requests seems overkill but smooths things out a lot. | |
(C) 2011 Tom Carden, released under the same BSD license as D3 itself: | |
https://github.com/mbostock/d3/blob/master/LICENSE | |
Forks welcome! --> | |
<script src="http://mbostock.github.com/d3/d3.min.js"></script> | |
<script type="text/javascript"> | |
var coord = [ 2, 2, 2 ], // col, row, zoom | |
roundCoord = null, // coord at an integer zoom level | |
tileSize = [ 256, 256 ], // px | |
redraw = null, // visible here for console hacking | |
loadedTiles = {}, // [src] --> millis when load completed | |
w = window.innerWidth, | |
h = window.innerHeight, | |
center = [ w/2, h/2 ]; // center of map in pixels | |
// borrowed from Modest Maps, inspired by LeafletJS | |
var transformProperty = (function(props) { | |
var style = document.documentElement.style; | |
for (var i = 0; i < props.length; i++) { | |
if (props[i] in style) { | |
return props[i]; | |
} | |
} | |
return false; | |
})(['transform', '-webkit-transform', '-o-transform', '-moz-transform', '-ms-transform']); | |
var matrixString = (function() { | |
if (('WebKitCSSMatrix' in window) && ('m11' in new WebKitCSSMatrix())) { | |
return function(scale,x,y,cx,cy) { | |
scale = scale || 1; | |
return 'translate3d(' + [ x, y, '0px' ].join('px,') + ') scale3d(' + [ scale,scale,1 ].join(',') + ')'; | |
/* return 'matrix3d(' + | |
[ scale, '0,0,0,0', | |
scale, '0,0,0,0,1,0', | |
(x + ((cx * scale) - cx)).toFixed(4), | |
(y + ((cy * scale) - cy)).toFixed(4), | |
'0,1'].join(',') + ')'; */ | |
} | |
} else { | |
return function(scale,x,y,cx,cy) { | |
var unit = (transformProperty == 'MozTransform') ? 'px' : ''; | |
return 'matrix(' + | |
[(scale || '1'), 0, 0, | |
(scale || '1'), | |
(x + ((cx * scale) - cx)) + unit, | |
(y + ((cy * scale) - cy)) + unit | |
].join(',') + ')'; | |
} | |
} | |
})(); | |
// make a tile provider that knows how to wrap tiles around the world | |
function provider(d) { | |
var c = d.slice(); | |
var minCol = 0, | |
maxCol = Math.pow(2,d[2]); | |
while (c[0] < minCol) c[0] += maxCol; | |
while (c[0] >= maxCol) c[0] -= maxCol; | |
var z = c[2], x = c[0], y = c[1]; | |
return 'http://otile1.mqcdn.com/tiles/1.0.0/osm/'+z+'/'+x+'/'+y+'.jpg'; | |
} | |
// ----- Queue stuff (should really be wrapped in an object) | |
var queue = [], | |
queueById = {}, | |
numOpenRequests = 0, | |
requestById = {}; | |
// called with .each, this == <img> | |
function addToQueue(d,i) { | |
var src = provider(d); | |
if (src in loadedTiles) { | |
// if we've seen it this session the browser cache probably has it | |
d3.select(this).attr("src", src); | |
} else { | |
var item = { id: this.id, img: this }; | |
queue.push(item); | |
queueById[item.id] = item; | |
} | |
} | |
// called with .each, this == <img> | |
function removeFromQueue(d,i) { | |
// attempt to cancel loading for incomplete tiles | |
// and prevent very large/tiny tiles from being scaled | |
// (remove these immediately so they don't slow down positioning) | |
if (!this.complete || (coord[2] - d[2] > 5) || (d[2] - coord[2] > 2)) { | |
this.src = null; | |
d3.select(this).remove(); | |
} | |
// also clear the open request | |
removeOpenRequest(this); | |
// and mark the image null in the queue so it will be skipped | |
var item = queueById[this.id]; | |
if (item) { | |
item.img = null; | |
delete queueById[this.id]; | |
} | |
} | |
// called when tiles are complete /or/ canceled | |
function removeOpenRequest(img) { | |
var request = requestById[img.id]; | |
if (request) { | |
request.img = null; | |
delete requestById[img.id]; | |
numOpenRequests--; | |
} | |
} | |
// request up to 8 things from the queue, skipping blank items | |
function processQueue() { | |
while (numOpenRequests < 8 && queue.length > 0) { | |
var request = queue.shift(); | |
if (request.img && request.img.parentNode) { | |
// luckily there's a magic mapping inside d3 | |
// that knows how to pass the correct data to provider() | |
d3.select(request.img) | |
.attr("src", provider); | |
requestById[request.id] = request; | |
numOpenRequests++; | |
} | |
delete queueById[request.id]; | |
} | |
} | |
// ----- end Queue stuff | |
// ----- Tile Positioning Functions | |
// remove round/ceil for greater accuracy but visible seams | |
function left(d) { | |
var scale = Math.pow(2, coord[2]-d[2]), | |
power = Math.pow(2, d[2] - roundCoord[2]), | |
centerCol = roundCoord[0] * power; | |
return Math.round(center[0] + (d[0] - centerCol) * tileSize[0] * scale) + 'px'; | |
} | |
function top(d) { | |
var scale = Math.pow(2, coord[2]-d[2]), | |
power = Math.pow(2, d[2] - roundCoord[2]), | |
centerRow = roundCoord[1] * power; | |
return Math.round(center[1] + (d[1] - centerRow) * tileSize[1] * scale) + 'px'; | |
} | |
function width(d) { | |
var scale = Math.pow(2, coord[2]-d[2]); | |
return Math.ceil(scale * tileSize[0])+'px'; | |
} | |
function height(d) { | |
var scale = Math.pow(2, coord[2]-d[2]); | |
return Math.ceil(scale * tileSize[1])+'px'; | |
} | |
// for 3D webkit mode | |
function transform(d) { | |
var scale = Math.pow(2, coord[2]-d[2]); | |
// adjust to nearest whole pixel scale (thx @tmcw) | |
if (scale * tileSize[0] % 1) { | |
scale += (1 - scale * tileSize[0] % 1) / tileSize[0]; | |
} | |
var zoomedCoord = zoomedBy(roundCoord, d[2] - roundCoord[2]), | |
x = Math.round(center[0] + (d[0] - zoomedCoord[0]) * tileSize[0] * scale), | |
y = Math.round(center[1] + (d[1] - zoomedCoord[1]) * tileSize[1] * scale); | |
return matrixString(scale, x, y, tileSize[0]/2.0, tileSize[1]/2.0); | |
} | |
// ----- end Tile Positioning Functions | |
// ----- Coordinate manipulation | |
// NB:- coords are just Arrays [ x,y,z ] | |
// create a new coord with [ col, row, zoom ] (x,y,z) | |
// copy a coord with c.slice() | |
function zoomedBy(c, dz) { | |
var power = Math.pow(2,dz); | |
return [ c[0] * power, c[1] * power, c[2] + dz ]; | |
} | |
function offsetBy(c, o) { | |
return [ c[0] + o[0], c[1] + o[1], c[2] + o[2] ]; | |
} | |
function container(c) { | |
c = zoomedBy(c, Math.round(c[2])-c[2]); | |
return [ Math.floor(c[0]), Math.floor(c[1]), c[2] ]; | |
} | |
// ----- end Coordinate manipulation | |
window.onload = function() { | |
var map = d3.select("body") | |
.insert("div","p") | |
.attr("class", "map") | |
.style("width", "100%") | |
.style("height", "100%"); | |
redraw = function() { | |
// apply coord limits | |
if (coord[2] > 18) { | |
coord = zoomedBy(coord, 18-coord[2]); | |
} else if (coord[2] < 0) { | |
coord = zoomedBy(coord, -coord[2]); | |
} | |
// find coordinate extents of map | |
var tl = offsetBy(coord, [ -center[0] / tileSize[0], -center[1] / tileSize[1], 0 ]), | |
br = offsetBy(coord, [ center[0] / tileSize[0], center[1] / tileSize[1], 0 ]); | |
// round coords to "best" zoom level | |
roundCoord = zoomedBy(coord, Math.round(coord[2])-coord[2]); | |
tl = zoomedBy(tl, Math.round(tl[2])-tl[2]); | |
br = zoomedBy(br, Math.round(br[2])-br[2]); | |
// generate visible tile coords | |
var padding = 0; | |
var cols = d3.range( Math.floor(tl[0]) - padding, Math.ceil(br[0]) + padding), | |
rows = d3.range( Math.floor(tl[1]) - padding, Math.ceil(br[1]) + padding), | |
visibleCoords = []; | |
rows.forEach(function(row) { | |
cols.forEach(function(col) { | |
visibleCoords.push([col,row,roundCoord[2]]); | |
}); | |
}); | |
// don't show above/below the poles | |
visibleCoords = visibleCoords.filter(function(c) { | |
var minRow = 0, | |
maxRow = Math.pow(2,c[2]); | |
return c[1] >= minRow && c[1] < maxRow; | |
}); | |
// explicitly preserve parent tiles for tiles we haven't already loaded | |
// not strictly necessary but helps with continuity on slow connections | |
var compensationCoords = []; | |
uniqueCompensations = {}; | |
/*function addPyramidParents(c) { | |
if (c[2] > 0) { | |
c = container(zoomedBy(c, -1)); | |
var src = provider(c); | |
if (!(src in uniqueCompensations)) { | |
uniqueCompensations[src] = true; | |
compensationCoords.push(c); | |
} | |
addPyramidParents(c); | |
} | |
} | |
visibleCoords.forEach(addPyramidParents); | |
compensationCoords.sort(function(c1,c2) { | |
return d3.ascending(c1[2],c2[2]); | |
})*/ | |
function addParentIfNeeded(c) { | |
if (c[2] > 0 && coord[2] - c[2] < 3) { | |
var src = provider(c); | |
if (!(src in loadedTiles) || (Date.now() - loadedTiles[src] < 250)) { | |
c = container(zoomedBy(c, -1)); | |
src = provider(c); | |
if (src in loadedTiles && !(src in uniqueCompensations)) { | |
uniqueCompensations[src] = true; | |
compensationCoords.push(c); | |
} | |
// better continuity if we loop, but slower (needs tuning) | |
addParentIfNeeded(c); | |
} | |
} | |
} | |
visibleCoords.forEach(addParentIfNeeded); | |
visibleCoords = compensationCoords.concat(visibleCoords); | |
var tiles = map.selectAll('img.tile') | |
.data(visibleCoords, String); | |
// setup new things | |
tiles.enter().append('img') | |
.attr("id", String) | |
.attr("class", "tile") | |
.style("opacity", 1e-6) | |
.style("display", "none") // opacity doesn't seem to "take" until load event fires | |
.style("z-index", function(d) { return 100 * d[2] }) | |
.on('load', function(d) { | |
loadedTiles[this.src] = Date.now(); | |
d3.select(this) | |
.style("display", "block") | |
.style("opacity", 1e-6) | |
.transition() | |
.duration(250) | |
.style("opacity", 1.0); | |
removeOpenRequest(this); | |
// request redraw (which will also check queue) | |
d3.timer(redraw,50); // TODO: only remove compensation tiles for this tile instead of a full redraw | |
}) | |
.each(addToQueue) // sets img src 8 at a time using provider() | |
// TODO: on('error')? | |
// ensure updating tiles are at opacity 1.0 (if old enough) | |
tiles.filter(function() { | |
return (this.src in loadedTiles) && (Date.now() - loadedTiles[this.src] > 500); | |
}).transition().duration(250).style("opacity", 1.0); | |
// clean up old things | |
tiles.exit() | |
.each(removeFromQueue) | |
.transition() | |
.duration(250) | |
.style("opacity", 1e-6) | |
.delay(250) | |
.remove() | |
.each('end',function() { | |
// prevents blank tiles if zooming confuses transitions | |
d3.timer(redraw,50); | |
}); | |
// update all positions, enter/update/exit alike | |
if (transformProperty) { | |
map.selectAll('img.tile') | |
.style(transformProperty, transform); | |
} else { | |
map.selectAll('img.tile') | |
.style("left", left) | |
.style("top", top) | |
.style("width", width) | |
.style("height", height); | |
} | |
//console.log(map.selectAll('img.tile')[0].length, 'img.tiles'); | |
// see what's new | |
processQueue(); | |
return true; | |
} | |
d3.timer(redraw); | |
map.on('mousedown.map', function() { | |
var prevMouse = [ d3.event.pageX, d3.event.pageY ]; | |
d3.select(window) | |
.on('mousemove.map', function() { | |
var mouse = prevMouse; | |
prevMouse = [ d3.event.pageX, d3.event.pageY ]; | |
coord = offsetBy(coord, [ | |
-((prevMouse[0] - mouse[0]) / tileSize[0]), | |
-((prevMouse[1] - mouse[1]) / tileSize[1]), | |
0 | |
]); | |
d3.event.preventDefault(); | |
d3.event.stopPropagation(); | |
d3.timer(redraw); | |
}) | |
.on('mouseup.map', function() { | |
prevMouse = null; | |
d3.select(window) | |
.on('mousemove.map',null) | |
.on('mouseup.map',null); | |
}) | |
d3.event.preventDefault(); | |
d3.event.stopPropagation(); | |
}) | |
.on('mousewheel.map', onWheel) | |
.on('DOMMouseScroll.map', onWheel); | |
function onWheel() { | |
// 18 = max zoom, 0 = min zoom | |
var delta = Math.min(18-coord[2],Math.max(0-coord[2],d3_behavior_zoomDelta())); | |
if (delta != 0) { | |
var mouse = [ d3.event.pageX, d3.event.pageY ]; | |
coord = offsetBy(coord, [ | |
((mouse[0]-center[0]) / tileSize[0]), | |
((mouse[1]-center[1]) / tileSize[1]), | |
0 | |
]); | |
coord = zoomedBy(coord, delta); | |
coord = offsetBy(coord, [ | |
-((mouse[0]-center[0]) / tileSize[0]), | |
-((mouse[1]-center[1]) / tileSize[1]), | |
0 | |
]); | |
d3.timer(redraw); | |
} | |
d3.event.preventDefault(); | |
d3.event.stopPropagation(); | |
} | |
d3.select(window).on('resize.map', function() { | |
center = [ window.innerWidth/2, window.innerHeight/2 ] | |
d3.timer(redraw); | |
}); | |
} | |
// expose this so our own mousewheel handler can use it | |
var d3_behavior_zoomDiv = null; | |
// detect the pixels that would be scrolled by this wheel event | |
function d3_behavior_zoomDelta() { | |
// mousewheel events are totally broken! | |
// https://bugs.webkit.org/show_bug.cgi?id=40441 | |
// not only that, but Chrome and Safari differ in re. to acceleration! | |
if (!d3_behavior_zoomDiv) { | |
d3_behavior_zoomDiv = d3.select("body").append("div") | |
.style("visibility", "hidden") | |
.style("top", 0) | |
.style("height", 0) | |
.style("width", 0) | |
.style("overflow-y", "scroll") | |
.append("div") | |
.style("height", "2000px") | |
.node().parentNode; | |
} | |
var e = d3.event, delta; | |
try { | |
d3_behavior_zoomDiv.scrollTop = 250; | |
d3_behavior_zoomDiv.dispatchEvent(e); | |
delta = 250 - d3_behavior_zoomDiv.scrollTop; | |
} catch (error) { | |
delta = e.wheelDelta || (-e.detail * 5); | |
} | |
return delta * .005; | |
} | |
</script> | |
<style> | |
html, body { | |
width: 100%; | |
height: 100%; | |
margin: 0; | |
padding: 0; | |
border: 0; | |
} | |
div.map { | |
position: absolute; | |
overflow: hidden; | |
margin: 0; | |
padding: 0; | |
border: 0; | |
} | |
img.tile { | |
display: block; | |
position: absolute; | |
margin: 0; | |
padding: 0; | |
border: 0; | |
-webkit-transform-origin: 0px 0px; | |
} | |
p { | |
font: bold 12px sans-serif; | |
position: absolute; | |
display: block; | |
right: 5px; | |
bottom: 5px; | |
color: white; | |
text-shadow: 1px 1px 4px rgba(0,0,0,0.75); | |
z-index: 250; | |
margin: 0; | |
padding: 5px; | |
} | |
p a { | |
color: white; | |
} | |
</style> | |
</head> | |
<body> | |
<p>Tiles © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, CC-BY-SA. Courtesy of <a href="http://www.mapquest.com/" target="_blank">MapQuest</a> <img src="http://developer.mapquest.com/content/osm/mq_logo.png"></p> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment