Skip to content

Instantly share code, notes, and snippets.

@armollica
Last active July 16, 2018 11:31
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save armollica/88ef1c807c4bb4cff6f7e033e25172ee to your computer and use it in GitHub Desktop.
Save armollica/88ef1c807c4bb4cff6f7e033e25172ee to your computer and use it in GitHub Desktop.
World Tour along Flying Arcs
height: 600
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment