Skip to content

Instantly share code, notes, and snippets.

@r-suen
Last active August 28, 2017 18:03
Show Gist options
  • Save r-suen/277e54e0879f56373668e638f6f0039e to your computer and use it in GitHub Desktop.
Save r-suen/277e54e0879f56373668e638f6f0039e to your computer and use it in GitHub Desktop.
Dynamic Map 1:50m

Dynamic map using D3 and TopoJSON with data from Natural Earth. The goal is to implement a performant nonintrusive background map at 1:50 million scale.

Natural Earth data is processed from the command-line to convert shape file to GeoJSON to TopoJSON. The data is reduced, quantized, and projected to Mercator to reduce file size and remove client side projection calculation.

The map is rendered using dynamic simplification and viewport clipping. The zoom level is based on a map tile of 256 x 256 pixels. Map panning is constrained to the y-axis.

Zoom in and out to see the map simplification along with states and cities at higher zoom levels.

<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Dynamic Map 1:50m</title>
<script src='https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.0/topojson.min.js'></script>
</head>
<style>
body {
margin: 0;
overflow: hidden;
}
</style>
<body>
<div id='map'></div>
<script>
var style = {
'background': {
'fill-color': '#000'
},
'countries': {
'fill-color': '#555',
'line-color': '#333',
'line-width': 1
},
'states': {
'line-color': '#777'
},
'places': {
'circle-color': '#999',
'circle-radius': 2.5,
'circle-stroke-color': '#000',
'circle-stroke-width': 1,
'text-color': '#999',
'text-stroke-color': '#000',
'text-stroke-width': 1,
'text-font': 'Helvetica',
'text-size': '13px',
'text-justify': 'center',
'text-baseline': 'bottom'
}
};
const convertZoomLevelToMercator = (zoomLevel) =>
Math.pow(2, 8 + zoomLevel) / 2 / Math.PI;
const convertZoomLevelFromMercator = (zoomLevelInMercator) =>
Math.log(zoomLevelInMercator * 2 * Math.PI) / Math.LN2 - 8;
var bbox;
var width = window.innerWidth,
height = window.innerHeight;
var dpr = window.devicePixelRatio;
var canvas = d3.select('#map').append('canvas')
.attr('width', width * dpr)
.attr('height', height * dpr)
.style('width', width + 'px')
.style('height', height + 'px');
var ctx = canvas.node().getContext('2d');
ctx.scale(dpr, dpr);
var minZ;
var transform = d3.geoIdentity()
.clipExtent([[0, 0], [width, height]]);
var simplify = d3.geoTransform({
point: function(x, y, z) {
if (z >= minZ) {
this.stream.point(x, y);
}
}
});
var zoom = d3.zoom()
.on('zoom', zoomed);
var path = d3.geoPath()
.projection({
stream: function(s) {
return simplify.stream(transform.stream(s));
}
})
.context(ctx);
var pathPlace = d3.geoPath()
.pointRadius(style.places['circle-radius'])
.projection(transform)
.context(ctx);
var land,
border,
states,
places;
d3.json('world-50m.json', function(error, data) {
if (error) throw error;
bbox = topojson.bbox(data),
bbox.width = bbox[2] - bbox[1],
bbox.height = bbox[3] - bbox[1];
data = topojson.presimplify(data);
land = topojson.merge(data, data.objects.countries.geometries);
border = topojson.mesh(data, data.objects.countries, function(a, b) { return a !== b; });
d3.json('states-50m.json', function(error, data) {
if (error) throw error;
data = topojson.presimplify(data);
states = topojson.mesh(data, data.objects.states, function(a, b) { return a !== b; });
d3.json('places-10m.json', function(error, data) {
if (error) throw error;
places = topojson.feature(data, data.objects.places);
let p = d3.geoMercator()([0, 0]),
s = height / bbox.height;
zoom
.scaleExtent([s, 1 << 10]);
canvas
.call(zoom)
.call(zoom.transform, d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(s)
.translate(-p[0], -p[1]));
});
});
});
function zoomed() {
let t = d3.event.transform;
let z = Math.floor(convertZoomLevelFromMercator(t.k * 256 / Math.PI));
minZ = 1 / (t.k * t.k);
t.y = (bbox.height * t.k >= height) ? Math.min(Math.max(-(bbox.height * t.k - height), t.y), 0)
: Math.max(Math.min(height - bbox.height * t.k, t.y), 0);
transform.translate([t.x, t.y]).scale(t.k);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = style.background['fill-color'];
ctx.fillRect(0, 0, width, height);
drawMap(z);
if (z >= 4) drawPlaces(z)
}
function drawMap(zoom) {
ctx.fillStyle = style.countries['fill-color'];
ctx.lineWidth = style.countries['line-width'];
ctx.beginPath();
path(land);
ctx.fill();
if (zoom >= 4) {
ctx.strokeStyle = style.states['line-color'];
path(states);
ctx.stroke();
}
ctx.strokeStyle = style.countries['line-color'];
ctx.beginPath();
path(border);
ctx.stroke();
}
function drawPlaces(zoom) {
let rank = { 4: 1, 5: 3, 6: 5 };
rank = (rank[zoom] && zoom >= 4 ) ? rank[zoom] : zoom;
ctx.font = style.places['text-size'] + ' ' + style.places['text-font'];
ctx.textAlign = style.places['text-justify'];
ctx.textBaseline = style.places['text-baseline'];
ctx.fillStyle = style.places['text-color'];
ctx.lineWidth = style.places['text-stroke-width'];
ctx.strokeStyle = style.places['text-stroke-color'];
ctx.beginPath();
places.features.filter(d => d.properties.scalerank <= rank).forEach(drawPlace);
ctx.fillStyle = style.places['circle-color'];
ctx.lineWidth = style.places['circle-stroke-width'];
ctx.strokeStyle = style.places['circle-stroke-color'];
ctx.fill();
ctx.stroke();
}
function drawPlace(data) {
let p = pathPlace.centroid(data);
pathPlace(data);
ctx.strokeText(data.properties.name, p[0], p[1] - 5);
ctx.fillText(data.properties.name, p[0], p[1] - 5);
}
</script>
</body>
</html>
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment