0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Women on the left, men on the right, one bar per five-year age group — and a hundred years of it in eight seconds, playing the moment you land, with the 1950 silhouette held on screen as a dashed outline so the broad-based triangle tipping into a top-heavy column is impossible to miss. Nothing is pre-baked: the page runs a small cohort projection in your browser — survival tables solved by bisection, births scaled to a target family size — so the median age, the 65-and-over share and every bar are computed live. Scrub to 2050 and click through all three examples: Fast aging ends up with more people over 65 than under 15, and 1.3 working-age adults for each of them. Figures are approximate illustrations of three familiar shapes, not real statistics.
Source — the code you see is the code that just ran 1265 lines 56.0 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Population Pyramid — sloprun.dev</title>
<style>
/* 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); }
/* ---- chart series colors (validated for both surfaces) ---- */
:root { --sA: #C05600; --sB: #1F5FA0; --sA-soft: #F6E3D2; --sB-soft: #DDE7F2;
--accent-ink: #A34700; } /* accent for TEXT: --accent is only 4.20:1 on --accent-soft */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --sA: #BC7E28; --sB: #4C90D8; --sA-soft: #33291A; --sB-soft: #1E2A38;
--accent-ink: #FFB454; }
}
:root[data-theme="dark"] { --sA: #BC7E28; --sB: #4C90D8; --sA-soft: #33291A; --sB-soft: #1E2A38;
--accent-ink: #FFB454; }
/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
line-height: 1.5; overflow-x: hidden; -webkit-text-size-adjust: 100%; font-size: 15px;
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 18px 14px 30px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---------- header ---------- */
.head {
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: flex-start;
justify-content: space-between; padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.head h1 { font-size: 1.32rem; }
.lede { margin: 5px 0 0; color: var(--muted); max-width: 64ch; font-size: 0.95rem; }
.status {
display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono);
font-size: 0.72rem; letter-spacing: 0.02em; padding: 6px 10px;
border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
color: var(--muted); white-space: nowrap; flex-shrink: 0;
}
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.status.ok { border-color: var(--run); background: var(--run-soft); color: var(--run); }
.status.ok .dot { background: var(--run); }
/* ---------- controls ---------- */
.controls {
display: flex; flex-wrap: wrap; gap: 10px; align-items: stretch;
margin-bottom: 12px;
}
.seg {
display: flex; gap: 4px; padding: 4px; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius); flex-wrap: wrap;
}
.seg button {
font: inherit; text-align: left; cursor: pointer; color: var(--muted);
background: transparent; border: 1px solid transparent; border-radius: 7px;
padding: 6px 10px; line-height: 1.25;
}
.seg button b { display: block; font-weight: 600; font-size: 0.88rem; color: inherit; }
.seg button span {
display: block; font-family: var(--font-mono); font-size: 0.66rem;
color: var(--muted); margin-top: 2px; letter-spacing: 0.01em;
}
.seg button:hover { color: var(--ink); background: var(--bg); }
.seg button[aria-checked="true"] {
background: var(--accent-soft); border-color: var(--accent); color: var(--ink);
}
.seg button[aria-checked="true"] span { color: var(--accent-ink); }
.seg.compact button { padding: 7px 12px; font-size: 0.84rem; font-family: var(--font-mono); }
.transport {
display: flex; align-items: center; gap: 10px; flex: 1 1 320px; min-width: 260px;
padding: 8px 12px; background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius);
}
.play {
font: inherit; font-weight: 600; cursor: pointer; flex: none;
display: inline-flex; align-items: center; gap: 7px; padding: 7px 13px;
border-radius: 7px; border: 1px solid var(--accent); color: var(--accent-ink);
background: var(--accent-soft);
}
/* solid-accent hover so the affordance is obvious on paper AND on the dark surface,
where dimming an already-dark chip read as nothing at all. */
.play:hover { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.play .glyph { font-size: 0.78rem; line-height: 1; }
.scrub { flex: 1 1 auto; min-width: 90px; display: flex; flex-direction: column; gap: 2px; }
.ticks {
display: flex; justify-content: space-between; font-family: var(--font-mono);
font-size: 0.6rem; color: var(--muted);
}
.yearbox {
flex: none; text-align: right; font-size: 1.25rem; font-weight: 600;
letter-spacing: 0.01em; min-width: 4.2ch;
}
.yearbox small { display: block; font-size: 0.56rem; font-weight: 400; color: var(--muted); letter-spacing: 0.08em; }
input[type=range] {
-webkit-appearance: none; appearance: none; background: transparent;
width: 100%; margin: 0; height: 20px; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 5px; background: var(--line); border-radius: 99px; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 17px; height: 17px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); margin-top: -6px;
}
input[type=range]::-moz-range-track { height: 5px; background: var(--line); border-radius: 99px; }
input[type=range]::-moz-range-thumb {
width: 15px; height: 15px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface);
}
/* ---------- layout ---------- */
.grid {
display: grid; gap: 12px;
grid-template-columns: minmax(0, 1fr) 322px;
grid-template-areas: "chart side" "stats stats";
}
@media (max-width: 900px) {
.grid { grid-template-columns: minmax(0, 1fr); grid-template-areas: "chart" "stats" "side"; }
}
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.chart-card { grid-area: chart; padding: 12px 12px 10px; min-width: 0; }
.side { grid-area: side; padding: 13px 14px 14px; display: flex; flex-direction: column; gap: 13px; }
.stats { grid-area: stats; display: grid; gap: 10px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
@media (max-width: 820px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* ---------- chart ---------- */
.chart-head {
display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
justify-content: space-between; margin-bottom: 6px;
}
.legend { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.legend i {
width: 11px; height: 11px; border-radius: 3px; display: inline-block;
vertical-align: -1px; margin-right: 6px;
}
.legend .k { font-family: var(--font-mono); font-size: 0.72rem; color: var(--muted); }
.legend .a i { background: var(--sA); }
.legend .b i { background: var(--sB); }
.pill {
font-family: var(--font-mono); font-size: 0.63rem; letter-spacing: 0.03em;
color: var(--muted); border: 1px dashed var(--line); border-radius: 99px; padding: 3px 9px;
}
.ghosttog {
display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono);
font-size: 0.68rem; color: var(--muted); cursor: pointer; user-select: none;
/* pad the label out to a thumb-sized target without moving it */
padding: 6px 4px; margin: -6px -4px;
}
.ghosttog:hover { color: var(--ink); }
.ghosttog input { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; cursor: pointer; }
.chartWrap { position: relative; }
svg#pyr { display: block; width: 100%; height: auto; touch-action: pan-y; }
svg#pyr:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 6px; }
.axcap {
margin: 4px 2px 0; font-family: var(--font-mono); font-size: 0.64rem; color: var(--muted);
display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
/* svg pieces */
.tk { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); }
.agelab { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); }
.sidelab { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.04em; }
.sidelab.a { fill: var(--sA); } .sidelab.b { fill: var(--sB); }
.gridline { stroke: var(--line); stroke-width: 1; }
.zeroline { stroke: var(--line); stroke-width: 1; }
.barA { fill: var(--sA); } .barB { fill: var(--sB); }
.ghost { fill: none; stroke: var(--ink); stroke-width: 1.2; stroke-opacity: 0.42; stroke-dasharray: 3 2.5; }
.oldband { fill: var(--ink); fill-opacity: 0.045; }
.oldedge { stroke: var(--ink); stroke-opacity: 0.22; stroke-width: 1; stroke-dasharray: 2 3; }
.oldlab { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); }
.medline { stroke: var(--ink); stroke-width: 1.5; stroke-dasharray: 5 3; stroke-opacity: 0.8; }
.medtag { font-family: var(--font-mono); font-size: 10px; fill: var(--ink); font-weight: 600; }
.medbg { fill: var(--surface); fill-opacity: 0.9; stroke: var(--line); }
.medmark { fill: var(--ink); fill-opacity: 0.8; }
.hit { fill: transparent; }
.rowhi { fill: var(--ink); fill-opacity: 0; }
.rowhi.on { fill-opacity: 0.06; }
.barA.on, .barB.on { stroke: var(--ink); stroke-width: 1.2; stroke-opacity: 0.55; }
.tip {
position: absolute; pointer-events: none; z-index: 5; min-width: 148px;
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
padding: 7px 9px; box-shadow: 0 6px 20px rgba(0,0,0,0.13); opacity: 0;
transition: opacity 0.1s linear; font-size: 0.72rem;
}
.tip.on { opacity: 1; }
.tip .th { font-family: var(--font-mono); font-size: 0.72rem; font-weight: 600; margin-bottom: 4px; }
.tip .tr { display: flex; justify-content: space-between; gap: 12px; font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); }
.tip .tr b { color: var(--ink); font-weight: 600; }
.tip .tr i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; margin-right: 5px; }
.tip .tr .a i { background: var(--sA); } .tip .tr .b i { background: var(--sB); }
.tip .tot { border-top: 1px solid var(--line); margin-top: 4px; padding-top: 4px; }
/* ---------- stat tiles ---------- */
.tile { padding: 9px 11px 10px; }
.tile .lab {
font-family: var(--font-mono); font-size: 0.6rem; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--muted); line-height: 1.3; min-height: 2.1em;
}
.tile .val {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; margin-top: 3px;
}
.tile .val u { text-decoration: none; font-size: 0.7rem; font-weight: 400; color: var(--muted); margin-left: 3px; }
.tile .dlt { font-family: var(--font-mono); font-size: 0.63rem; color: var(--muted); margin-top: 1px; }
/* ---------- side panel ---------- */
.side h2 { font-size: 0.74rem; font-family: var(--font-mono); letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.narr { font-size: 0.92rem; margin: 0; }
.narr .mono { font-weight: 600; }
.drivers { display: grid; gap: 8px; }
.drv { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: baseline; }
.drv .n { font-size: 0.82rem; color: var(--muted); }
.drv .v { font-family: var(--font-mono); font-size: 0.92rem; font-weight: 600; }
.meter { grid-column: 1 / -1; height: 5px; background: var(--line); border-radius: 99px; overflow: hidden; }
.meter i { display: block; height: 100%; background: var(--accent); border-radius: 99px; }
.cmp { display: grid; gap: 3px; }
.cmphd {
display: grid; grid-template-columns: 1fr auto auto; gap: 10px; padding: 0 8px 3px;
font-family: var(--font-mono); font-size: 0.58rem; letter-spacing: 0.05em;
text-transform: uppercase; color: var(--muted);
}
.cmphd span:first-child { visibility: hidden; }
.cmp button {
display: grid; grid-template-columns: 1fr auto auto; gap: 10px; align-items: baseline;
font: inherit; text-align: left; cursor: pointer; background: transparent;
border: 1px solid transparent; border-radius: 7px; padding: 5px 8px; color: var(--muted);
}
.cmp button:hover { background: var(--bg); color: var(--ink); }
.cmp button[aria-current="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--ink); }
.cmp .nm { font-size: 0.8rem; }
.cmp .m1 {
font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 0.86rem;
font-weight: 600; color: var(--ink); min-width: 4.2ch; text-align: right;
}
.cmp .m2 {
font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 0.68rem;
color: var(--muted); min-width: 5.2ch; text-align: right;
}
.spark { width: 100%; height: auto; display: block; }
.spark .sl { fill: none; stroke: var(--muted); stroke-opacity: 0.28; stroke-width: 1.5; }
.spark .sl.cur { stroke: var(--accent); stroke-opacity: 1; stroke-width: 2; }
.spark .sdot { fill: var(--accent); stroke: var(--surface); stroke-width: 2; }
.spark .sax { stroke: var(--line); stroke-width: 1; }
.spark .st { font-family: var(--font-mono); font-size: 8.5px; fill: var(--muted); }
.hint { font-family: var(--font-mono); font-size: 0.63rem; color: var(--muted); line-height: 1.6; margin-top: auto; padding-top: 2px; }
kbd {
font-family: var(--font-mono); font-size: 0.94em; border: 1px solid var(--line);
border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; background: var(--bg);
}
/* ---------- table ---------- */
.tblwrap { margin-top: 10px; }
details summary {
cursor: pointer; font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted);
list-style: none; display: inline-flex; align-items: center; gap: 6px;
border: 1px solid var(--line); border-radius: 99px; padding: 4px 11px; background: var(--surface);
}
details summary::-webkit-details-marker { display: none; }
details summary:hover { color: var(--ink); }
details[open] summary { margin-bottom: 8px; }
.tbl { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 0.68rem; }
.tbl th, .tbl td { text-align: right; padding: 3px 6px; border-bottom: 1px solid var(--line); }
.tbl th:first-child, .tbl td:first-child { text-align: left; }
.tbl thead th { color: var(--muted); font-weight: 600; letter-spacing: 0.03em; }
.tblscroll { max-height: 260px; overflow: auto; }
/* ---------- footer ---------- */
.foot {
margin-top: 18px; padding-top: 11px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
@media (max-width: 460px) {
.head h1 { font-size: 1.14rem; }
.tile .val { font-size: 1.28rem; }
.yearbox { font-size: 1.15rem; }
/* give the scrubber a full-width row of its own rather than a 100px stub */
.transport { flex-wrap: wrap; row-gap: 7px; }
.play { order: 1; }
.yearbox { order: 2; margin-left: auto; }
.scrub { order: 3; flex: 1 0 100%; }
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.001ms !important; animation-duration: 0.001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div>
<h1>Population Pyramid</h1>
<p class="lede">Every bar is a five-year age group: women on the left, men on the right. Press play and
watch the shape tip over as families get smaller and people live longer.</p>
</div>
<div class="status" id="status"><span class="dot"></span><span id="statusText">building model…</span></div>
</header>
<div class="controls">
<div class="seg" role="radiogroup" aria-label="Example population" id="profSeg"></div>
<div class="transport">
<button class="play" id="play" aria-label="Play the years 1950 to 2050">
<span class="glyph" id="playGlyph" aria-hidden="true">▶</span><span id="playLabel">Play</span>
</button>
<div class="scrub">
<input type="range" id="year" min="1950" max="2050" step="1" value="1950"
aria-label="Year" aria-valuetext="1950">
<div class="ticks" aria-hidden="true"><span>1950</span><span>1975</span><span>2000</span><span>2025</span><span>2050</span></div>
</div>
<div class="yearbox mono" id="yearOut">1950<small>YEAR</small></div>
</div>
<div class="seg compact" role="radiogroup" aria-label="Bar length shows" id="modeSeg"></div>
</div>
<div class="grid">
<section class="card chart-card" aria-labelledby="chartTitle">
<h2 id="chartTitle" class="sr">Age pyramid</h2>
<div class="chart-head">
<div class="legend">
<span class="k a"><i></i>women</span>
<span class="k b"><i></i>men</span>
<label class="ghosttog"><input type="checkbox" id="ghost" checked>1950 outline</label>
</div>
<span class="pill" id="approxPill">approximate example figures</span>
</div>
<div class="chartWrap" id="chartWrap">
<svg id="pyr" tabindex="0" role="img" aria-describedby="chartHint"></svg>
<div class="tip" id="tip" aria-hidden="true"></div>
</div>
<p class="axcap"><span id="axCap"> </span><span id="axNote">dashed line = median age</span></p>
<div class="tblwrap">
<details id="tblDetails">
<summary id="tblSummary">show the numbers</summary>
<div class="tblscroll">
<table class="tbl" id="tbl">
<caption class="sr">Age groups for the selected year. Counts are in millions of people.</caption>
<thead><tr><th scope="col">age</th><th scope="col">women m</th><th scope="col">men m</th><th scope="col">total m</th><th scope="col">share</th></tr></thead>
<tbody></tbody>
</table>
</div>
</details>
</div>
</section>
<aside class="card side">
<div>
<h2>The story so far</h2>
<p class="narr" id="narr"> </p>
</div>
<div>
<h2 style="margin-bottom:7px">What is driving it</h2>
<div class="drivers">
<div class="drv">
<span class="n">Babies per woman</span><span class="v mono" id="drvTfr">—</span>
<span class="meter"><i id="mTfr" style="width:0%"></i></span>
</div>
<div class="drv">
<span class="n">Years lived, on average</span><span class="v mono" id="drvE0">—</span>
<span class="meter"><i id="mE0" style="width:0%"></i></span>
</div>
</div>
</div>
<div>
<h2 style="margin-bottom:6px">All three, this year</h2>
<div class="cmphd" aria-hidden="true"><span>example</span><span>median</span><span>65+</span></div>
<div class="cmp" id="cmp"></div>
</div>
<div>
<h2 style="margin-bottom:5px">Median age, 1950–2050</h2>
<svg class="spark" id="spark" viewBox="0 0 300 104" role="img" aria-label="Median age over time for all three examples"></svg>
</div>
<p class="hint" id="chartHint">
<kbd>space</kbd> play / pause · <kbd>←</kbd><kbd>→</kbd> step 5 years ·
focus the chart then <kbd>↑</kbd><kbd>↓</kbd> to read each age group.
</p>
</aside>
<div class="stats" id="stats"></div>
</div>
<p class="sr" id="live" aria-live="polite"></p>
<footer class="foot">
<span>demo · sloprun.dev</span>
<span>illustrative examples — approximate, not real statistics</span>
</footer>
</div>
<script>
(function () {
"use strict";
/* ================================================================== *
* 1. The model — a small cohort projection, run in the browser.
* 19 five-year age bands (0-4 … 85-89, 90+), five-year time steps.
* Mortality: a Siler-style hazard whose level is solved by bisection
* to hit a target "years lived on average". Fertility: a fixed age
* pattern scaled to a target babies-per-woman.
* ================================================================== */
var NB = 19;
var FERT_W = [0.08, 0.22, 0.26, 0.22, 0.13, 0.07, 0.02]; // bands 3..9 = ages 15-19 … 45-49
var GIRL_SHARE = 1 / 2.05;
var STEP = 0.25, TOPAGE = 120, NSTEP = Math.round(TOPAGE / STEP);
function hazard(x, k) {
return k * (0.055 * Math.exp(-1.4 * x) + 0.0011) + Math.sqrt(k) * 0.000012 * Math.exp(0.115 * x);
}
function lifeTable(k) {
var l = new Float64Array(NSTEP + 1), i, x;
l[0] = 1;
for (i = 0; i < NSTEP; i++) {
x = i * STEP;
l[i + 1] = l[i] * Math.exp(-(hazard(x, k) + hazard(x + STEP, k)) / 2 * STEP);
}
var e0 = 0;
for (i = 0; i < NSTEP; i++) e0 += (l[i] + l[i + 1]) / 2 * STEP;
return { l: l, e0: e0 };
}
function tailYears(t, age) {
var s = 0;
for (var j = Math.round(age / STEP); j < NSTEP; j++) s += (t.l[j] + t.l[j + 1]) / 2 * STEP;
return s;
}
function bandYears(t, i) {
if (i === NB - 1) return tailYears(t, 5 * i);
var per = Math.round(5 / STEP), a = i * per, s = 0;
for (var j = a; j < a + per; j++) s += (t.l[j] + t.l[j + 1]) / 2 * STEP;
return s;
}
var tableCount = 0;
function solveLevel(targetE0) {
var lo = 0.002, hi = 300, m;
for (var i = 0; i < 34; i++) {
m = Math.sqrt(lo * hi); tableCount++;
if (lifeTable(m).e0 > targetE0) lo = m; else hi = m;
}
return Math.sqrt(lo * hi);
}
function survival(e0) {
var t = lifeTable(solveLevel(e0)); tableCount++;
var L = [], i;
for (i = 0; i < NB; i++) L.push(bandYears(t, i));
var s = [];
for (i = 0; i < NB - 2; i++) s.push(L[i + 1] / L[i]);
/* the top group is open-ended: 85-89 and 90+ pour into the same bucket, so
both use the standard open-interval ratio T(90+) / T(85+). */
var sTop = L[NB - 1] / (L[NB - 2] + L[NB - 1]);
s.push(sTop); s.push(sTop);
return { s: s, birth: L[0] / 5 };
}
function advance(pop, sv, tfr) {
var f = pop.f, m = pop.m, nf = new Array(NB), nm = new Array(NB), i;
for (i = 0; i < NB; i++) { nf[i] = 0; nm[i] = 0; }
for (i = 0; i < NB - 2; i++) { nf[i + 1] += f[i] * sv.s[i]; nm[i + 1] += m[i] * sv.s[i]; }
/* 85-89 survivors and 90+ survivors both land in the open 90+ group */
nf[NB - 1] += (f[NB - 2] + f[NB - 1]) * sv.s[NB - 1];
nm[NB - 1] += (m[NB - 2] + m[NB - 1]) * sv.s[NB - 1];
var births = 0;
for (var j = 0; j < 7; j++) { var b = 3 + j; births += (tfr * FERT_W[j] / 5) * 5 * (f[b] + nf[b]) / 2; }
var kids = births * sv.birth;
nf[0] = kids * GIRL_SHARE; nm[0] = kids * (1 - GIRL_SHARE);
return { f: nf, m: nm };
}
function atYear(anchors, y) {
if (y <= anchors[0][0]) return anchors[0][1];
for (var i = 0; i < anchors.length - 1; i++) {
if (y <= anchors[i + 1][0]) {
var t = (y - anchors[i][0]) / (anchors[i + 1][0] - anchors[i][0]);
return anchors[i][1] + (anchors[i + 1][1] - anchors[i][1]) * t;
}
}
return anchors[anchors.length - 1][1];
}
function medianAge(f, m) {
var T = 0, i, c = 0;
for (i = 0; i < NB; i++) T += f[i] + m[i];
for (i = 0; i < NB; i++) {
var v = f[i] + m[i];
if (c + v >= T / 2) return 5 * i + 5 * ((T / 2 - c) / v);
c += v;
}
return 90;
}
var START_MILLIONS = 20; // every example starts the same size, so growth is comparable
var PROFILES = [
{ id: "young", name: "Young and growing", sub: "big families, slow decline",
tfr: [[1950, 6.6], [1970, 6.7], [1990, 5.9], [2010, 4.6], [2030, 3.4], [2050, 2.6]],
e0: [[1950, 36], [1970, 44], [1990, 52], [2010, 60], [2030, 66], [2050, 71]] },
{ id: "boom", name: "Boom, then settle", sub: "a wave of births, then few",
tfr: [[1950, 3.0], [1960, 3.6], [1970, 2.5], [1980, 1.85], [2000, 1.95], [2020, 1.75], [2050, 1.7]],
e0: [[1950, 67], [1970, 70], [1990, 74], [2010, 78], [2030, 81], [2050, 84]] },
{ id: "fast", name: "Fast aging", sub: "births fell early, stayed low",
tfr: [[1950, 3.6], [1960, 2.1], [1975, 2.05], [1990, 1.6], [2005, 1.35], [2020, 1.28], [2050, 1.4]],
e0: [[1950, 58], [1970, 70], [1990, 77], [2010, 81], [2030, 84], [2050, 86]] }
];
function buildProfile(p) {
var cache = {}, i;
function sv(e0) { var k = e0.toFixed(2); return cache[k] || (cache[k] = survival(e0)); }
var pop = { f: [], m: [] };
for (i = 0; i < NB; i++) { pop.f.push(1); pop.m.push(1); }
var sv0 = sv(atYear(p.e0, 1950)), tfr0 = atYear(p.tfr, 1950);
for (i = 0; i < 90; i++) pop = advance(pop, sv0, tfr0); // settle into a steady shape
var T = 0;
for (i = 0; i < NB; i++) T += pop.f[i] + pop.m[i];
for (i = 0; i < NB; i++) { pop.f[i] *= START_MILLIONS / T; pop.m[i] *= START_MILLIONS / T; }
var snaps = [];
for (var y = 1950; y <= 2050; y += 5) {
if (y > 1950) pop = advance(pop, sv(atYear(p.e0, y - 2.5)), atYear(p.tfr, y - 2.5));
snaps.push({ year: y, f: pop.f.slice(), m: pop.m.slice() });
}
p.snaps = snaps;
p.stats1950 = derive(snaps[0].f, snaps[0].m, 1950, p);
p.sizeMax = 0;
p.medPath = [];
for (i = 0; i < snaps.length; i++) {
var s = snaps[i], j;
for (j = 0; j < NB; j++) p.sizeMax = Math.max(p.sizeMax, s.f[j], s.m[j]);
p.medPath.push(medianAge(s.f, s.m));
}
return p;
}
function derive(f, m, year, p) {
var i, T = 0, old = 0, kids = 0, mid = 0;
for (i = 0; i < NB; i++) {
var v = f[i] + m[i];
T += v;
if (i >= 13) old += v; // 65+
else if (i >= 4) mid += v; // 20-64
if (i < 3) kids += v; // under 15
}
return {
f: f, m: m, total: T, med: medianAge(f, m),
oldShare: old / T, kidShare: kids / T,
support: old > 0 ? mid / old : 0,
tfr: atYear(p.tfr, year), e0: atYear(p.e0, year), year: year
};
}
var t0 = (window.performance && performance.now) ? performance.now() : Date.now();
var shareMax = 0;
for (var pi = 0; pi < PROFILES.length; pi++) {
var P = buildProfile(PROFILES[pi]);
for (var si = 0; si < P.snaps.length; si++) {
var sn = P.snaps[si], tt = 0, j2;
for (j2 = 0; j2 < NB; j2++) tt += sn.f[j2] + sn.m[j2];
for (j2 = 0; j2 < NB; j2++) shareMax = Math.max(shareMax, 100 * sn.f[j2] / tt, 100 * sn.m[j2] / tt);
}
}
var buildMs = ((window.performance && performance.now) ? performance.now() : Date.now()) - t0;
var LADDER = [1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
function niceUp(x) {
var e = Math.pow(10, Math.floor(Math.log(x) / Math.LN10)), r = x / e;
for (var i = 0; i < LADDER.length; i++) if (r <= LADDER[i] + 1e-9) return LADDER[i] * e;
return 10 * e;
}
function trim(v, d) { return String(parseFloat(v.toFixed(d))); }
var SHARE_MAX = niceUp(shareMax); // one fixed share scale for every example
for (pi = 0; pi < PROFILES.length; pi++) PROFILES[pi].sizeAxis = niceUp(PROFILES[pi].sizeMax);
/* ================================================================== *
* 2. State
* ================================================================== */
var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var state = {
prof: 1, mode: "share", year: 1950, playing: false, ghost: true, hover: -1
};
var MODES = [{ id: "share", label: "share %" }, { id: "size", label: "actual size" }];
function $(id) { return document.getElementById(id); }
function svgEl(t, a) {
var e = document.createElementNS("http://www.w3.org/2000/svg", t);
if (a) for (var k in a) e.setAttribute(k, a[k]);
return e;
}
/* values at any (fractional) year, interpolated between five-year snapshots */
function valuesAt(prof, year) {
var p = PROFILES[prof], snaps = p.snaps;
var x = (year - 1950) / 5, i0 = Math.floor(x);
if (i0 < 0) i0 = 0;
if (i0 > snaps.length - 2) i0 = snaps.length - 2;
var t = Math.min(1, Math.max(0, x - i0));
var a = snaps[i0], b = snaps[i0 + 1], f = [], m = [];
for (var i = 0; i < NB; i++) {
f.push(a.f[i] + (b.f[i] - a.f[i]) * t);
m.push(a.m[i] + (b.m[i] - a.m[i]) * t);
}
return derive(f, m, year, p);
}
/* ================================================================== *
* 3. Controls
* ================================================================== */
var profSeg = $("profSeg"), modeSeg = $("modeSeg");
PROFILES.forEach(function (p, i) {
var b = document.createElement("button");
b.type = "button"; b.setAttribute("role", "radio");
b.innerHTML = "<b>" + p.name + "</b><span>" + p.sub + "</span>";
b.addEventListener("click", function () { setProfile(i); });
profSeg.appendChild(b);
});
MODES.forEach(function (mo, i) {
var b = document.createElement("button");
b.type = "button"; b.setAttribute("role", "radio");
b.textContent = mo.label;
b.addEventListener("click", function () { setMode(mo.id); });
modeSeg.appendChild(b);
});
function wireRadioKeys(seg, onPick) {
seg.addEventListener("keydown", function (e) {
var btns = [].slice.call(seg.querySelectorAll("button"));
var i = btns.indexOf(document.activeElement);
if (i < 0) return;
var n = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") n = (i + 1) % btns.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") n = (i - 1 + btns.length) % btns.length;
if (n >= 0) { e.preventDefault(); btns[n].focus(); onPick(n); }
});
}
wireRadioKeys(profSeg, setProfile);
wireRadioKeys(modeSeg, function (n) { setMode(MODES[n].id); });
function paintSegs() {
[].slice.call(profSeg.children).forEach(function (b, i) {
var on = i === state.prof;
b.setAttribute("aria-checked", on ? "true" : "false");
b.tabIndex = on ? 0 : -1;
});
[].slice.call(modeSeg.children).forEach(function (b, i) {
var on = MODES[i].id === state.mode;
b.setAttribute("aria-checked", on ? "true" : "false");
b.tabIndex = on ? 0 : -1;
});
}
var tween = null;
function startTween() {
if (reduce || !lastFrac) { tween = null; return; }
tween = { from: { fa: lastFrac.fa.slice(), fb: lastFrac.fb.slice(), med: lastFrac.med }, t: 0 };
kick();
}
function setProfile(i) {
if (i === state.prof) return;
state.prof = i; paintSegs(); startTween();
layoutStatic(); drawGhost(); ghostVis(); refresh(true);
}
function setMode(id) {
if (id === state.mode) return;
state.mode = id; paintSegs(); startTween();
layoutStatic(); drawGhost(); ghostVis(); refresh(true);
}
function ghostVis() { gGhost.style.display = state.ghost ? "" : "none"; }
var yearInput = $("year");
yearInput.addEventListener("input", function () {
state.year = +yearInput.value;
stop();
tween = null;
refresh(true);
});
$("ghost").addEventListener("change", function () {
state.ghost = this.checked;
ghostVis();
});
var playBtn = $("play");
playBtn.addEventListener("click", function () { state.playing ? stop() : play(); });
var stepTimer = 0;
function play() {
if (state.year >= 2049.99) state.year = 1950;
state.playing = true;
paintPlay();
last = 0;
if (reduce) {
refresh(true);
stepTimer = setInterval(function () {
state.year = Math.min(2050, state.year + 5);
refresh(true);
if (state.year >= 2050) stop();
}, 780);
} else kick();
}
function paintPlay() {
if (state.playing) {
$("playGlyph").innerHTML = "❙❙";
$("playLabel").textContent = "Pause";
playBtn.setAttribute("aria-label", "Pause");
return;
}
var end = state.year >= 2049.99;
$("playGlyph").innerHTML = "▶";
$("playLabel").textContent = end ? "Replay" : "Play";
playBtn.setAttribute("aria-label", end ? "Replay from 1950" : "Play the years 1950 to 2050");
}
function stop() {
if (stepTimer) { clearInterval(stepTimer); stepTimer = 0; }
if (!state.playing) { paintPlay(); return; }
state.playing = false;
paintPlay();
announce();
}
document.addEventListener("keydown", function (e) {
var tag = (e.target && e.target.tagName || "").toLowerCase();
var inField = tag === "input" || tag === "textarea" || tag === "select" || tag === "summary" || tag === "button";
if (e.key === " " || e.key === "Spacebar") {
if (inField) return;
e.preventDefault(); state.playing ? stop() : play();
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
if (tag === "input" || tag === "button") return;
e.preventDefault(); stop();
var d = e.key === "ArrowRight" ? 5 : -5;
state.year = Math.min(2050, Math.max(1950, Math.round((state.year + d) / 5) * 5));
tween = null; refresh(true); announce();
}
});
/* ================================================================== *
* 4. Chart scaffold
* ================================================================== */
var svg = $("pyr"), wrapEl = $("chartWrap"), tip = $("tip");
var gGrid = svgEl("g"), gOld = svgEl("g"), gGhost = svgEl("g"), gRows = svgEl("g"),
gBars = svgEl("g"), gAges = svgEl("g"), gMed = svgEl("g"), gHit = svgEl("g"), gLab = svgEl("g");
[gGrid, gOld, gRows, gBars, gGhost, gAges, gMed, gLab, gHit].forEach(function (g) { svg.appendChild(g); });
var barsA = [], barsB = [], rowHi = [], hits = [], ageTexts = [];
for (var i = 0; i < NB; i++) {
var a = svgEl("path", { "class": "barA" }), b = svgEl("path", { "class": "barB" });
gBars.appendChild(a); gBars.appendChild(b); barsA.push(a); barsB.push(b);
var r = svgEl("rect", { "class": "rowhi" }); gRows.appendChild(r); rowHi.push(r);
var h = svgEl("rect", { "class": "hit" }); gHit.appendChild(h); hits.push(h);
}
var ghostL = svgEl("path", { "class": "ghost" }), ghostR = svgEl("path", { "class": "ghost" });
gGhost.appendChild(ghostL); gGhost.appendChild(ghostR);
var oldRect = svgEl("rect", { "class": "oldband" }),
oldLine = svgEl("line", { "class": "oldedge" }),
oldText = svgEl("text", { "class": "oldlab", "text-anchor": "end" });
gOld.appendChild(oldRect); gOld.appendChild(oldLine); gOld.appendChild(oldText);
var medLine = svgEl("line", { "class": "medline" }),
medBg = svgEl("rect", { "class": "medbg", rx: 4 }),
medTxt = svgEl("text", { "class": "medtag" }),
medM1 = svgEl("path", { "class": "medmark" }), medM2 = svgEl("path", { "class": "medmark" });
[medLine, medM1, medM2, medBg, medTxt].forEach(function (n) { gMed.appendChild(n); });
var labA = svgEl("text", { "class": "sidelab a", "text-anchor": "end" }),
labB = svgEl("text", { "class": "sidelab b", "text-anchor": "start" });
labA.textContent = "women"; labB.textContent = "men";
gLab.appendChild(labA); gLab.appendChild(labB);
var L = {}; // layout
/* Measuring while the parser is still in the document can hand back 0, which used
to lock the first paint to a 320-wide viewBox stretched across a 790px card —
every label and stroke 2.5x too big until something resized. Fall back to the
card, then to the page's own arithmetic, and re-measure before the first frame. */
function measureW() {
var w = wrapEl.clientWidth || Math.round(wrapEl.getBoundingClientRect().width);
if (!w && wrapEl.parentNode && wrapEl.parentNode.clientWidth) {
w = wrapEl.parentNode.clientWidth - 24; // .chart-card padding
}
if (!w) {
var vw = document.documentElement.clientWidth || 360;
w = Math.min(1180, vw) - 28; // .wrap max-width + padding
if (vw > 900) w -= 334; // side column + grid gap
w -= 24; // .chart-card padding
}
return Math.max(280, w);
}
function layoutStatic() {
var W = measureW();
var H = Math.round(Math.max(392, Math.min(566, W * 0.68)));
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.setAttribute("height", H);
var gut = W < 430 ? 34 : 46, pad = 3, top = 24, bot = 30;
var side = (W - pad * 2 - gut) / 2;
L = { W: W, H: H, top: top, bot: bot, pad: pad, gut: gut, side: side,
cL: pad + side, cR: pad + side + gut,
bandH: (H - top - bot) / NB, plotTop: top, plotBot: H - bot };
L.max = state.mode === "share" ? SHARE_MAX : PROFILES[state.prof].sizeAxis;
// gridlines + ticks
while (gGrid.firstChild) gGrid.removeChild(gGrid.firstChild);
var stepv = niceUp(L.max / 5), v, dense = W >= 400;
var idx = 0;
for (v = 0; v <= L.max + 1e-9; v += stepv) {
var dx = v / L.max * side;
[L.cL - dx, L.cR + dx].forEach(function (x) {
gGrid.appendChild(svgEl("line", { "class": v === 0 ? "zeroline" : "gridline",
x1: x, x2: x, y1: L.plotTop, y2: L.plotBot }));
if (v > 0 && (dense || idx % 2 === 1)) {
var tx = x, anch = "middle";
if (x < 12) { tx = L.pad; anch = "start"; }
else if (x > W - 12) { tx = W - L.pad; anch = "end"; }
var t = svgEl("text", { "class": "tk", x: tx, y: L.plotBot + 13, "text-anchor": anch });
t.textContent = fmtTick(v);
gGrid.appendChild(t);
}
});
idx++;
}
var t0lab = svgEl("text", { "class": "tk", x: (L.cL + L.cR) / 2, y: L.plotBot + 13, "text-anchor": "middle" });
t0lab.textContent = "0";
gGrid.appendChild(t0lab);
// 65+ shading
var y65 = yForAge(65);
oldRect.setAttribute("x", L.pad); oldRect.setAttribute("y", L.plotTop);
oldRect.setAttribute("width", W - L.pad * 2); oldRect.setAttribute("height", Math.max(0, y65 - L.plotTop));
oldLine.setAttribute("x1", L.pad); oldLine.setAttribute("x2", W - L.pad);
oldLine.setAttribute("y1", y65); oldLine.setAttribute("y2", y65);
oldText.setAttribute("x", W - L.pad - 2); oldText.setAttribute("y", y65 - 4);
oldText.textContent = "65 and over";
// age labels down the middle
while (gAges.firstChild) gAges.removeChild(gAges.firstChild);
ageTexts = [];
for (var k = 0; k < NB; k += 2) {
var y = L.plotTop + (NB - 1 - k) * L.bandH + L.bandH / 2 + 3.4;
var tx = svgEl("text", { "class": "agelab", x: (L.cL + L.cR) / 2, y: y, "text-anchor": "middle" });
tx.textContent = k === NB - 1 ? "90+" : String(k * 5);
gAges.appendChild(tx);
}
labA.setAttribute("x", L.cL); labA.setAttribute("y", 13);
labB.setAttribute("x", L.cR); labB.setAttribute("y", 13);
// rows + hit areas
for (var q = 0; q < NB; q++) {
var yy = L.plotTop + (NB - 1 - q) * L.bandH;
rowHi[q].setAttribute("x", L.pad); rowHi[q].setAttribute("y", yy);
rowHi[q].setAttribute("width", W - L.pad * 2); rowHi[q].setAttribute("height", L.bandH);
hits[q].setAttribute("x", L.pad); hits[q].setAttribute("y", yy);
hits[q].setAttribute("width", W - L.pad * 2); hits[q].setAttribute("height", L.bandH);
}
$("axCap").textContent = state.mode === "share"
? "bar length = share of everyone, 0–" + fmtTick(L.max) + "% · same scale for all three examples"
: "bar length = millions of people · scale fits this example";
}
function yForAge(age) {
var band = Math.min(NB, age / 5);
return L.plotTop + (NB - band) * L.bandH;
}
function fmtTick(v) {
return state.mode === "share" ? trim(v, 1) : trim(v, v >= 10 ? 1 : 2);
}
function barPath(cx, y, w, h, dir) {
var r = Math.min(3, w / 2, h / 2);
if (w <= 0.3) return "M" + cx + "," + y + "h0";
var x2 = cx + dir * w;
if (r <= 0.4) return "M" + cx + "," + y + "L" + x2 + "," + y + "L" + x2 + "," + (y + h) + "L" + cx + "," + (y + h) + "Z";
return "M" + cx + "," + y +
"L" + (x2 - dir * r) + "," + y +
"A" + r + "," + r + " 0 0 " + (dir > 0 ? 1 : 0) + " " + x2 + "," + (y + r) +
"L" + x2 + "," + (y + h - r) +
"A" + r + "," + r + " 0 0 " + (dir > 0 ? 1 : 0) + " " + (x2 - dir * r) + "," + (y + h) +
"L" + cx + "," + (y + h) + "Z";
}
/* ================================================================== *
* 5. Render
* ================================================================== */
var shown = null; // the values currently drawn
var lastFrac = null; // their bar lengths as a fraction of the axis (for tweening)
function scaleOf(v, tot) {
return state.mode === "share" ? 100 * v / tot : v;
}
function drawGhost() {
var p = PROFILES[state.prof], s = p.snaps[0], tot = 0, i;
for (i = 0; i < NB; i++) tot += s.f[i] + s.m[i];
var dL = "", dR = "";
for (i = 0; i < NB; i++) {
var yT = L.plotTop + (NB - 1 - i) * L.bandH, yB = yT + L.bandH;
var wL = scaleOf(s.f[i], tot) / L.max * L.side, wR = scaleOf(s.m[i], tot) / L.max * L.side;
dL += (i === 0 ? "M" : "L") + (L.cL - wL) + "," + yB + "L" + (L.cL - wL) + "," + yT;
dR += (i === 0 ? "M" : "L") + (L.cR + wR) + "," + yB + "L" + (L.cR + wR) + "," + yT;
}
ghostL.setAttribute("d", dL); ghostR.setAttribute("d", dR);
}
function render(v, ov) {
shown = v;
var i, gap = Math.min(2.6, L.bandH * 0.24), h = Math.max(1.5, L.bandH - gap);
var fa = [], fb = [];
for (i = 0; i < NB; i++) {
var y = L.plotTop + (NB - 1 - i) * L.bandH + gap / 2;
var ra = ov ? ov.fa[i] : scaleOf(v.f[i], v.total) / L.max;
var rb = ov ? ov.fb[i] : scaleOf(v.m[i], v.total) / L.max;
fa.push(ra); fb.push(rb);
barsA[i].setAttribute("d", barPath(L.cL, y, Math.max(0, ra * L.side), h, -1));
barsB[i].setAttribute("d", barPath(L.cR, y, Math.max(0, rb * L.side), h, 1));
}
var med = ov ? ov.med : v.med;
lastFrac = { fa: fa, fb: fb, med: med };
var my = yForAge(med);
medLine.setAttribute("x1", L.pad); medLine.setAttribute("x2", L.W - L.pad);
medLine.setAttribute("y1", my); medLine.setAttribute("y2", my);
medM1.setAttribute("d", "M" + L.cL + "," + (my - 4) + "L" + (L.cL + 5) + "," + my + "L" + L.cL + "," + (my + 4) + "Z");
medM2.setAttribute("d", "M" + L.cR + "," + (my - 4) + "L" + (L.cR - 5) + "," + my + "L" + L.cR + "," + (my + 4) + "Z");
var label = (L.W < 430 ? "median " : "median age ") + med.toFixed(1);
medTxt.textContent = label;
var tw = label.length * 5.9 + 10;
var ty = my < L.plotTop + 16 ? my + 6 : my - 6;
medBg.setAttribute("x", L.pad + 1); medBg.setAttribute("y", ty - 10.5);
medBg.setAttribute("width", tw); medBg.setAttribute("height", 14);
medTxt.setAttribute("x", L.pad + 6); medTxt.setAttribute("y", ty);
paintReadouts(v);
}
/* ---- readouts ---- */
var TILES = [
{ lab: "median age", get: function (v) { return v.med.toFixed(1); }, unit: "yrs",
dlt: function (v, b) { return signed(v.med - b.med, 1) + " yrs vs 1950"; } },
{ lab: "aged 65 and over", get: function (v) { return (v.oldShare * 100).toFixed(1); }, unit: "%",
dlt: function (v, b) { return signed((v.oldShare - b.oldShare) * 100, 1) + " pts vs 1950"; } },
{ lab: "children under 15", get: function (v) { return (v.kidShare * 100).toFixed(1); }, unit: "%",
dlt: function (v, b) { return signed((v.kidShare - b.kidShare) * 100, 1) + " pts vs 1950"; } },
{ lab: "adults 20–64 per person 65+", get: function (v) { return v.support.toFixed(1); }, unit: "",
dlt: function (v, b) { return signed(v.support - b.support, 1) + " vs 1950"; } },
{ lab: "people, all ages", get: function (v) { return v.total >= 100 ? v.total.toFixed(0) : v.total.toFixed(1); }, unit: "m",
dlt: function (v, b) { return "×" + (v.total / b.total).toFixed(2) + " vs 1950"; } }
];
function signed(x, d) { return (x >= 0 ? "+" : "−") + Math.abs(x).toFixed(d); }
var statsEl = $("stats"), tileNodes = [];
TILES.forEach(function (t) {
var d = document.createElement("div");
d.className = "card tile";
d.innerHTML = '<div class="lab">' + t.lab + '</div><div class="val">—</div><div class="dlt">—</div>';
statsEl.appendChild(d);
tileNodes.push({ val: d.querySelector(".val"), dlt: d.querySelector(".dlt") });
});
var cmpEl = $("cmp"), cmpNodes = [];
PROFILES.forEach(function (p, i) {
var b = document.createElement("button");
b.type = "button";
b.innerHTML = '<span class="nm">' + p.name + '</span><span class="m1">—</span><span class="m2">—</span>';
b.addEventListener("click", function () { setProfile(i); });
cmpEl.appendChild(b);
cmpNodes.push({ btn: b, m1: b.querySelector(".m1"), m2: b.querySelector(".m2") });
});
function paintCmp(year) {
PROFILES.forEach(function (p, i) {
var v = i === state.prof ? shownCache : valuesAt(i, year);
var med = v.med.toFixed(1), old65 = (v.oldShare * 100).toFixed(1);
cmpNodes[i].m1.textContent = med;
cmpNodes[i].m2.textContent = old65 + "%";
cmpNodes[i].btn.setAttribute("aria-current", i === state.prof ? "true" : "false");
cmpNodes[i].btn.setAttribute("aria-label",
"Show " + p.name + ": median age " + med + " years, " + old65 + " percent aged 65 and over in " +
Math.round(year) + ".");
});
}
var sparkPaths = [], sparkDot = null;
function buildSpark() {
var s = $("spark");
while (s.firstChild) s.removeChild(s.firstChild);
sparkPaths = [];
var x0 = 26, x1 = 294, y0 = 12, y1 = 84, lo = 14, hi = 62;
function sx(y) { return x0 + (y - 1950) / 100 * (x1 - x0); }
function sy(m) { return y1 - (m - lo) / (hi - lo) * (y1 - y0); }
s.appendChild(svgEl("line", { "class": "sax", x1: x0, x2: x1, y1: y1, y2: y1 }));
[20, 40, 60].forEach(function (m) {
var t = svgEl("text", { "class": "st", x: x0 - 5, y: sy(m) + 3, "text-anchor": "end" });
t.textContent = m; s.appendChild(t);
});
[1950, 2000, 2050].forEach(function (y) {
var t = svgEl("text", { "class": "st", x: sx(y), y: y1 + 12, "text-anchor": y === 1950 ? "start" : (y === 2050 ? "end" : "middle") });
t.textContent = y; s.appendChild(t);
});
PROFILES.forEach(function (p, i) {
var d = "";
p.medPath.forEach(function (m, k) { d += (k ? "L" : "M") + sx(1950 + k * 5).toFixed(1) + "," + sy(m).toFixed(1); });
var path = svgEl("path", { "class": "sl", d: d, "stroke-linejoin": "round", "stroke-linecap": "round" });
s.appendChild(path); sparkPaths.push(path);
});
sparkDot = svgEl("circle", { "class": "sdot", r: 4, cx: sx(1950), cy: sy(PROFILES[state.prof].medPath[0]) });
s.appendChild(sparkDot);
s.__sx = sx; s.__sy = sy;
}
function paintSpark(v) {
var s = $("spark");
sparkPaths.forEach(function (p, i) { p.setAttribute("class", "sl" + (i === state.prof ? " cur" : "")); });
sparkDot.setAttribute("cx", s.__sx(v.year).toFixed(1));
sparkDot.setAttribute("cy", s.__sy(Math.max(14, Math.min(62, v.med))).toFixed(1));
}
function narrative(v) {
var o = v.oldShare * 100, one = Math.max(2, Math.round(100 / o));
var a = "In <span class=\"mono\">" + Math.round(v.year) + "</span>, half of these people are older than <span class=\"mono\">" +
v.med.toFixed(0) + "</span>.";
var b;
if (o >= 25) b = "More than <b>one in four</b> is 65 or older";
else if (o >= 8) b = "About one in <span class=\"mono\">" + one + "</span> is 65 or older";
else b = "Only <span class=\"mono\">" + o.toFixed(1) + "%</span> are 65 or older";
b += ", and there are <span class=\"mono\">" + v.support.toFixed(1) + "</span> adults aged 20–64 for each of them.";
var c = v.kidShare >= 0.35
? " Children under 15 are still <span class=\"mono\">" + (v.kidShare * 100).toFixed(0) + "%</span> of everyone."
: (v.kidShare < 0.13 ? " Each new age group is smaller than the one above it." : "");
return a + " " + b + c;
}
var shownCache = null;
function paintReadouts(v) {
var base = PROFILES[state.prof].stats1950;
shownCache = v;
paintCmp(v.year);
TILES.forEach(function (t, i) {
tileNodes[i].val.innerHTML = t.get(v) + (t.unit ? '<u>' + t.unit + '</u>' : '');
tileNodes[i].dlt.textContent = t.dlt(v, base);
});
$("narr").innerHTML = narrative(v);
$("drvTfr").textContent = v.tfr.toFixed(2);
$("mTfr").style.width = Math.min(100, v.tfr / 7 * 100).toFixed(1) + "%";
$("drvE0").textContent = v.e0.toFixed(0) + " yrs";
$("mE0").style.width = Math.min(100, (v.e0 - 30) / 60 * 100).toFixed(1) + "%";
paintSpark(v);
var yr = Math.round(v.year);
$("yearOut").innerHTML = yr + "<small>YEAR</small>";
if (+yearInput.value !== yr) yearInput.value = yr;
yearInput.setAttribute("aria-valuetext", String(yr));
svg.setAttribute("aria-label",
PROFILES[state.prof].name + ", " + yr +
": median age " + v.med.toFixed(1) + " years, " + (v.oldShare * 100).toFixed(1) +
" percent aged 65 and over, " + (v.kidShare * 100).toFixed(1) + " percent under 15.");
if ($("tblDetails").open) paintTable(v);
if (state.hover >= 0) paintTip(state.hover, null);
}
function bandName(i) { return i === NB - 1 ? "90+" : (i * 5) + "–" + (i * 5 + 4); }
function fmtVal(x, tot) {
return state.mode === "share" ? (100 * x / tot).toFixed(2) + "%" : x.toFixed(2) + "m";
}
function paintTable(v) {
var tb = $("tbl").querySelector("tbody"), html = "";
for (var i = NB - 1; i >= 0; i--) {
var tot = v.f[i] + v.m[i];
html += "<tr><th scope=\"row\">" + bandName(i) + "</th><td>" + v.f[i].toFixed(2) + "</td><td>" +
v.m[i].toFixed(2) + "</td><td>" + tot.toFixed(2) + "</td><td>" + (100 * tot / v.total).toFixed(2) + "%</td></tr>";
}
tb.innerHTML = html;
}
$("tblDetails").addEventListener("toggle", function () {
$("tblSummary").textContent = this.open ? "hide the numbers" : "show the numbers";
if (this.open && shown) paintTable(shown);
});
/* ---- hover / focus on bands ---- */
var lastPointer = null;
function paintTip(i, evt) {
if (i < 0 || !shown) { tip.className = "tip"; return; }
var v = shown, tot = v.f[i] + v.m[i];
tip.innerHTML = '<div class="th">age ' + bandName(i) + '</div>' +
'<div class="tr"><span class="a"><i></i>women</span><b>' + fmtVal(v.f[i], v.total) + '</b></div>' +
'<div class="tr"><span class="b"><i></i>men</span><b>' + fmtVal(v.m[i], v.total) + '</b></div>' +
'<div class="tr tot"><span>share of all</span><b>' + (100 * tot / v.total).toFixed(2) + '%</b></div>';
tip.className = "tip on";
var wrapW = wrapEl.clientWidth, scale = wrapW / L.W;
var y = (L.plotTop + (NB - 1 - i) * L.bandH + L.bandH / 2) * scale;
var px = evt ? evt.px : (lastPointer !== null ? lastPointer : wrapW / 2);
lastPointer = px;
var tw = tip.offsetWidth || 160, th = tip.offsetHeight || 70;
var x = px + 14;
if (x + tw > wrapW - 2) x = px - tw - 14;
if (x < 2) x = 2;
tip.style.left = x + "px";
tip.style.top = Math.max(2, Math.min(wrapEl.clientHeight - th - 2, y - th / 2)) + "px";
}
function setHover(i, evt) {
state.hover = i;
for (var k = 0; k < NB; k++) {
rowHi[k].setAttribute("class", "rowhi" + (k === i ? " on" : ""));
barsA[k].setAttribute("class", "barA" + (k === i ? " on" : ""));
barsB[k].setAttribute("class", "barB" + (k === i ? " on" : ""));
}
paintTip(i, evt);
}
hits.forEach(function (h, i) {
h.addEventListener("mouseenter", function (e) { setHover(i, { px: pxOf(e) }); });
h.addEventListener("mousemove", function (e) { if (state.hover === i) paintTip(i, { px: pxOf(e) }); });
});
function pxOf(e) { return e.clientX - wrapEl.getBoundingClientRect().left; }
hits.forEach(function (h, i) {
h.addEventListener("pointerdown", function (e) {
if (e.pointerType === "mouse") return; // mouse is handled by hover
setHover(state.hover === i ? -1 : i, { px: pxOf(e) });
});
});
document.addEventListener("pointerdown", function (e) {
if (state.hover >= 0 && !wrapEl.contains(e.target)) setHover(-1, null);
}, true);
svg.addEventListener("mouseleave", function () { setHover(-1, null); });
svg.addEventListener("blur", function () { setHover(-1, null); });
svg.addEventListener("keydown", function (e) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
var i = state.hover < 0 ? 0 : state.hover + (e.key === "ArrowUp" ? 1 : -1);
i = Math.max(0, Math.min(NB - 1, i));
lastPointer = wrapEl.clientWidth / 2;
setHover(i, null);
if (shown) $("live").textContent = "Age " + bandName(i) + ": women " + fmtVal(shown.f[i], shown.total) +
", men " + fmtVal(shown.m[i], shown.total) + ".";
} else if (e.key === "Escape") { setHover(-1, null); }
});
/* ================================================================== *
* 6. Loop
* ================================================================== */
var raf = 0, last = 0;
var YEARS_PER_SEC = 12.5;
function frame(ts) {
raf = 0;
var dt = last ? Math.min(0.06, (ts - last) / 1000) : 0;
last = ts;
var busy = false;
if (state.playing && !reduce) {
state.year += dt * YEARS_PER_SEC;
if (state.year >= 2050) { state.year = 2050; stop(); }
else busy = true;
}
var target = valuesAt(state.prof, state.year);
if (tween) {
tween.t += dt / 0.42;
if (tween.t >= 1) { tween = null; render(target); }
else {
var e = tween.t < 0.5 ? 2 * tween.t * tween.t : 1 - Math.pow(-2 * tween.t + 2, 2) / 2;
var ov = { fa: [], fb: [], med: tween.from.med + (target.med - tween.from.med) * e };
for (var i = 0; i < NB; i++) {
var ta = scaleOf(target.f[i], target.total) / L.max;
var tb = scaleOf(target.m[i], target.total) / L.max;
ov.fa.push(tween.from.fa[i] + (ta - tween.from.fa[i]) * e);
ov.fb.push(tween.from.fb[i] + (tb - tween.from.fb[i]) * e);
}
render(target, ov);
busy = true;
}
} else {
render(target);
}
if (busy) kick();
}
function kick() { if (!raf) raf = requestAnimationFrame(frame); }
function refresh(now) {
if (now) last = 0;
/* draw straight away — never wait on a queued frame, which a hidden or
throttled tab may not deliver for a long time. */
if (!tween) render(valuesAt(state.prof, state.year));
kick();
}
function announce() {
if (!shown) return;
$("live").textContent = Math.round(shown.year) + ": median age " + shown.med.toFixed(1) +
" years, " + (shown.oldShare * 100).toFixed(1) + "% aged 65 and over.";
}
/* ================================================================== *
* 7. Boot
* ================================================================== */
paintSegs();
buildSpark();
layoutStatic();
drawGhost();
render(valuesAt(state.prof, state.year));
$("statusText").textContent = "model built · " + buildMs.toFixed(0) + " ms · " +
tableCount.toLocaleString("en-US") + " life tables";
$("status").className = "status ok";
$("status").title = "Three example populations projected in five-year steps, in your browser.";
var rz;
function relayoutNow() {
layoutStatic(); drawGhost(); ghostVis();
if (shown) render(shown);
}
/* correct the boot measurement before the browser paints, and once more after
load in case fonts or scrollbars shifted the column. */
requestAnimationFrame(relayoutNow);
window.addEventListener("load", relayoutNow);
window.addEventListener("resize", function () {
clearTimeout(rz);
rz = setTimeout(relayoutNow, 90);
});
if (window.ResizeObserver) {
var ro = new ResizeObserver(function () {
if (Math.abs((wrapEl.clientWidth || 0) - L.W) > 2) relayoutNow();
});
ro.observe(wrapEl);
}
/* a hidden tab stops delivering frames; catch up when it comes back */
document.addEventListener("visibilitychange", function () {
if (document.visibilityState !== "visible") return;
last = 0;
if (!tween) render(valuesAt(state.prof, state.year));
kick();
});
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
if (!reduce) {
setTimeout(play, 550);
} else {
$("axNote").textContent = "steps 5 years at a time";
}
announce();
})();
</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.