$sloprun.dev

Budget Flow Diagram

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

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

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

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A month of take-home pay drawn as a river: paychecks merge into one trunk, the trunk splits into rent, groceries, car and the rest, and whatever survives the split becomes its own green ribbon at the bottom. The sankey is hand-rolled SVG with no chart library and no requests, so every amount you type re-solves the layout and the ribbons resize while you type — hover one to get its share of income and its annual cost, or click it to jump straight to the matching input. Push rent up until the green ribbon disappears and a red "short" ribbon grows on the income side instead, which is the honest way to draw overspending. Then load "house, two kids", a month that already runs $195 in the red.

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/budget-sankey" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Budget Flow Diagram — 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/budget-sankey

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 987 lines 43.3 KB index.html
demos/budget-sankey/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Budget Flow Diagram</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); }

/* ---- ribbon palette (theme-aware, derived from the tokens above) ---- */
:root {
  --k0:#3E6DB2; --k1:#A9457E; --k2:#B0543F; --k3:#21798C;
  --k4:#7A5AA8; --k5:#96702A; --k6:#4E5A69; --k7:#7A5230;
  --k8:#6D93C9; --k9:#7E2E5C; --k10:#4FA0B0; --k11:#55407A;
  --i0:#8792A2; --i1:#5F6B7E; --i2:#AAB2BD;
  --shadow: 0 1px 2px rgba(20,22,26,.06), 0 8px 24px rgba(20,22,26,.05);
  --inset: #FAFAF8;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --k0:#79A6E8; --k1:#E884B4; --k2:#EE9077; --k3:#56C3D4;
    --k4:#B79AE8; --k5:#D9B15C; --k6:#A5B0BE; --k7:#CFA07A;
    --k8:#A9C6F2; --k9:#C25E93; --k10:#8FDCE8; --k11:#8E76C4;
    --i0:#7C879B; --i1:#586274; --i2:#9EA8B6;
    --shadow: none; --inset: #171A20;
  }
}
:root[data-theme="dark"] {
  --k0:#79A6E8; --k1:#E884B4; --k2:#EE9077; --k3:#56C3D4;
  --k4:#B79AE8; --k5:#D9B15C; --k6:#A5B0BE; --k7:#CFA07A;
  --k8:#A9C6F2; --k9:#C25E93; --k10:#8FDCE8; --k11:#8E76C4;
  --i0:#7C879B; --i1:#586274; --i2:#9EA8B6;
  --shadow: none; --inset: #171A20;
}

/* ---- 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: 1120px; margin: 0 auto; padding: 22px 16px 36px; }

header.top { margin-bottom: 16px; }
.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.6vw, 2rem); line-height: 1.14; margin: 0 0 6px;
     letter-spacing: -.02em; font-weight: 650; }
.lede { margin: 0; color: var(--muted); font-size: .97rem; max-width: 60ch; }

.layout { display: grid; grid-template-columns: minmax(0,1fr); gap: 14px; align-items: start; }
@media (min-width: 980px) { .layout { grid-template-columns: minmax(0,1fr) 336px; } }
.col { display: grid; gap: 14px; min-width: 0; }

.card { background: var(--surface); border: 1px solid var(--line);
        border-radius: var(--radius); box-shadow: var(--shadow); }

/* ---- hero ---- */
.hero { padding: 16px 18px 14px; border-left: 3px solid var(--accent); }
.hero-k { margin: 0; font-size: .9rem; color: var(--muted); }
.hero-n { margin: 1px 0 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
          font-size: clamp(2.3rem, 10.5vw, 3.7rem); line-height: 1.03; font-weight: 650;
          letter-spacing: -.035em; color: var(--accent); word-break: break-word; }
.hero-sub { margin: 6px 0 0; font-size: .9rem; color: var(--muted); max-width: 58ch; }
.hero-sub b { color: var(--ink); font-weight: 600; font-family: var(--font-mono);
              font-variant-numeric: tabular-nums; }
.hero.good { border-left-color: var(--run); } .hero.good .hero-n { color: var(--run); }
.hero.bad  { border-left-color: var(--danger); } .hero.bad .hero-n { color: var(--danger); }

.stats { display: flex; flex-wrap: wrap; gap: 8px 22px; list-style: none; margin: 12px 0 0;
         padding: 11px 0 0; border-top: 1px solid var(--line); }
.stats li { display: grid; gap: 1px; }
.stats .lb { font-family: var(--font-mono); font-size: 10px; letter-spacing: .09em;
             text-transform: uppercase; color: var(--muted); }
.stats .vl { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
             font-size: 15px; font-weight: 600; }

/* ---- figure ---- */
.figcard { padding: 12px 12px 10px; min-width: 0; overflow: hidden; }
.fighead { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;
           justify-content: space-between; margin: 2px 4px 6px; }
.eyebrow { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .09em;
           text-transform: uppercase; color: var(--muted); margin: 0; }
.readout { margin: 0; font-size: 12px; color: var(--ink); font-weight: 600;
           min-width: 0; overflow-wrap: anywhere; }
.readout .rd-mute { color: var(--muted); font-weight: 400; }
#figure { width: 100%; border-radius: 8px; }
#figure:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
#svg { display: block; width: 100%; }
.hint { margin: 4px 4px 0; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }

.rib { cursor: pointer; }
.node { pointer-events: none; }
.lbl { pointer-events: none; }
.ldr { pointer-events: none; fill: none; stroke-width: 1; stroke-opacity: .5; }
.lbl .nmt { font-size: 12px; fill: var(--ink); }
.lbl .amt { font-size: 11px; fill: var(--muted); font-family: var(--font-mono); }
.narrow .lbl .nmt { font-size: 10.5px; }
.hubt { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; fill: var(--muted); }
.empty { font-size: 13px; fill: var(--muted); }
#svg .rib, #svg .lbl, #svg .node, #svg .ldr, #svg .hubt { transition: opacity .14s ease; }
#svg.dim .rib, #svg.dim .lbl, #svg.dim .node, #svg.dim .ldr, #svg.dim .hubt { opacity: .13; }
#svg.dim.soft .rib, #svg.dim.soft .lbl, #svg.dim.soft .node,
#svg.dim.soft .ldr, #svg.dim.soft .hubt { opacity: .4; }
#svg.dim .rib.on, #svg.dim .lbl.on, #svg.dim .node.on, #svg.dim .ldr.on { opacity: 1; }
#svg.dim .lbl.on .nmt { font-weight: 650; }

