Animated transition between map projections

Dataviz logo representing a Map chart.

Every world map is a lie. You cannot flatten a sphere onto a screen without breaking something, so each projection picks what to sacrifice. Mercator keeps angles correct, which is why it won the web, and pays for it by inflating everything far from the equator. Natural Earth gives up on both angles and areas to simply look right.

The map below morphs between the two. Watch Greenland: it starts out the size of Africa and shrinks back to something closer to Algeria.

The interesting part is how the animation works. The trick is to interpolate the projection itself rather than the shapes it produces.

Useful links

The plot

One button, sitting on top of the map. Click it and the whole world slides from one projection to the other.

Click the button to morph the map between the Mercator and the Natural Earth projections. Every frame in between is a real projection, not a blend of pictures.

Why you cannot just animate the paths

A world map is a list of <path> elements, one per country. So the obvious idea is to compute the d attribute with Mercator, compute it again with Natural Earth, and tween between the two strings.

This does not work. Tweening two path strings requires them to have the same structure: the same commands, in the same order, with the same number of points. Two projections give no such guarantee. They clip differently at the antimeridian, they resample curves differently, and a country that is one polygon in one projection can be cut into two in the other.

When the structures do not match, the interpolation either gives up or produces garbage geometry that flickers across the screen.

Interpolate the projection

The fix is to move one level down. A projection is, at heart, a function: give it a longitude and a latitude in radians, it hands back an x and a y. D3 exposes those raw functions directly as d3.geoMercatorRaw and d3.geoNaturalEarth1Raw.

So instead of blending the output, blend the function. Build a new raw projection that calls both and mixes their results, then hand it to d3.geoProjection():

export const interpolatedProjection = (progress: number) => {
  const raw = (lambda: number, phi: number): [number, number] => {
    const [x0, y0] = clampedMercatorRaw(lambda, phi);
    const [x1, y1] = d3.geoNaturalEarth1Raw(lambda, phi);

    return [x0 + (x1 - x0) * progress, y0 + (y1 - y0) * progress];
  };

  return d3.geoProjection(raw);
};

Now every intermediate value of progress is itself a valid projection. D3 clips, resamples and closes the shapes for that projection exactly as it would for a real one, so the countries stay watertight polygons the whole way across. Set it to 0 and you get pixel-identical output to d3.geoMercator(). Set it to 1 and you get d3.geoNaturalEarth1().

The Mercator pole problem

There is one trap. Mercator stretches toward infinity as you approach the poles: that is the whole reason Greenland looks so enormous. Feed it a latitude of 90 degrees and it returns Infinity.

An Infinity in the blend above becomes a NaN the moment it is multiplied by progress, and a single NaN poisons the entire path string. The browser then throws the whole country away.

Real web maps solve this by simply cutting the world off around 85 degrees, and so do we:

const MAX_MERCATOR_LAT = (85 * Math.PI) / 180;

const clampedMercatorRaw = (lambda: number, phi: number): [number, number] =>
  d3.geoMercatorRaw(
    lambda,
    Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, phi))
  );

Driving it with motion

All that is left to animate is a single number going from 0 to 1, which is a much smaller problem than animating a map.

This example uses motion. A useMotionValue holds the progress and animate() moves it. There is one twist though: a motion value deliberately does not trigger a React render, because it is designed to be piped straight into a motion component. Here each frame has to go back through D3 to rebuild the projection, so the value is mirrored into state with useMotionValueEvent:

const progress = useMotionValue(0);
const [renderedProgress, setRenderedProgress] = useState(0);

useMotionValueEvent(progress, 'change', setRenderedProgress);

const toggle = () => {
  const next = isNaturalEarth ? 0 : 1;
  setIsNaturalEarth(!isNaturalEarth);
  animate(progress, next, { duration: 1.2, ease: 'easeInOut' });
};

Note the tween rather than a spring. A spring overshoots past its target, and past 1 this blend stops interpolating between the two projections and starts extrapolating beyond Natural Earth, which visibly bulges the continents before settling. A spring is the right default for most animations, just not for this one.

Learn more about springs, tweens and when each one is appropriate in the React & D3.js course, which has a whole module on animation.

Map

Contact

👋 Hey, I'm Yan and I'm currently working on this project!

Feedback is welcome ❤️. You can fill an issue on Github, drop me a message on LinkedIn, or even send me an email pasting yan.holtz.data with gmail.com. You can also subscribe to the newsletter to know when I publish more content!