0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Fourteen countries, 120 years, ten seconds: income runs left to right on a log scale, life expectancy runs upward, and every bubble is sized by population. It opens already playing, so the first thing you see is the twentieth century happening — the 1918 flu dropping Russia to the floor of the chart, China's Great Leap famine, the Soviet collapse, southern Africa's HIV decade, and the long diagonal climb of almost everyone toward the top-right corner. Turn on Trails and scrub back to 1900 to see all fourteen paths at once, then hover a bubble for its numbers or open Numbers for the table behind the picture. Figures are approximate — decade-scale estimates reshaped from the Maddison Project and Gapminder / HMD, right in shape and order of magnitude but not citable.
Source — the code you see is the code that just ran 1148 lines 53.5 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animated Gapminder — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── region palette ───────────────────────────────────────────────────────────
Four Gapminder world regions. A bubble chart puts every mark on screen at once,
so adjacency is not enough — these are validated all-pairs AND each colour is
checked against the plot field it is drawn on (WCAG 1.4.11 non-text contrast,
3:1 floor: the full-alpha bubble outline is what carries the mark boundary).
light on --field #FBFBF9 eu 4.3 · af 3.7 · am 4.2 · as 6.1
dark on --field #171A20 eu 4.8 · af 5.7 · am 4.4 · as 3.5
worst pair, ΔE76 in Lab light 70 normal / 31 deuteranopia
dark 73 normal / 25 deuteranopia
The light amber and magenta are deliberately darker than their dark-theme twins
— on near-white the earlier #eda100 / #e87ba4 measured 2.1:1 and 2.6:1 and the
bubbles washed out. Identity still never rests on hue alone: a persistent
legend, direct labels drawn on every large bubble, the hover/selection readout
and the full numbers table all name the country and its region in words. ---- */
:root {
--r-eu: #2a78d6; /* Europe — blue */
--r-af: #B67500; /* Africa — amber */
--r-am: #D2417B; /* Americas — magenta */
--r-as: #00701F; /* Asia & Oceania— green */
--field: #FBFBF9;
--accent-ink: #A34700; /* accent for TEXT: --accent is only 4.20:1 on --accent-soft */
--shadow: 0 6px 22px rgba(0,0,0,.16);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--r-eu: #3987e5; --r-af: #c98500; --r-am: #d55181; --r-as: #008300;
--field: #171A20; --accent-ink: #FFB454;
--shadow: 0 8px 26px rgba(0,0,0,.62);
}
}
:root[data-theme="dark"] {
--r-eu: #3987e5; --r-af: #c98500; --r-am: #d55181; --r-as: #008300;
--field: #171A20; --accent-ink: #FFB454;
--shadow: 0 8px 26px rgba(0,0,0,.62);
}
/* ── shell ────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0; padding: 18px 14px 26px; line-height: 1.5; font-size: 15px;
overflow-x: hidden;
}
.wrap { max-width: 1060px; margin: 0 auto; display: grid; gap: 14px; }
header .kicker {
font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); margin: 0 0 6px;
}
h1 { font-size: clamp(22px, 5.2vw, 30px); line-height: 1.15; margin: 0 0 7px; letter-spacing: -.015em; }
.lede { margin: 0; color: var(--muted); max-width: 68ch; font-size: clamp(13.5px, 3.3vw, 15px); }
.lede b { color: var(--ink); font-weight: 600; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px; min-width: 0;
}
/* ── transport bar ────────────────────────────────────────────────────────── */
.transport { display: grid; gap: 10px; grid-template-columns: 1fr; }
.tline { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
.year {
font-family: var(--font-mono); font-size: clamp(26px, 7.4vw, 38px);
font-weight: 600; letter-spacing: -.02em; line-height: 1;
font-variant-numeric: tabular-nums; color: var(--ink); flex: none;
min-width: 4.2ch; text-align: right;
}
.scrub { flex: 1 1 210px; min-width: 0; display: grid; gap: 3px; }
.scrub-ends {
display: flex; justify-content: space-between;
font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; color: var(--muted);
}
input[type=range] {
width: 100%; margin: 0; accent-color: var(--accent);
background: transparent; cursor: pointer; height: 26px;
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 5px; }
.btn {
background: var(--surface); border: 1px solid var(--line); color: var(--ink);
border-radius: 8px; padding: 7px 11px; cursor: pointer; white-space: nowrap;
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.35;
display: inline-flex; align-items: center; gap: 6px;
}
.btn:hover { border-color: var(--accent); background: var(--accent-soft); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); }
.btn.play {
background: var(--accent); border-color: var(--accent); color: var(--surface);
font-weight: 600; min-width: 92px; justify-content: center; padding: 9px 12px;
}
.btn.play:hover { filter: brightness(1.07); }
.glyph { font-size: 11px; line-height: 1; }
.seg { position: relative; display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--field); flex: none; }
.seg input { position: absolute; opacity: 0; pointer-events: none; }
.seg label {
padding: 6px 9px; font-family: var(--font-mono); font-size: 12px; cursor: pointer;
color: var(--muted); border-right: 1px solid var(--line); user-select: none; white-space: nowrap;
}
.seg label:last-of-type { border-right: 0; }
.seg label:hover { color: var(--ink); background: var(--accent-soft); }
.seg input:checked + label { background: var(--accent-soft); color: var(--accent-ink); font-weight: 600; }
.seg input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: -2px; }
.seg-cap {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em;
text-transform: uppercase; color: var(--muted); margin-right: 2px; flex: none;
}
.tline.opts { flex-wrap: wrap; gap: 8px; }
.spacer { flex: 1 1 auto; }
/* ── plot ─────────────────────────────────────────────────────────────────── */
.plotwrap { position: relative; }
canvas { display: block; width: 100%; height: auto; border-radius: 6px; touch-action: pan-y; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tip {
position: absolute; z-index: 5; pointer-events: none; opacity: 0;
transform: translate(-50%, -100%); transition: opacity .12s ease;
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
padding: 8px 10px; min-width: 168px; box-shadow: var(--shadow);
}
.tip.on { opacity: 1; }
.tip .tname { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 7px; }
.tip .sw { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.tip .treg { font-family: var(--font-mono); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 2px 0 6px 16px; }
.tip dl { margin: 0; display: grid; grid-template-columns: auto auto; gap: 2px 12px; }
.tip dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
.tip dd { margin: 0; font-family: var(--font-mono); font-size: 12.5px; text-align: right; font-variant-numeric: tabular-nums; }
/* ── legend ───────────────────────────────────────────────────────────────── */
.legend { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
.lg {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
border: 1px solid var(--line); background: var(--field); border-radius: 999px;
padding: 4px 10px 4px 7px; font-family: var(--font-mono); font-size: 11.5px; color: var(--ink);
}
.lg:hover { border-color: var(--accent); }
.lg .sw { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.lg[aria-pressed="false"] { color: var(--muted); border-style: dashed; }
.lg[aria-pressed="false"] .sw {
background: transparent !important; box-shadow: inset 0 0 0 1.5px var(--muted);
}
.lg .off { display: none; }
.lg[aria-pressed="false"] .off { display: inline; }
.sizekey {
display: inline-flex; align-items: center; gap: 6px; margin-left: auto;
font-family: var(--font-mono); font-size: 10px; letter-spacing: .05em;
text-transform: uppercase; color: var(--muted);
}
.sizekey i { display: inline-block; border-radius: 50%; border: 1.5px solid var(--muted); opacity: .8; }
.sizekey i.a { width: 7px; height: 7px; }
.sizekey i.b { width: 15px; height: 15px; }
/* ── caption ──────────────────────────────────────────────────────────────── */
.caption {
display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: start;
border-left: 3px solid var(--accent); background: var(--accent-soft);
border-radius: 0 8px 8px 0; padding: 9px 12px; margin-top: 10px;
}
.caption .span {
font-family: var(--font-mono); font-size: 11px; color: var(--accent-ink);
letter-spacing: .04em; white-space: nowrap; padding-top: 2px; font-weight: 600;
}
.caption p { margin: 0; font-size: 13.5px; color: var(--ink); }
/* ── stat tiles ───────────────────────────────────────────────────────────── */
.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; }
.tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; min-width: 0; }
.tile .k {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); display: block; margin-bottom: 4px;
}
.tile .v {
font-family: var(--font-mono); font-size: clamp(17px, 4.6vw, 22px); font-weight: 600;
font-variant-numeric: tabular-nums; display: block; line-height: 1.1; letter-spacing: -.01em;
}
.tile .n { font-family: var(--font-sans); font-size: 11.5px; color: var(--muted); display: block; margin-top: 3px; }
/* ── table ────────────────────────────────────────────────────────────────── */
.tablehead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h2 {
font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; margin: 0;
letter-spacing: .07em; text-transform: uppercase; color: var(--muted);
}
.tscroll { overflow-x: auto; margin-top: 10px; }
table { border-collapse: collapse; width: 100%; min-width: 460px; font-size: 13px; }
th, td { text-align: right; padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em;
text-transform: uppercase; color: var(--muted); font-weight: 600;
border-bottom-color: var(--ink); position: sticky; top: 0; background: var(--surface);
}
td.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
th.l, td.l { text-align: left; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr.sel { background: var(--accent-soft); }
.rowbtn {
background: none; border: 0; padding: 2px 0; cursor: pointer; text-align: left;
display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: inherit;
min-height: 24px;
}
.rowbtn:hover { color: var(--accent-ink); }
.rowbtn .sw { width: 9px; height: 9px; border-radius: 50%; flex: none; }
td.reg { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); text-align: left; }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; }
footer .note { display: block; margin-bottom: 5px; text-transform: none; letter-spacing: 0; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (max-width: 560px) {
body { padding: 12px 12px 20px; }
.wrap { gap: 10px; }
.card { padding: 10px; }
.transport { gap: 8px; }
h1 { margin-bottom: 5px; }
.legend { margin-top: 8px; }
.caption { margin-top: 8px; padding: 8px 10px; }
/* with no room to spread, the pushed-right button just orphans on its own row */
.tline .spacer { display: none; }
.stats { grid-template-columns: minmax(0,1fr); }
.sizekey { margin-left: 0; width: 100%; }
table { min-width: 420px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<p class="kicker">dataviz · 14 countries · 1900 → 2020</p>
<h1>Animated Gapminder</h1>
<p class="lede">
Every bubble is a country: <b>income to the right</b>, <b>lifespan upward</b>,
<b>size is population</b>. Press play and 120 years go by in ten seconds.
</p>
</header>
<section class="card transport" aria-label="Playback">
<div class="tline">
<button class="btn play" id="play" type="button" aria-label="Play the animation">
<span class="glyph" id="playGlyph" aria-hidden="true">▶</span><span id="playLabel">Play</span>
</button>
<span class="year" id="yearOut">1900</span>
<div class="scrub">
<input type="range" id="scrub" min="1900" max="2020" step="1" value="1900"
aria-label="Year">
<span class="scrub-ends"><span>1900</span><span>1960</span><span>2020</span></span>
</div>
</div>
<div class="tline opts">
<span class="seg-cap">speed</span>
<div class="seg" role="radiogroup" aria-label="Playback speed">
<input type="radio" name="spd" id="spd05" value="0.5"><label for="spd05">0.5×</label>
<input type="radio" name="spd" id="spd1" value="1" checked><label for="spd1">1×</label>
<input type="radio" name="spd" id="spd2" value="2"><label for="spd2">2×</label>
</div>
<button class="btn" id="trails" type="button" aria-pressed="false">Trails</button>
<button class="btn" id="rewind" type="button"><span class="glyph" aria-hidden="true">⟲</span>1900</button>
<span class="spacer"></span>
<button class="btn" id="tableBtn" type="button" aria-pressed="false" aria-controls="tablecard">Numbers</button>
</div>
</section>
<section class="card" aria-label="Chart">
<div class="plotwrap" id="plotwrap">
<canvas id="c" tabindex="0" role="img" aria-label="Bubble chart of life expectancy against income for 14 countries."></canvas>
<div class="tip" id="tip" aria-hidden="true">
<div class="tname"><span class="sw" id="tipSw"></span><span id="tipName">—</span></div>
<div class="treg" id="tipReg">—</div>
<dl>
<dt>income</dt><dd id="tipGdp">—</dd>
<dt>life exp</dt><dd id="tipLe">—</dd>
<dt>people</dt><dd id="tipPop">—</dd>
</dl>
</div>
</div>
<div class="legend" id="legend" role="group" aria-label="Filter by region">
<button class="lg" type="button" data-r="Europe" aria-pressed="true"><span class="sw" style="background:var(--r-eu)"></span>Europe<span class="off"> · off</span></button>
<button class="lg" type="button" data-r="Americas" aria-pressed="true"><span class="sw" style="background:var(--r-am)"></span>Americas<span class="off"> · off</span></button>
<button class="lg" type="button" data-r="Africa" aria-pressed="true"><span class="sw" style="background:var(--r-af)"></span>Africa<span class="off"> · off</span></button>
<button class="lg" type="button" data-r="Asia & Oceania" aria-pressed="true"><span class="sw" style="background:var(--r-as)"></span>Asia & Oceania<span class="off"> · off</span></button>
<span class="sizekey"><i class="a"></i><i class="b"></i> bubble = population</span>
</div>
<div class="caption">
<span class="span" id="capSpan">1900–13</span>
<p id="capText">Most of humanity lives under 40 years. Only the industrial west has crossed 50.</p>
</div>
<p class="sr-only" id="canvasHelp">
Chart keyboard controls: left and right arrows step one year, hold Shift for ten years,
up and down arrows move between countries, Enter or Space plays and pauses.
The Numbers button below opens the same data as a table.
</p>
</section>
<section class="stats" aria-label="Summary for the current year">
<div class="tile"><span class="k">life expectancy · mean</span><span class="v" id="stLe">32.6 yrs</span><span class="n" id="stLeN">across 1.03 bn people</span></div>
<div class="tile"><span class="k">income · median</span><span class="v" id="stGdp">$1,900</span><span class="n">GDP per person, 2011 $</span></div>
<div class="tile"><span class="k">richest ÷ poorest</span><span class="v" id="stGap">10.0×</span><span class="n" id="stGapN">$8,000 vs $800</span></div>
</section>
<section class="card" id="tablecard" hidden aria-label="Numbers">
<div class="tablehead">
<h2>Values in <span id="tblYear">1900</span></h2>
<span class="spacer"></span>
<span class="seg-cap" id="tblCount">14 countries</span>
</div>
<div class="tscroll">
<table>
<thead>
<tr>
<th class="l" scope="col">Country</th>
<th class="l" scope="col">Region</th>
<th scope="col">GDP / person</th>
<th scope="col">Life exp</th>
<th scope="col">Population</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
</section>
<footer>
<span class="note">Figures are <b>approximate</b> — decade-scale estimates reshaped from the Maddison Project
(income) and Gapminder / Human Mortality Database (life expectancy, population). Good enough to show the
shape of the century, not to cite.</span>
demo · sloprun.dev
</footer>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
/* ── theme plumbing ─────────────────────────────────────────────────────── */
var root = document.documentElement;
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
/* CSS re-themes itself; the canvas is painted from resolved token values, so it
has to be told. Everything that can change the theme funnels through here. */
function repaintTheme() { readTheme(); draw(); }
window.addEventListener("message", function (e) {
var d = e.data;
if (!d || d.type !== "sloprun:theme") return;
if (d.theme === "light" || d.theme === "dark") root.setAttribute("data-theme", d.theme);
else root.removeAttribute("data-theme"); /* "system"/unknown → prefers-color-scheme */
repaintTheme();
});
/* The contract also allows data-theme to be set on <html> directly. CSS follows
that for free; the canvas would have stayed on the old palette. */
if (window.MutationObserver) {
new MutationObserver(repaintTheme)
.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
}
if (mqDark) {
if (mqDark.addEventListener) mqDark.addEventListener("change", repaintTheme);
else if (mqDark.addListener) mqDark.addListener(repaintTheme);
}
if (mqRM) {
var onRM = function () {
reduced = mqRM.matches;
if (reduced && playing) { setPlaying(false); say("Reduced motion turned on. Paused."); }
};
if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM);
}
/* ── data ───────────────────────────────────────────────────────────────────
14 countries × 13 decades. Each row is [GDP per person (2011 $ PPP),
life expectancy at birth (years), population (millions)]. APPROXIMATE:
hand-shaped from published decade series so the trajectories, the shocks and
the orders of magnitude are right; individual figures are not exact. ---- */
var YEAR0 = 1900, STEP = 10;
var EU = "Europe", AM = "Americas", AF = "Africa", AS = "Asia & Oceania";
var DATA = [
["United Kingdom", EU, [
[6500,48.5,38.4],[7200,52.5,42.0],[6900,55.5,44.0],[8000,60.8,46.0],[9800,61.0,48.2],
[11000,68.7,50.4],[13500,70.9,52.4],[16800,71.9,55.7],[20500,73.7,56.3],[26000,75.9,57.2],
[32000,77.9,58.9],[36500,80.4,62.8],[39000,80.4,67.1]]],
["Germany", EU, [
[5300,44.0,56.0],[6300,49.0,64.6],[5000,47.0,61.0],[6300,60.0,65.1],[8500,60.0,69.8],
[6300,66.6,68.4],[12500,69.6,72.5],[17000,70.6,78.0],[22000,72.8,78.3],[27500,75.3,79.4],
[33500,78.0,82.2],[38000,80.1,81.8],[44000,81.1,83.2]]],
["Russia", EU, [
[2100,32.0,74.0],[2500,33.0,86.0],[1600,21.0,88.0],[3000,33.0,101.0],[4200,38.5,110.0],
[5500,57.0,102.0],[8500,66.7,119.9],[11000,68.1,130.1],[13500,67.2,138.1],[15000,69.2,147.7],
[11000,65.3,146.6],[19000,68.9,142.8],[24500,71.5,144.1]]],
["United States", AM, [
[8000,47.3,76.1],[9800,50.0,92.2],[10800,54.1,106.5],[11500,59.7,123.1],[13500,62.9,132.1],
[18000,68.2,152.3],[21500,69.8,180.7],[27500,70.8,205.1],[33500,73.7,227.2],[40500,75.4,249.6],
[48500,76.8,282.2],[51500,78.5,309.3],[60000,77.0,331.5]]],
["Brazil", AM, [
[1200,30.0,17.4],[1500,32.0,22.2],[1700,34.0,27.4],[1900,36.0,33.6],[2200,38.5,41.2],
[3000,48.0,54.0],[4200,54.0,72.2],[5800,58.5,96.4],[9500,61.8,121.7],[10000,65.3,149.0],
[11500,70.0,175.9],[14200,73.4,196.4],[14300,74.0,213.2]]],
["Mexico", AM, [
[2100,27.0,13.6],[2400,27.5,15.0],[2000,30.0,14.3],[2200,34.0,16.6],[2600,38.5,19.8],
[4200,48.5,28.3],[5800,57.0,38.7],[8600,61.5,52.8],[11500,67.0,68.3],[11800,70.8,84.6],
[14000,74.0,98.9],[15200,75.5,114.1],[16500,71.0,126.0]]],
["China", AS, [
[850,32.0,400.0],[900,32.0,423.0],[950,33.0,472.0],[1000,33.0,489.0],[900,32.0,519.0],
[800,43.0,554.0],[900,31.5,667.0],[1300,59.2,818.3],[1800,66.8,981.2],[3000,69.3,1135.2],
[5500,72.0,1262.6],[11500,75.0,1337.7],[17500,77.5,1411.1]]],
["India", AS, [
[950,24.0,238.0],[1000,23.0,251.0],[950,21.0,251.0],[1100,30.0,279.0],[1100,32.0,318.0],
[1150,36.6,357.0],[1350,42.0,445.9],[1500,48.7,555.2],[1750,53.9,696.8],[2300,58.5,870.5],
[3200,62.5,1056.6],[5000,66.7,1240.6],[6600,70.1,1396.4]]],
["Japan", AS, [
[1700,37.0,44.1],[2100,39.0,50.0],[2600,40.0,55.4],[3000,45.0,64.2],[4000,47.0,72.0],
[3000,59.1,82.8],[6500,67.7,92.5],[14000,72.0,104.3],[19500,76.1,116.8],[27000,78.9,123.5],
[30000,81.1,126.8],[32000,82.9,128.1],[34000,84.6,125.8]]],
["Indonesia", AS, [
[900,30.0,42.0],[1050,30.0,47.0],[1200,29.0,51.0],[1400,32.0,60.7],[1300,33.0,70.0],
[1200,40.0,69.5],[1400,47.0,88.4],[1800,53.0,115.2],[2900,58.5,147.5],[4200,63.3,181.4],
[5300,66.3,211.5],[7800,68.4,244.0],[11000,71.5,273.5]]],
["Australia", AS, [
[5500,55.0,3.8],[6200,57.5,4.4],[6300,60.0,5.4],[6800,64.5,6.5],[8500,66.0,7.1],
[12000,69.2,8.3],[14500,70.9,10.3],[19000,71.0,12.7],[22500,74.4,14.7],[27000,77.0,17.1],
[34000,79.6,19.2],[41000,81.9,22.0],[46000,83.2,25.7]]],
["Nigeria", AF, [
[800,28.0,16.0],[850,28.5,17.5],[900,29.0,19.0],[950,30.0,21.0],[1100,31.0,24.0],
[1400,34.0,37.9],[1800,37.0,45.1],[2200,41.0,55.6],[3000,45.5,73.4],[2600,46.0,95.2],
[2700,46.5,122.9],[4900,51.5,158.5],[5100,55.0,206.1]]],
["Egypt", AF, [
[1300,30.0,10.0],[1400,31.0,11.3],[1450,32.0,13.2],[1500,35.0,14.7],[1600,36.0,16.6],
[2000,42.0,21.5],[2600,48.0,27.0],[3200,52.5,34.8],[5000,57.5,43.7],[7000,64.5,57.0],
[9000,68.5,68.8],[10500,70.5,87.3],[12000,71.8,107.5]]],
["South Africa", AF, [
[3000,34.0,4.5],[3400,35.0,5.5],[3700,37.0,6.9],[4200,40.0,8.6],[5200,42.0,10.7],
[6500,45.0,13.7],[8000,49.5,17.4],[10500,53.5,22.4],[11500,57.5,29.1],[11000,62.0,39.9],
[11200,55.5,44.9],[12500,55.0,51.2],[12800,65.0,59.3]]]
];
var NSTEP = DATA[0][2].length; /* 13 decade samples */
var YEND = YEAR0 + (NSTEP - 1) * STEP; /* 2020 */
var COUNTRY = DATA.map(function (d, i) {
return { i: i, name: d[0], region: d[1], rows: d[2] };
});
var REGION_VAR = {}; REGION_VAR[EU] = "--r-eu"; REGION_VAR[AM] = "--r-am";
REGION_VAR[AF] = "--r-af"; REGION_VAR[AS] = "--r-as";
/* ── the story track ────────────────────────────────────────────────────── */
var EVENTS = [
[1900, 1913, "1900–13", "Most of humanity lives under 40 years. Only the industrial west has crossed 50."],
[1914, 1922, "1914–22", "War, revolution and the 1918 flu. Russia's life expectancy collapses toward 21; India loses roughly 18 million people in a single season."],
[1923, 1938, "1923–38", "Between the wars. The Depression is a four-year notch, too short to show on a decade grid — but sanitation and clean water push lifespans up right through it."],
[1939, 1946, "1939–46", "The Second World War. Germany's income falls back to where it stood in 1910, Japan's to 1930 — thirty years and ten, given back in five."],
[1947, 1957, "1947–57", "Antibiotics, vaccines and public health arrive in the poor world. Bubbles start climbing almost vertically — years gained without income gained."],
[1958, 1963, "1958–63", "China's Great Leap famine: life expectancy falls to about 31 — almost as deep as Russia's 1918 — then rebounds within a decade."],
[1964, 1979, "1964–79", "The great convergence. The bottom-left corner empties out as Asia and Latin America move right and up together."],
[1980, 1989, "1980–89", "Africa's lost decade. Nigeria's income slides and stays down for twenty years while its lifespan sits still near 46 and everyone else keeps moving."],
[1990, 1998, "1990–98", "The Soviet collapse. Russia's income drops a quarter and takes four years of life expectancy with it — a peacetime fall with no modern parallel. HIV starts pulling South Africa down."],
[1999, 2008, "1999–2008", "HIV/AIDS peaks in southern Africa: South Africa is stuck near 55, seven years below where it was in 1990. China's income doubles in the same ten years."],
[2009, 2018, "2009–18", "China passes $10,000 a head. For the first time the world's spread is mostly about money, not survival."],
[2019, 2020, "2019–20", "COVID-19. The United States and Mexico step backwards, Britain stalls — the first global fall in life expectancy since the Second World War."]
];
/* ── colour reading ─────────────────────────────────────────────────────── */
var COL = {};
function hexToRgb(h) {
h = String(h).trim();
if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
var n = parseInt(h, 16);
if (isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function tok(name, fb) {
var v = getComputedStyle(root).getPropertyValue(name);
return hexToRgb(v) || fb;
}
function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function readTheme() {
COL.surface = tok("--surface", [255,255,255]);
COL.ink = tok("--ink", [26,29,33]);
COL.muted = tok("--muted", [90,96,104]);
COL.line = tok("--line", [227,226,220]);
COL.accent = tok("--accent", [192,86,0]);
COL.field = tok("--field", [251,251,249]);
/* fallbacks mirror the light :root values above */
COL[EU] = tok("--r-eu", [42,120,214]);
COL[AM] = tok("--r-am", [210,65,123]);
COL[AF] = tok("--r-af", [182,117,0]);
COL[AS] = tok("--r-as", [0,112,31]);
}
/* ── dom ────────────────────────────────────────────────────────────────── */
var wrapEl = document.getElementById("plotwrap");
var cv = document.getElementById("c");
var ctx = cv.getContext("2d");
var tip = document.getElementById("tip");
var scrub = document.getElementById("scrub");
var yearOut= document.getElementById("yearOut");
var playBtn= document.getElementById("play");
var live = document.getElementById("live");
var tbody = document.getElementById("tbody");
/* ── state ──────────────────────────────────────────────────────────────── */
var reduced = !!(mqRM && mqRM.matches);
var year = YEAR0; /* fractional year */
var playing = false;
var speed = 1; /* decades-per-second multiplier */
var showTrails = false;
var hidden = {}; /* region -> true when filtered out */
var hoverIdx = -1;
var selIdx = -1;
var lastShownYear = -1;
var hits = [];
var W = 0, H = 0, dpr = 1, narrow = false;
var MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
var SANS = 'system-ui, -apple-system, "Segoe UI", sans-serif';
/* ── scales ─────────────────────────────────────────────────────────────── */
var GDP_MIN = 600, GDP_MAX = 80000;
var LE_MIN = 18, LE_MAX = 88;
var LGMIN = Math.log(GDP_MIN), LGSPAN = Math.log(GDP_MAX) - LGMIN;
var TICKS_X = [1000, 2000, 5000, 10000, 20000, 50000];
var pad = { l: 46, r: 16, t: 14, b: 34 };
function px(gdp) { return pad.l + (Math.log(gdp) - LGMIN) / LGSPAN * (W - pad.l - pad.r); }
function py(le) { return H - pad.b - (le - LE_MIN) / (LE_MAX - LE_MIN) * (H - pad.t - pad.b); }
function lerp(a, b, t) { return a + (b - a) * t; }
function sample(c, y) {
var t = (y - YEAR0) / STEP;
var i = Math.floor(t), f = t - i;
if (i >= NSTEP - 1) { i = NSTEP - 2; f = 1; }
if (i < 0) { i = 0; f = 0; }
var a = c.rows[i], b = c.rows[i + 1];
return {
gdp: Math.exp(lerp(Math.log(a[0]), Math.log(b[0]), f)),
le: lerp(a[1], b[1], f),
pop: Math.exp(lerp(Math.log(a[2]), Math.log(b[2]), f))
};
}
var POP_MAX = 0;
for (var pi = 0; pi < COUNTRY.length; pi++) {
var prows = COUNTRY[pi].rows;
for (var pj = 0; pj < prows.length; pj++) if (prows[pj][2] > POP_MAX) POP_MAX = prows[pj][2];
}
function radius(pop) {
var plotW = W - pad.l - pad.r;
var rMax = Math.max(24, Math.min(48, plotW * 0.062));
return Math.max(3.6, rMax * Math.sqrt(pop / POP_MAX));
}
function visible(c) { return !hidden[c.region]; }
/* ── formatting ─────────────────────────────────────────────────────────── */
function money(v) {
var n = Math.round(v);
return "$" + String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function shortMoney(v) {
return v >= 1000 ? "$" + (v / 1000) + "k" : "$" + v;
}
function people(m) {
if (m >= 1000) return (m / 1000).toFixed(2) + " bn";
if (m >= 10) return m.toFixed(0) + " m";
return m.toFixed(1) + " m";
}
/* ── drawing ────────────────────────────────────────────────────────────── */
function resize() {
var w = wrapEl.clientWidth;
if (!w) return;
var h = Math.round(Math.max(300, Math.min(540, w * 0.64)));
var d = Math.min(window.devicePixelRatio || 1, 2.5);
/* The observer watches #plotwrap and we resize #plotwrap, so it fires again on
the way out; without this the canvas is cleared and repainted for nothing. */
if (w === W && h === H && d === dpr) return;
dpr = d; W = w; H = h;
narrow = w < 460;
pad.l = narrow ? 34 : 46;
pad.b = narrow ? 30 : 34;
pad.t = narrow ? 26 : 14;
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
cv.style.height = h + "px";
draw();
}
function drawFrame() {
ctx.fillStyle = rgba(COL.field, 1);
ctx.fillRect(0, 0, W, H);
/* the big ghosted year — the Gapminder signature */
ctx.save();
ctx.font = "700 " + Math.round(Math.min(150, (W - pad.l - pad.r) * 0.30)) + "px " + MONO;
ctx.textAlign = "right"; ctx.textBaseline = "bottom";
ctx.fillStyle = rgba(COL.ink, 0.065);
ctx.fillText(String(Math.round(year)), W - pad.r - 6, H - pad.b - 6);
ctx.restore();
ctx.font = "10px " + MONO;
ctx.lineWidth = 1;
/* y grid */
ctx.textAlign = "right"; ctx.textBaseline = "middle";
for (var le = 20; le <= 85; le += 10) {
var y = Math.round(py(le)) + 0.5;
ctx.strokeStyle = rgba(COL.line, 1);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
ctx.fillStyle = rgba(COL.muted, 1);
ctx.fillText(String(le), pad.l - 7, y);
}
/* x grid */
ctx.textAlign = "center"; ctx.textBaseline = "top";
for (var k = 0; k < TICKS_X.length; k++) {
var gx = Math.round(px(TICKS_X[k])) + 0.5;
if (gx < pad.l || gx > W - pad.r) continue;
ctx.strokeStyle = rgba(COL.line, 1);
ctx.beginPath(); ctx.moveTo(gx, pad.t); ctx.lineTo(gx, H - pad.b); ctx.stroke();
ctx.fillStyle = rgba(COL.muted, 1);
ctx.fillText(shortMoney(TICKS_X[k]), gx, H - pad.b + 6);
}
/* axis titles */
ctx.fillStyle = rgba(COL.muted, 1);
ctx.textAlign = "right"; ctx.textBaseline = "bottom";
ctx.fillText(narrow ? "income → log" : "income per person, 2011 $ · log scale →", W - pad.r, H - 3);
if (narrow) {
ctx.textAlign = "left"; ctx.textBaseline = "top";
ctx.fillText("↑ life expectancy, years", 4, 5);
} else {
ctx.save();
ctx.translate(11, pad.t + 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "right"; ctx.textBaseline = "top";
ctx.fillText("↑ life expectancy at birth, years", 0, 0);
ctx.restore();
}
}
function drawTrails() {
for (var i = 0; i < COUNTRY.length; i++) {
var c = COUNTRY[i];
if (!visible(c)) continue;
var strong = (i === selIdx || i === hoverIdx);
if (!showTrails && !strong) continue;
var col = COL[c.region];
ctx.beginPath();
var steps = Math.max(1, Math.ceil((year - YEAR0) / 2));
for (var s = 0; s <= steps; s++) {
var yy = YEAR0 + (year - YEAR0) * (s / steps);
var v = sample(c, yy);
var X = px(v.gdp), Y = py(v.le);
if (s === 0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y);
}
ctx.lineWidth = strong ? 2.5 : 1.5;
ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.strokeStyle = rgba(col, strong ? 0.85 : 0.30);
ctx.stroke();
}
}
function drawBubbles() {
hits.length = 0;
var order = [];
for (var i = 0; i < COUNTRY.length; i++) if (visible(COUNTRY[i])) order.push(COUNTRY[i]);
var snap = {};
for (var j = 0; j < order.length; j++) snap[order[j].i] = sample(order[j], year);
order.sort(function (a, b) { return snap[b.i].pop - snap[a.i].pop; });
for (var k = 0; k < order.length; k++) {
var c = order[k], v = snap[c.i];
var x = px(v.gdp), y = py(v.le), r = radius(v.pop);
var col = COL[c.region];
var active = (c.i === hoverIdx || c.i === selIdx);
/* 2px surface gap ring so overlapping bubbles stay separable */
ctx.beginPath(); ctx.arc(x, y, r + 1.2, 0, Math.PI * 2);
ctx.lineWidth = 2.4; ctx.strokeStyle = rgba(COL.field, 0.95); ctx.stroke();
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = rgba(col, active ? 0.80 : 0.60); ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = rgba(col, 1); ctx.stroke();
if (active) {
ctx.beginPath(); ctx.arc(x, y, r + 5, 0, Math.PI * 2);
ctx.lineWidth = 1.5; ctx.strokeStyle = rgba(COL.ink, 0.75); ctx.stroke();
}
hits.push({ i: c.i, x: x, y: y, r: r });
}
/* Direct labels — identity never rests on hue alone. Biggest bubbles win the
space; anything that would collide with an already-placed label is dropped
(the table view and the tooltip still name every country). A hovered or
selected country is always labelled, collision or not. */
var minR = narrow ? 16 : 11;
ctx.font = "600 11px " + SANS;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
var queue = hits.slice().sort(function (a, b) {
var aa = (a.i === hoverIdx || a.i === selIdx) ? 1 : 0;
var bb = (b.i === hoverIdx || b.i === selIdx) ? 1 : 0;
return aa !== bb ? bb - aa : b.r - a.r;
});
var placed = [];
for (var m = 0; m < queue.length; m++) {
var h = queue[m];
var cc = COUNTRY[h.i];
var on = (h.i === hoverIdx || h.i === selIdx);
if (h.r < minR && !on) continue;
var lw = ctx.measureText(cc.name).width;
/* label sits inside the bubble only when it actually fits */
var ly = (lw + 8 <= h.r * 2) ? h.y : Math.max(9, h.y - h.r - 8);
var box = [h.x - lw / 2 - 3, ly - 8, h.x + lw / 2 + 3, ly + 8];
var clash = false;
for (var q = 0; q < placed.length && !clash; q++) {
var o = placed[q];
clash = box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1];
}
if (clash && !on) continue;
placed.push(box);
ctx.lineWidth = 3.2; ctx.lineJoin = "round";
ctx.strokeStyle = rgba(COL.field, 0.9);
ctx.strokeText(cc.name, h.x, ly);
ctx.fillStyle = rgba(COL.ink, on ? 1 : 0.86);
ctx.fillText(cc.name, h.x, ly);
}
}
/* Switch every region off and the plot is empty, not broken. Say which. */
function drawEmpty() {
var mx = pad.l + (W - pad.l - pad.r) / 2, my = pad.t + (H - pad.t - pad.b) / 2;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.font = "600 13px " + SANS;
ctx.fillStyle = rgba(COL.ink, 0.75);
ctx.fillText("Nothing to plot", mx, my - 9);
ctx.font = "12px " + SANS;
ctx.fillStyle = rgba(COL.muted, 1);
ctx.fillText("Turn a region back on in the legend.", mx, my + 10);
}
function draw() {
if (!W || !H) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
drawFrame();
drawTrails();
drawBubbles();
if (!hits.length) drawEmpty();
}
/* ── readouts ───────────────────────────────────────────────────────────── */
var stLe = document.getElementById("stLe"), stLeN = document.getElementById("stLeN");
var stGdp = document.getElementById("stGdp");
var stGap = document.getElementById("stGap"), stGapN = document.getElementById("stGapN");
var capSpan = document.getElementById("capSpan"), capText = document.getElementById("capText");
var tblYear = document.getElementById("tblYear"), tblCount = document.getElementById("tblCount");
function eventFor(y) {
for (var i = 0; i < EVENTS.length; i++) if (y >= EVENTS[i][0] && y <= EVENTS[i][1]) return EVENTS[i];
return EVENTS[EVENTS.length - 1];
}
function updateReadouts() {
var y = Math.round(year);
var vis = COUNTRY.filter(visible);
var sumPop = 0, sumLe = 0, gdps = [];
for (var i = 0; i < vis.length; i++) {
var v = sample(vis[i], year);
sumPop += v.pop; sumLe += v.le * v.pop; gdps.push(v.gdp);
}
if (vis.length) {
stLe.textContent = (sumLe / sumPop).toFixed(1) + " yrs";
stLeN.textContent = "across " + people(sumPop) + " people";
gdps.sort(function (a, b) { return a - b; });
var mid = gdps.length % 2 ? gdps[(gdps.length - 1) / 2]
: (gdps[gdps.length / 2 - 1] + gdps[gdps.length / 2]) / 2;
stGdp.textContent = money(mid);
var ratio = gdps[gdps.length - 1] / gdps[0];
stGap.textContent = ratio.toFixed(1) + "×";
stGapN.textContent = money(gdps[gdps.length - 1]) + " vs " + money(gdps[0]);
} else {
stLe.textContent = "—"; stLeN.textContent = "no regions shown";
stGdp.textContent = "—"; stGap.textContent = "—"; stGapN.textContent = "income spread";
}
var ev = eventFor(y);
if (capSpan.textContent !== ev[2]) { capSpan.textContent = ev[2]; capText.textContent = ev[3]; }
tblYear.textContent = String(y);
tblCount.textContent = vis.length + (vis.length === 1 ? " country" : " countries");
if (!tableCard.hidden) renderTable(vis);
cv.setAttribute("aria-label",
"Bubble chart, year " + y + ". Horizontal axis: income per person on a log scale. " +
"Vertical axis: life expectancy. Bubble size is population. " + vis.length +
" countries shown; population-weighted life expectancy " +
(vis.length ? (sumLe / sumPop).toFixed(1) + " years." : "none."));
}
var tblKey = "";
function renderTable(vis) {
var rows = vis.map(function (c) {
var v = sample(c, year);
return { c: c, v: v };
}).sort(function (a, b) { return b.v.gdp - a.v.gdp; });
/* Same countries in the same order? Repaint the three number cells in place.
Rebuilding innerHTML 12×/second during playback would blow away a focused
row button and make a screen reader re-announce it on every frame. */
var key = rows.map(function (r) { return r.c.i; }).join(",");
if (key === tblKey && tbody.children.length === rows.length) {
for (var n = 0; n < rows.length; n++) {
var tr = tbody.children[n], vv = rows[n].v;
tr.className = (rows[n].c.i === selIdx) ? "sel" : "";
tr.children[2].textContent = money(vv.gdp);
tr.children[3].textContent = vv.le.toFixed(1);
tr.children[4].textContent = people(vv.pop);
}
return;
}
tblKey = key;
var html = "";
for (var i = 0; i < rows.length; i++) {
var c = rows[i].c, v = rows[i].v;
html += '<tr class="' + (c.i === selIdx ? "sel" : "") + '">' +
'<td class="l"><button type="button" class="rowbtn" data-i="' + c.i + '">' +
'<span class="sw" style="background:var(' + REGION_VAR[c.region] + ')"></span>' + c.name +
'</button></td>' +
'<td class="reg">' + c.region.replace("&", "&") + '</td>' +
'<td class="num">' + money(v.gdp) + '</td>' +
'<td class="num">' + v.le.toFixed(1) + '</td>' +
'<td class="num">' + people(v.pop) + '</td>' +
'</tr>';
}
var act = document.activeElement;
var keep = (act && act.classList && act.classList.contains("rowbtn"))
? act.getAttribute("data-i") : null;
tbody.innerHTML = html;
if (keep !== null) {
var again = tbody.querySelector('.rowbtn[data-i="' + keep + '"]');
if (again) again.focus();
}
}
/* ── tooltip ────────────────────────────────────────────────────────────── */
var tipSw = document.getElementById("tipSw"), tipName = document.getElementById("tipName"),
tipReg = document.getElementById("tipReg"), tipGdp = document.getElementById("tipGdp"),
tipLe = document.getElementById("tipLe"), tipPop = document.getElementById("tipPop");
var tipW = 180, tipH = 90, tipFor = -1;
function showTip(i) {
var hit = null;
for (var k = 0; k < hits.length; k++) if (hits[k].i === i) hit = hits[k];
if (!hit) { tip.classList.remove("on"); return; }
var c = COUNTRY[i], v = sample(c, year);
tipSw.style.background = "var(" + REGION_VAR[c.region] + ")";
tipName.textContent = c.name;
tipReg.textContent = c.region;
tipGdp.textContent = money(v.gdp);
tipLe.textContent = v.le.toFixed(1) + " yrs";
tipPop.textContent = people(v.pop);
tip.classList.add("on");
/* offsetWidth forces a synchronous layout; while playing showTip runs on
every frame, so only re-measure when the tooltip changes country. */
if (tipFor !== i) { tipFor = i; tipW = tip.offsetWidth || 180; tipH = tip.offsetHeight || 90; }
var tw = tipW, th = tipH;
var left = Math.max(tw / 2 + 4, Math.min(W - tw / 2 - 4, hit.x));
var top = hit.y - hit.r - 10;
if (top - th < 0) top = Math.min(H, hit.y + hit.r + 10 + th);
tip.style.left = left + "px";
tip.style.top = top + "px";
}
function hideTip() { tip.classList.remove("on"); }
function pick(mx, my, slop) {
var tol = slop || 4;
var best = -1, bestD = Infinity;
for (var k = hits.length - 1; k >= 0; k--) {
var h = hits[k];
var d = Math.sqrt((mx - h.x) * (mx - h.x) + (my - h.y) * (my - h.y));
if (d <= h.r + tol && d - h.r < bestD) { bestD = d - h.r; best = h.i; }
}
return best;
}
/* ── playback ───────────────────────────────────────────────────────────── */
var raf = 0, lastT = 0;
var YEARS_PER_SEC = 12; /* 120 years in 10 s at 1× */
function setPlaying(on) {
if (on === playing) return;
playing = on;
document.getElementById("playGlyph").textContent = on ? "❚❚" : "▶";
document.getElementById("playLabel").textContent = on ? "Pause" : "Play";
playBtn.setAttribute("aria-label", on ? "Pause the animation" : "Play the animation");
if (on) {
if (year >= YEND - 0.001) year = YEAR0;
lastT = 0;
raf = requestAnimationFrame(tick);
} else if (raf) {
cancelAnimationFrame(raf); raf = 0;
}
}
function tick(ts) {
if (!playing) return;
if (!lastT) lastT = ts;
var dt = Math.min(0.1, (ts - lastT) / 1000);
lastT = ts;
year += dt * YEARS_PER_SEC * speed;
if (year >= YEND) {
year = YEND;
setYear(year);
setPlaying(false);
say("Reached " + YEND + ". Press play to run it again.");
return;
}
setYear(year);
raf = requestAnimationFrame(tick);
}
function setYear(y) {
year = Math.max(YEAR0, Math.min(YEND, y));
var r = Math.round(year);
if (r !== lastShownYear) {
lastShownYear = r;
yearOut.textContent = String(r);
scrub.value = String(r);
updateReadouts();
}
draw();
if (hoverIdx >= 0) showTip(hoverIdx);
else if (selIdx >= 0) showTip(selIdx);
}
function say(msg) { live.textContent = msg; }
/* ── wiring ─────────────────────────────────────────────────────────────── */
playBtn.addEventListener("click", function () { setPlaying(!playing); });
scrub.addEventListener("input", function () {
setPlaying(false);
lastShownYear = -1;
setYear(parseFloat(scrub.value));
});
document.getElementById("rewind").addEventListener("click", function () {
setPlaying(false);
lastShownYear = -1;
setYear(YEAR0);
say("Rewound to 1900.");
});
var spdInputs = document.querySelectorAll('input[name="spd"]');
for (var s = 0; s < spdInputs.length; s++) {
spdInputs[s].addEventListener("change", function () {
speed = parseFloat(this.value);
say("Speed set to " + this.value + " times normal.");
});
}
var trailsBtn = document.getElementById("trails");
trailsBtn.addEventListener("click", function () {
showTrails = !showTrails;
trailsBtn.setAttribute("aria-pressed", showTrails ? "true" : "false");
draw();
say(showTrails ? "Trails on." : "Trails off.");
});
var tableCard = document.getElementById("tablecard");
var tableBtn = document.getElementById("tableBtn");
tableBtn.addEventListener("click", function () {
var on = tableCard.hidden;
tableCard.hidden = !on;
tableBtn.setAttribute("aria-pressed", on ? "true" : "false");
if (on) renderTable(COUNTRY.filter(visible));
say(on ? "Numbers table shown." : "Numbers table hidden.");
});
document.getElementById("legend").addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest(".lg") : null;
if (!b) return;
var r = b.getAttribute("data-r");
hidden[r] = !hidden[r];
b.setAttribute("aria-pressed", hidden[r] ? "false" : "true");
if (hidden[r]) {
if (selIdx >= 0 && COUNTRY[selIdx].region === r) selIdx = -1;
if (hoverIdx >= 0 && COUNTRY[hoverIdx].region === r) { hoverIdx = -1; hideTip(); }
}
lastShownYear = -1;
setYear(year);
say(r + (hidden[r] ? " hidden." : " shown."));
});
tbody.addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest(".rowbtn") : null;
if (!b) return;
selectCountry(parseInt(b.getAttribute("data-i"), 10));
});
function selectCountry(i) {
selIdx = (selIdx === i) ? -1 : i;
draw();
if (selIdx >= 0) {
showTip(selIdx);
var c = COUNTRY[selIdx], v = sample(c, year);
say(c.name + " selected. " + Math.round(year) + ": income " + money(v.gdp) +
", life expectancy " + v.le.toFixed(1) + " years, population " + people(v.pop) + ".");
} else { hideTip(); say("Selection cleared."); }
if (!tableCard.hidden) renderTable(COUNTRY.filter(visible));
}
/* pointer */
cv.addEventListener("pointermove", function (e) {
var rect = cv.getBoundingClientRect();
var i = pick(e.clientX - rect.left, e.clientY - rect.top);
if (i !== hoverIdx) {
hoverIdx = i;
draw();
if (i >= 0) showTip(i); else if (selIdx >= 0) showTip(selIdx); else hideTip();
cv.style.cursor = i >= 0 ? "pointer" : "default";
} else if (i >= 0) { showTip(i); }
});
cv.addEventListener("pointerleave", function () {
hoverIdx = -1; draw();
if (selIdx >= 0) showTip(selIdx); else hideTip();
});
cv.addEventListener("pointerdown", function (e) {
var rect = cv.getBoundingClientRect();
var i = pick(e.clientX - rect.left, e.clientY - rect.top,
e.pointerType === "touch" ? 14 : 4);
/* Touch has no hover. Leaving hoverIdx set meant a second tap de-selected the
country but setYear kept re-opening its tooltip on the next frame. */
if (e.pointerType === "touch") hoverIdx = -1;
if (i >= 0) { selectCountry(i); }
else if (selIdx >= 0) { selIdx = -1; draw(); hideTip(); }
});
/* keyboard on the plot */
cv.addEventListener("keydown", function (e) {
var k = e.key, step = e.shiftKey ? 10 : 1;
if (k === "ArrowRight" || k === "ArrowLeft") {
e.preventDefault(); setPlaying(false);
lastShownYear = -1;
setYear(Math.round(year) + (k === "ArrowRight" ? step : -step));
say(String(Math.round(year)));
} else if (k === "ArrowUp" || k === "ArrowDown") {
e.preventDefault();
var vis = COUNTRY.filter(visible);
if (!vis.length) return;
var at = -1;
for (var n = 0; n < vis.length; n++) if (vis[n].i === selIdx) at = n;
if (at === -1) at = (k === "ArrowDown") ? 0 : vis.length - 1;
else at = (at + (k === "ArrowDown" ? 1 : -1) + vis.length) % vis.length;
selIdx = -1; selectCountry(vis[at].i);
} else if (k === " " || k === "Enter") {
e.preventDefault(); setPlaying(!playing);
say(playing ? "Playing." : "Paused at " + Math.round(year) + ".");
}
});
cv.setAttribute("aria-describedby", "canvasHelp");
/* ── boot ───────────────────────────────────────────────────────────────── */
readTheme();
if (window.ResizeObserver) {
new ResizeObserver(resize).observe(wrapEl);
} else {
window.addEventListener("resize", resize);
}
resize();
lastShownYear = -1;
setYear(YEAR0);
if (reduced) {
setPlaying(false);
say("Reduced motion is on, so the animation starts paused. Press play, or drag the year slider.");
} else {
setPlaying(true);
}
document.addEventListener("visibilitychange", function () {
if (document.hidden && playing) setPlaying(false);
});
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.