/* ---- editor panels ---- */
.panel { padding: 12px 12px 10px; }
.ph { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
      margin: 0 2px 8px; }
.ph h2 { margin: 0; font-size: 13px; font-weight: 650; letter-spacing: -.01em; }
.ph .tot { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
           font-size: 13px; font-weight: 600; color: var(--muted); }
ul.rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; }
.row { display: grid; align-items: center; gap: 7px;
       grid-template-columns: 9px minmax(0,1fr) 84px 38px 26px; }
.row .chip { width: 9px; height: 22px; border-radius: 3px; }
.row input { font: inherit; font-size: 13px; color: var(--ink); background: var(--inset);
             border: 1px solid var(--line); border-radius: 6px; padding: 5px 7px;
             min-width: 0; width: 100%; }
.row input:hover { border-color: var(--muted); }
.row input:focus { border-color: var(--accent); background: var(--surface);
                   box-shadow: 0 0 0 2px var(--accent-soft); }
.row input:focus:not(:focus-visible) { outline: none; }
.row input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.row .amtwrap { position: relative; }
.row .amtwrap .cur { position: absolute; left: 7px; top: 50%; transform: translateY(-50%);
                     font-size: 12px; color: var(--muted); pointer-events: none; }
.row input.amt { padding-left: 17px; font-family: var(--font-mono);
                 font-variant-numeric: tabular-nums; text-align: right; }
.row input.amt[aria-invalid="true"] { border-color: var(--danger); }
.row .pct { font-family: var(--font-mono); font-size: 11px; color: var(--muted);
            text-align: right; font-variant-numeric: tabular-nums; }
.row .rm { width: 26px; height: 26px; padding: 0; border-radius: 6px; cursor: pointer;
           border: 1px solid transparent; background: none; color: var(--muted);
           font-size: 16px; line-height: 1; }
.row .rm:hover { border-color: var(--danger); color: var(--danger); background: var(--surface); }
.row.hot { background: var(--accent-soft); border-radius: 8px;
           box-shadow: 0 0 0 4px var(--accent-soft); }
@media (max-width: 419px) { .row { grid-template-columns: 9px minmax(0,1fr) 76px 26px; }
                            .row .pct { display: none; } }

.addbtn { margin-top: 8px; }
.btn { font: inherit; font-size: 12px; font-family: var(--font-mono); color: var(--ink);
       background: var(--surface); border: 1px solid var(--line); border-radius: 7px;
       padding: 6px 11px; cursor: pointer; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent);
                            color: var(--accent); font-weight: 600; }
