$sloprun.dev

Compound Interest Playground

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

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

▶ 6 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

Two people save the same amount every month and stop at the same age — one just started ten years earlier, and on the default settings that head start is worth $651,045. Only $60,000 of it is money they actually saved; the other 91% is growth the late starter never had time to earn. Drag the monthly amount, the annual return, the start age or the saving horizon and the twin comparison recomputes live — or drag across the chart (focus it and press ← →) to scrub through the years and watch contributions and growth trade places. Try flipping "late starter waits" to 15 years, then drop the return to 1% and watch the gap collapse: compounding, not saving, is what the extra decade buys you.

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/compound-interest" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Compound Interest Playground — 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/compound-interest

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 658 lines 29.5 KB index.html
demos/compound-interest/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Compound Interest Playground — 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 colours (theme-aware, derived from the tokens above) ---- */
:root {
  --c-in: #7E8CA0;        /* money you put in — quiet slate */
  --c-in-soft: #D2D9E2;
  --c-grow: #C05600;      /* growth — the phosphor moment */
  --c-grow-soft: #F7CFA4;
  --c-late: #3E444C;      /* the late starter's line */
  --shadow: 0 1px 2px rgba(20,22,26,.06), 0 8px 24px rgba(20,22,26,.05);
  color-scheme: light;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --c-in: #667588; --c-in-soft: #333D4A;
    --c-grow: #FFB454; --c-grow-soft: #5E4220;
    --c-late: #B9BFC7;
    --shadow: none;
    color-scheme: dark;
  }
}
:root[data-theme="dark"] {
  --c-in: #667588; --c-in-soft: #333D4A;
  --c-grow: #FFB454; --c-grow-soft: #5E4220;
  --c-late: #B9BFC7;
  --shadow: none;
  color-scheme: dark;
}

/* ---- base ---- */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
      clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

.wrap { max-width: 1080px; margin: 0 auto; padding: 16px 16px 34px; }

header.top { margin-bottom: 12px; }
.kicker { font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
          text-transform: uppercase; color: var(--muted); margin: 0 0 6px; }
h1 { font-size: clamp(1.45rem, 4.4vw, 2rem); line-height: 1.15; margin: 0 0 6px;
     letter-spacing: -.02em; font-weight: 650; }
.lede { margin: 0; color: var(--muted); font-size: .97rem; max-width: 68ch; }

.layout { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; align-items: start; }
@media (min-width: 940px) { .layout { grid-template-columns: minmax(0,1fr) 292px; } }

.card { background: var(--surface); border: 1px solid var(--line);
        border-radius: var(--radius); box-shadow: var(--shadow); }
.col { display: grid; gap: 12px; min-width: 0; }

/* ---- hero ---- */
.hero { padding: 15px 18px 14px; border-left: 3px solid var(--accent); }
.hero-k { margin: 0; font-size: .9rem; color: var(--muted); }
.hero-k b { color: var(--ink); font-weight: 600; }
.hero-n { margin: 2px 0 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
          font-size: clamp(2.5rem, 11vw, 3.6rem); line-height: 1.02; font-weight: 650;
          letter-spacing: -.035em; color: var(--accent); word-break: break-word; }
.hero-sub { margin: 8px 0 0; font-size: .9rem; color: var(--muted); max-width: 62ch; }
.hero-sub b { color: var(--ink); font-weight: 600; font-family: var(--font-mono);
              font-variant-numeric: tabular-nums; }

/* ---- chart ---- */
.chartcard { padding: 12px 14px 10px; min-width: 0; }
.chart-head { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline;
              justify-content: space-between; margin-bottom: 8px; }
.head-l { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
.ageread { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
           font-size: 12px; font-weight: 600; color: var(--accent); white-space: nowrap; }
.eyebrow { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .09em;
           text-transform: uppercase; color: var(--muted); margin: 0; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 12px; list-style: none; margin: 0; padding: 0; }
.legend li { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono);
             font-size: 11px; color: var(--muted); }
.sw { width: 11px; height: 11px; border-radius: 3px; flex: none; }
.sw.in { background: var(--c-in-soft); border: 1px solid var(--c-in); }
.sw.grow { background: var(--c-grow-soft); border: 1px solid var(--c-grow); }
.sw.late { width: 14px; height: 0; border: 0; border-top: 2px dashed var(--c-late); border-radius: 0; }

