block by armollica 88ef1c807c4bb4cff6f7e033e25172ee

World Tour along Flying Arcs

Full Screen

Inspired by Derek Watkins’s Faux-3D Arcs and Mike Bostock’s World Tour

index.html

<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>

Makefile

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