.tools { padding: 12px; display: grid; gap: 9px; }
.tools .eyebrow { margin: 0 0 1px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.foot { margin: 22px 2px 0; font-family: var(--font-mono); font-size: 11px; color: var(--muted); }

@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
</style>
</head>
<body>
<div class="wrap">
  <header class="top">
    <p class="kicker">monthly budget · dollars per month</p>
    <h1>Where the money actually goes</h1>
    <p class="lede">One month of take-home pay, drawn as a river. Edit any name or amount on the
      right and the diagram redraws while you type — whatever is left at the end becomes its own ribbon.</p>
  </header>

  <div class="layout">
    <div class="col">
      <section class="card hero" id="hero">
        <p class="hero-k" id="heroK">left over each month</p>
        <p class="hero-n mono" id="heroN">$0</p>
        <p class="hero-sub" id="heroS"></p>
        <ul class="stats">
          <li><span class="lb">money in</span><span class="vl" id="sIn">$0</span></li>
          <li><span class="lb">money out</span><span class="vl" id="sOut">$0</span></li>
          <li><span class="lb">kept</span><span class="vl" id="sRate">0%</span></li>
          <li><span class="lb">biggest line</span><span class="vl" id="sBig">—</span></li>
        </ul>
      </section>

      <section class="card figcard">
        <div class="fighead">
          <p class="eyebrow" id="figEyebrow">flow diagram</p>
          <p class="readout mono" id="readout"></p>
        </div>
        <div id="figure" tabindex="0" role="group" aria-label="Budget flow diagram. Press arrow keys to step through each flow." aria-describedby="hint">
          <svg id="svg" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"></svg>
        </div>
        <p class="hint mono" id="hint">hover a ribbon · click one to edit it · ← → steps through flows</p>
      </section>
    </div>

    <div class="col">
      <section class="card panel">
        <div class="ph"><h2>Money in</h2><span class="tot" id="totIn">$0</span></div>
        <ul class="rows" id="rowsIn"></ul>
        <button class="btn addbtn" type="button" id="addIn">+ add income</button>
      </section>

      <section class="card panel">
        <div class="ph"><h2>Where it goes</h2><span class="tot" id="totOut">$0</span></div>
        <ul class="rows" id="rowsOut"></ul>
        <button class="btn addbtn" type="button" id="addOut">+ add expense</button>
      </section>

      <section class="card tools">
        <p class="eyebrow">try another month</p>
        <div class="chips" id="presets"></div>
        <div class="chips"><button class="btn" type="button" id="reset">↺ reset this one</button></div>
      </section>
    </div>
  </div>

  <p class="foot mono">demo · sloprun.dev</p>
</div>
<p class="sr" id="live" role="status" aria-live="polite"></p>

<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 NS = "http://www.w3.org/2000/svg";
  var CAT = ["var(--k0)","var(--k1)","var(--k2)","var(--k3)","var(--k4)","var(--k5)",
             "var(--k6)","var(--k7)","var(--k8)","var(--k9)","var(--k10)","var(--k11)"];
  var INC = ["var(--i0)","var(--i1)","var(--i2)"];
  var reduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
  function noMotion() { return !!(reduce && reduce.matches); }

  /* ---------------- data ---------------- */
  var PRESETS = [
    { key: "two", label: "two paychecks",
      inc: [["Main paycheck", 4320], ["Side work", 640]],
      out: [["Rent", 1650], ["Groceries", 520], ["Car & gas", 430], ["Loan payment", 340],
            ["Eating out", 260], ["Utilities", 210], ["Insurance", 190],
            ["Phone & internet", 145], ["Subscriptions", 95]] },
    { key: "solo", label: "just moved out",
      inc: [["Paycheck", 2780]],
      out: [["Rent (shared)", 1150], ["Groceries", 340], ["Loan payment", 210],
            ["Eating out", 180], ["Bus pass", 95], ["Utilities", 95],
            ["Phone", 60], ["Subscriptions", 35], ["Gym", 30]] },
    { key: "house", label: "house, two kids",
      inc: [["Paycheck one", 4850], ["Paycheck two", 3410]],
      out: [["Mortgage", 2640], ["Childcare", 1560], ["Groceries", 1080], ["Car & gas", 620],
            ["Insurance", 540], ["Loan payment", 480], ["Home repairs", 420],
            ["Eating out", 380], ["Utilities", 320], ["Kids' activities", 250],
            ["Phone & internet", 165]] }
  ];

  var uid = 1, ciIn = 0, ciOut = 0;
  var state = { inc: [], out: [], preset: "two" };

  function mkRow(name, amt, list) {
    return { id: "r" + (uid++), name: name, amt: amt,
             ci: list === "inc" ? (ciIn++) % INC.length : (ciOut++) % CAT.length,
             kind: list };
  }
  function loadPreset(key) {
    var p = null, i;
    for (i = 0; i < PRESETS.length; i++) if (PRESETS[i].key === key) p = PRESETS[i];
    if (!p) p = PRESETS[0];
    ciIn = 0; ciOut = 0;
    state.preset = p.key;
    state.inc = p.inc.map(function (r) { return mkRow(r[0], r[1], "inc"); });
    state.out = p.out.map(function (r) { return mkRow(r[0], r[1], "out"); });
  }

  /* ---------------- helpers ---------------- */
  function num(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0; }
  function sum(a) { var t = 0, i; for (i = 0; i < a.length; i++) t += a[i]; return t; }
  function money(v) { return "$" + Math.round(v).toLocaleString("en-US"); }
  function pct(part, whole) { return whole > 0 ? (Math.round(part / whole * 1000) / 10) + "%" : "—"; }
  function colorOf(r) { return r.kind === "inc" ? INC[r.ci % INC.length] : CAT[r.ci % CAT.length]; }
  function parseAmt(s) {
    var cleaned = String(s).replace(/[^0-9.]/g, "");
    if (cleaned === "" || cleaned === ".") return null;
    var n = parseFloat(cleaned);
    if (!isFinite(n)) return null;
    return Math.min(n, 9999999);
  }
  function fmtInput(v) { return Math.round(v).toLocaleString("en-US"); }

  /* ---------------- model ---------------- */
  /* Builds the node/link model. `vals` maps row id -> value (tweened or true). */
  function model(vals) {
    var src = [], tgt = [], i, r, v;
    for (i = 0; i < state.inc.length; i++) {
      r = state.inc[i]; v = vals[r.id];
      if (v > 0) src.push({ id: r.id, name: r.name, val: v, tv: num(r.amt), color: colorOf(r), kind: "inc" });
    }
    for (i = 0; i < state.out.length; i++) {
      r = state.out[i]; v = vals[r.id];
      if (v > 0) tgt.push({ id: r.id, name: r.name, val: v, tv: num(r.amt), color: colorOf(r), kind: "out" });
    }
    var tIn = sum(src.map(function (n) { return n.val; }));
    var tOut = sum(tgt.map(function (n) { return n.val; }));
    var left = tIn - tOut;
    var tvIn = sum(state.inc.map(function (x) { return num(x.amt); }));
    var tvOut = sum(state.out.map(function (x) { return num(x.amt); }));
    var tvLeft = tvIn - tvOut;
    /* Labels quote the TRUE leftover. Mid-tween the drawn sign can still disagree with
       the true one (you just typed the budget into the red); fall back to the drawn
       value then, so a visible ribbon is never labelled "$0". */
    if (left > 0.5) tgt.push({ id: "LEFT", name: "Left over", val: left,
                               tv: tvLeft > 0.5 ? tvLeft : left,
                               color: "var(--run)", kind: "left" });
    else if (left < -0.5) src.push({ id: "SHORT", name: "Short", val: -left,
                                     tv: tvLeft < -0.5 ? -tvLeft : -left,
                                     color: "var(--danger)", kind: "short" });
    return { src: src, tgt: tgt, tIn: tIn, tOut: tOut, left: left, grand: Math.max(tIn, tOut),
             tvIn: tvIn, tvOut: tvOut, tvLeft: tvLeft };
  }

  /* ---------------- layout ---------------- */
  var PADV = 12, GAP = 7, NW = 11;

  function layout(m, W, narrow) {
    var nS = m.src.length, nT = m.tgt.length, big = Math.max(nS, nT);
    var rowH = narrow ? 26 : 30;
    var H = Math.max(narrow ? 240 : 280, Math.min(600, big * rowH + 46));
    var avail = H - 2 * PADV;
    var gapS = Math.max(0, nS - 1) * GAP, gapT = Math.max(0, nT - 1) * GAP;
    var usable = Math.max(20, avail - Math.max(gapS, gapT));
    var mint = Math.min(2.5, usable / Math.max(1, big));
    var s = m.grand > 0 ? usable / m.grand : 0;

    function side(list) { var t = 0, i; for (i = 0; i < list.length; i++) t += Math.max(mint, list[i].val * s); return t; }
    var it, a, b, mx;
    for (it = 0; it < 24; it++) {
      a = side(m.src); b = side(m.tgt); mx = Math.max(a, b);
      if (mx <= usable + 0.5 || s <= 0) break;
      s *= (usable / mx) * 0.999;
    }
    var thS = side(m.src), thT = side(m.tgt);

    var labelW = narrow ? 0 : Math.max(100, Math.min(178, Math.round(W * 0.215)));
    var rLabelW = narrow ? Math.max(74, Math.min(112, Math.round(W * 0.30))) : labelW;
    var x0 = narrow ? 4 : labelW + 6;
    var x2 = W - rLabelW - 8 - NW;
    var x1 = Math.round(x0 + NW + (x2 - x0 - NW) * (narrow ? 0.26 : 0.31) - NW / 2);

    function place(list, total, x) {
      var y = PADV + (avail - total) / 2, i, n, h;
      for (i = 0; i < list.length; i++) {
        n = list[i]; h = Math.max(mint, n.val * s);
        n.y = y; n.h = h; n.x = x; y += h + GAP;
      }
    }
    place(m.src, thS + gapS, x0);
    place(m.tgt, thT + gapT, x2);

    var hubH = Math.max(thS, thT);
    var hubY = PADV + (avail - hubH) / 2;
    var y, i;
    y = hubY + (hubH - thS) / 2;
    for (i = 0; i < m.src.length; i++) { m.src[i].hy = y; y += m.src[i].h; }
    y = hubY + (hubH - thT) / 2;
    for (i = 0; i < m.tgt.length; i++) { m.tgt[i].hy = y; y += m.tgt[i].h; }

    m.hub = { x: x1, y: hubY, h: hubH };
    m.W = W; m.H = H; m.narrow = narrow; m.rLabelW = rLabelW; m.labelW = labelW;
    return m;
  }

  /* ---------------- svg drawing ---------------- */
  var svg = $("svg"), figure = $("figure");
  var hoverId = null, selId = null, flows = [], hoverSoft = false, selSoft = false;

  function el(tag, attrs) {
    var e = document.createElementNS(NS, tag), k;
    for (k in attrs) if (Object.prototype.hasOwnProperty.call(attrs, k)) e.setAttribute(k, attrs[k]);
    return e;
  }
  function r2(v) { return Math.round(v * 100) / 100; }
  function ribbonPath(x0, y0, x1, y1, h) {
    var cx = r2(x0 + (x1 - x0) * 0.5);
    var a = r2(y0), b = r2(y1), a2 = r2(y0 + h), b2 = r2(y1 + h);
    return "M" + r2(x0) + "," + a + "C" + cx + "," + a + " " + cx + "," + b + " " + r2(x1) + "," + b +
           "L" + r2(x1) + "," + b2 + "C" + cx + "," + b2 + " " + cx + "," + a2 + " " + r2(x0) + "," + a2 + "Z";
  }
  function truncate(s, maxChars) {
    if (s.length <= maxChars) return s;
    return s.slice(0, Math.max(1, maxChars - 1)).replace(/\s+$/, "") + "…";
  }
  function declash(items, minGap, H) {
    var i;
    items.sort(function (a, b) { return a.ly - b.ly; });
    for (i = 1; i < items.length; i++)
      if (items[i].ly - items[i - 1].ly < minGap) items[i].ly = items[i - 1].ly + minGap;
    var over = items.length ? items[items.length - 1].ly - (H - 8) : 0;
    if (over > 0) for (i = items.length - 1; i >= 0; i--) {
      items[i].ly -= over;
      if (i > 0 && items[i].ly - items[i - 1].ly >= minGap) break;
    }
    if (items.length && items[0].ly < 10) {
      var sh = 10 - items[0].ly;
      for (i = 0; i < items.length; i++) items[i].ly += sh;
    }
  }

  function draw(vals) {
    var W = Math.max(240, Math.round(figure.clientWidth || 320));
    var narrow = W < 560;
    var m = layout(model(vals), W, narrow);

    while (svg.firstChild) svg.removeChild(svg.firstChild);
    svg.setAttribute("viewBox", "0 0 " + W + " " + m.H);
    svg.setAttribute("height", m.H);
    svg.classList.toggle("narrow", narrow);

    if (m.grand <= 0) {
      var t = el("text", { x: W / 2, y: m.H / 2, "text-anchor": "middle", "class": "empty" });
      t.textContent = "Add an amount on the right to see the flow.";
      svg.appendChild(t);
      flows = [];
      $("hint").textContent = "nothing to draw yet · add a line on the right";
      return m;
    }
    $("hint").textContent = "hover a ribbon · click one to edit it · \u2190 \u2192 steps through flows";

    var defs = el("defs", {});
    svg.appendChild(defs);
    var gRib = el("g", {}), gNode = el("g", {}), gLdr = el("g", {}), gLbl = el("g", {});
    svg.appendChild(gRib); svg.appendChild(gNode); svg.appendChild(gLdr); svg.appendChild(gLbl);

    var order = [], i, n;
    for (i = 0; i < m.src.length; i++) order.push(m.src[i]);
    for (i = 0; i < m.tgt.length; i++) order.push(m.tgt[i]);
    flows = order;

    function grad(id, x0, x1, color, o0, o1) {
      var g = el("linearGradient", { id: id, x1: r2(x0), x2: r2(x1), y1: 0, y2: 0,
                                     gradientUnits: "userSpaceOnUse" });
      var s0 = el("stop", { offset: "0" }), s1 = el("stop", { offset: "1" });
      s0.setAttribute("style", "stop-color:" + color + ";stop-opacity:" + o0);
      s1.setAttribute("style", "stop-color:" + color + ";stop-opacity:" + o1);
      g.appendChild(s0); g.appendChild(s1); defs.appendChild(g);
      return "url(#" + id + ")";
    }

    /* ribbons: sources -> hub */
    for (i = 0; i < m.src.length; i++) {
      n = m.src[i];
      var f1 = grad("gs" + i, n.x + NW, m.hub.x, n.color, 0.52, 0.30);
      var p1 = el("path", { d: ribbonPath(n.x + NW, n.y, m.hub.x, n.hy, n.h), "class": "rib",
                            fill: f1, "data-id": n.id });
      var ti1 = el("title", {});
      ti1.textContent = n.name + " — " + money(n.tv) + " in";
      p1.appendChild(ti1);
      gRib.appendChild(p1);
    }
    /* ribbons: hub -> targets */
    for (i = 0; i < m.tgt.length; i++) {
      n = m.tgt[i];
      var f2 = grad("gt" + i, m.hub.x + NW, n.x, n.color, 0.26, 0.62);
      var p2 = el("path", { d: ribbonPath(m.hub.x + NW, n.hy, n.x, n.y, n.h), "class": "rib",
                            fill: f2, "data-id": n.id });
      var ti2 = el("title", {});
      ti2.textContent = n.name + " — " + money(n.tv) + " of " + money(m.tvIn);
      p2.appendChild(ti2);
      gRib.appendChild(p2);
    }

    /* nodes */
    function nodeRect(x, y, h, color, id) {
      var rr = el("rect", { x: r2(x), y: r2(y), width: NW, height: r2(Math.max(1.5, h)),
                            rx: 2.5, fill: color, "class": "node" });
      if (id) rr.setAttribute("data-id", id);
      gNode.appendChild(rr);
    }
    for (i = 0; i < m.src.length; i++) nodeRect(m.src[i].x, m.src[i].y, m.src[i].h, m.src[i].color, m.src[i].id);
    for (i = 0; i < m.tgt.length; i++) nodeRect(m.tgt[i].x, m.tgt[i].y, m.tgt[i].h, m.tgt[i].color, m.tgt[i].id);
    var hub = el("rect", { x: m.hub.x, y: r2(m.hub.y), width: NW, height: r2(Math.max(1.5, m.hub.h)),
                           rx: 2.5, fill: "var(--accent)", "class": "node" });
    gNode.appendChild(hub);
    var hubLab = el("text", { x: m.hub.x + NW / 2, y: Math.max(11, m.hub.y - 7),
                              "text-anchor": "middle", "class": "hubt" });
    hubLab.textContent = (narrow ? "IN  " : "MONEY IN  ") + money(m.tvIn);
    svg.appendChild(hubLab);

    /* labels */
    var labs = [];
    for (i = 0; i < m.tgt.length; i++) {
      n = m.tgt[i];
      labs.push({ n: n, ly: n.y + n.h / 2, side: "r" });
    }
    if (!narrow) for (i = 0; i < m.src.length; i++) {
      n = m.src[i];
      labs.push({ n: n, ly: n.y + n.h / 2, side: "l" });
    }
    var right = labs.filter(function (l) { return l.side === "r"; });
    var leftL = labs.filter(function (l) { return l.side === "l"; });
    declash(right, 15, m.H); declash(leftL, 15, m.H);

    function label(l) {
      var n = l.n, isR = l.side === "r";
      var x = isR ? n.x + NW + 11 : n.x - 11;
      /* thin ribbons get their label nudged clear of the neighbours — draw the
         hairline that says which ribbon the nudged label belongs to */
      var cy = n.y + n.h / 2;
      if (Math.abs(l.ly - cy) > 1.4) {
        var ax = isR ? n.x + NW + 1 : n.x - 1;
        var bx = isR ? x - 3 : x + 3;
        var mx = (ax + bx) / 2;
        var ld = el("path", { d: "M" + r2(ax) + "," + r2(cy) + "C" + r2(mx) + "," + r2(cy) +
                                 " " + r2(mx) + "," + r2(l.ly) + " " + r2(bx) + "," + r2(l.ly),
                              "class": "ldr", "data-id": n.id, stroke: n.color });
        gLdr.appendChild(ld);
      }
      var g = el("text", { x: r2(x), y: r2(l.ly), "class": "lbl",
                           "text-anchor": isR ? "start" : "end", "data-id": n.id,
                           "dominant-baseline": "middle" });
      var box = isR ? m.rLabelW : m.labelW;
      var showAmt = !narrow;
      var amtStr = money(n.tv);
      var room = box - 4 - (showAmt ? amtStr.length * 6.7 + 7 : 0);
      var chars = Math.max(5, Math.floor(room / (narrow ? 5.9 : 6.2)));
      var nameTxt = truncate(n.name, chars);
      var t1 = el("tspan", { "class": "nmt" });
      t1.textContent = nameTxt;
      g.appendChild(t1);
      if (showAmt) {
        var t2 = el("tspan", { "class": "amt", dx: "6" });
        t2.textContent = money(n.tv);
        g.appendChild(t2);
      }
      var tt = el("title", {});
      tt.textContent = n.name + " — " + money(n.tv);
      g.appendChild(tt);
      gLbl.appendChild(g);
    }
    for (i = 0; i < leftL.length; i++) label(leftL[i]);
    for (i = 0; i < right.length; i++) label(right[i]);

    applyHighlight();
    return m;
  }

  /* ---------------- highlight ---------------- */
  function activeId() { return hoverId || selId; }
  function applyHighlight() {
    var id = activeId(), i;
    var marks = svg.querySelectorAll("[data-id]");
    for (i = 0; i < marks.length; i++)
      marks[i].classList.toggle("on", !!id && marks[i].getAttribute("data-id") === id);
    svg.classList.toggle("dim", !!id);
    svg.classList.toggle("soft", !!id && (hoverId ? hoverSoft : selSoft));
    var rows = document.querySelectorAll(".row");
    for (i = 0; i < rows.length; i++)
      rows[i].classList.toggle("hot", !!id && rows[i].getAttribute("data-id") === id);
    setReadout();
  }

  /* Look the flow up in the TRUE model, never in the tweened `flows` array, so the
     readout can never disagree with the hero number while ribbons are still moving. */
  function trueFlow(m, id) {
    var i;
    if (!id) return null;
    for (i = 0; i < m.src.length; i++) if (m.src[i].id === id) return m.src[i];
    for (i = 0; i < m.tgt.length; i++) if (m.tgt[i].id === id) return m.tgt[i];
    return null;
  }

  function setReadout() {
    var m = model(trueVals());
    var f = trueFlow(m, activeId());
    var node = $("readout");
    while (node.firstChild) node.removeChild(node.firstChild);
    var lead = document.createElement("span");
    var tail = document.createElement("span");
    tail.className = "rd-mute";
    if (f) {
      var yr = money(f.val * 12) + " / yr";
      var noIn = m.tIn <= 0;
      if (f.kind === "short") {
        lead.textContent = "SHORT  " + money(f.val) + "  ";
        tail.textContent = "· " + (noIn ? "nothing coming in yet"
          : "spending is " + pct(m.tOut, m.tIn) + " of money in") + " · " + yr;
      } else {
        var nm = f.kind === "left" ? "LEFT OVER" : f.name.toUpperCase();
        lead.textContent = nm + "  " + money(f.val) + (f.kind === "inc" ? " in" : "") + "  ";
        tail.textContent = "· " + (noIn ? "nothing coming in yet"
          : pct(f.val, m.tIn) + " of money in") + " · " + yr;
      }
    } else {
      var n = state.out.length;
      tail.textContent = n + (n === 1 ? " line out · " : " lines out · ") + money(m.tOut) +
        " spent · " + money(Math.abs(m.left)) + (m.left < -0.5 ? " short" : " left");
    }
    node.appendChild(lead); node.appendChild(tail);
  }

  function speak(s) { $("live").textContent = s; }

  /* ---------------- values + tween ---------------- */
  var disp = {};
  function trueVals() {
    var v = {}, i;
    for (i = 0; i < state.inc.length; i++) v[state.inc[i].id] = num(state.inc[i].amt);
    for (i = 0; i < state.out.length; i++) v[state.out[i].id] = num(state.out[i].amt);
    return v;
  }
  function keysOf(o) { var k = [], p; for (p in o) if (Object.prototype.hasOwnProperty.call(o, p)) k.push(p); return k.sort().join(","); }

  var raf = 0, rafTo = 0, pending = false;
  function stopTween() {
    if (raf) { window.cancelAnimationFrame(raf); raf = 0; }
    if (rafTo) { window.clearTimeout(rafTo); rafTo = 0; }
    pending = false;
  }
  /* requestAnimationFrame is paused in hidden tabs, offscreen iframes and headless
     capture — and the tween is the ONLY redraw path for an edited amount. Race a
     timer against it so the numbers and the picture can never drift apart. */
  function nextTween() {
    if (pending) return;
    pending = true;
    var fired = false;
    var go = function () {
      if (fired) return;
      fired = true;
      stopTween();
      tick();
    };
    raf = window.requestAnimationFrame ? window.requestAnimationFrame(go) : 0;
    rafTo = window.setTimeout(go, 48);
  }
  function tick() {
    var t = trueVals(), moving = false, k;
    for (k in t) if (Object.prototype.hasOwnProperty.call(t, k)) {
      var d = disp[k] === undefined ? t[k] : disp[k];
      var nd = d + (t[k] - d) * 0.28;
      if (Math.abs(t[k] - nd) < 0.6) nd = t[k]; else moving = true;
      disp[k] = nd;
    }
    draw(disp);
    if (moving) nextTween();
  }

  function sync(structural) {
    var t = trueVals();
    if (structural || noMotion() || keysOf(t) !== keysOf(disp)) {
      disp = t;
      stopTween();
      draw(disp);
    } else {
      nextTween();
    }
    updateStats();
  }

  /* ---------------- stats + rows ---------------- */
  function updateStats() {
    var m = model(trueVals()), i;
    $("sIn").textContent = money(m.tIn);
    $("sOut").textContent = money(m.tOut);
    $("sRate").textContent = m.tIn > 0 ? pct(Math.max(0, m.left), m.tIn) : "—";
    var big = null;
    for (i = 0; i < state.out.length; i++)
      if (!big || num(state.out[i].amt) > num(big.amt)) big = state.out[i];
    $("sBig").textContent = big && num(big.amt) > 0
      ? truncate(big.name || "unnamed", 14) + " · " + money(num(big.amt)) : "—";
    $("totIn").textContent = money(m.tIn);
    $("totOut").textContent = money(m.tOut);
    $("figEyebrow").textContent = "flow diagram · " + (m.src.length + m.tgt.length) + " ribbons";

    var hero = $("hero");
    hero.classList.toggle("good", m.left > 0.5);
    hero.classList.toggle("bad", m.left < -0.5);
    if (m.left > 0.5) {
      $("heroK").textContent = "left over each month";
      $("heroN").textContent = money(m.left);
      $("heroS").innerHTML = "<b>" + pct(m.left, m.tIn) + "</b> of the <b>" + money(m.tIn) +
        "</b> that comes in never gets spent — that is the green ribbon at the bottom.";
    } else if (m.left < -0.5) {
      $("heroK").textContent = "short each month";
      $("heroN").textContent = money(-m.left);
      $("heroS").innerHTML = "Spending is <b>" + money(m.tOut) + "</b> against <b>" + money(m.tIn) +
        "</b> coming in. The red ribbon on the left is the gap something else has to cover.";
    } else {
      $("heroK").textContent = "left over each month";
      $("heroN").textContent = money(0);
      $("heroS").innerHTML = m.tIn > 0
        ? "Every dollar in is a dollar out — <b>" + money(m.tIn) + "</b> in, <b>" + money(m.tOut) + "</b> out."
        : "Put an amount in the <b>money in</b> list to start.";
    }

    /* per-row percentages */
    for (i = 0; i < state.inc.length; i++) setPct(state.inc[i], m.tIn);
    for (i = 0; i < state.out.length; i++) setPct(state.out[i], m.tIn);
    setReadout();
  }
  function setPct(r, tIn) {
    var li = document.querySelector('.row[data-id="' + r.id + '"]');
    if (!li) return;
    var p = li.querySelector(".pct");
    if (p) p.textContent = tIn > 0 ? Math.round(num(r.amt) / tIn * 100) + "%" : "—";
  }

  function rowEl(r) {
    var li = document.createElement("li");
    li.className = "row";
    li.setAttribute("data-id", r.id);

    var chip = document.createElement("span");
    chip.className = "chip";
    chip.style.background = colorOf(r);
    li.appendChild(chip);

    var nm = document.createElement("input");
    nm.type = "text"; nm.className = "nm"; nm.value = r.name;
    nm.maxLength = 40;
    nm.setAttribute("aria-label", (r.kind === "inc" ? "Income" : "Expense") + " name");
    li.appendChild(nm);

    var wrap = document.createElement("div");
    wrap.className = "amtwrap";
    var cur = document.createElement("span");
    cur.className = "cur mono"; cur.textContent = "$";
    var amt = document.createElement("input");
    amt.type = "text"; amt.className = "amt mono"; amt.value = fmtInput(num(r.amt));
    amt.setAttribute("inputmode", "decimal");
    amt.maxLength = 12;
    amt.setAttribute("aria-label", (r.name || "line") + " amount per month, dollars");
    wrap.appendChild(cur); wrap.appendChild(amt);
    li.appendChild(wrap);

    var p = document.createElement("span");
    p.className = "pct"; p.textContent = "—";
    li.appendChild(p);

    var rm = document.createElement("button");
    rm.type = "button"; rm.className = "rm"; rm.innerHTML = "&times;";
    rm.setAttribute("aria-label", "Remove " + (r.name || "line"));
    li.appendChild(rm);

    return li;
  }

  function renderRows() {
    var lists = [["rowsIn", state.inc], ["rowsOut", state.out]], i, j, ul;
    for (i = 0; i < lists.length; i++) {
      ul = $(lists[i][0]);
      while (ul.firstChild) ul.removeChild(ul.firstChild);
      for (j = 0; j < lists[i][1].length; j++) ul.appendChild(rowEl(lists[i][1][j]));
    }
  }

  function findRow(id) {
    var i;
    for (i = 0; i < state.inc.length; i++) if (state.inc[i].id === id) return state.inc[i];
    for (i = 0; i < state.out.length; i++) if (state.out[i].id === id) return state.out[i];
    return null;
  }

  /* ---------------- events: editor ---------------- */
  function panelHandlers(ul) {
    ul.addEventListener("input", function (e) {
      var li = e.target.closest ? e.target.closest(".row") : null;
      if (!li) return;
      var r = findRow(li.getAttribute("data-id"));
      if (!r) return;
      if (e.target.classList.contains("nm")) {
        r.name = e.target.value;
        var rm = li.querySelector(".rm"); if (rm) rm.setAttribute("aria-label", "Remove " + (r.name || "line"));
        li.querySelector(".amt").setAttribute("aria-label", (r.name || "line") + " amount per month, dollars");
        sync(false);
      } else if (e.target.classList.contains("amt")) {
        var v = parseAmt(e.target.value);
        e.target.setAttribute("aria-invalid", v === null && e.target.value.trim() !== "" ? "true" : "false");
        r.amt = v === null ? 0 : v;
        sync(false);
      }
    });

    ul.addEventListener("keydown", function (e) {
      if (!e.target.classList || !e.target.classList.contains("amt")) return;
      if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
      var li = e.target.closest(".row"), r = li && findRow(li.getAttribute("data-id"));
      if (!r) return;
      e.preventDefault();
      var step = e.shiftKey ? 100 : 25;
      r.amt = Math.max(0, num(r.amt) + (e.key === "ArrowUp" ? step : -step));
      e.target.value = fmtInput(r.amt);
      e.target.setAttribute("aria-invalid", "false");
      sync(false);
    });

    ul.addEventListener("focusout", function (e) {
      if (e.target.classList && e.target.classList.contains("amt")) {
        var li = e.target.closest(".row"), r = li && findRow(li.getAttribute("data-id"));
        if (r) { e.target.value = fmtInput(num(r.amt)); e.target.setAttribute("aria-invalid", "false"); }
      }
      /* focus left the list entirely — drop the selection so the diagram un-dims */
      var to = e.relatedTarget;
      if (selSoft && selId && (!to || !ul.contains(to))) { selId = null; applyHighlight(); }
    });

    ul.addEventListener("click", function (e) {
      var btn = e.target.closest ? e.target.closest(".rm") : null;
      if (!btn) return;
      var li = btn.closest(".row"), id = li.getAttribute("data-id");
      var gone = findRow(id);
      if (!gone) return;
      var idx = Array.prototype.indexOf.call(ul.children, li);
      state.inc = state.inc.filter(function (r) { return r.id !== id; });
      state.out = state.out.filter(function (r) { return r.id !== id; });
      if (hoverId === id) hoverId = null;
      if (selId === id) selId = null;
      delete disp[id];
      renderRows(); sync(true); applyHighlight();
      /* the button that had focus is gone — hand focus to its neighbour, or the add button */
      var next = ul.children[Math.min(Math.max(idx, 0), ul.children.length - 1)];
      var nextBtn = next ? next.querySelector(".rm") : ul.parentNode.querySelector(".addbtn");
      if (nextBtn) nextBtn.focus();
      speak("Removed " + (gone.name || "line") + ".");
    });

    /* hovering / focusing a row lights up its ribbon */
    ul.addEventListener("mouseover", function (e) {
      var li = e.target.closest ? e.target.closest(".row") : null;
      if (li) { hoverId = li.getAttribute("data-id"); hoverSoft = true; applyHighlight(); }
    });
    ul.addEventListener("mouseleave", function () { hoverId = null; applyHighlight(); });
    ul.addEventListener("focusin", function (e) {
      var li = e.target.closest ? e.target.closest(".row") : null;
      if (li) { selId = li.getAttribute("data-id"); selSoft = true; applyHighlight(); }
    });
  }
  panelHandlers($("rowsIn"));
  panelHandlers($("rowsOut"));

  function addRow(kind) {
    var r = mkRow(kind === "inc" ? "New income" : "New expense", kind === "inc" ? 400 : 120, kind);
    (kind === "inc" ? state.inc : state.out).push(r);
    renderRows(); sync(true);
    var li = document.querySelector('.row[data-id="' + r.id + '"]');
    if (li) { var nm = li.querySelector(".nm"); nm.focus(); nm.select(); }
    speak("Added " + r.name + " at " + money(num(r.amt)) + " per month.");
  }
  $("addIn").addEventListener("click", function () { addRow("inc"); });
  $("addOut").addEventListener("click", function () { addRow("out"); });

  /* presets */
  var chips = $("presets");
  PRESETS.forEach(function (p) {
    var b = document.createElement("button");
    b.type = "button"; b.className = "btn"; b.textContent = p.label;
    b.setAttribute("data-key", p.key);
    b.setAttribute("aria-pressed", "false");
    b.addEventListener("click", function () { applyPreset(p.key); });
    chips.appendChild(b);
  });
  function markPresets() {
    var bs = chips.querySelectorAll("button"), i;
    for (i = 0; i < bs.length; i++)
      bs[i].setAttribute("aria-pressed", bs[i].getAttribute("data-key") === state.preset ? "true" : "false");
  }
  function labelOf(key) {
    for (var i = 0; i < PRESETS.length; i++) if (PRESETS[i].key === key) return PRESETS[i].label;
    return PRESETS[0].label;
  }
  function applyPreset(key, verb) {
    hoverId = null; selId = null; hoverSoft = false; selSoft = false;
    loadPreset(key);
    disp = {};
    renderRows(); markPresets(); sync(true); applyHighlight();
    var m = model(trueVals());
    speak((verb || "Loaded") + " " + labelOf(state.preset) + ": " + money(m.tIn) +
          " in, " + money(m.tOut) + " out.");
  }
  $("reset").addEventListener("click", function () { applyPreset(state.preset, "Reset"); });

  /* ---------------- events: diagram ---------------- */
  svg.addEventListener("mouseover", function (e) {
    var t = e.target.closest ? e.target.closest("[data-id]") : null;
    var id = t ? t.getAttribute("data-id") : null;
    if (id !== hoverId) { hoverId = id; hoverSoft = false; applyHighlight(); }
  });
  svg.addEventListener("mouseleave", function () {
    if (hoverId !== null) { hoverId = null; applyHighlight(); }
  });
  svg.addEventListener("click", function (e) {
    var t = e.target.closest ? e.target.closest("[data-id]") : null;
    /* clicking bare canvas is the obvious way out of a selection — the "left over"
       ribbon has no editor row to take focus, so nothing else would clear it */
    if (!t) { if (selId !== null) { selId = null; applyHighlight(); } return; }
    var id = t.getAttribute("data-id");
    selId = id; selSoft = false; applyHighlight();
    jumpToRow(id);
  });
  function jumpToRow(id) {
    var li = document.querySelector('.row[data-id="' + id + '"]');
    if (!li) { figure.focus(); return; }
    var amt = li.querySelector(".amt");
    if (amt) { amt.focus(); amt.select(); }
  }

  figure.addEventListener("keydown", function (e) {
    if (!flows.length) return;
    var i = -1, j;
    for (j = 0; j < flows.length; j++) if (flows[j].id === selId) i = j;
    var handled = true;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") i = (i + 1) % flows.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") i = (i <= 0 ? flows.length : i) - 1;
    else if (e.key === "Home") i = 0;
    else if (e.key === "End") i = flows.length - 1;
    else if (e.key === "Escape") { selId = null; applyHighlight(); return; }
    else if (e.key === "Enter" || e.key === " ") { if (selId) { jumpToRow(selId); } else handled = false; }
    else handled = false;
    if (!handled) return;
    e.preventDefault();
    if (e.key !== "Enter" && e.key !== " ") {
      selId = flows[i].id; selSoft = false;
      applyHighlight();
      var m = model(trueVals()), f = trueFlow(m, selId) || flows[i];
      speak(f.name + ", " + money(f.val) + " per month, " + pct(f.val, m.tIn) + " of money in.");
    }
  });

  /* ---------------- resize ---------------- */
  var lastW = 0;
  function onResize() {
    var w = figure.clientWidth;
    if (Math.abs(w - lastW) < 1) return;
    lastW = w;
    draw(disp);
  }
  if (window.ResizeObserver) {
    var ro = new ResizeObserver(function () { onResize(); });
    ro.observe(figure);
  }
  window.addEventListener("resize", onResize);
  if (reduce && reduce.addEventListener) reduce.addEventListener("change", function () { sync(true); });

  /* ---------------- boot ---------------- */
  loadPreset("two");
  renderRows();
  markPresets();
  lastW = figure.clientWidth;
  sync(true);
})();
</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