0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Every payment on a fixed-rate loan is exactly the same size, but where the money goes is not: on the default $420,000 at 6.50% over 30 years, the very first payment is 80% interest and hands the lender $2,275 for the privilege of owing them money for one more month. The stacked area chart shows that split for all 360 payments — drag across it (or focus it and press ← →) to inspect any single one, and flip to "running total" to watch the interest pile up against the $420,000 you actually borrowed, with a dashed line tracking what the same loan would cost untouched. The thing worth trying first is the extra-payment slider: $200 a month, less than a phone bill, erases 64 payments and $113,223 of interest, and the chart marks the years it deletes in green.
Source — the code you see is the code that just ran 809 lines 36.1 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loan Amortization Explorer</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-int: #C05600; /* interest — the phosphor cost */
--c-int-soft: #F7CFA4;
--c-prin: #45708D; /* balance paid down — calm slate blue */
--c-prin-soft: #CFDCE7;
--c-base: #3E444C; /* the no-extra-payment reference line */
--c-free: rgba(26,127,55,.10); /* the stretch of payments extra money deletes */
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 8px 24px rgba(20,22,26,.05);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--c-int: #FFB454; --c-int-soft: #5A3F1E;
--c-prin: #82B2D8; --c-prin-soft: #26394A;
--c-base: #B9BFC7; --c-free: rgba(63,185,80,.16); --shadow: none;
}
}
:root[data-theme="dark"] {
--c-int: #FFB454; --c-int-soft: #5A3F1E;
--c-prin: #82B2D8; --c-prin-soft: #26394A;
--c-base: #B9BFC7; --c-free: rgba(63,185,80,.16); --shadow: none;
}
/* ---- 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; }
.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.4rem, 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: 66ch; }
.layout { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; align-items: start;
grid-template-areas: "hero" "panel" "rest"; }
@media (min-width: 940px) {
.layout { grid-template-columns: minmax(0,1fr) 296px;
grid-template-areas: "hero panel" "rest panel"; }
}
.card { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow); }
.col { grid-area: rest; display: grid; gap: 12px; min-width: 0; }
/* ---- hero ---- */
.hero { grid-area: hero; padding: 15px 18px 14px; border-left: 3px solid var(--accent); }
.hero-k { margin: 0; font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); }
.hero-n { margin: 2px 0 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: clamp(2.4rem, 10.5vw, 3.5rem); 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 12px; min-width: 0; }
.chart-head { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline;
justify-content: space-between; margin-bottom: 8px; }
.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.int { background: var(--c-int-soft); border: 1px solid var(--c-int); }
.sw.prin { background: var(--c-prin-soft); border: 1px solid var(--c-prin); }
.sw.base { width: 14px; height: 0; border: 0; border-top: 2px dashed var(--c-base); border-radius: 0; }
#legBase[hidden] { display: none; }
#plot { position: relative; width: 100%; height: clamp(198px, 30vw, 268px);
touch-action: pan-y; border-radius: 6px; -webkit-user-select: none; user-select: none; }
#plot svg { display: block; width: 100%; height: 100%; }
#plot:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.grid { stroke: var(--line); stroke-width: 1; }
.tick { fill: var(--muted); font-family: var(--font-mono); font-size: 10.5px; }
.a-int { fill: var(--c-int-soft); stroke: none; }
.a-prin { fill: var(--c-prin-soft); stroke: none; }
.l-int { fill: none; stroke: var(--c-int); stroke-width: 1.6; stroke-linejoin: round; }
.l-top { fill: none; stroke: var(--c-prin); stroke-width: 1.8; stroke-linejoin: round; }
.l-base { fill: none; stroke: var(--c-base); stroke-width: 1.7; stroke-dasharray: 5 4;
stroke-linejoin: round; }
.free { fill: var(--c-free); }
.free-e { stroke: var(--run); stroke-width: 1.5; }
.free-t { fill: var(--run); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .05em; }
.cross { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 3 3; opacity: .45; }
.cross-t { fill: var(--muted); font-family: var(--font-mono); font-size: 10.5px; }
.scrub { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 2 3; opacity: .6; }
.dot { stroke: var(--surface); stroke-width: 2; }
.dot.i { fill: var(--c-int); }
.dot.p { fill: var(--c-prin); }
.hint { margin: 8px 0 0; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }
/* ---- scrub readout ---- */
.readout { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px 12px;
margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
@media (min-width: 620px) { .readout { grid-template-columns: repeat(4, minmax(0,1fr)); } }
.ro-k { display: block; font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em;
text-transform: uppercase; color: var(--muted); }
.ro-v { display: block; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: .98rem; font-weight: 600; margin-top: 2px; letter-spacing: -.01em; }
.ro-v.i { color: var(--c-int); }
.ro-v.p { color: var(--c-prin); }
.bar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: var(--line);
margin-top: 10px; }
.bar span { display: block; height: 100%; }
.bar .b-i { background: var(--c-int); }
.bar .b-p { background: var(--c-prin); }
.barcap { margin: 6px 0 0; font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.barcap b { color: var(--ink); font-weight: 600; }
@media (prefers-reduced-motion: no-preference) {
.bar span { transition: width .18s ease; }
}
/* ---- stats ---- */
.stats { display: grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 560px) { .stats { grid-template-columns: 1.25fr 1fr; } }
.stat { padding: 13px 15px 14px; }
.stat.headline { border-left: 3px solid var(--c-int); }
.stat h2 { margin: 0; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); font-weight: 500; }
.stat .big { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: clamp(1.5rem, 6.4vw, 1.95rem); font-weight: 650; letter-spacing: -.028em;
margin: 3px 0 0; }
.stat.headline .big { color: var(--c-int); }
.stat p.sub { margin: 5px 0 0; font-size: .85rem; color: var(--muted); }
.stat p.sub b { color: var(--ink); font-weight: 600; font-family: var(--font-mono);
font-variant-numeric: tabular-nums; }
.pair { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px;
font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 11.5px;
color: var(--muted); }
.pair b { color: var(--ink); font-weight: 600; }
/* ---- extra payment result ---- */
.saved { padding: 13px 15px 14px; border-left: 3px solid var(--line); }
.saved.on { border-left-color: var(--run); background: var(--run-soft); }
.saved h2 { margin: 0; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); font-weight: 500; }
.saved .big { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: clamp(1.3rem, 5.4vw, 1.7rem); font-weight: 650; letter-spacing: -.025em;
margin: 3px 0 0; }
.saved.on .big { color: var(--run); }
.saved p.sub { margin: 5px 0 0; font-size: .87rem; color: var(--muted); }
.saved.on p.sub b { color: var(--ink); font-weight: 600; font-family: var(--font-mono);
font-variant-numeric: tabular-nums; }
.caveat { margin: 0; padding: 9px 12px; border-left: 2px solid var(--accent);
background: var(--accent-soft); border-radius: 0 6px 6px 0; font-size: .84rem;
color: var(--ink); }
/* ---- controls ---- */
.panel { grid-area: 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-note { 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; position: relative; }
.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>Every payment is the same size. Where it goes is not.</h1>
<p class="lede">A fixed-rate loan splits each payment between interest and the balance
you actually owe. For years it is mostly interest. Drag anything below and watch the
split move.</p>
</header>
<div class="layout">
<section class="card hero" aria-labelledby="heroLabel">
<p class="hero-k" id="heroLabel">Payment every month</p>
<p class="hero-n mono" id="heroNum">$2,854.69</p>
<p class="hero-sub">Borrowing <b id="hAmt">$420,000</b> at <b id="hRate">6.50%</b> over
<b id="hTerm">30 years</b><span id="hExtraWrap">, plus <b id="hExtra">$200</b> extra each
month</span>. That is <b id="hTotal">$842,464</b> handed over in total.</p>
</section>
<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="amount">Amount borrowed</label>
<output class="mono" id="oAmount" for="amount">$420,000</output></div>
<input type="range" id="amount" min="25000" max="1500000" step="5000" value="420000">
</div>
<div class="ctrl">
<div class="row"><label for="rate">Interest rate</label>
<output class="mono" id="oRate" for="rate">6.50%</output></div>
<input type="range" id="rate" min="0.5" max="12" step="0.05" value="6.5">
</div>
<div class="ctrl">
<div class="row"><label for="term">Length of loan</label>
<output class="mono" id="oTerm" for="term">30 yrs</output></div>
<input type="range" id="term" min="5" max="40" step="1" value="30">
</div>
<div class="ctrl">
<div class="row"><label for="extra">Extra each month</label>
<output class="mono" id="oExtra" for="extra">$200</output></div>
<input type="range" id="extra" min="0" max="2000" step="25" value="200">
<span class="sub-note" id="sExtra">paid straight off the balance</span>
</div>
<fieldset class="seg">
<legend>Chart shows</legend>
<div class="segrow">
<input type="radio" name="mode" id="mEach" value="each" checked><label for="mEach">each payment</label>
<input type="radio" name="mode" id="mSum" value="sum"><label for="mSum">running total</label>
</div>
</fieldset>
</form>
<div class="col">
<section class="card chartcard" aria-labelledby="chartTitle">
<div class="chart-head">
<p class="eyebrow" id="chartTitle">Where each payment goes</p>
<ul class="legend">
<li><span class="sw int" aria-hidden="true"></span>interest</li>
<li><span class="sw prin" aria-hidden="true"></span>balance</li>
<li id="legBase" hidden><span class="sw base" aria-hidden="true"></span>no extra</li>
</ul>
</div>
<div id="plot" tabindex="0" role="slider" aria-label="Inspect a payment"
aria-valuemin="0" aria-valuemax="295" aria-valuenow="0" aria-valuetext="payment 1"></div>
<p class="hint">drag across the chart — or focus it and use ← →</p>
<div class="readout">
<div><span class="ro-k" id="roK1">payment</span><span class="ro-v mono" id="roV1">—</span></div>
<div><span class="ro-k" id="roK2">interest</span><span class="ro-v mono i" id="roV2">—</span></div>
<div><span class="ro-k" id="roK3">to balance</span><span class="ro-v mono p" id="roV3">—</span></div>
<div><span class="ro-k" id="roK4">still owed</span><span class="ro-v mono" id="roV4">—</span></div>
</div>
<div class="bar" aria-hidden="true"><span class="b-i" id="barI" style="width:50%"></span><span class="b-p" id="barP" style="width:50%"></span></div>
<p class="barcap" id="barCap">—</p>
</section>
<div class="stats">
<section class="card stat headline">
<h2>Total interest</h2>
<p class="big mono" id="sInterest">$422,464</p>
<p class="sub">That is <b id="sRatio">$1.01</b> of interest for every $1 borrowed.</p>
</section>
<section class="card stat">
<h2>Paid off in</h2>
<p class="big mono" id="sPayoff">24 yr 8 mo</p>
<div class="pair"><span>total paid <b id="sTotal">$842,464</b></span></div>
<div class="pair"><span>borrowed <b id="sPrincipal">$420,000</b></span></div>
</section>
</div>
<section class="card saved" id="savedCard" aria-labelledby="savedTitle">
<h2 id="savedTitle">Extra payments</h2>
<p class="big mono" id="savedBig">—</p>
<p class="sub" id="savedSub">Nudge the extra-payment slider and this fills in.</p>
</section>
<p class="caveat">Educational estimate: principal and interest only, one fixed rate,
every payment made on time. A real quote also carries property tax, insurance, fees
and its own rounding rules.</p>
<p class="sr" id="live" aria-live="polite"></p>
</div>
</div>
<p class="foot">mortgage-amortization · 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 money0 = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
var money2 = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 2 });
function f0(v) { return money0.format(Math.round(v)); }
function f2(v) { return money2.format(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);
}
function perDollar(v) {
if (v >= 1) return "$" + v.toFixed(2);
var c = v * 100;
return (c >= 1 ? String(Math.round(c)) : c.toFixed(1)) + "\u00A2";
}
function dur(months) {
var y = Math.floor(months / 12), mo = months % 12;
if (y <= 0) return mo + " mo";
if (mo === 0) return y + " yr";
return y + " yr " + mo + " mo";
}
var DEFAULTS = { amount: 420000, rate: 6.5, term: 30, extra: 200, mode: "each" };
var s = { amount: 0, rate: 0, term: 0, extra: 0, mode: "each" };
var model = null;
var scrub = 0; /* payment index being inspected (0-based) */
var frame = 0; /* pending requestAnimationFrame id */
/* ---------- amortization ---------- */
function schedule(P, annual, months, extra) {
var r = annual / 100 / 12;
var pay = r > 0 ? (P * r) / (1 - Math.pow(1 + r, -months)) : P / months;
var interest = [], principal = [], cumI = [0], cumP = [0], bal = [P];
var b = P, ti = 0, tp = 0, i = 0, cap = months + 4;
while (b > 0.005 && i < cap) {
var ip = b * r;
var pp = pay + extra - ip;
if (pp <= 0) break; /* cannot amortize (guard) */
if (pp > b) pp = b;
b -= pp; ti += ip; tp += pp;
interest.push(ip); principal.push(pp);
cumI.push(ti); cumP.push(tp); bal.push(b);
i++;
}
return {
pay: pay, n: i, interest: interest, principal: principal,
cumI: cumI, cumP: cumP, bal: bal,
totalInterest: ti, totalPaid: ti + tp
};
}
function build() {
var months = s.term * 12;
var base = schedule(s.amount, s.rate, months, 0);
var cur = s.extra > 0 ? schedule(s.amount, s.rate, months, s.extra) : base;
if (!cur.n) cur = base; /* guard: never render an empty schedule */
var cross = -1;
for (var i = 0; i < cur.n; i++) {
if (cur.principal[i] >= cur.interest[i]) { cross = i; break; }
}
model = {
base: base, cur: cur, months: months, xMax: Math.max(1, base.n), cross: cross,
savedMoney: Math.max(0, base.totalInterest - cur.totalInterest),
savedMonths: Math.max(0, base.n - cur.n)
};
if (scrub > maxIdx()) scrub = maxIdx();
if (scrub < 0) scrub = 0;
}
function maxIdx() { return s.mode === "each" ? Math.max(0, model.cur.n - 1) : model.cur.n; }
/* ---------- chart ---------- */
var plot = $("plot");
var M = { l: 46, r: 13, t: 12, b: 22 };
function niceStep(max, target) {
var raw = max / Math.max(1, target);
if (raw <= 0) return 1;
var mag = Math.pow(10, Math.floor(Math.log10(raw)));
var norm = raw / mag;
var step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10;
return step * mag;
}
function esc(t) { return String(t).replace(/&/g, "&").replace(/</g, "<"); }
function draw() {
var W = plot.clientWidth, H = plot.clientHeight;
if (!W || !H || !model || !model.cur.n) return;
var pw = Math.max(10, W - M.l - M.r), ph = Math.max(10, H - M.t - M.b);
var cur = model.cur, base = model.base, each = s.mode === "each";
var xMax = model.xMax;
var yMax, mid, top, baseLine, lastIdx, baseLast;
if (each) {
yMax = (cur.pay + s.extra) * 1.12;
mid = function (i) { return cur.interest[i]; };
top = function (i) { return cur.interest[i] + cur.principal[i]; };
baseLine = function (i) { return base.interest[i]; };
lastIdx = cur.n - 1; baseLast = base.n - 1;
} else {
yMax = (base.totalPaid || 1) * 1.08;
mid = function (i) { return cur.cumI[i]; };
top = function (i) { return cur.cumI[i] + cur.cumP[i]; };
baseLine = function (i) { return base.cumI[i]; };
lastIdx = cur.n; baseLast = base.n;
}
if (lastIdx < 0) lastIdx = 0;
var X = function (i) { return M.l + (i / xMax) * pw; };
var Y = function (v) { return M.t + ph - (v / yMax) * ph; };
var y0 = Y(0);
function idxList(last) {
var stride = Math.max(1, Math.ceil((last + 1) / 320)), a = [];
for (var i = 0; i <= last; i += stride) a.push(i);
if (a[a.length - 1] !== last) a.push(last);
return a;
}
var idx = idxList(lastIdx);
function pathOf(list, fn, move) {
var d = "";
for (var k = 0; k < list.length; k++) {
d += (k === 0 && move !== false ? "M" : "L") + X(list[k]).toFixed(1) + "," + Y(fn(list[k])).toFixed(1);
}
return d;
}
function backOf(list, fn) {
var d = "";
for (var k = list.length - 1; k >= 0; k--) {
d += "L" + X(list[k]).toFixed(1) + "," + Y(fn(list[k])).toFixed(1);
}
return d;
}
var midPath = pathOf(idx, mid);
var topPath = pathOf(idx, top);
var aInt = midPath + "L" + X(lastIdx).toFixed(1) + "," + y0.toFixed(1) +
"L" + X(0).toFixed(1) + "," + y0.toFixed(1) + "Z";
var aPrin = topPath + backOf(idx, mid) + "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).toFixed(1) + '" 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, in years — the step widens on narrow screens so labels never collide */
var pxPerYear = (12 / xMax) * pw;
var wantStep = s.term <= 10 ? 2 : s.term <= 32 ? 5 : 10;
var CAND = [1, 2, 5, 10, 20], yrStep = CAND[CAND.length - 1];
for (var cs = 0; cs < CAND.length; cs++) {
if (CAND[cs] >= wantStep && CAND[cs] * pxPerYear >= 46) { yrStep = CAND[cs]; break; }
}
/* drop an interior tick that would crowd the final "Ny" label */
var minGap = Math.max(34, Math.min(yrStep * pxPerYear * 0.6, 44)), xt = "";
var marks = [];
for (var yv = 0; yv < s.term; yv += yrStep) {
if (yv === 0 || X(xMax) - X(yv * 12) > minGap) marks.push(yv);
}
marks.push(s.term);
for (var q = 0; q < marks.length; q++) {
var mi = Math.min(marks[q] * 12, xMax);
xt += '<text class="tick" x="' + X(mi).toFixed(1) + '" y="' + (H - 7) + '" text-anchor="' +
(q === marks.length - 1 ? "end" : q === 0 ? "start" : "middle") + '">' +
(q === 0 ? "0" : marks[q] + "y") + "</text>";
}
/* the stretch of payments the extra money cancels */
var freed = "";
if (model.savedMonths > 0) {
var fx = X(cur.n), fw = Math.max(0, X(xMax) - fx);
freed = '<rect class="free" x="' + fx.toFixed(1) + '" y="' + M.t + '" width="' + fw.toFixed(1) +
'" height="' + ph.toFixed(1) + '" rx="3"/>' +
'<line class="free-e" x1="' + fx.toFixed(1) + '" y1="' + M.t + '" x2="' + fx.toFixed(1) +
'" y2="' + y0.toFixed(1) + '"/>';
var flabel = dur(model.savedMonths) + " skipped";
if (fw < flabel.length * 6.4 + 16) flabel = dur(model.savedMonths);
if (fw > flabel.length * 6.4 + 16) {
freed += '<text class="free-t" x="' + (X(xMax) - 6).toFixed(1) + '" y="' + (M.t + 12) +
'" text-anchor="end">' + esc(flabel) + "</text>";
}
}
/* the month the balance finally gets more than the interest */
var crossMark = "";
if (each && model.cross > 0 && model.cross < cur.n - 1) {
var cx = X(model.cross);
crossMark = '<line class="cross" x1="' + cx.toFixed(1) + '" y1="' + M.t + '" x2="' + cx.toFixed(1) +
'" y2="' + y0.toFixed(1) + '"/>';
if (cx - M.l > 96) {
crossMark += '<text class="cross-t" x="' + (cx - 6).toFixed(1) + '" y="' + (M.t + 12) +
'" text-anchor="end">balance wins →</text>';
}
}
/* reference line: the same loan with no extra payments */
var baseSvg = "";
if (s.extra > 0 && baseLast > 0) {
baseSvg = '<path class="l-base" d="' + pathOf(idxList(baseLast), baseLine) + '"/>';
}
/* scrub marker */
var si = Math.min(scrub, lastIdx), sx = X(si);
var marker = '<line class="scrub" x1="' + sx.toFixed(1) + '" y1="' + M.t + '" x2="' + sx.toFixed(1) +
'" y2="' + y0.toFixed(1) + '"/>' +
'<circle class="dot p" cx="' + sx.toFixed(1) + '" cy="' + Y(top(si)).toFixed(1) + '" r="3.6"/>' +
'<circle class="dot i" cx="' + sx.toFixed(1) + '" cy="' + Y(mid(si)).toFixed(1) + '" r="3.6"/>';
plot.innerHTML =
'<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + " " + H +
'" aria-hidden="true" focusable="false">' +
g + freed +
'<path class="a-int" d="' + aInt + '"/>' +
'<path class="a-prin" d="' + aPrin + '"/>' +
'<path class="l-int" d="' + midPath + '"/>' +
'<path class="l-top" d="' + topPath + '"/>' +
baseSvg + crossMark + marker +
'<line class="grid" x1="' + M.l + '" y1="' + y0.toFixed(1) + '" x2="' + (M.l + pw).toFixed(1) +
'" y2="' + y0.toFixed(1) + '"/>' + yl + xt +
"</svg>";
}
/* ---------- readout ---------- */
function paintReadout() {
if (!model || !model.cur.n) return;
var cur = model.cur, each = s.mode === "each";
var i = Math.min(scrub, maxIdx());
var iv, pv, left, label, pct;
if (each) {
iv = cur.interest[i]; pv = cur.principal[i]; left = cur.bal[i + 1];
label = (i + 1) + " of " + cur.n;
$("roK2").textContent = "interest";
$("roK3").textContent = "to balance";
$("roK4").textContent = "still owed";
$("roV2").textContent = f2(iv);
$("roV3").textContent = f2(pv);
} else {
iv = cur.cumI[i]; pv = cur.cumP[i]; left = cur.bal[i];
label = "month " + i;
$("roK2").textContent = "interest so far";
$("roK3").textContent = "balance cleared";
$("roK4").textContent = "still owed";
$("roV2").textContent = f0(iv);
$("roV3").textContent = f0(pv);
}
var tot = iv + pv;
pct = tot > 0 ? (iv / tot) * 100 : 0;
$("roK1").textContent = each ? "payment" : "point in time";
$("roV1").textContent = label;
$("roV4").textContent = f0(left);
var none = tot <= 0;
$("barI").style.width = none ? "0%" : pct.toFixed(2) + "%";
$("barP").style.width = none ? "0%" : (100 - pct).toFixed(2) + "%";
var capMain = Math.round(pct) + "% interest · " + (100 - Math.round(pct)) + "% balance";
if (none) {
$("barCap").textContent = "nothing paid yet — month 0 is the day the loan starts";
} else {
$("barCap").innerHTML = each
? "<b>" + esc(capMain) + "</b> — of payment " + (i + 1) + ", " +
(i + 1 <= 12 ? "in the first year" : "at " + dur(i + 1) + " in")
: "<b>" + esc(capMain) + "</b> — of everything handed over by month " + i;
}
plot.setAttribute("aria-valuemin", "0");
plot.setAttribute("aria-valuemax", String(maxIdx()));
plot.setAttribute("aria-valuenow", String(i));
plot.setAttribute("aria-valuetext", each
? "payment " + (i + 1) + " of " + cur.n + ": " + f2(iv) + " interest, " + f2(pv) +
" off the balance, " + f0(left) + " still owed"
: "month " + i + ": " + f0(iv) + " interest paid so far, " + f0(pv) +
" of the balance cleared, " + f0(left) + " still owed");
}
/* ---------- everything else on screen ---------- */
function paint() {
var cur = model.cur, base = model.base;
$("heroNum").textContent = f2(cur.pay + s.extra);
$("heroLabel").textContent = s.extra > 0 ? "Payment every month, extra included" : "Payment every month";
$("hAmt").textContent = f0(s.amount);
$("hRate").textContent = s.rate.toFixed(2) + "%";
$("hTerm").textContent = s.term + " years";
$("hExtraWrap").hidden = s.extra === 0;
$("hExtra").textContent = f0(s.extra);
$("hTotal").textContent = f0(cur.totalPaid);
$("sInterest").textContent = f0(cur.totalInterest);
$("sRatio").textContent = perDollar(cur.totalInterest / s.amount);
$("sPayoff").textContent = dur(cur.n);
$("sTotal").textContent = f0(cur.totalPaid);
$("sPrincipal").textContent = f0(s.amount);
var card = $("savedCard");
if (s.extra > 0 && model.savedMonths > 0) {
card.className = "card saved on";
$("savedBig").textContent = f0(model.savedMoney) + " never charged";
$("savedSub").innerHTML = "Adding <b>" + esc(f0(s.extra)) + "</b> a month clears the loan <b>" +
esc(dur(model.savedMonths)) + "</b> early — <b>" + model.savedMonths +
"</b> payments that never happen, and the interest inside them disappears with them.";
} else if (s.extra > 0) {
card.className = "card saved";
$("savedBig").textContent = f0(model.savedMoney);
$("savedSub").textContent = "Interest avoided at this extra amount.";
} else {
card.className = "card saved";
$("savedBig").textContent = "no extra yet";
$("savedSub").textContent = "Drag “extra each month” and this fills in with the years and the money it saves.";
}
$("legBase").hidden = s.extra === 0;
$("chartTitle").textContent = s.mode === "each" ? "Where each payment goes" : "Running total, interest vs balance";
$("oAmount").textContent = f0(s.amount);
$("oRate").textContent = s.rate.toFixed(2) + "%";
$("oTerm").textContent = s.term + " yrs";
$("oExtra").textContent = s.extra === 0 ? "none" : f0(s.extra);
$("sExtra").textContent = s.extra === 0
? "paid straight off the balance"
: "each month: " + f2(cur.pay) + " + " + f0(s.extra);
paintReadout();
draw();
}
function announce() {
$("live").textContent = f2(model.cur.pay + s.extra) + " a month, " +
f0(model.cur.totalInterest) + " interest in total, paid off in " + dur(model.cur.n) + ".";
}
/* ---------- input wiring ---------- */
function readControls() {
s.amount = +$("amount").value;
s.rate = +$("rate").value;
s.term = +$("term").value;
s.extra = +$("extra").value;
s.mode = $("mSum").checked ? "sum" : "each";
}
function update(fromChange) {
if (frame) { cancelAnimationFrame(frame); frame = 0; }
readControls();
build();
paint();
if (fromChange) announce();
}
["amount", "rate", "term", "extra"].forEach(function (id) {
$(id).addEventListener("input", function () { update(false); });
$(id).addEventListener("change", function () { update(true); });
});
["mEach", "mSum"].forEach(function (id) {
$(id).addEventListener("change", function () {
var wasEach = s.mode === "each";
readControls();
/* keep the inspected moment roughly in place across the two views */
if (wasEach && s.mode === "sum") scrub = scrub + 1;
else if (!wasEach && s.mode === "each") scrub = Math.max(0, scrub - 1);
update(true);
});
});
$("controls").addEventListener("submit", function (e) { e.preventDefault(); });
$("reset").addEventListener("click", function () {
$("amount").value = DEFAULTS.amount;
$("rate").value = DEFAULTS.rate;
$("term").value = DEFAULTS.term;
$("extra").value = DEFAULTS.extra;
$("mEach").checked = true;
scrub = 0;
update(true);
});
/* ---------- scrubbing ---------- */
/* pointermove can fire far faster than the screen refreshes, and every redraw
rebuilds the whole SVG string — coalesce them onto one frame. */
function renderSoon() {
if (frame) return;
frame = requestAnimationFrame(function () { frame = 0; paintReadout(); draw(); });
}
function setScrub(i) {
var m = maxIdx();
i = Math.max(0, Math.min(m, Math.round(i)));
if (i === scrub) return;
scrub = i;
renderSoon();
}
function fromX(clientX) {
var r = plot.getBoundingClientRect();
var pw = Math.max(10, r.width - M.l - M.r);
return ((clientX - r.left - M.l) / pw) * model.xMax;
}
var dragging = false;
plot.addEventListener("pointerdown", function (e) {
dragging = true;
if (plot.setPointerCapture) { try { plot.setPointerCapture(e.pointerId); } catch (err) {} }
setScrub(fromX(e.clientX));
e.preventDefault();
});
plot.addEventListener("pointermove", function (e) {
if (dragging || e.pointerType === "mouse") setScrub(fromX(e.clientX));
});
function stop() { dragging = false; }
plot.addEventListener("pointerup", stop);
plot.addEventListener("pointercancel", stop);
plot.addEventListener("keydown", function (e) {
var k = e.key, m = maxIdx(), step = 0, abs = null;
if (k === "ArrowRight" || k === "ArrowUp") step = 1;
else if (k === "ArrowLeft" || k === "ArrowDown") step = -1;
else if (k === "PageUp") step = 12;
else if (k === "PageDown") step = -12;
else if (k === "Home") abs = 0;
else if (k === "End") abs = m;
else return;
e.preventDefault();
setScrub(abs !== null ? abs : scrub + step);
});
if (window.ResizeObserver) {
var ro = new ResizeObserver(function () { draw(); });
ro.observe(plot);
} else {
window.addEventListener("resize", draw);
}
update(false);
announce();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.