$sloprun.dev

Treemap Explorer

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 ordinary spending — rent, groceries, the gym, the trip fund — packed into one rectangle where area is the amount. The layout is a squarified treemap written from scratch in about forty lines: it fills a row of blocks and starts a new one the instant the shapes would get worse, which is why nothing here is a sliver you can't read. Click any block to zoom into its group, then edit the numbers in the side list and watch the whole picture rearrange itself — the footer times each pass, and it lands around a millisecond. Try setting Rent to 900 to see how much of the frame that one line was quietly holding, or drop your take-home pay below the total and watch the balance flip from spare to short.

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/spending-treemap" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Treemap 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/spending-treemap

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1120 lines 49.7 KB index.html
demos/spending-treemap/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Treemap Explorer</title>
<link rel="icon" href="data:,">
<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); }

/* ---------- base ---------- */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { -webkit-text-size-adjust: 100%; line-height: 1.5; overflow-x: hidden; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 22px 16px 26px; }
h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
p { margin: 0; }
button { font: inherit; color: inherit; }

:where(button, input, select, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 6px;
}
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
      overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* ---------- header ---------- */
header { margin-bottom: 15px; }
.eyebrow {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); display: flex;
  align-items: center; gap: 8px; flex-wrap: wrap;
}
.eyebrow .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: none; }
h1 { font-size: clamp(21px, 4.6vw, 30px); margin: 6px 0 6px; }
.lede { color: var(--muted); font-size: 15px; max-width: 68ch; text-wrap: pretty; }
.lede b { color: var(--ink); font-weight: 600; }

/* ---------- layout ---------- */
.grid { display: grid; grid-template-columns: minmax(0, 1.62fr) minmax(0, 1fr); gap: 14px; align-items: start; }
.col { display: grid; gap: 14px; min-width: 0; }
@media (max-width: 920px) {
  .grid { grid-template-columns: minmax(0, 1fr); }
  .grid > .col { display: contents; }
  #cMap { order: 1; } #cBudget { order: 2; } #cList { order: 3; } #cMethod { order: 4; }
}

.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 13px; min-width: 0;
}
.card > h2 {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); font-weight: 600;
  display: flex; justify-content: space-between; align-items: center; gap: 10px;
  margin-bottom: 10px; flex-wrap: wrap;
}
.card > h2 .meta { color: var(--ink); letter-spacing: .04em; font-variant-numeric: tabular-nums; }

