Inspired by Derek Watkins’s Faux-3D Arcs and Mike Bostock’s World Tour
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
</head>
<body>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<script>
var oceanColor = '#f9f9f9',
landColor = '#ddd',
flyingArcColor = 'tomato',
flyingArcShadowColor = '#333',
flyingArcShadowStrokeColor = '#ccc',
cityMarkerColor = '#999',
cityLabelColor = '#666',
cityLabelShadowColor = '#eee';
var flyingArcWidth = 2,
flyingArcShadowWidth = 0.5,
flyingArcShadowOpacity = 0.5,
flyingArcShadowBlur = 5,
cityMarkerRadius = 2,
cityLabelFont = '16px "Montserrat", sans-serif',
cityLabelTextAlign = 'center',
cityLabelOffset = [0, -7],
cityLabelShadowBlur = 5,
loftedness = 1.3,
transitionDuration = 4000,
transitionEase = d3.easeQuad;
// TODO: These probably shouldn't be global
var link,
focalPoint,
flyingArcLength;
var width = 960,
height = 600;
var canvas = d3.select('body').append('canvas')
.attr('width', width)
.attr('height', height);
var context = canvas.node().getContext('2d');
context.font = cityLabelFont;
context.textAlign = cityLabelTextAlign;
var projection = d3.geoOrthographic()
.scale((height - 10) / 2)
.translate([width / 2, height / 2])
.precision(0.1);
var loftedProjection = d3.geoOrthographic()
.scale(((height - 10) / 2) * loftedness)
.translate([width / 2, height / 2])
.precision(0.1);
var path = d3.geoPath()
.projection(projection)
.context(context);
var swoosh = d3.line()
.curve(d3.curveNatural)
.defined(function(d) { return projection.invert(d); })
.context(context);
var links = [],
linksMap = d3.map();
var draw = function() {}
d3.queue()
.defer(d3.json, 'https://unpkg.com/world-atlas@1/world/110m.json')
.defer(d3.json, 'capitals.json')
.await(ready);
function ready(error, world, capitals) {
if (error) throw error;
var sphere = {type: "Sphere"},
land = topojson.feature(world, world.objects.land);
// Add unique ID for each capital city
capitals.features.forEach(function(d, i) { d.id = i; });
// Spawn links between capital city locations
capitals.features.forEach(function(a) {
capitals.features.forEach(function(b) {
// Don't want a city to link to itself
if (a !== b) {
var source = a.geometry.coordinates,
target = b.geometry.coordinates;
// Build GeoJSON feature from this link
var feature = {
type: 'Feature',
geometry: {
type: "LineString",
coordinates: [source, target]
},
properties: {
sourceName: a.properties.name,
targetName: b.properties.name,
sourceId: a.id,
targetId: b.id
}
};
// Two restrictions:
// 1) Don't link cities that are too close together
// 2) Don't link cities that are too far apart
// TODO: Figure out clipping and remove restriction (2)
var length = d3.geoLength(feature),
minLength = Math.PI / 6,
maxLength = Math.PI / 2;
if (length > minLength && length < maxLength) {
links.push({
sourceId: a.id,
targetId: b.id,
source: source,
target: target,
feature: feature
});
}
}
});
});
linksMap = d3.nest()
.key(function(d) { return d.sourceId; })
.map(links);
draw = function(t) {
context.clearRect(0, 0, width, height);
// Rotate globe to focus on the flying arc
focusGlobeOnPoint(focalPoint(t));
// Oceans
context.beginPath();
path(sphere);
context.fillStyle = oceanColor;
context.fill();
// Land
context.beginPath();
path(land);
context.fillStyle = landColor;
context.fill();
// Flying arc
context.beginPath();
swoosh(flyingArc(link));
context.setLineDash([t * flyingArcLength * 1.7, 1e6]);
context.lineWidth = flyingArcWidth;
context.strokeStyle = flyingArcColor;
context.stroke();
// Flying arc's shadow
context.beginPath();
path(link.feature);
context.setLineDash([t * flyingArcLength * 1.6, 1e6]);
context.globalAlpha = flyingArcShadowOpacity;
context.shadowColor = flyingArcShadowColor;
context.shadowBlur = flyingArcShadowBlur;
context.lineWidth = flyingArcShadowWidth;
context.strokeStyle = flyingArcShadowStrokeColor;
context.stroke();
context.shadowBlur = 0;
context.globalAlpha = 1;
// Source city marker
var p = projection(link.source),
x = p[0],
y = p[1];
context.beginPath();
context.arc(x, y, cityMarkerRadius, 0, 2 * Math.PI);
context.fillStyle = cityMarkerColor;
context.fill();
// Source city label
var x = x + cityLabelOffset[0],
y = y + cityLabelOffset[1];
context.shadowBlur = cityLabelShadowBlur;
context.shadowColor = cityLabelShadowColor;
context.fillStyle = cityLabelColor;
context.fillText(link.feature.properties.sourceName, x, y);
context.shadowBlur = 0;
// Target city marker
var p = projection(link.target),
x = p[0],
y = p[1];
context.beginPath();
context.arc(x, y, cityMarkerRadius, 0, 2 * Math.PI);
context.fillStyle = cityMarkerColor;
context.fill();
// Target city label
var x = x + cityLabelOffset[0],
y = y + cityLabelOffset[1];
context.shadowBlur = cityLabelShadowBlur;
context.shadowColor = cityLabelShadowColor;
context.fillStyle = cityLabelColor;
context.fillText(link.feature.properties.targetName, x, y);
context.shadowBlur = 0;
};
link = pluckRandom(links);
function shuffle() {
focalPoint = d3.geoInterpolate(link.source, link.target);
flyingArcLength = lineLength(flyingArc(link));
var timer = d3.timer(tick);
function tick(elapsed) {
var t0 = elapsed / transitionDuration,
t = transitionEase(t0);
draw(t);
if (t0 >= 1) {
timer.stop();
// The current target becomes the next source. Pick the next
// target at random.
var targetLinks = linksMap.get(link.targetId);
link = pluckRandom(targetLinks);
shuffle();
};
}
}
shuffle();
}
function flyingArc(link) {
var source = link.source,
target = link.target,
middle = locationAlongArc(source, target, 0.5);
return [
projection(source),
loftedProjection(middle),
projection(target)
];
}
function locationAlongArc(start, end, theta) {
return d3.geoInterpolate(start, end)(theta);
}
function focusGlobeOnPoint(point) {
var x = point[0],
y = point[1],
cx = x,
cy = y - 25,
rotation = [-cx, -cy];
projection.rotate(rotation);
loftedProjection.rotate(rotation);
}
function lineLength(points) {
var d = 0;
for (var i = 0; i < points.length - 1; i++) {
var x0 = points[0][0],
y0 = points[0][1],
x1 = points[1][0],
y1 = points[1][1],
dx = x1 - x0,
dy = y1 - y0;
d += Math.sqrt(dx * dx + dy * dy);
}
return d;
}
function randomInt(n) {
return Math.floor(Math.random() * n);
}
function pluckRandom(array) {
var n = array.length - 1,
i = randomInt(n);
return array[i];
}
</script>
</body>
</html>
all: capitals.json
zip/ne_110m_populated_places_simple.zip:
mkdir -p $(dir $@)
curl -L -o $@.download 'http://www.naturalearthdata.com/http//www.naturalearthdata.com/download/110m/cultural/ne_110m_populated_places_simple.zip'
mv $@.download $@
shp/ne_110m_populated_places_simple.shp: zip/ne_110m_populated_places_simple.zip
mkdir -p $(dir $@)
unzip -d $(dir $@) $<
touch $@
capitals.json: shp/ne_110m_populated_places_simple.shp
mapshaper \
-i $< \
-filter 'featurecla === "Admin-0 capital"' \
-each 'countryName = adm0name' \
-filter-fields name,countryName \
-o $@ format=geojson force