The World Bank publishes life expectancy at birth for every country back to 1960 — 64 years of it arrive in a single call. Each year is a frame: press play and watch the map lighten as the post-war gains spread, with the dips where they happened — Cambodia in the late 1970s, Rwanda in 1994.
fetching 64 years from the World Bank…
// which World Bank codes are countries rather than aggregates https://api.worldbank.org/v2/country?format=json&per_page=400 // 64 years of life expectancy for every country, in one call https://api.worldbank.org/v2/country/all/indicator/SP.DYN.LE00.IN?format=json&per_page=20000&date=1960:2023 // geometry — mapjson https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=medium&properties=iso2,name
The World Bank keys its rows by ISO alpha-2 in country.id, which is exactly
mapjson's gid for the countries layer — so the join is a straight lookup, with no
name matching involved. (When a dataset gives you names instead, that is what
/v1/resolve is for; the
renewable electricity example takes that route.)
One catch is worth knowing about. The World Bank returns regional and income aggregates in
the same response as countries, and eleven of their aggregate codes collide with mapjson gids
for tiny territories — XD is High income to them and Dhekelia to us,
XT is Upper middle income and Bir Tawil. A naive join paints those
aggregate values onto real map features, and because the features are specks you would never
notice. The fix is the first call above: /v2/country marks every aggregate with
region.id === "NA", so they can be dropped before the join.
An equal-area world map is unkind to small states: anything under about six square pixels is filled correctly and still invisible. Those get a dot at their centroid in the year's colour, recoloured on every frame like the fills. Scroll to zoom in and each dot retires exactly when its own country grows past that threshold — at scale k a shape covers its area times k², so the marker's job is done.
Note what stays grey. The World Bank reports by reporting economy, not by territory: Martinique, Guadeloupe, Réunion and French Guiana have no series of their own because France reports for them, so they are absent rather than zero. Places that do report separately — Greenland, Bermuda, Hong Kong, Puerto Rico — are present and get markers.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Life expectancy, 1960–2023</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
body { margin: 0; background: #efece6; font: 13px 'IBM Plex Mono', ui-monospace, monospace; color: #3b3a35; }
#map-wrap { position: relative; }
#map-wrap svg { width: 100%; display: block; }
.controls { display: flex; align-items: center; gap: 14px; padding: 12px 14px; }
.controls button { font: inherit; width: 64px; padding: 5px 0; border: 1px solid #3b3a35; background: #fff; cursor: pointer; }
.controls input { flex: 1; accent-color: #2c5f7c; }
.controls span { color: #6b6a63; }
.tip { position: absolute; background: #26251f; color: #f4f1ea; font-size: 11px; padding: 5px 9px;
pointer-events: none; opacity: 0; white-space: nowrap; }
</style>
</head>
<body>
<div id="map-wrap"><div class="tip" id="tip"></div></div>
<div class="controls">
<button id="play">play</button>
<input type="range" id="slider" min="1960" max="2023" value="1960" step="1">
<button id="reset">reset</button>
</div>
<script>
const FROM = 1960, TO = 2023, STEP_MS = 200;
// Under this projected size a country is a pixel or so and its fill cannot be
// read, however right it is — those get a dot instead. See the zoom handler:
// each dot retires once its own shape grows past the same bar.
const MIN_AREA = 6;
const WB_SERIES = "https://api.worldbank.org/v2/country/all/indicator/SP.DYN.LE00.IN"
+ "?format=json&per_page=20000&date=" + FROM + ":" + TO;
const WB_COUNTRIES = "https://api.worldbank.org/v2/country?format=json&per_page=400";
const GEO = "https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=medium"
+ "&properties=iso2,name&format=geojson";
// The World Bank mixes regional and income aggregates in with countries, and
// eleven of their aggregate codes collide with real mapjson gids for tiny
// territories — "XD" is High income to them and Dhekelia to us, "XT" is Upper
// middle income and Bir Tawil. /v2/country flags aggregates with region.id
// "NA", so this drops them before the join instead of painting them on the map.
async function realCountryCodes() {
const [, rows] = await d3.json(WB_COUNTRIES);
return new Set(rows.filter(r => r.region.id !== "NA").map(r => r.iso2Code));
}
(async function () {
const [codes, wb, world] = await Promise.all([
realCountryCodes(),
d3.json(WB_SERIES),
d3.json(GEO),
]);
// year -> Map(iso2 -> life expectancy)
const byYear = new Map();
for (const r of wb[1]) {
if (r.value == null || !codes.has(r.country.id)) continue;
if (!byYear.has(r.date)) byYear.set(r.date, new Map());
byYear.get(r.date).set(r.country.id, r.value);
}
const W = 1200, H = 600;
const svg = d3.select("#map-wrap").append("svg").attr("viewBox", "0 0 " + W + " " + H);
const projection = d3.geoEqualEarth().rotate([-11, 0]);
const path = d3.geoPath(projection);
projection.fitExtent([[10, 10], [W - 10, H - 70]], world);
const color = d3.scaleSequential(d3.interpolateYlGnBu).domain([35, 85]).clamp(true);
// Tooltip coordinates are measured against #map-wrap explicitly. On SVG
// children Chrome reports the pointer offset relative to the hovered shape's
// own box, which lands the tooltip somewhere different for every country.
const wrap = document.getElementById("map-wrap");
const tip = d3.select("#tip");
// Everything that zooms lives in one group. The year stamp and the ramp are
// appended to the svg itself so they hold their size and place.
const gZoom = svg.append("g");
const shapes = gZoom.append("g").selectAll("path").data(world.features).join("path")
.attr("d", path)
.attr("stroke", "#efece6")
.attr("stroke-width", 0.4)
.on("mousemove", function (e, d) {
const v = byYear.get(String(year))?.get(d.properties.iso2);
const [mx, my] = d3.pointer(e, wrap);
tip.style("opacity", 1)
.style("left", (mx + 14) + "px")
.style("top", (my - 6) + "px")
.html(d.properties.name + (v == null ? " · no data" : " · <b>" + v.toFixed(1) + "</b> years"));
})
.on("mouseout", () => tip.style("opacity", 0));
// Small states carry values like anywhere else and disappear at this scale.
// Only the ones that have a reading in some year are worth a marker.
const ever = new Set();
for (const vals of byYear.values()) for (const iso of vals.keys()) ever.add(iso);
const tiny = world.features.filter(d =>
ever.has(d.properties.iso2) && path.area(d) < MIN_AREA);
const tinyArea = new Map(tiny.map(d => [d, path.area(d)]));
const dots = gZoom.append("g").selectAll("circle").data(tiny).join("circle")
.attr("cx", d => path.centroid(d)[0])
.attr("cy", d => path.centroid(d)[1])
.attr("r", 2.8)
.attr("stroke", "#efece6").attr("stroke-width", 0.7)
.on("mousemove", function (e, d) {
const v = byYear.get(String(year))?.get(d.properties.iso2);
const [mx, my] = d3.pointer(e, wrap);
tip.style("opacity", 1)
.style("left", (mx + 14) + "px")
.style("top", (my - 6) + "px")
.html(d.properties.name + (v == null ? " · no data" : " · <b>" + v.toFixed(1) + "</b> years"));
})
.on("mouseout", () => tip.style("opacity", 0));
// Baked into the SVG rather than the HTML controls so a saved PNG says which
// year it is.
const stamp = svg.append("text").attr("x", 16).attr("y", H - 22)
.attr("font-family", "'IBM Plex Mono', ui-monospace, monospace")
.attr("font-size", 40).attr("font-weight", 600).attr("fill", "#26251f");
const legend = svg.append("g").attr("transform", "translate(" + (W - 330) + "," + (H - 46) + ")");
const grad = svg.append("defs").append("linearGradient").attr("id", "ramp");
grad.selectAll("stop").data(d3.range(0, 1.01, 0.1)).join("stop")
.attr("offset", d => (d * 100) + "%")
.attr("stop-color", d => color(35 + d * 50));
legend.append("rect").attr("width", 240).attr("height", 10).attr("fill", "url(#ramp)");
legend.append("text").attr("y", 26).attr("font-size", 12).attr("fill", "#6b6a63")
.attr("font-family", "'IBM Plex Mono', ui-monospace, monospace").text("35");
legend.append("text").attr("x", 240).attr("y", 26).attr("text-anchor", "end").attr("font-size", 12)
.attr("font-family", "'IBM Plex Mono', ui-monospace, monospace")
.attr("fill", "#6b6a63").text("85 years");
let year = FROM, timer = null;
const slider = document.getElementById("slider");
const playBtn = document.getElementById("play");
function draw() {
const vals = byYear.get(String(year)) || new Map();
const fill = (d) => {
const v = vals.get(d.properties.iso2);
return v == null ? "#d9d5cc" : color(v);
};
shapes.attr("fill", fill);
dots.attr("fill", fill);
stamp.text(year);
slider.value = year;
}
// Zoom transforms the drawn group instead of reprojecting, so panning is
// cheap even mid-animation. Strokes and dots divide by k to hold their size,
// and a dot vanishes once its country covers MIN_AREA at the current scale.
const zoom = d3.zoom().scaleExtent([1, 60]).on("zoom", (e) => {
const k = e.transform.k;
gZoom.attr("transform", e.transform);
shapes.attr("stroke-width", 0.4 / k);
dots.attr("r", 2.8 / k).attr("stroke-width", 0.7 / k)
.attr("display", d => (tinyArea.get(d) * k * k >= MIN_AREA ? "none" : null));
});
svg.call(zoom).style("cursor", "grab");
const resetBtn = document.getElementById("reset");
if (resetBtn) resetBtn.onclick = () =>
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
function stop() { if (timer) { timer.stop(); timer = null; } playBtn.textContent = "play"; }
function start() {
if (year >= TO) year = FROM;
playBtn.textContent = "pause";
timer = d3.interval(() => {
year = year >= TO ? FROM : year + 1;
draw();
}, STEP_MS);
}
playBtn.onclick = () => (timer ? stop() : start());
slider.oninput = () => { stop(); year = +slider.value; draw(); };
draw();
start();
// The example page on mapjson.com has a status line; the standalone does not.
const live = document.getElementById("live");
if (live) live.innerHTML = "<b>" + byYear.get(String(TO)).size + "</b> countries in " + TO
+ ", " + byYear.size + " years of them, from one World Bank call — press play.";
})();
</script>
</body>
</html>