/* ---------- crumbs ---------- */
.crumbs { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-bottom: 9px; min-height: 28px; }
.crumb {
  font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .04em;
  background: transparent; border: 1px solid var(--line); border-radius: 6px;
  padding: 4px 9px; cursor: pointer; color: var(--muted);
}
.crumb:hover { border-color: var(--accent); color: var(--ink); }
.crumb[aria-current="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); cursor: default; }
.crumb[aria-current="true"]:hover { color: var(--accent); }
.crumbs .sep { color: var(--muted); font-family: var(--font-mono); font-size: 12px; }
.crumbs .swatch { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; }
.hint { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .04em; }

/* ---------- treemap ---------- */
.treebox {
  position: relative; overflow: hidden; border-radius: 8px;
  background: var(--bg); border: 1px solid var(--line);
  height: 470px; touch-action: manipulation;
}
@media (max-width: 920px) { .treebox { height: 400px; } }
@media (max-width: 480px) { .treebox { height: 340px; } }

.tile, .hdr {
  position: absolute; margin: 0; border: 0; padding: 5px 6px; text-align: left;
  border-radius: 4px; overflow: hidden; cursor: pointer; display: flex;
  flex-direction: column; align-items: flex-start; justify-content: flex-start;
  line-height: 1.15; min-width: 0;
  transition: left .34s cubic-bezier(.36,.05,.2,1), top .34s cubic-bezier(.36,.05,.2,1),
              width .34s cubic-bezier(.36,.05,.2,1), height .34s cubic-bezier(.36,.05,.2,1),
              opacity .22s linear;
}
.tile .nm { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tile .vl { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 11px;
            opacity: .92; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
.tile.quiet .nm, .tile.quiet .vl { display: none; }
.tile.noval .vl { display: none; }
.tile:hover, .hdr:hover { filter: brightness(1.045) saturate(1.08); }
:root[data-theme="dark"] .tile:hover, :root[data-theme="dark"] .hdr:hover { filter: brightness(1.22); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .tile:hover,
  :root:not([data-theme="light"]) .hdr:hover { filter: brightness(1.22); }
}
.tile.sel { z-index: 5; box-shadow: 0 0 0 2px var(--accent) inset; }
.tile:focus-visible, .hdr:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; z-index: 6; }
.hdr { flex-direction: row; align-items: center; justify-content: space-between; gap: 6px;
       font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .09em;
       text-transform: uppercase; font-weight: 700; padding: 0 7px; }
.hdr .hn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hdr .hv { flex: none; font-variant-numeric: tabular-nums; opacity: .82; letter-spacing: .02em; }
.hdr.quiet .hv { display: none; }

.tip {
  position: absolute; z-index: 30; pointer-events: none; opacity: 0;
  background: var(--surface); color: var(--ink); border: 1px solid var(--line);
  border-radius: 7px; padding: 7px 9px; box-shadow: 0 6px 18px rgba(0,0,0,.16);
  font-family: var(--font-mono); font-size: 11.5px; line-height: 1.35;
  font-variant-numeric: tabular-nums; transition: opacity .12s linear;
  max-width: min(92%, 260px); overflow-wrap: anywhere;
}
.tip.on { opacity: 1; }
.tip b { display: block; font-size: 12.5px; margin-bottom: 3px; letter-spacing: .01em; }
.tip .t2 { color: var(--muted); }
.empty {
  position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
  text-align: center; padding: 20px; color: var(--muted); font-size: 14px;
}
.empty.on { display: flex; }

.readout {
  margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  font-variant-numeric: tabular-nums; min-height: 18px;
}
.readout b { color: var(--ink); font-weight: 600; }
.readout .k { opacity: .75; }

/* ---------- budget card ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 1px;
  background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.stat { background: var(--surface); padding: 9px 10px; min-width: 0; }
.stat .k { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); }
.stat .v { font-family: var(--font-mono); font-size: 17px; font-weight: 600; margin-top: 3px;
  font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat .s { font-size: 11.5px; color: var(--muted); font-family: var(--font-mono);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat .v.ok { color: var(--run); }
.stat .v.bad { color: var(--danger); }
.stat input {
  font-family: var(--font-mono); font-size: 17px; font-weight: 600; width: 100%;
  background: transparent; border: 0; border-bottom: 1px dashed var(--line);
  color: var(--ink); padding: 1px 0 0; margin-top: 2px; font-variant-numeric: tabular-nums;
}
.stat input:hover { border-bottom-color: var(--accent); }
.stat input:focus { outline: none; border-bottom: 1px solid var(--accent); }

.bar { position: relative; display: flex; height: 15px; border-radius: 5px; overflow: hidden;
  background: var(--bg); border: 1px solid var(--line); margin-top: 12px; }
.bar .seg { height: 100%; min-width: 0; }
/* transparent stripes let the bar's own --bg show through, so "left over" reads as
   unspent room rather than another solid category block */
.bar .left { height: 100%; flex: 1 1 auto;
  background-image: repeating-linear-gradient(135deg, var(--run-soft) 0 5px, transparent 5px 10px); }
.bar .mark { position: absolute; top: -1px; bottom: -1px; width: 2px; background: var(--ink); }
.barlab { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
  font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 6px;
  letter-spacing: .04em; font-variant-numeric: tabular-nums; }
.pill { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase;
  padding: 3px 9px; border-radius: 999px; border: 1px solid transparent; white-space: nowrap; }
.pill.ok { background: var(--run-soft); color: var(--run); }
.pill.bad { border-color: var(--danger); color: var(--danger); }

/* ---------- list ---------- */
.listwrap { max-height: 430px; overflow-y: auto; overflow-x: hidden; margin: 0 -3px; padding: 0 3px; }
@media (max-width: 920px) { .listwrap { max-height: none; } }
/* On wide screens the list grows to fill the height the map sets — but never past its own
   content, so a zoomed group of four lines still reads as a short card, not a hollow one. */
@media (min-width: 921px) {
  .grid { align-items: stretch; }
  /* height:0 + min-height:100% keeps this column from voting on the row height —
     the map column sets it, and the list takes whatever is left. */
  .col.side { height: 0; min-height: 100%; grid-template-rows: minmax(220px, 1fr) auto; }
  #cList { align-self: start; max-height: 100%; display: flex; flex-direction: column; min-height: 0; }
  .listwrap { flex: 1 1 auto; min-height: 0; max-height: none; }
}
.grp { border-top: 1px solid var(--line); padding-top: 7px; margin-top: 9px; }
.grp:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
.ghead { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
.ghead .sw { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.ghead .gname { flex: 1 1 auto; min-width: 0; font-weight: 650; font-size: 13.5px;
  background: transparent; border: 0; border-bottom: 1px dashed transparent; color: var(--ink); padding: 1px 0; }
.ghead .gname:hover { border-bottom-color: var(--line); }
.ghead .gname:focus { outline: none; border-bottom: 1px solid var(--accent); }
.ghead .gtot { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.mini { flex: none; width: 24px; height: 24px; border-radius: 6px; border: 1px solid var(--line);
  background: transparent; color: var(--muted); cursor: pointer; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center; font-size: 14px; padding: 0; }
.mini:hover { border-color: var(--accent); color: var(--accent); }
.row { display: grid; grid-template-columns: minmax(0,1fr) 78px 46px 26px; gap: 6px;
  align-items: center; padding: 1px 0; }
.row + .row { margin-top: 3px; }
.row.on { background: var(--accent-soft); border-radius: 6px; box-shadow: 0 0 0 4px var(--accent-soft); }
.row .rn { min-width: 0; width: 100%; font-size: 13px; background: transparent; border: 0;
  border-bottom: 1px dashed transparent; color: var(--ink); padding: 2px 0; }
.row .rn:hover { border-bottom-color: var(--line); }
.row .rn:focus { outline: none; border-bottom: 1px solid var(--accent); }
.row .rv { font-family: var(--font-mono); font-size: 12.5px; font-variant-numeric: tabular-nums;
  width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
  color: var(--ink); padding: 3px 6px; text-align: right; }
.row .rv:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
.row .rp { font-family: var(--font-mono); font-size: 11px; color: var(--muted); text-align: right;
  font-variant-numeric: tabular-nums; }
.row .del { width: 22px; height: 22px; border-radius: 5px; border: 1px solid transparent;
  background: transparent; color: var(--muted); cursor: pointer; font-size: 15px; line-height: 1; padding: 0; }
.row .del:hover { color: var(--danger); border-color: var(--danger); }
@media (max-width: 420px) { .row { grid-template-columns: minmax(0,1fr) 72px 26px; } .row .rp { display: none; } }
@media (pointer: coarse) {
  .mini { width: 30px; height: 30px; }
  .row .del { width: 26px; height: 26px; font-size: 17px; }
  .crumb { padding: 6px 11px; }
}
.listfoot { position: relative; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
  margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--line); flex: none; }
/* soft edge so a clipped row reads as "scroll me", not as a rendering seam */
.listfoot::before { content: ""; position: absolute; left: 0; right: 0; bottom: 100%; height: 20px;
  background: linear-gradient(to top, var(--surface), transparent); pointer-events: none; }
.btn { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .04em; padding: 6px 11px;
  border-radius: 7px; border: 1px solid var(--line); background: transparent; color: var(--ink); cursor: pointer; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:disabled, .btn:disabled:hover { color: var(--muted); border-color: var(--line); cursor: default; opacity: .55; }
.listfoot .note { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-left: auto; }

/* ---------- method ---------- */
.method summary { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); cursor: pointer; font-weight: 600; }
.method .body { margin-top: 10px; font-size: 13.5px; color: var(--muted); display: grid; gap: 9px; }
.method .body b { color: var(--ink); font-weight: 600; }
.eq { font-family: var(--font-mono); font-size: 12px; color: var(--ink);
  background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
  padding: 9px 11px; overflow-x: auto; white-space: pre; }

footer { margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

<header>
  <p class="eyebrow"><span class="dot"></span><span>treemap · squarified</span></p>
  <h1>Treemap Explorer</h1>
  <p class="lede">Every rectangle is money, and <b>area is the amount</b> — one month of spending, packed into a single frame. Click a block to zoom into that part of the month, then edit any number on the right and watch the whole picture rearrange itself.</p>
</header>

<div class="grid">
  <div class="col">

    <section class="card" id="cMap">
      <h2><span>the month, by area</span><span class="meta" id="mapMeta">—</span></h2>
      <div class="crumbs" id="crumbs" aria-label="Breadcrumb"></div>
      <div class="treebox" id="treebox">
        <div class="tip" id="tip" role="presentation" aria-hidden="true"></div>
        <div class="empty" id="empty">Nothing above zero to draw. Add an amount in the list.</div>
      </div>
      <div class="readout mono" id="readout" aria-hidden="true"></div>
    </section>

    <section class="card" id="cBudget">
      <h2><span>does it balance?</span><span class="pill" id="pill">—</span></h2>
      <div class="tiles">
        <div class="stat">
          <div class="k">spent</div>
          <div class="v" id="sSpent">—</div>
          <div class="s" id="sItems">—</div>
        </div>
        <div class="stat">
          <div class="k"><label for="pay">take-home pay</label></div>
          <input id="pay" type="number" min="0" step="50" value="5200" inputmode="decimal" aria-describedby="payNote">
          <div class="s" id="payNote">per month</div>
        </div>
        <div class="stat">
          <div class="k" id="leftK">left over</div>
          <div class="v" id="sLeft">—</div>
          <div class="s" id="sLeftPct">—</div>
        </div>
        <div class="stat">
          <div class="k">biggest single line</div>
          <div class="v" id="sBig">—</div>
          <div class="s" id="sBigSub">—</div>
        </div>
      </div>
      <div class="bar" id="bar" aria-hidden="true"></div>
      <div class="barlab"><span id="barL">—</span><span id="barR">—</span></div>
    </section>

  </div>
  <div class="col side">

    <section class="card" id="cList">
      <h2><span id="listTitle">every line</span><span class="meta" id="listMeta">—</span></h2>
      <div class="listwrap" id="listwrap"></div>
      <div class="listfoot">
        <button class="btn" id="btnReset" type="button">reset budget</button>
        <button class="btn" id="btnUp" type="button">zoom out</button>
        <span class="note">edits redraw live</span>
      </div>
    </section>

    <section class="card method" id="cMethod">
      <details>
        <summary>how the packing works</summary>
        <div class="body">
          <p>A treemap turns a total into a rectangle, then slices it up so each piece takes area in proportion to its share. The naive slice-and-dice version leaves you with long paper-thin slivers you can't compare. <b>Squarified</b> layout fixes that: it fills a row of blocks one at a time and stops the moment adding the next block would make the row's shapes worse, then starts a fresh row against the remaining space.</p>
          <p>"Worse" has an exact meaning — the worst aspect ratio in the row, for a row of areas <i>r</i> laid along a side of length <i>w</i>:</p>
          <div class="eq">s = sum(r)
worst(r, w) = max( w²·max(r) / s² , s² / (w²·min(r)) )</div>
          <p>The algorithm is about forty lines and runs in about a millisecond here (the footer times every pass). Everything you see — nesting, zoom, the label sizing — is plain DOM elements positioned by that one function, so the browser animates the rearrangement for free.</p>
          <p><b>Keyboard:</b> Tab moves between blocks, Enter zooms in or picks a line, Escape zooms back out.</p>
        </div>
      </details>
    </section>

  </div>
</div>

<footer>
  <span>demo · sloprun.dev</span>
  <span id="perf">layout · —</span>
</footer>

<p class="sr" role="status" id="live"></p>
</div>

<script>
(function () {
  'use strict';
  var $ = function (id) { return document.getElementById(id); };

  /* ---------------- seed data: one month, two levels ---------------- */
  var SEED = [
    { id: 'home', name: 'Home & bills', hue: 22, items: [
      { id: 'rent',    name: 'Rent',              value: 1450 },
      { id: 'power',   name: 'Electricity & gas', value: 128 },
      { id: 'net',     name: 'Internet',          value: 60 },
      { id: 'phone',   name: 'Phone plan',        value: 55 },
      { id: 'water',   name: 'Water',             value: 42 },
      { id: 'hins',    name: 'Home insurance',    value: 24 }
    ]},
    { id: 'food', name: 'Food & drink', hue: 45, items: [
      { id: 'groc',    name: 'Groceries',         value: 520 },
      { id: 'rest',    name: 'Restaurants',       value: 235 },
      { id: 'deliv',   name: 'Delivery orders',   value: 95 },
      { id: 'coffee',  name: 'Coffee shops',      value: 68 }
    ]},
    { id: 'save', name: 'Saving', hue: 192, items: [
      { id: 'emerg',   name: 'Emergency fund',    value: 400 },
      { id: 'ret',     name: 'Retirement',        value: 350 },
      { id: 'trip',    name: 'Trip fund',         value: 120 }
    ]},
    { id: 'move', name: 'Getting around', hue: 228, items: [
      { id: 'carpay',  name: 'Car payment',       value: 310 },
      { id: 'fuel',    name: 'Fuel',              value: 132 },
      { id: 'cins',    name: 'Car insurance',     value: 118 },
      { id: 'bus',     name: 'Bus pass',          value: 45 },
      { id: 'park',    name: 'Parking & tolls',   value: 38 }
    ]},
    { id: 'fun', name: 'Fun & hobbies', hue: 338, chroma: .72, items: [
      { id: 'nights',  name: 'Nights out',        value: 88 },
      { id: 'hobby',   name: 'Hobby supplies',    value: 61 },
      { id: 'books',   name: 'Books & games',     value: 52 },
      { id: 'stream',  name: 'Streaming',         value: 46 }
    ]},
    { id: 'extra', name: 'Everyday extras', hue: 272, chroma: .86, items: [
      { id: 'clothes', name: 'Clothes',           value: 90 },
      { id: 'pet',     name: 'Pet food & vet',    value: 72 },
      { id: 'gifts',   name: 'Gifts',             value: 55 },
      { id: 'laundry', name: 'Laundry & soap',    value: 28 }
    ]},
    { id: 'care', name: 'Health & care', hue: 305, chroma: .62, items: [
      { id: 'gym',     name: 'Gym membership',    value: 45 },
      { id: 'pharm',   name: 'Pharmacy',          value: 36 },
      { id: 'hair',    name: 'Haircuts',          value: 34 },
      { id: 'dent',    name: 'Dentist savings',   value: 30 }
    ]}
  ];

  function cloneSeed() {
    return SEED.map(function (c) {
      return { id: c.id, name: c.name, hue: c.hue, chroma: c.chroma, items: c.items.map(function (t) {
        return { id: t.id, name: t.name, value: t.value };
      }) };
    });
  }

  var S = { data: cloneSeed(), zoom: null, sel: null, pay: 5200 };
  var uid = 0;
  function newId() { uid++; return 'x' + uid; }

  /* ---------------- helpers ---------------- */
  var nfMoney = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
  function money(v) { return nfMoney.format(Math.round(v)); }
  function pct(part, whole) { return whole > 0 ? (part / whole * 100).toFixed(1) + '%' : '0.0%'; }
  function catTotal(c) {
    var s = 0;
    for (var i = 0; i < c.items.length; i++) { var v = c.items[i].value; if (v > 0) s += v; }
    return s;
  }
  function grandTotal() {
    var s = 0;
    for (var i = 0; i < S.data.length; i++) s += catTotal(S.data[i]);
    return s;
  }
  function itemCount() {
    var n = 0;
    for (var i = 0; i < S.data.length; i++) n += S.data[i].items.length;
    return n;
  }
  function getCat(id) {
    for (var i = 0; i < S.data.length; i++) if (S.data[i].id === id) return S.data[i];
    return null;
  }
  function isDark() {
    var a = document.documentElement.getAttribute('data-theme');
    if (a === 'dark') return true;
    if (a === 'light') return false;
    return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
  }
  // `chroma` (default 1) damps hues that read louder than their share deserves —
  // pink and magenta at equal HSL saturation shout over orange and blue.
  function shades(hue, rank, n, chroma) {
    var k = (typeof chroma === 'number' && chroma > 0) ? chroma : 1;
    var t = n > 1 ? rank / (n - 1) : 0;
    function sat(base) { return (base * k).toFixed(1) + '%'; }
    if (isDark()) {
      var L = 20 + t * 13;
      return { bg: 'hsl(' + hue + ', ' + sat(30) + ', ' + L.toFixed(1) + '%)',
               fg: 'hsl(' + hue + ', ' + sat(52) + ', 89%)',
               dim: 'hsl(' + hue + ', ' + sat(34) + ', 80%)',
               hbg: 'hsl(' + hue + ', ' + sat(34) + ', 15%)',
               solid: 'hsl(' + hue + ', ' + sat(44) + ', 52%)' };
    }
    var M = 91 - t * 11;
    return { bg: 'hsl(' + hue + ', ' + sat(64) + ', ' + M.toFixed(1) + '%)',
             fg: 'hsl(' + hue + ', ' + sat(62) + ', 20%)',
             dim: 'hsl(' + hue + ', ' + sat(38) + ', 33%)',
             hbg: 'hsl(' + hue + ', ' + sat(46) + ', 78%)',
             solid: 'hsl(' + hue + ', ' + sat(56) + ', 62%)' };
  }

  /* ---------------- squarified treemap ---------------- */
  function worstRatio(areas, side, sum) {
    var mx = 0, mn = Infinity, i, a;
    for (i = 0; i < areas.length; i++) {
      a = areas[i];
      if (a > mx) mx = a;
      if (a < mn) mn = a;
    }
    if (mn <= 0 || sum <= 0 || side <= 0) return Infinity;
    var s2 = sum * sum, w2 = side * side;
    return Math.max((w2 * mx) / s2, s2 / (w2 * mn));
  }

  // nodes: [{ref, area}] sorted by area desc, areas summing to w*h. Returns [{ref,x,y,w,h}].
  function squarify(nodes, x, y, w, h) {
    var out = [], idx = 0, n = nodes.length;
    while (idx < n) {
      if (w < 0.6 || h < 0.6) {
        for (; idx < n; idx++) out.push({ ref: nodes[idx].ref, x: x, y: y, w: 0, h: 0 });
        break;
      }
      var horiz = w >= h;                 // rows are laid along the shorter side
      var side = horiz ? h : w;
      var rowAreas = [], rowSum = 0, best = Infinity, i = idx;
      while (i < n) {
        var cand = rowAreas.concat([nodes[i].area]);
        var cs = rowSum + nodes[i].area;
        var r = worstRatio(cand, side, cs);
        if (rowAreas.length === 0 || r <= best) { rowAreas = cand; rowSum = cs; best = r; i++; }
        else break;
      }
      var thick = Math.min(rowSum / side, horiz ? w : h);
      if (!(thick > 0)) thick = horiz ? w : h;
      var off = 0;
      for (var j = idx; j < i; j++) {
        var ext = (nodes[j].area / rowSum) * side;
        if (j === i - 1) ext = Math.max(0, side - off);   // absorb float drift
        if (horiz) out.push({ ref: nodes[j].ref, x: x, y: y + off, w: thick, h: ext });
        else       out.push({ ref: nodes[j].ref, x: x + off, y: y, w: ext, h: thick });
        off += ext;
      }
      if (horiz) { x += thick; w -= thick; } else { y += thick; h -= thick; }
      idx = i;
    }
    return out;
  }

  /* ---------------- treemap rendering ---------------- */
  var treebox = $('treebox'), tip = $('tip'), emptyEl = $('empty');
  var els = {};        // key -> DOM node
  var info = {};       // item id -> {cat, item, rank, n}
  var lastMs = 0, lastRects = 0;

  function sortedItems(c) {
    return c.items.filter(function (t) { return t.value > 0; })
                  .sort(function (a, b) { return b.value - a.value; });
  }

  function layoutMap() {
    var W = treebox.clientWidth, H = treebox.clientHeight;
    if (W < 20 || H < 20) return;
    var t0 = (window.performance && performance.now) ? performance.now() : 0;
    var placed = [], seen = {};
    info = {};

    var total = grandTotal();
    if (S.zoom) {
      var c = getCat(S.zoom);
      if (c) {
        var its = sortedItems(c), ct = catTotal(c);
        if (ct > 0) {
          var nodes = its.map(function (t) { return { ref: t, area: t.value / ct * (W * H) }; });
          squarify(nodes, 0, 0, W, H).forEach(function (q, k) {
            info[q.ref.id] = { cat: c, item: q.ref, rank: k, n: its.length };
            placed.push({ key: 'T' + q.ref.id, kind: 'tile', cat: c, item: q.ref, rank: k, n: its.length,
                          x: q.x + 2, y: q.y + 2, w: q.w - 4, h: q.h - 4 });
          });
        }
      }
    } else {
      var cats = S.data.filter(function (k) { return catTotal(k) > 0; })
                       .sort(function (a, b) { return catTotal(b) - catTotal(a); });
      if (total > 0) {
        var cnodes = cats.map(function (k) { return { ref: k, area: catTotal(k) / total * (W * H) }; });
        squarify(cnodes, 0, 0, W, H).forEach(function (r) {
          var cc = r.ref;
          var ix = r.x + 2, iy = r.y + 2, iw = r.w - 4, ih = r.h - 4;
          if (iw < 2 || ih < 2) return;
          var hh = 0;
          if (ih > 54 && iw > 66) hh = 19;
          else if (ih > 30 && iw > 44) hh = 15;
          if (hh) placed.push({ key: 'H' + cc.id, kind: 'hdr', cat: cc, x: ix, y: iy, w: iw, h: hh });
          var ay = iy + (hh ? hh + 2 : 0), ah = ih - (hh ? hh + 2 : 0);
          if (ah < 2) return;
          var list = sortedItems(cc), ctot = catTotal(cc);
          if (ctot <= 0) return;
          var inodes = list.map(function (t) { return { ref: t, area: t.value / ctot * (iw * ah) }; });
          squarify(inodes, ix, ay, iw, ah).forEach(function (q, k) {
            info[q.ref.id] = { cat: cc, item: q.ref, rank: k, n: list.length };
            placed.push({ key: 'T' + q.ref.id, kind: 'tile', cat: cc, item: q.ref, rank: k, n: list.length,
                          x: q.x + 1, y: q.y + 1, w: q.w - 2, h: q.h - 2 });
          });
        });
      }
    }

    // A sub-pixel rect paints as nothing but would still be a tabbable button —
    // drop it so keyboard focus never lands on an invisible block.
    placed = placed.filter(function (q) { return q.w >= 1 && q.h >= 1; });

    for (var p = 0; p < placed.length; p++) { seen[placed[p].key] = 1; paint(placed[p], total); }
    for (var key in els) {
      if (!seen[key]) { if (els[key].parentNode) els[key].parentNode.removeChild(els[key]); delete els[key]; }
    }
    emptyEl.classList.toggle('on', placed.length === 0);

    lastRects = placed.length;
    lastMs = ((window.performance && performance.now) ? performance.now() : 0) - t0;
    $('perf').textContent = 'layout · ' + (lastMs < 0.01 ? '<0.01' : lastMs.toFixed(2)) +
                            ' ms · ' + lastRects + ' rects';
  }

  function paint(p, total) {
    var el = els[p.key], fresh = false;
    if (!el) {
      el = document.createElement('button');
      el.type = 'button';
      el.className = p.kind === 'hdr' ? 'hdr' : 'tile';
      el.innerHTML = p.kind === 'hdr'
        ? '<span class="hn"></span><span class="hv"></span>'
        : '<span class="nm"></span><span class="vl"></span>';
      el.style.left = p.x + 'px'; el.style.top = p.y + 'px';
      el.style.width = Math.max(0, p.w) + 'px'; el.style.height = Math.max(0, p.h) + 'px';
      el.style.opacity = '0';
      treebox.appendChild(el);
      els[p.key] = el;
      fresh = true;
    }
    el.style.left = p.x + 'px'; el.style.top = p.y + 'px';
    el.style.width = Math.max(0, p.w) + 'px'; el.style.height = Math.max(0, p.h) + 'px';
    if (fresh) { void el.offsetWidth; el.style.opacity = '1'; }

    if (p.kind === 'hdr') {
      var ct = catTotal(p.cat), sh = shades(p.cat.hue, 0, 1, p.cat.chroma);
      el.style.background = sh.hbg; el.style.color = sh.fg;
      el.querySelector('.hn').textContent = p.cat.name;
      el.querySelector('.hv').textContent = money(ct) + '  ' + pct(ct, total);
      el.classList.toggle('quiet', p.w < 132);
      el.dataset.cat = p.cat.id; el.dataset.kind = 'hdr';
      el.setAttribute('aria-label', p.cat.name + ', ' + money(ct) + ', ' +
        pct(ct, total) + ' of the month. Zoom in.');
      return;
    }

    var s = shades(p.cat.hue, p.rank, p.n, p.cat.chroma);
    var ct2 = catTotal(p.cat), v = p.item.value;
    el.style.background = s.bg;
    el.style.color = s.fg;
    var nm = el.querySelector('.nm'), vl = el.querySelector('.vl');
    var fs = Math.max(11, Math.min(19, Math.min(p.w / 6.2, p.h / 3.1)));
    nm.style.fontSize = fs.toFixed(1) + 'px';
    nm.textContent = p.item.name;
    // the printed share always matches the share of the frame you are looking at
    vl.textContent = money(v) + '  ·  ' + pct(v, S.zoom ? ct2 : total);
    vl.style.color = s.dim;
    el.classList.toggle('quiet', p.w < 44 || p.h < 21);
    el.classList.toggle('noval', p.w < 96 || p.h < 40);
    el.classList.toggle('sel', S.sel === p.item.id);
    el.dataset.item = p.item.id; el.dataset.cat = p.cat.id; el.dataset.kind = 'tile';
    el.setAttribute('aria-label', p.item.name + ', ' + money(v) + ', ' + pct(v, total) +
      ' of the month, ' + pct(v, ct2) + ' of ' + p.cat.name + '. ' +
      (S.zoom ? 'Select to edit.' : 'Zoom into ' + p.cat.name + '.'));
  }

  /* ---------------- readout + tooltip ---------------- */
  function baseReadout() {
    var total = grandTotal();
    if (S.zoom) {
      var c = getCat(S.zoom);
      if (c) {
        $('readout').innerHTML = '<span><span class="k">showing</span> <b>' + esc(c.name) + '</b></span>' +
          '<span><span class="k">total</span> <b>' + money(catTotal(c)) + '</b></span>' +
          '<span><span class="k">share of month</span> <b>' + pct(catTotal(c), total) + '</b></span>' +
          '<span><span class="k">lines</span> <b>' + c.items.length + '</b></span>';
        return;
      }
    }
    $('readout').innerHTML = '<span><span class="k">showing</span> <b>the whole month</b></span>' +
      '<span><span class="k">total</span> <b>' + money(total) + '</b></span>' +
      '<span><span class="k">groups</span> <b>' + S.data.length + '</b></span>' +
      '<span><span class="k">lines</span> <b>' + itemCount() + '</b></span>';
  }
  function esc(s) { return String(s).replace(/[&<>"]/g, function (m) {
    return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[m]; }); }

  // The map only holds tiles for lines above zero, so resolve the id against the data
  // itself as a fallback — zeroing a line still reports that line, not the whole month.
  function findItem(id) {
    for (var i = 0; i < S.data.length; i++) {
      var c = S.data[i];
      for (var j = 0; j < c.items.length; j++) {
        if (c.items[j].id === id) return { cat: c, item: c.items[j] };
      }
    }
    return null;
  }

  function showItemReadout(id) {
    var f = info[id] || findItem(id);
    if (!f) { baseReadout(); return; }
    var total = grandTotal(), ct = catTotal(f.cat);
    $('readout').innerHTML =
      '<span><b>' + esc(f.cat.name) + ' › ' + esc(f.item.name) + '</b></span>' +
      '<span><span class="k">amount</span> <b>' + money(f.item.value) + '</b></span>' +
      '<span><span class="k">of month</span> <b>' + pct(f.item.value, total) + '</b></span>' +
      '<span><span class="k">of group</span> <b>' + pct(f.item.value, ct) + '</b></span>';
  }
  // whatever the pointer was doing, fall back to the line the user has selected
  function restoreReadout() { if (S.sel) showItemReadout(S.sel); else baseReadout(); }

  function showCatReadout(cid) {
    var c = getCat(cid); if (!c) return;
    var total = grandTotal(), ct = catTotal(c);
    $('readout').innerHTML =
      '<span><b>' + esc(c.name) + '</b></span>' +
      '<span><span class="k">total</span> <b>' + money(ct) + '</b></span>' +
      '<span><span class="k">of month</span> <b>' + pct(ct, total) + '</b></span>' +
      '<span><span class="k">lines</span> <b>' + c.items.length + '</b></span>';
  }

  function tipFor(el) {
    var total = grandTotal();
    if (el.dataset.kind === 'hdr') {
      var c = getCat(el.dataset.cat); if (!c) return '';
      return '<b>' + esc(c.name) + '</b><span class="t2">' + money(catTotal(c)) + ' · ' +
        pct(catTotal(c), total) + ' of month · ' + c.items.length + ' lines</span>';
    }
    var f = info[el.dataset.item]; if (!f) return '';
    return '<b>' + esc(f.item.name) + ' · ' + money(f.item.value) + '</b>' +
      '<span class="t2">' + pct(f.item.value, total) + ' of month · ' +
      pct(f.item.value, catTotal(f.cat)) + ' of ' + esc(f.cat.name) + '</span>';
  }
  function moveTip(ev) {
    var r = treebox.getBoundingClientRect();
    var x = ev.clientX - r.left + 14, y = ev.clientY - r.top + 16;
    var tw = tip.offsetWidth, th = tip.offsetHeight;
    if (x + tw > r.width - 4) x = ev.clientX - r.left - tw - 12;
    if (x < 4) x = 4;
    if (y + th > r.height - 4) y = ev.clientY - r.top - th - 12;
    if (y < 4) y = 4;
    tip.style.left = x + 'px'; tip.style.top = y + 'px';
  }

  /* ---------------- breadcrumb ---------------- */
  function renderCrumbs() {
    var box = $('crumbs');
    box.innerHTML = '';
    var root = document.createElement('button');
    root.type = 'button'; root.className = 'crumb'; root.textContent = 'The whole month';
    if (!S.zoom) { root.setAttribute('aria-current', 'true'); root.disabled = true; }
    else root.addEventListener('click', function () { zoomTo(null); });
    box.appendChild(root);

    if (S.zoom) {
      var c = getCat(S.zoom);
      var sep = document.createElement('span');
      sep.className = 'sep'; sep.textContent = '›'; sep.setAttribute('aria-hidden', 'true');
      box.appendChild(sep);
      var b = document.createElement('button');
      b.type = 'button'; b.className = 'crumb'; b.setAttribute('aria-current', 'true'); b.disabled = true;
      var sw = document.createElement('span');
      sw.className = 'swatch'; sw.style.background = shades(c ? c.hue : 22, 0, 1, c ? c.chroma : 1).solid;
      b.appendChild(sw);
      b.appendChild(document.createTextNode(c ? c.name : '—'));
      box.appendChild(b);
    }
    var hint = document.createElement('span');
    hint.className = 'hint';
    hint.textContent = S.zoom ? 'esc to zoom out' : 'click a block to zoom in';
    box.appendChild(hint);
  }

  /* ---------------- list ---------------- */
  var rowRefs = {};   // item id -> {row, pctEl, valEl}
  var grpRefs = {};   // cat id -> {totEl}

  function buildList() {
    var wrap = $('listwrap');
    wrap.innerHTML = '';
    rowRefs = {}; grpRefs = {};
    var cats = S.zoom ? [getCat(S.zoom)].filter(Boolean) : S.data;
    cats.forEach(function (c) {
      var g = document.createElement('div');
      g.className = 'grp';

      var head = document.createElement('div');
      head.className = 'ghead';
      var sw = document.createElement('span');
      sw.className = 'sw'; sw.style.background = shades(c.hue, 0, 1, c.chroma).solid;
      head.appendChild(sw);

      var gn = document.createElement('input');
      gn.className = 'gname'; gn.value = c.name; gn.setAttribute('aria-label', 'Group name');
      gn.addEventListener('input', function () {
        c.name = gn.value || 'Untitled';
        relabelGroup();
        layoutMap(); renderCrumbs(); restoreReadout(); renderStats();
      });
      head.appendChild(gn);

      var gt = document.createElement('span');
      gt.className = 'gtot';
      head.appendChild(gt);
      grpRefs[c.id] = { totEl: gt };

      if (!S.zoom) {
        var zb = document.createElement('button');
        zb.type = 'button'; zb.className = 'mini';
        zb.innerHTML = '&rsaquo;';
        zb.addEventListener('click', function () { zoomTo(c.id); });
        head.appendChild(zb);
      }

      var ab = document.createElement('button');
      ab.type = 'button'; ab.className = 'mini';
      ab.textContent = '+';
      ab.addEventListener('click', function () { addItem(c); });
      head.appendChild(ab);
      g.appendChild(head);

      function relabelGroup() {
        var nm = c.name || 'this group';
        if (zb) zb.setAttribute('aria-label', 'Zoom into ' + nm);
        ab.setAttribute('aria-label', 'Add a line to ' + nm);
      }
      relabelGroup();

      c.items.forEach(function (t) {
        g.appendChild(buildRow(c, t));
      });
      wrap.appendChild(g);
    });
    syncList();
  }

  function buildRow(c, t) {
    var row = document.createElement('div');
    row.className = 'row';
    row.dataset.item = t.id;

    var rn = document.createElement('input');
    rn.className = 'rn'; rn.type = 'text'; rn.value = t.name;
    rn.setAttribute('aria-label', 'Name of line in ' + c.name);
    rn.addEventListener('input', function () {
      t.name = rn.value;
      relabel();
      layoutMap();
      if (S.sel === t.id) showItemReadout(t.id);
    });
    row.appendChild(rn);

    var rv = document.createElement('input');
    rv.className = 'rv'; rv.type = 'number'; rv.min = '0'; rv.step = '5';
    rv.value = String(t.value); rv.inputMode = 'decimal';
    rv.addEventListener('input', function () {
      var v = parseFloat(rv.value);
      t.value = (isFinite(v) && v > 0) ? v : 0;
      S.sel = t.id;
      markSelected();
      layoutMap(); syncList(); renderStats(); showItemReadout(t.id);
    });
    rv.addEventListener('change', function () { rv.value = String(t.value); });
    rv.addEventListener('focus', function () { S.sel = t.id; markSelected(); showItemReadout(t.id); });
    row.appendChild(rv);

    var rp = document.createElement('span');
    rp.className = 'rp';
    row.appendChild(rp);

    var del = document.createElement('button');
    del.type = 'button'; del.className = 'del';
    del.innerHTML = '&times;';
    del.addEventListener('click', function () { removeItem(c, t); });
    row.appendChild(del);

    // keep the spoken labels honest while the line is being renamed
    function relabel() {
      var nm = t.name || 'this line';
      rv.setAttribute('aria-label', nm + ' amount in dollars per month');
      del.setAttribute('aria-label', 'Remove ' + nm);
    }
    relabel();

    rowRefs[t.id] = { row: row, pctEl: rp, valEl: rv };
    return row;
  }

  function syncList() {
    var total = grandTotal();
    var cats = S.zoom ? [getCat(S.zoom)].filter(Boolean) : S.data;
    cats.forEach(function (c) {
      var ct = catTotal(c), g = grpRefs[c.id];
      if (g) g.totEl.textContent = money(ct) + ' · ' + pct(ct, total);
      c.items.forEach(function (t) {
        var r = rowRefs[t.id];
        if (r) r.pctEl.textContent = pct(t.value, ct);
      });
    });
    $('listTitle').textContent = S.zoom ? 'lines in this group' : 'every line';
    var shown = 0;
    cats.forEach(function (c) { shown += c.items.length; });
    $('listMeta').textContent = shown + ' editable';
    $('mapMeta').textContent = itemCount() + ' lines · ' + money(total);
    $('btnUp').disabled = !S.zoom;
  }

  function markSelected() {
    for (var id in rowRefs) rowRefs[id].row.classList.toggle('on', id === S.sel);
    for (var k in els) {
      var e = els[k];
      if (e.dataset.kind === 'tile') e.classList.toggle('sel', e.dataset.item === S.sel);
    }
  }

  /* ---------------- mutations ---------------- */
  function addItem(c) {
    var t = { id: newId(), name: 'New line', value: 50 };
    c.items.push(t);
    S.sel = t.id;
    buildList(); layoutMap(); renderStats(); markSelected(); showItemReadout(t.id);
    say('Added a line to ' + c.name + '.');
    var r = rowRefs[t.id];
    if (r) { var input = r.row.querySelector('.rn'); input.focus(); input.select(); }
  }

  function removeItem(c, t) {
    var i = c.items.indexOf(t);
    if (i < 0) return;
    c.items.splice(i, 1);
    if (S.sel === t.id) S.sel = null;
    buildList(); layoutMap(); renderStats(); markSelected(); restoreReadout();
    say('Removed ' + t.name + '.');
    var g = grpRefs[c.id];
    var head = g && g.totEl.parentNode ? g.totEl.parentNode : $('listwrap').querySelector('.ghead');
    if (head) {
      var minis = head.querySelectorAll('.mini');
      if (minis.length) minis[minis.length - 1].focus();
    }
  }

  function zoomTo(catId) {
    if (catId && !getCat(catId)) catId = null;
    S.zoom = catId;
    S.sel = null;
    hideTip();
    renderCrumbs(); buildList(); layoutMap(); baseReadout(); renderStats();
    var c = catId ? getCat(catId) : null;
    say(c ? ('Zoomed into ' + c.name + ', ' + c.items.length + ' lines, ' + money(catTotal(c)) + '.')
          : ('Showing the whole month, ' + itemCount() + ' lines, ' + money(grandTotal()) + '.'));
  }

  function say(msg) { $('live').textContent = msg; }

  /* ---------------- budget card ---------------- */
  function renderStats() {
    var total = grandTotal(), pay = S.pay;
    var left = pay - total;
    $('sSpent').textContent = money(total);
    $('sItems').textContent = itemCount() + ' lines · ' + S.data.length + ' groups';

    $('leftK').textContent = left >= 0 ? 'left over' : 'over by';
    var lv = $('sLeft');
    lv.textContent = money(Math.abs(left));
    lv.className = 'v ' + (left >= 0 ? 'ok' : 'bad');
    $('sLeftPct').textContent = pay > 0 ? (Math.abs(left) / pay * 100).toFixed(1) + '% of pay' : 'set your pay';

    var pill = $('pill');
    if (pay <= 0) { pill.className = 'pill'; pill.textContent = 'no pay set'; }
    else if (left >= 0) { pill.className = 'pill ok'; pill.textContent = 'it balances · ' + money(left) + ' spare'; }
    else { pill.className = 'pill bad'; pill.textContent = 'over budget · ' + money(-left); }

    var big = null;
    S.data.forEach(function (c) {
      c.items.forEach(function (t) {
        if (t.value > 0 && (!big || t.value > big.value)) big = t;
      });
    });
    $('sBig').textContent = big ? big.name : '—';
    $('sBigSub').textContent = big ? (money(big.value) + ' · ' + pct(big.value, total) + ' of month') : '—';

    // stacked bar: every group scaled against max(total, pay)
    var scale = Math.max(total, pay);
    var bar = $('bar');
    bar.innerHTML = '';
    if (scale > 0) {
      S.data.forEach(function (c) {
        var ct = catTotal(c);
        if (ct <= 0) return;
        var d = document.createElement('div');
        d.className = 'seg';
        d.style.width = (ct / scale * 100).toFixed(3) + '%';
        d.style.background = shades(c.hue, 0, 1, c.chroma).solid;
        d.title = c.name + ' — ' + money(ct);
        bar.appendChild(d);
      });
      if (left > 0) {
        var l = document.createElement('div');
        l.className = 'left';
        l.title = 'left over — ' + money(left);
        bar.appendChild(l);
      } else if (left < 0 && pay > 0) {
        var m = document.createElement('div');
        m.className = 'mark';
        m.style.left = (pay / scale * 100).toFixed(3) + '%';
        m.title = 'take-home pay — ' + money(pay);
        bar.appendChild(m);
      }
    }
    $('barL').textContent = 'spent ' + money(total) + (pay > 0 ? ' · ' + pct(total, pay) + ' of pay' : '');
    $('barR').textContent = left >= 0 ? ('left over ' + money(left)) : ('short ' + money(-left));
  }

  /* ---------------- treemap interaction ---------------- */
  function hideTip() { tip.classList.remove('on'); }

  treebox.addEventListener('pointerover', function (e) {
    var el = e.target.closest ? e.target.closest('.tile, .hdr') : null;
    if (!el) return;
    if (el.dataset.kind === 'hdr') showCatReadout(el.dataset.cat);
    else showItemReadout(el.dataset.item);
    if (e.pointerType === 'touch') return;
    var html = tipFor(el);
    if (!html) return;
    tip.innerHTML = html;
    tip.classList.add('on');
    moveTip(e);
  });
  treebox.addEventListener('pointermove', function (e) {
    if (tip.classList.contains('on')) moveTip(e);
  });
  treebox.addEventListener('pointerout', function (e) {
    var el = e.target.closest ? e.target.closest('.tile, .hdr') : null;
    if (!el) return;
    if (e.relatedTarget && treebox.contains(e.relatedTarget) &&
        e.relatedTarget.closest && e.relatedTarget.closest('.tile, .hdr')) return;
    hideTip();
    restoreReadout();
  });
  treebox.addEventListener('pointerleave', function () {
    hideTip();
    restoreReadout();
  });

  treebox.addEventListener('click', function (e) {
    var el = e.target.closest ? e.target.closest('.tile, .hdr') : null;
    if (!el) return;
    if (el.dataset.kind === 'hdr') { zoomTo(el.dataset.cat); return; }
    if (!S.zoom) { zoomTo(el.dataset.cat); return; }
    var id = el.dataset.item;
    S.sel = id;
    markSelected();
    showItemReadout(id);
    var r = rowRefs[id];
    if (r) {
      r.valEl.focus();
      r.valEl.select();
      if (r.row.scrollIntoView) r.row.scrollIntoView({ block: 'nearest' });
    }
  });

  treebox.addEventListener('focusin', function (e) {
    var el = e.target.closest ? e.target.closest('.tile, .hdr') : null;
    if (!el) return;
    if (el.dataset.kind === 'hdr') showCatReadout(el.dataset.cat);
    else showItemReadout(el.dataset.item);
  });

  document.addEventListener('keydown', function (e) {
    if (e.key !== 'Escape' || (!S.zoom && !S.sel)) return;
    var t = e.target;
    // clicking a block parks the cursor in that line's amount box, so Escape has to
    // work from inside an input too — otherwise the "esc to zoom out" hint lies.
    if (t && t.blur && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) t.blur();
    if (S.zoom) { zoomTo(null); return; }
    S.sel = null; markSelected(); baseReadout();   // at the top level, Escape drops the pick
  });

  /* ---------------- controls ---------------- */
  $('pay').addEventListener('input', function (e) {
    var v = parseFloat(e.target.value);
    S.pay = (isFinite(v) && v > 0) ? v : 0;
    renderStats();
  });
  $('btnUp').addEventListener('click', function () { if (S.zoom) zoomTo(null); });
  $('btnReset').addEventListener('click', function () {
    S.data = cloneSeed(); S.zoom = null; S.sel = null; S.pay = 5200;
    $('pay').value = '5200';
    renderCrumbs(); buildList(); layoutMap(); renderStats(); baseReadout();
    say('Budget reset to the starting month.');
  });

  /* ---------------- theme + resize ---------------- */
  function repaintTheme() { layoutMap(); renderCrumbs(); renderStats(); buildList(); markSelected(); }
  window.addEventListener('message', function (e) {
    var d = e.data;
    if (d && d.type === 'sloprun:theme' && (d.theme === 'light' || d.theme === 'dark')) {
      document.documentElement.setAttribute('data-theme', d.theme);
      repaintTheme();
    }
  });
  if (window.matchMedia) {
    var mq = window.matchMedia('(prefers-color-scheme: dark)');
    if (mq.addEventListener) mq.addEventListener('change', repaintTheme);
    else if (mq.addListener) mq.addListener(repaintTheme);
  }
  if (window.ResizeObserver) {
    var ro = new ResizeObserver(function () { layoutMap(); });
    ro.observe(treebox);
  } else {
    window.addEventListener('resize', function () { layoutMap(); });
  }

  /* ---------------- boot ---------------- */
  renderCrumbs();
  buildList();
  renderStats();
  baseReadout();
  layoutMap();
})();
</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