$sloprun.dev

Loan Amortization Explorer

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

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

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

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.

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/mortgage-amortization" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Loan Amortization Explorer — 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/mortgage-amortization

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 809 lines 36.1 KB index.html
demos/mortgage-amortization/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 &larr; &rarr;</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, "&amp;").replace(/</g, "&lt;"); }

  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 &rarr;</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 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