#plot { position: relative; width: 100%; height: clamp(200px, 30vw, 264px);
        touch-action: pan-y; border-radius: 6px; -webkit-user-select: none; user-select: none; }
#plot svg { display: block; width: 100%; height: 100%; }
.grid { stroke: var(--line); stroke-width: 1; }
.axis { stroke: var(--line); stroke-width: 1; }
.tick { fill: var(--muted); font-family: var(--font-mono); font-size: 10.5px; }
.a-in { fill: var(--c-in-soft); stroke: none; }
.a-grow { fill: var(--c-grow-soft); stroke: none; }
.l-in { fill: none; stroke: var(--c-in); stroke-width: 1.5; }
.l-early { fill: none; stroke: var(--c-grow); stroke-width: 2.25; stroke-linejoin: round; }
.l-late { fill: none; stroke: var(--c-late); stroke-width: 2; stroke-dasharray: 5 4;
          stroke-linejoin: round; }
.brk { stroke: var(--accent); stroke-width: 1.5; fill: none; }
.brk-t { fill: var(--accent); font-family: var(--font-mono); font-size: 10.5px;
         letter-spacing: .06em; paint-order: stroke fill;
         stroke: var(--surface); stroke-width: 3px; stroke-linejoin: round; }
.scrub { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 2 3; opacity: .55; }
.dot { stroke: var(--surface); stroke-width: 2; }
.dot.early { fill: var(--c-grow); }
.dot.late { fill: var(--c-late); }
#plot:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.note { margin: 8px 0 0; padding: 7px 10px; border-left: 2px solid var(--accent);
        background: var(--accent-soft); border-radius: 0 6px 6px 0; font-size: .84rem;
        color: var(--ink); }
.hint { margin: 8px 0 0; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }

/* ---- twin readout ---- */
.twins { display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 560px) { .twins { grid-template-columns: 1fr 1fr; } }
.twin { padding: 12px 14px 13px; }
.twin h2 { margin: 0; font-family: var(--font-mono); font-size: 11px; letter-spacing: .07em;
           text-transform: uppercase; color: var(--muted); font-weight: 500;
           display: flex; align-items: center; gap: 7px; }
.twin h2 b { font-weight: inherit; }
.twin .tag { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.tag.early { background: var(--c-grow); }
.tag.late { background: var(--c-late); }
.twin .big { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
             font-size: clamp(1.35rem, 5.6vw, 1.7rem); font-weight: 600; letter-spacing: -.02em;
             margin: 4px 0 9px; }
.bar { display: flex; height: 9px; border-radius: 99px; overflow: hidden; background: var(--line); }
.bar span { display: block; height: 100%; }
.bar .b-in { background: var(--c-in); }
.bar .b-grow { background: var(--c-grow); }
.split { display: flex; justify-content: space-between; gap: 10px; margin-top: 7px;
         font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 11.5px;
         color: var(--muted); }
.split b { color: var(--ink); font-weight: 600; }
@media (prefers-reduced-motion: no-preference) {
  .bar span { transition: width .18s ease; }
}

/* ---- controls ---- */
.panel { padding: 14px 14px 16px; position: sticky; top: 12px; }
@media (max-width: 939px) { .panel { position: static; } }
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
              margin-bottom: 12px; }
.reset { font-family: var(--font-mono); font-size: 11px; color: var(--muted); background: none;
         border: 1px solid var(--line); border-radius: 7px; padding: 4px 9px; cursor: pointer; }
.reset:hover { color: var(--accent); border-color: var(--accent); }
.ctrl { margin-bottom: 15px; }
.ctrl .row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.ctrl label { font-size: .84rem; color: var(--muted); }
.ctrl output { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
               font-size: .92rem; font-weight: 600; color: var(--ink); }
.sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 1px;
       display: block; }
input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 22px;
                    background: transparent; cursor: pointer; margin: 2px 0 0; display: block; }
input[type=range]::-webkit-slider-runnable-track { height: 4px; background: var(--line); border-radius: 99px; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 17px; height: 17px;
  margin-top: -6.5px; border-radius: 50%; background: var(--surface); border: 2px solid var(--accent); }
