Skip to content

Instantly share code, notes, and snippets.

@RandomEtc
Created September 22, 2011 02:41
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save RandomEtc/1233904 to your computer and use it in GitHub Desktop.
Map Tiles in D3

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.

<!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 &copy; <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