$sloprun.dev

Population Pyramid

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/population-pyramid" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Population Pyramid — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/population-pyramid

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1265 lines 56.0 KB index.html
demos/population-pyramid/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">&#9654;</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">&nbsp;</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">&nbsp;</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&ndash;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 &middot; <kbd>&larr;</kbd><kbd>&rarr;</kbd> step 5 years &middot;
        focus the chart then <kbd>&uarr;</kbd><kbd>&darr;</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 &middot; sloprun.dev</span>
    <span>illustrative examples &mdash; 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 = "&#10073;&#10073;";
      $("playLabel").textContent = "Pause";
      playBtn.setAttribute("aria-label", "Pause");
      return;
    }
    var end = state.year >= 2049.99;
    $("playGlyph").innerHTML = "&#9654;";
    $("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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong