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.
Source — the code you see is the code that just ran 658 lines 29.5 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>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 —
<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 · 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 totalNo comments yet. If you ran it, say what happened.