input[type=range]::-moz-range-track { height: 4px; background: var(--line); border-radius: 99px; }
input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%;
  background: var(--surface); border: 2px solid var(--accent); }
input[type=range]:focus-visible { outline: none; }
input[type=range]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 0 5px var(--accent); }
input[type=range]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 0 5px var(--accent); }
fieldset.seg { border: 0; padding: 0; margin: 0 0 4px; }
fieldset.seg legend { padding: 0; font-size: .84rem; color: var(--muted); }
.segrow { display: flex; gap: 6px; margin-top: 6px; }
.segrow input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.segrow label { flex: 1; text-align: center; font-family: var(--font-mono); font-size: 12px;
                padding: 7px 2px; border: 1px solid var(--line); border-radius: 8px;
                color: var(--muted); cursor: pointer; }
.segrow input:checked + label { border-color: var(--accent); background: var(--accent-soft);
                                color: var(--ink); font-weight: 600; }
.segrow input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }

.foot { margin-top: 22px; font-family: var(--font-mono); font-size: 11px; color: var(--muted);
        letter-spacing: .04em; }
</style>
</head>
<body>
<div class="wrap">

  <header class="top">
    <p class="kicker">finance · runs in your browser</p>
    <h1>Two savers, same paycheck, <span id="h1Delay">ten</span> years apart</h1>
    <p class="lede">They both put away the same amount every month until they turn
      <span id="ledeEnd">65</span>. One just started earlier. Drag anything below and watch the
      gap move.</p>
  </header>

  <div class="layout">
    <div class="col">

      <section class="card hero" aria-labelledby="heroLabel heroNum">
        <p class="hero-k" id="heroLabel">Starting at <b>35</b> instead of <b>25</b> costs</p>
        <p class="hero-n mono" id="heroNum">$651,045</p>
        <p class="hero-sub">By age <b id="hEndAge">65</b> they hold <b id="hLate">$584,726</b> instead of
          <b id="hEarly">$1,235,771</b>. <span id="hInsight">They only saved <b>$60,000</b> less &mdash;
          <b>91%</b> of the gap is growth that never got the time to happen.</span></p>
      </section>

      <section class="card chartcard" aria-labelledby="chartTitle">
        <div class="chart-head">
          <div class="head-l">
            <p class="eyebrow" id="chartTitle">Balance by age</p>
            <p class="ageread" id="ageRead">age 65 &middot; finish</p>
          </div>
          <ul class="legend">
            <li><span class="sw in" aria-hidden="true"></span>money in</li>
            <li><span class="sw grow" aria-hidden="true"></span>growth</li>
            <li><span class="sw late" aria-hidden="true"></span>late starter</li>
          </ul>
        </div>
        <div id="plot" tabindex="0" role="slider" aria-label="Scrub through the years"
             aria-describedby="hint"
             aria-valuemin="25" aria-valuemax="65" aria-valuenow="65" aria-valuetext="age 65"></div>
        <p class="hint" id="hint">drag across the chart — or focus it and use ← →</p>
        <p class="note" id="note" hidden></p>
      </section>

      <div class="twins">
        <section class="card twin">
          <h2><span class="tag early" aria-hidden="true"></span><span id="t1Title">Starts at 25</span></h2>
          <p class="big" id="t1Total">$1,235,771</p>
          <div class="bar" aria-hidden="true"><span class="b-in" id="t1BarIn" style="width:19.42%"></span><span class="b-grow" id="t1BarGrow" style="width:80.58%"></span></div>
          <p class="split"><span>in <b id="t1In">$240,000</b></span><span>growth <b id="t1Grow">$995,771</b></span></p>
        </section>
        <section class="card twin">
          <h2><span class="tag late" aria-hidden="true"></span><span id="t2Title">Starts at 35</span></h2>
          <p class="big" id="t2Total">$584,726</p>
          <div class="bar" aria-hidden="true"><span class="b-in" id="t2BarIn" style="width:30.78%"></span><span class="b-grow" id="t2BarGrow" style="width:69.22%"></span></div>
          <p class="split"><span>in <b id="t2In">$180,000</b></span><span>growth <b id="t2Grow">$404,726</b></span></p>
        </section>
      </div>
      <p class="sr" id="live" aria-live="polite"></p>
    </div>

    <form class="card panel" id="controls" autocomplete="off">
      <div class="panel-head">
        <p class="eyebrow">Controls</p>
        <button type="button" class="reset" id="reset">reset</button>
      </div>

      <div class="ctrl">
        <div class="row"><label for="monthly">Saved each month</label>
          <output class="mono" id="oMonthly" for="monthly">$500</output></div>
        <input type="range" id="monthly" min="25" max="3000" step="25" value="500">
      </div>

      <div class="ctrl">
        <div class="row"><label for="rate">Annual return</label>
          <output class="mono" id="oRate" for="rate">7.0%</output></div>
        <input type="range" id="rate" min="1" max="12" step="0.1" value="7">
      </div>

      <div class="ctrl">
        <div class="row"><label for="startAge">Early starter begins at</label>
          <output class="mono" id="oStart" for="startAge">age 25</output></div>
        <input type="range" id="startAge" min="18" max="50" step="1" value="25">
      </div>

      <div class="ctrl">
        <div class="row"><label for="years">Years of saving</label>
          <output class="mono" id="oYears" for="years">40 yrs</output></div>
        <input type="range" id="years" min="5" max="45" step="1" value="40">
        <span class="sub" id="sYears">both stop at age 65</span>
      </div>

      <fieldset class="seg">
        <legend>Late starter waits</legend>
        <div class="segrow">
          <input type="radio" name="delay" id="d5" value="5"><label for="d5">5 yrs</label>
          <input type="radio" name="delay" id="d10" value="10" checked><label for="d10">10 yrs</label>
          <input type="radio" name="delay" id="d15" value="15"><label for="d15">15 yrs</label>
        </div>
      </fieldset>
    </form>
  </div>

  <p class="foot">demo · sloprun.dev</p>
</div>

<script>
(function () {
  "use strict";

  /* platform theme bridge (also works standalone via prefers-color-scheme) */
  window.addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  var $ = function (id) { return document.getElementById(id); };
  var money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
  var fmt = function (v) { return money.format(Math.round(v)); };
  function compact(v) {
    if (v >= 999500) return "$" + (v / 1e6).toFixed(v >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M";
    if (v >= 999.5) return "$" + (v / 1e3).toFixed(v >= 1e4 ? 0 : 1).replace(/\.0$/, "") + "k";
    return "$" + Math.round(v);
  }

  var DEFAULTS = { monthly: 500, rate: 7, startAge: 25, years: 40, delay: 10 };
  var s = Object.assign({}, DEFAULTS);
  var model = null;      /* computed series */
  var scrub = null;      /* month index, null = pinned to the end */

  /* ---------- math ---------- */
  function run(monthly, rm, months, waitMonths) {
    var total = new Float64Array(months + 1), paid = new Float64Array(months + 1);
    var b = 0, c = 0;
    for (var i = 1; i <= months; i++) {
      b *= (1 + rm);
      if (i > waitMonths) { b += monthly; c += monthly; }
      total[i] = b; paid[i] = c;
    }
    return { total: total, paid: paid };
  }

  function compute() {
    var months = s.years * 12;
    var rm = Math.pow(1 + s.rate / 100, 1 / 12) - 1;
    var wait = Math.min(s.delay * 12, months);
    var early = run(s.monthly, rm, months, 0);
    var late = run(s.monthly, rm, months, wait);
    model = {
      months: months, rm: rm, wait: wait, early: early, late: late,
      startAge: s.startAge, lateAge: s.startAge + s.delay, endAge: s.startAge + s.years,
      max: early.total[months], gap: early.total[months] - late.total[months],
      gapPaid: early.paid[months] - late.paid[months],
      neverStarts: s.delay >= s.years
    };
  }

  /* ---------- chart ---------- */
  var plot = $("plot");
  var M = { l: 52, r: 26, t: 14, b: 26 };

  function draw() {
    var W = plot.clientWidth, H = plot.clientHeight;
    if (!W || !H) return;
    var pw = Math.max(10, W - M.l - M.r), ph = Math.max(10, H - M.t - M.b);
    var m = model, n = m.months;
    var yMax = m.max > 0 ? m.max * 1.06 : 100;
    var X = function (i) { return M.l + (i / n) * pw; };
    var Y = function (v) { return M.t + ph - (v / yMax) * ph; };
    var y0 = Y(0);

    var stride = Math.max(1, Math.ceil(n / 220));
    var idx = [];
    for (var i = 0; i <= n; i += stride) idx.push(i);
    if (idx[idx.length - 1] !== n) idx.push(n);

    function line(arr) {
      var d = "";
      for (var k = 0; k < idx.length; k++) {
        d += (k ? "L" : "M") + X(idx[k]).toFixed(1) + "," + Y(arr[idx[k]]).toFixed(1);
      }
      return d;
    }
    function backwards(arr) {
      var d = "";
      for (var k = idx.length - 1; k >= 0; k--) {
        d += "L" + X(idx[k]).toFixed(1) + "," + Y(arr[idx[k]]).toFixed(1);
      }
      return d;
    }

    var paidLine = line(m.early.paid);
    var totalLine = line(m.early.total);
    var areaIn = paidLine + "L" + X(n).toFixed(1) + "," + y0.toFixed(1) +
                 "L" + X(0).toFixed(1) + "," + y0.toFixed(1) + "Z";
    var areaGrow = totalLine + backwards(m.early.paid) + "Z";

    /* y gridlines */
    var step = niceStep(yMax, 4), g = "", yl = "";
    for (var v = 0; v <= yMax + 1e-6; v += step) {
      var yy = Y(v).toFixed(1);
      g += '<line class="grid" x1="' + M.l + '" y1="' + yy + '" x2="' + (M.l + pw) + '" y2="' + yy + '"/>';
      yl += '<text class="tick" x="' + (M.l - 8) + '" y="' + (Y(v) + 3.5).toFixed(1) +
            '" text-anchor="end">' + compact(v) + "</text>";
    }

    /* x ticks */
    var xstep = s.years <= 12 ? 2 : s.years <= 26 ? 5 : 10, xt = "";
    var ages = [];
    for (var a = m.startAge; a < m.endAge; a += xstep) {
      if (X(n) - X((a - m.startAge) * 12) > 26) ages.push(a);
    }
    ages.push(m.endAge);
    for (var q = 0; q < ages.length; q++) {
      var mi = (ages[q] - m.startAge) * 12;
      xt += '<text class="tick" x="' + X(mi).toFixed(1) + '" y="' + (H - 8) + '" text-anchor="' +
            (q === ages.length - 1 ? "end" : q === 0 ? "start" : "middle") + '">' + ages[q] + "</text>";
    }

    /* gap bracket in the right margin */
    var bx = M.l + pw + 9, yE = Y(m.early.total[n]), yL = Y(m.late.total[n]);
    var brk = '<path class="brk" d="M' + (bx - 4) + "," + yE.toFixed(1) + "H" + bx +
              "V" + yL.toFixed(1) + "H" + (bx - 4) + '"/>';
    if (yL - yE > 26 && pw > 260) {
      brk += '<text class="brk-t" x="' + (M.l + pw - 5) + '" y="' + ((yE + yL) / 2 + 3.5).toFixed(1) +
             '" text-anchor="end">gap</text>';
    }

    /* scrub marker */
    var k = scrub === null ? n : scrub;
    var sx = X(k);
    var mark = '<line class="scrub" x1="' + sx.toFixed(1) + '" y1="' + M.t + '" x2="' + sx.toFixed(1) +
               '" y2="' + y0.toFixed(1) + '"/>' +
      '<circle class="dot late" cx="' + sx.toFixed(1) + '" cy="' + Y(m.late.total[k]).toFixed(1) +
        '" r="4"/>' +
      '<circle class="dot early" cx="' + sx.toFixed(1) + '" cy="' + Y(m.early.total[k]).toFixed(1) +
        '" r="4.5"/>';

    plot.innerHTML =
      '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + " " + H + '" aria-hidden="true" focusable="false">' +
      g +
      '<path class="a-in" d="' + areaIn + '"/>' +
      '<path class="a-grow" d="' + areaGrow + '"/>' +
      '<path class="l-in" d="' + paidLine + '"/>' +
      '<path class="l-late" d="' + line(m.late.total) + '"/>' +
      '<path class="l-early" d="' + totalLine + '"/>' +
      brk + mark +
      '<line class="axis" x1="' + M.l + '" y1="' + y0.toFixed(1) + '" x2="' + (M.l + pw) +
        '" y2="' + y0.toFixed(1) + '"/>' +
      yl + xt +
      "</svg>";
  }

  function niceStep(max, count) {
    var raw = max / count;
    if (!(raw > 0)) return 1;
    var mag = Math.pow(10, Math.floor(Math.log10(raw)));
    var norm = raw / mag;
    var f = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10;
    return f * mag;
  }

  /* ---------- readouts ---------- */
  function paint() {
    var m = model, n = m.months, k = scrub === null ? n : scrub;
    var age = Math.floor(m.startAge + k / 12);

    /* hero — always the headline number, at the finish line */
    $("heroLabel").innerHTML = m.neverStarts
      ? "Never starting at all, instead of starting at <b>" + m.startAge + "</b>, costs"
      : "Starting at <b>" + m.lateAge + "</b> instead of <b>" + m.startAge + "</b> costs";
    $("hEndAge").textContent = m.endAge;
    $("heroNum").textContent = fmt(m.gap);
    $("hEarly").textContent = fmt(m.early.total[n]);
    $("hLate").textContent = fmt(m.late.total[n]);

    var growShare = m.gap > 0 ? Math.round(((m.gap - m.gapPaid) / m.gap) * 100) : 0;
    $("hInsight").innerHTML = m.neverStarts
      ? "Waiting " + s.delay + " years means they never start at all."
      : "They only saved <b>" + fmt(m.gapPaid) + "</b> less — <b>" + growShare +
        "%</b> of the gap is growth that never got the time to happen.";

    var note = $("note");
    if (m.neverStarts) {
      note.hidden = false;
      note.textContent = "The late starter waits " + s.delay + " years but everyone stops saving at " +
        m.endAge + " — so they contribute nothing at all.";
    } else { note.hidden = true; note.textContent = ""; }

    /* twins, at the scrubbed age */
    twin("t1", "Starts at " + m.startAge, m.early.total[k], m.early.paid[k]);
    twin("t2", m.neverStarts ? "Never starts" : "Starts at " + m.lateAge, m.late.total[k], m.late.paid[k]);

    $("ageRead").textContent = scrub === null
      ? "age " + m.endAge + " \u00b7 finish"
      : "age " + age;

    plot.setAttribute("aria-valuemin", m.startAge);
    plot.setAttribute("aria-valuemax", m.endAge);
    plot.setAttribute("aria-valuenow", age);
    plot.setAttribute("aria-valuetext", "age " + age + ": " + fmt(m.early.total[k]) +
      " versus " + fmt(m.late.total[k]));
    announce();
  }

  var liveTimer = 0;
  function announce() {
    clearTimeout(liveTimer);
    liveTimer = setTimeout(function () {
      /* while the plot is focused its own aria-valuetext already reports the scrub */
      $("live").textContent = document.activeElement === plot ? ""
        : "Starting at " + model.startAge + " ends with " + fmt(model.early.total[model.months]) +
          "; starting at " + model.lateAge + " ends with " + fmt(model.late.total[model.months]) +
          ". Gap " + fmt(model.gap) + ".";
    }, 450);
  }

  function twin(id, title, total, paid) {
    var grow = Math.max(0, total - paid);
    $(id + "Title").textContent = title;
    $(id + "Total").textContent = fmt(total);
    $(id + "In").textContent = fmt(paid);
    $(id + "Grow").textContent = fmt(grow);
    /* with nothing saved yet the bar must be empty, not 100% growth */
    var pct = total > 0 ? (paid / total) * 100 : 0;
    $(id + "BarIn").style.width = (total > 0 ? pct : 0).toFixed(2) + "%";
    $(id + "BarGrow").style.width = (total > 0 ? 100 - pct : 0).toFixed(2) + "%";
  }

  function update() {
    compute();
    if (scrub !== null) scrub = Math.min(scrub, model.months);
    draw();
    paint();
  }

  /* ---------- controls ---------- */
  var WORDS = { 5: "five", 10: "ten", 15: "fifteen" };

  function labels() {
    $("h1Delay").textContent = WORDS[s.delay] || s.delay;
    $("ledeEnd").textContent = s.startAge + s.years;
    $("oMonthly").textContent = fmt(s.monthly);
    $("oRate").textContent = s.rate.toFixed(1) + "%";
    $("oStart").textContent = "age " + s.startAge;
    $("oYears").textContent = s.years + " yrs";
    $("sYears").textContent = "both stop at age " + (s.startAge + s.years);
    $("monthly").setAttribute("aria-valuetext", fmt(s.monthly) + " a month");
    $("rate").setAttribute("aria-valuetext", s.rate.toFixed(1) + " percent a year");
    $("startAge").setAttribute("aria-valuetext", "age " + s.startAge);
    $("years").setAttribute("aria-valuetext", s.years + " years, stopping at age " + (s.startAge + s.years));
  }

  function bind(id, key, cast) {
    var el = $(id);
    el.addEventListener("input", function () {
      s[key] = cast(el.value);
      labels(); update();
    });
  }
  bind("monthly", "monthly", Number);
  bind("rate", "rate", Number);
  bind("startAge", "startAge", Number);
  bind("years", "years", Number);

  Array.prototype.forEach.call(document.querySelectorAll('input[name="delay"]'), function (r) {
    r.addEventListener("change", function () {
      if (r.checked) { s.delay = Number(r.value); labels(); update(); }
    });
  });

  $("controls").addEventListener("submit", function (e) { e.preventDefault(); });

  $("reset").addEventListener("click", function () {
    s = Object.assign({}, DEFAULTS);
    $("monthly").value = s.monthly; $("rate").value = s.rate;
    $("startAge").value = s.startAge; $("years").value = s.years;
    $("d" + s.delay).checked = true;
    scrub = null;
    labels(); update();
  });

  /* ---------- scrubbing ---------- */
  function monthAt(clientX) {
    var r = plot.getBoundingClientRect();
    var pw = Math.max(10, r.width - M.l - M.r);
    var t = (clientX - r.left - M.l) / pw;
    return Math.max(0, Math.min(model.months, Math.round(t * model.months)));
  }
  var dragging = false;
  plot.addEventListener("pointerdown", function (e) {
    dragging = true;
    try { plot.setPointerCapture(e.pointerId); } catch (err) {}
    scrub = monthAt(e.clientX); draw(); paint();
  });
  plot.addEventListener("pointermove", function (e) {
    if (!dragging && e.pointerType !== "mouse") return;
    if (e.pointerType === "mouse" && e.buttons === 0 && !dragging) {
      scrub = monthAt(e.clientX); draw(); paint(); return;
    }
    if (dragging) { e.preventDefault(); scrub = monthAt(e.clientX); draw(); paint(); }
  });
  plot.addEventListener("pointerup", function (e) {
    dragging = false;
    try { plot.releasePointerCapture(e.pointerId); } catch (err) {}
  });
  plot.addEventListener("pointercancel", function () { dragging = false; });
  plot.addEventListener("pointerleave", function (e) {
    /* touch fires pointerleave right after pointerup — only the mouse un-pins the scrub,
       otherwise a tap-and-release on mobile would snap straight back to the finish line */
    if (!dragging && e.pointerType === "mouse") { scrub = null; draw(); paint(); }
  });
  plot.addEventListener("blur", function () {
    if (!dragging) { scrub = null; draw(); paint(); }
  });
  plot.addEventListener("keydown", function (e) {
    var n = model.months, k = scrub === null ? n : scrub, hit = true;
    switch (e.key) {
      case "ArrowLeft": case "ArrowDown": k = Math.max(0, k - 12); break;
      case "ArrowRight": case "ArrowUp": k = Math.min(n, k + 12); break;
      case "PageDown": k = Math.max(0, k - 60); break;
      case "PageUp": k = Math.min(n, k + 60); break;
      case "Home": k = 0; break;
      case "End": k = n; break;
      default: hit = false;
    }
    if (!hit) return;
    e.preventDefault();
    scrub = k; draw(); paint();
  });

  /* ---------- boot ---------- */
  labels();
  update();
  requestAnimationFrame(draw);   /* re-measure once layout has settled */
  if (window.ResizeObserver) {
    var ro = new ResizeObserver(function () { draw(); });
    ro.observe(plot);
  } else {
    window.addEventListener("resize", draw);
  }
})();
</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