$sloprun.dev

Compression Tree

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

Huffman coding is why a text file shrinks: count the characters first, then hand the common ones short codes and the rare ones long ones. This builds the tree in front of you — the eleven distinct symbols in the opening sentence get glued together two at a time, always the two rarest, until every character falls out with a code attached and the sentence replays underneath as 114 bits instead of 296. Hover any run of bits to light up that character at once in the tree, in the code table and everywhere else it appears in the encoded string. Then tick also count the code table — the decoder needs that table shipped alongside the data, and once you pay for it a short string like hello comes out bigger than it started.

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/huffman-tree" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Compression Tree — 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/huffman-tree

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1204 lines 52.3 KB index.html
demos/huffman-tree/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Compression Tree</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); }

/* --- demo-local supplements (derived; same three theme states) --- */
:root { --danger-soft: #FCEBEA; --canvas: #FBFBF8; --shadow: 0 1px 2px rgba(20,22,26,.05); --tint: rgba(26,29,33,.085); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --danger-soft: #2B1A18; --canvas: #171A20; --shadow: 0 1px 2px rgba(0,0,0,.3); --tint: rgba(255,255,255,.11); }
}
:root[data-theme="dark"] { --danger-soft: #2B1A18; --canvas: #171A20; --shadow: 0 1px 2px rgba(0,0,0,.3); --tint: rgba(255,255,255,.11); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { -webkit-text-size-adjust: 100%; line-height: 1.45; overflow-x: hidden; }

.wrap { max-width: 1040px; margin: 0 auto; padding: 22px 16px 30px; }

header h1 { font-size: 21px; margin: 0 0 4px; letter-spacing: -.01em; }
header p.lede { margin: 0; color: var(--muted); font-size: 14.5px; max-width: 66ch; }
.chip {
  display: inline-block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .06em;
  text-transform: uppercase; color: var(--accent); background: var(--accent-soft);
  border: 1px solid var(--line); border-radius: 999px; padding: 3px 9px; margin-bottom: 10px;
}

.panel {
  margin-top: 14px; background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;
}
.phead {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--bg);
}
.phead h2 {
  margin: 0; font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted); font-weight: 500;
}
.phead .sub { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }

/* ---- input ---- */
.inbox { padding: 12px; }
textarea {
  display: block; width: 100%; resize: vertical; min-height: 62px;
  font-family: var(--font-sans); font-size: 15px; line-height: 1.5; color: var(--ink);
  background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px;
}
.inmeta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
.inmeta .lab { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.count { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; margin-left: auto; }
.count.warn { color: var(--danger); }

button {
  font-family: var(--font-sans); font-size: 13.5px; color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px; padding: 6px 11px; min-height: 34px;
  cursor: pointer; white-space: nowrap;
}
button:hover:not(:disabled) { border-color: var(--muted); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); font-weight: 600; }
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button.tiny { font-family: var(--font-mono); font-size: 11.5px; padding: 4px 9px; min-height: 30px; }
button.tiny[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---- stats ---- */
.stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 1px; background: var(--line); }
.stat { background: var(--surface); padding: 9px 11px; }
.stat .lab { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
.stat .val { font-family: var(--font-mono); font-size: 17px; font-variant-numeric: tabular-nums; line-height: 1.3; }
.stat .val small { font-size: 10.5px; color: var(--muted); font-weight: 400; }

/* ---- verdict + meters ---- */
.verdict { border-top: 1px solid var(--line); padding: 11px 12px; }
.vline {
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  font-family: var(--font-mono); font-size: 13.5px;
}
.vbadge {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
  border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px; color: var(--muted); background: var(--bg);
}
.verdict.good .vbadge { color: var(--run); background: var(--run-soft); border-color: var(--run); }
.verdict.bad .vbadge { color: var(--danger); background: var(--danger-soft); border-color: var(--danger); }
.verdict .vnum { font-variant-numeric: tabular-nums; }
.verdict.good .vnum.hero { color: var(--run); font-weight: 600; }
.verdict.bad .vnum.hero { color: var(--danger); font-weight: 600; }
.meters { margin-top: 9px; display: grid; gap: 6px; }
.meter { display: grid; grid-template-columns: 74px 1fr auto; align-items: center; gap: 9px; }
.meter .mlab { font-family: var(--font-mono); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.meter .track { height: 12px; border-radius: 4px; background: var(--bg); border: 1px solid var(--line); overflow: hidden; }
.meter .fill { height: 100%; width: 0; background: var(--muted); opacity: .45; transition: width .35s ease; }
.meter .mval { font-family: var(--font-mono); font-size: 12px; font-variant-numeric: tabular-nums; color: var(--muted); min-width: 8.5ch; text-align: right; }
.meter.enc .fill { opacity: 1; background: var(--muted); }
.verdict.good .meter.enc .fill { background: var(--run); }
.verdict.bad .meter.enc .fill { background: var(--danger); }
.verdict.good .meter.enc .mval { color: var(--run); }
.verdict.bad .meter.enc .mval { color: var(--danger); }
.tblrow { display: flex; align-items: center; gap: 7px; margin-top: 10px; font-size: 12.5px; color: var(--muted); flex-wrap: wrap; }
.tblrow label { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; }
.tblrow input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
.tblrow code { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); }

/* ---- tree ---- */
.controls { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; padding: 9px 12px; border-bottom: 1px solid var(--line); }
.controls .spacer { flex: 1 1 auto; }
select {
  font-family: var(--font-mono); font-size: 12.5px; color: var(--ink); background: var(--bg);
  border: 1px solid var(--line); border-radius: 8px; padding: 6px 7px; min-height: 34px;
}
.field { display: flex; align-items: center; gap: 6px; }
.field label { font-family: var(--font-mono); font-size: 10px; color: var(--muted); letter-spacing: .05em; text-transform: uppercase; }

.note {
  display: flex; align-items: flex-start; gap: 9px; padding: 9px 12px;
  font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5;
  border-bottom: 1px solid var(--line); border-left: 3px solid var(--line);
  background: var(--bg); min-height: 40px; word-break: break-word;
}
.note .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: 0 0 auto; margin-top: 6px; }
.note.info { border-left-color: var(--accent); background: var(--accent-soft); }
.note.info .dot { background: var(--accent); }
.note.ok { border-left-color: var(--run); background: var(--run-soft); }
.note.ok .dot { background: var(--run); }

.canvas-shell { position: relative; background: var(--canvas); }
.canvas-wrap {
  overflow-x: auto; overflow-y: hidden; padding: 4px 0 2px;
  display: flex; align-items: flex-start; justify-content: center; justify-content: safe center;
}
svg.tree { display: block; flex: 0 0 auto; }
.fade { position: absolute; top: 0; bottom: 0; width: 26px; pointer-events: none; opacity: 0; transition: opacity .18s linear; }
.fade-l { left: 0; background: linear-gradient(to right, var(--canvas), transparent); }
.fade-r { right: 0; background: linear-gradient(to left, var(--canvas), transparent); }
.canvas-shell.can-l .fade-l, .canvas-shell.can-r .fade-r { opacity: 1; }

.nd .box { fill: var(--surface); stroke: var(--line); stroke-width: 1.25; }
.nd .dotn { fill: var(--bg); stroke: var(--line); stroke-width: 1.25; }
.nd text.g { fill: var(--ink); font-family: var(--font-mono); font-size: 13px; text-anchor: middle; }
.nd text.w { fill: var(--muted); font-family: var(--font-mono); font-size: 9.5px; text-anchor: middle; font-variant-numeric: tabular-nums; }
.nd text.iw { fill: var(--muted); font-family: var(--font-mono); font-size: 10px; text-anchor: middle; font-variant-numeric: tabular-nums; }
.nd .hit { fill: transparent; cursor: pointer; }
.nd.pick .box { stroke: var(--accent); stroke-width: 2.2; fill: var(--accent-soft); }
.nd.pick .dotn { stroke: var(--accent); stroke-width: 2.2; fill: var(--accent-soft); }
.nd.pick text.w, .nd.pick text.iw { fill: var(--accent); }
.nd.hot .box { stroke: var(--accent); stroke-width: 2.2; fill: var(--accent-soft); }
.nd.hot text.g { fill: var(--accent); font-weight: 600; }
.nd.born .dotn { stroke: var(--run); stroke-width: 2.2; fill: var(--run-soft); }
.nd.born text.iw { fill: var(--run); }
.edge { stroke: var(--line); stroke-width: 1.5; fill: none; }
.edge.hot { stroke: var(--accent); stroke-width: 2.4; }
.edge.fresh { stroke: var(--run); stroke-width: 2.2; }
text.bit { font-family: var(--font-mono); font-size: 9px; fill: var(--muted); text-anchor: middle; }
text.bit.hot { fill: var(--accent); font-weight: 700; }
.rootcap { font-family: var(--font-mono); font-size: 13px; fill: var(--muted); text-anchor: middle; letter-spacing: .08em; }

/* queue strip */
.queue { display: flex; gap: 6px; align-items: center; padding: 8px 12px; border-top: 1px solid var(--line); overflow-x: auto; }
.queue:empty { display: none; }
.queue .qlab { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); flex: 0 0 auto; margin-right: 2px; }
.qchip {
  flex: 0 0 auto; font-family: var(--font-mono); font-size: 11.5px; border: 1px solid var(--line);
  border-radius: 6px; padding: 3px 7px; background: var(--surface); color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.qchip b { color: var(--ink); font-weight: 500; }
.qchip.next { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.qchip.next b { color: var(--accent); }
.qchip.done { opacity: .55; }

/* ---- lower grid ---- */
.grid2 { display: grid; grid-template-columns: minmax(0,340px) minmax(0,1fr); gap: 14px; align-items: start; }
@media (max-width: 760px) { .grid2 { grid-template-columns: minmax(0,1fr); } }

.tablewrap { max-height: 340px; overflow-y: auto; }
table.ct { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 12px; }
table.ct thead th {
  position: sticky; top: 0; background: var(--surface); z-index: 1;
  font-size: 9.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted);
  font-weight: 500; text-align: right; padding: 6px 8px; border-bottom: 1px solid var(--line);
}
table.ct thead th:first-child, table.ct thead th.l { text-align: left; }
table.ct td { padding: 3px 8px; border-bottom: 1px solid var(--line); text-align: right; font-variant-numeric: tabular-nums; vertical-align: middle; }
table.ct td.l { text-align: left; }
table.ct tr:last-child td { border-bottom: 0; }
table.ct tbody tr { cursor: pointer; }
table.ct tbody tr:hover, table.ct tbody tr.hot { background: var(--accent-soft); }
table.ct tbody tr.hot td { color: var(--accent); }
.glyphbtn {
  font-family: var(--font-mono); font-size: 13px; min-height: 24px; padding: 1px 7px;
  border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); min-width: 30px;
}
tr.hot .glyphbtn { border-color: var(--accent); color: var(--accent); background: var(--surface); }
.wslab { font-size: 9.5px; color: var(--muted); letter-spacing: .04em; margin-left: 5px; }
.bar { display: block; height: 8px; border-radius: 3px; background: var(--muted); opacity: .35; min-width: 2px; }
tr.hot .bar { background: var(--accent); opacity: 1; }
.code { color: var(--ink); letter-spacing: .06em; }
tr.hot .code { color: var(--accent); font-weight: 600; }

/* ---- bitstring ---- */
.bits {
  font-family: var(--font-mono); font-size: 12.5px; line-height: 2.05; letter-spacing: .04em;
  padding: 10px 12px; max-height: 240px; overflow-y: auto; word-break: break-all;
}
.bits:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
.sym { border-radius: 4px; padding: 2px 0; }
.sym.alt { background: var(--tint); }
.sym.hot { background: var(--accent-soft); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.sym.cur { box-shadow: 0 0 0 2px var(--accent); background: var(--accent-soft); color: var(--accent); }
.readout {
  border-top: 1px solid var(--line); padding: 8px 12px; background: var(--bg);
  font-family: var(--font-mono); font-size: 12px; color: var(--muted); min-height: 36px;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.readout b { color: var(--ink); font-weight: 600; }
.readout .k { color: var(--accent); }
.trunc { padding: 0 12px 10px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); }

.empty { padding: 26px 14px; text-align: center; color: var(--muted); font-family: var(--font-mono); font-size: 12.5px; }
.hint { margin: 12px 2px 0; font-size: 12.5px; color: var(--muted); }
.hint b { font-family: var(--font-mono); font-weight: 600; color: var(--ink); }
.hint kbd { font-family: var(--font-mono); font-size: 11px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 1px 5px; background: var(--surface); color: var(--ink); }
footer { margin-top: 18px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .04em; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

@media (max-width: 620px) {
  .wrap { padding: 16px 10px 24px; }
  header h1 { font-size: 19px; }
  .stats { grid-template-columns: repeat(3, 1fr); }
  .stat .val { font-size: 15px; }
  .meter { grid-template-columns: 60px 1fr auto; gap: 7px; }
  .bits { font-size: 12px; line-height: 1.95; }
  .tablewrap { max-height: 300px; }
}
@media (max-width: 380px) {
  .stats { grid-template-columns: repeat(2, 1fr); }
  button { font-size: 13px; padding: 6px 9px; }
}
@media (prefers-reduced-motion: reduce) {
  .fade, .meter .fill { transition: none; }
  * { scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <div class="chip">Huffman coding · built live in your browser</div>
    <h1>Compression Tree</h1>
    <p class="lede">Normal text spends 8 bits on every character, even the ones you use constantly. Huffman coding builds a tree that gives common characters short codes and rare ones long codes — then the same sentence fits in far fewer bits. Type anything and watch the tree get built, merge by merge.</p>
  </header>

  <!-- INPUT -->
  <section class="panel">
    <div class="phead">
      <h2>Text</h2>
      <span class="sub" id="presetLab">try</span>
      <button type="button" class="tiny" data-preset="0">Tongue twister</button>
      <button type="button" class="tiny" data-preset="1">Mississippi</button>
      <button type="button" class="tiny" data-preset="2">All different</button>
      <button type="button" class="tiny" data-preset="3">One letter</button>
    </div>
    <div class="inbox">
      <label class="sr" for="txt">Text to compress</label>
      <textarea id="txt" rows="2" maxlength="400" spellcheck="false" autocomplete="off"></textarea>
      <div class="inmeta">
        <span class="lab">edit the text — everything below updates</span>
        <span class="count" id="count">0 / 400</span>
      </div>
    </div>
  </section>

  <!-- SCORE -->
  <section class="panel" aria-label="Compression result">
    <div class="stats">
      <div class="stat"><div class="lab">characters</div><div class="val" id="sChars">0</div></div>
      <div class="stat"><div class="lab">distinct</div><div class="val" id="sSyms">0</div></div>
      <div class="stat"><div class="lab">original</div><div class="val" id="sOrig">0 <small>bits</small></div></div>
      <div class="stat"><div class="lab">encoded</div><div class="val" id="sEnc">0 <small>bits</small></div></div>
      <div class="stat"><div class="lab">avg / char</div><div class="val" id="sAvg">0 <small>bits</small></div></div>
      <div class="stat"><div class="lab">longest code</div><div class="val" id="sDepth">0 <small>bits</small></div></div>
    </div>
    <div class="verdict" id="verdict">
      <div class="vline">
        <span class="vbadge" id="vbadge">idle</span>
        <span class="vnum hero" id="vhero">—</span>
        <span class="vnum" id="vdetail" style="color:var(--muted)"></span>
      </div>
      <div class="meters">
        <div class="meter"><span class="mlab">original</span><span class="track"><span class="fill" id="mOrig"></span></span><span class="mval" id="mOrigV">0</span></div>
        <div class="meter enc"><span class="mlab">huffman</span><span class="track"><span class="fill" id="mEnc"></span></span><span class="mval" id="mEncV">0</span></div>
      </div>
      <div class="tblrow">
        <label><input type="checkbox" id="cbTable"> also count the code table you must ship with the data</label>
        <code id="tblCost">+0 bits</code>
      </div>
    </div>
  </section>

  <!-- TREE -->
  <section class="panel" aria-label="Tree construction">
    <div class="controls">
      <button type="button" class="primary" id="btnPlay">▶ Build</button>
      <button type="button" id="btnStep">Step ▸</button>
      <button type="button" id="btnEnd">Skip to end</button>
      <button type="button" id="btnReset">↺ Restart</button>
      <span class="spacer"></span>
      <div class="field">
        <label for="speed">Speed</label>
        <select id="speed">
          <option value="1100">Slow</option>
          <option value="700" selected>Normal</option>
          <option value="380">Fast</option>
        </select>
      </div>
    </div>

    <p class="note" id="note"><span class="dot"></span><span id="noteText">ready</span></p>

    <div class="canvas-shell" id="shell">
      <div class="canvas-wrap" id="cwrap">
        <svg class="tree" id="svg" width="400" height="220" role="img" aria-labelledby="svgT" aria-describedby="svgD">
          <title id="svgT">Huffman tree</title>
          <desc id="svgD">The tree being built.</desc>
          <g id="edgeL"></g>
          <g id="labelL"></g>
          <g id="nodeL"></g>
        </svg>
      </div>
      <div class="fade fade-l" aria-hidden="true"></div>
      <div class="fade fade-r" aria-hidden="true"></div>
    </div>

    <div class="queue" id="queue" aria-hidden="true"></div>
  </section>

  <div class="grid2">
    <!-- CODE TABLE -->
    <section class="panel" style="margin-top:14px" aria-label="Frequency and code table">
      <div class="phead">
        <h2>Frequency &amp; codes</h2>
        <span class="spacer" style="flex:1"></span>
        <button type="button" class="tiny" id="sortBtn" title="Change sort order">sort: frequency</button>
      </div>
      <div class="tablewrap" id="tablewrap">
        <table class="ct" id="ct">
          <thead><tr>
            <th class="l">char</th><th>freq</th><th class="l">share</th><th class="l">code</th><th>bits</th>
          </tr></thead>
          <tbody id="ctb"></tbody>
        </table>
      </div>
    </section>

    <!-- BITSTRING -->
    <section class="panel" style="margin-top:14px" aria-label="Encoded bitstring">
      <div class="phead">
        <h2>Encoded bits</h2>
        <span class="sub" id="bitsSub">—</span>
      </div>
      <div class="bits" id="bits" tabindex="0" role="group"
           aria-label="Encoded bitstring. Use left and right arrow keys to step through characters."></div>
      <p class="trunc" id="trunc" hidden></p>
      <div class="readout" id="readout"><span>hover any run of bits — or any row in the table — to see which character it is</span></div>
    </section>
  </div>

  <p class="hint">The tree is read <b>0 = left</b>, <b>1 = right</b>; a character's code is the path from the root down to its box. Most-used characters end up nearest the root, so they get the shortest codes. Try <b>One letter</b> for the extreme case, or <b>All different</b> to see what happens when nothing repeats.</p>
  <p class="sr" id="sr" aria-live="polite" role="status"></p>
  <footer>demo · sloprun.dev</footer>
</div>

<script>
(function () {
  "use strict";

  /* ---------- theme bridge (platform postMessage + standalone media query) ---------- */
  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 SVG_NS = "http://www.w3.org/2000/svg";
  var RM = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : { matches: false };

  var PRESETS = [
    "she sells sea shells by the sea shore",
    "mississippi riverbank",
    "abcdefghijklmnop",
    "aaaaaaaaaaaaaaaaaaaa"
  ];

  /* header cost model: canonical Huffman ships one byte per symbol plus a 5-bit
     code length per symbol, and a byte saying how many symbols there are. */
  function headerBits(n) { return n ? 8 + n * 13 : 0; }

  var MAX_RENDER_CHARS = 200;

  var el = {
    txt: $("txt"), count: $("count"), note: $("note"), noteText: $("noteText"),
    svg: $("svg"), edgeL: $("edgeL"), labelL: $("labelL"), nodeL: $("nodeL"),
    svgD: $("svgD"), shell: $("shell"), cwrap: $("cwrap"), queue: $("queue"),
    ctb: $("ctb"), bits: $("bits"), readout: $("readout"), trunc: $("trunc"),
    bitsSub: $("bitsSub"), sortBtn: $("sortBtn"), sr: $("sr"),
    btnPlay: $("btnPlay"), btnStep: $("btnStep"), btnEnd: $("btnEnd"), btnReset: $("btnReset"),
    speed: $("speed"), cbTable: $("cbTable"), verdict: $("verdict"),
    vbadge: $("vbadge"), vhero: $("vhero"), vdetail: $("vdetail"),
    mOrig: $("mOrig"), mEnc: $("mEnc"), mOrigV: $("mOrigV"), mEncV: $("mEncV"), tblCost: $("tblCost")
  };

  var SORTS = [
    { key: "freq", lab: "frequency" },
    { key: "len", lab: "code length" },
    { key: "char", lab: "character" }
  ];

  var M = null;                 /* current model */
  var step = 0;                 /* merges applied */
  var playing = false;
  var timer = null;
  var hotChar = null;           /* highlighted character */
  var cursor = -1;              /* bitstring roving cursor (char index) */
  var rowIdx = 0;               /* table roving tabindex */
  var sortIdx = 0;

  /* =======================================================================
     glyphs
     ======================================================================= */
  function glyph(ch) {
    if (ch === " ") return "␣";
    if (ch === "\n") return "↵";
    if (ch === "\t") return "⇥";
    var c = ch.charCodeAt(0);
    if (c < 32 || c === 127) return "·";
    return ch;
  }
  function wsLabel(ch) {
    if (ch === " ") return "space";
    if (ch === "\n") return "newline";
    if (ch === "\t") return "tab";
    var c = ch.charCodeAt(0);
    if (c < 32 || c === 127) return "ctrl";
    return "";
  }
  function nameOf(ch) {
    var w = wsLabel(ch);
    return w ? w : "“" + ch + "”";
  }
  function fmt(n) { return n.toLocaleString("en-US"); }

  /* =======================================================================
     model
     ======================================================================= */
  function analyze(text) {
    var chars = Array.from(text);
    var counts = new Map();
    for (var i = 0; i < chars.length; i++) {
      counts.set(chars[i], (counts.get(chars[i]) || 0) + 1);
    }
    if (counts.size === 0) return null;

    var seq = 0, id = 0, nodes = [], live = [];
    counts.forEach(function (w, ch) {
      var n = { id: id++, ch: ch, w: w, leaf: true, left: null, right: null, parent: null,
                bit: "", seq: seq++, createdAt: -1, absorbedAt: Infinity, slot: 0, minLeaf: 0 };
      nodes.push(n); live.push(n);
    });
    /* stable, reproducible starting order: rarest first, ties by code point */
    live.sort(function (a, b) { return a.w - b.w || (a.ch < b.ch ? -1 : a.ch > b.ch ? 1 : 0); });
    live.forEach(function (n, i) { n.seq = i; });
    seq = live.length;

    var steps = [];
    var k = 0;
    while (live.length > 1) {
      live.sort(function (a, b) { return a.w - b.w || a.seq - b.seq; });
      var a = live.shift(), b = live.shift();
      var p = { id: id++, ch: null, w: a.w + b.w, leaf: false, left: a, right: b, parent: null,
                bit: "", seq: seq++, createdAt: k, absorbedAt: Infinity, slot: 0, minLeaf: 0 };
      a.parent = p; b.parent = p; a.bit = "0"; b.bit = "1";
      a.absorbedAt = k; b.absorbedAt = k;
      live.push(p); nodes.push(p);
      steps.push({ a: a, b: b, p: p });
      k++;
    }
    var root = live[0];

    /* codes + in-order leaf slots */
    var leaves = [];
    var maxDepth = 0;
    (function walk(n, code, d) {
      if (n.leaf) {
        n.code = code === "" ? "0" : code;   /* lone symbol still needs one bit */
        n.depth = n.code.length;
        if (n.depth > maxDepth) maxDepth = n.depth;
        n.slot = leaves.length; n.minLeaf = n.slot;
        leaves.push(n);
        return;
      }
      walk(n.left, code + "0", d + 1);
      walk(n.right, code + "1", d + 1);
      n.minLeaf = Math.min(n.left.minLeaf, n.right.minLeaf);
    })(root, "", 0);

    var codeOf = new Map();
    leaves.forEach(function (n) { codeOf.set(n.ch, n); });

    var payload = 0;
    leaves.forEach(function (n) { payload += n.w * n.depth; });

    return {
      text: text, chars: chars, len: chars.length,
      nodes: nodes, root: root, leaves: leaves, steps: steps,
      codeOf: codeOf, maxDepth: maxDepth,
      origBits: chars.length * 8, payload: payload,
      header: headerBits(leaves.length)
    };
  }

  /* =======================================================================
     geometry
     ======================================================================= */
  var GEO = { slot: 46, level: 54, top: 26, left: 30, leafW: 30, leafH: 26, r: 13 };

  function fitGeometry() {
    var n = M.leaves.length, d = M.maxDepth;
    GEO.slot = n <= 14 ? 48 : n <= 24 ? 40 : n <= 40 ? 34 : 30;
    GEO.level = d <= 6 ? 56 : d <= 9 ? 46 : 38;
    GEO.left = Math.max(24, GEO.slot / 2 + 6);
    var w = GEO.left * 2 + (n - 1) * GEO.slot;
    if (w < 260) { GEO.left += (260 - w) / 2; w = 260; }
    var h = GEO.top + d * GEO.level + 34;
    el.svg.setAttribute("width", String(Math.round(w)));
    el.svg.setAttribute("height", String(Math.round(h)));
    el.svg.setAttribute("viewBox", "0 0 " + Math.round(w) + " " + Math.round(h));
  }

  function layoutAt(k) {
    var pos = Object.create(null);
    var roots = M.nodes.filter(function (n) { return n.createdAt < k && n.absorbedAt >= k; });
    roots.sort(function (a, b) { return a.minLeaf - b.minLeaf; });
    var deepest = 0;
    function place(n, d) {
      var x;
      if (d > deepest) deepest = d;
      if (n.leaf) { x = GEO.left + n.slot * GEO.slot; }
      else {
        place(n.left, d + 1); place(n.right, d + 1);
        x = (pos[n.left.id].x + pos[n.right.id].x) / 2;
      }
      pos[n.id] = { x: x, y: GEO.top + d * GEO.level, d: d };
    }
    roots.forEach(function (r) { place(r, 0); });
    /* the SVG is sized for the finished tree; centre the partial forest inside it
       so the early merges don't sit in a mostly-empty box */
    var dy = (M.maxDepth - deepest) * GEO.level / 2;
    if (dy > 0) { for (var id in pos) pos[id].y += dy; }
    return { pos: pos, roots: roots };
  }

  /* =======================================================================
     tree rendering (persistent elements + tween)
     ======================================================================= */
  var els = Object.create(null);   /* node id -> {g, cur, tgt, from} */
  var edges = Object.create(null); /* child id -> {path, lab} */
  var raf = 0, tweenStart = 0, tweenDur = 0;

  function clearTree() {
    el.edgeL.textContent = ""; el.labelL.textContent = ""; el.nodeL.textContent = "";
    els = Object.create(null); edges = Object.create(null);
    if (raf) { cancelAnimationFrame(raf); raf = 0; }
  }

  function mk(tag, attrs) {
    var n = document.createElementNS(SVG_NS, tag);
    for (var k in attrs) n.setAttribute(k, attrs[k]);
    return n;
  }

  function makeNodeEl(n) {
    var g = mk("g", { "class": "nd" });
    if (n.leaf) {
      g.appendChild(mk("rect", { "class": "box", x: -GEO.leafW / 2, y: -GEO.leafH / 2,
        width: GEO.leafW, height: GEO.leafH, rx: 6 }));
      var t = mk("text", { "class": "g", x: 0, y: 4.5 });
      t.appendChild(document.createTextNode(glyph(n.ch)));
      g.appendChild(t);
      var w = mk("text", { "class": "w", x: 0, y: GEO.leafH / 2 + 11 });
      w.appendChild(document.createTextNode(String(n.w)));
      g.appendChild(w);
      var hit = mk("rect", { "class": "hit", x: -GEO.slot / 2, y: -GEO.leafH / 2 - 3,
        width: GEO.slot, height: GEO.leafH + 18 });
      g.appendChild(hit);
      g.addEventListener("mouseenter", function () { setHot(n.ch, -1); });
      g.addEventListener("mouseleave", function () { setHot(null, -1); });
    } else {
      g.appendChild(mk("circle", { "class": "dotn", cx: 0, cy: 0, r: GEO.r }));
      var iw = mk("text", { "class": "iw", x: 0, y: 3.5 });
      iw.appendChild(document.createTextNode(String(n.w)));
      g.appendChild(iw);
    }
    el.nodeL.appendChild(g);
    return g;
  }

  function makeEdgeEl(childId) {
    var p = mk("path", { "class": "edge", d: "" });
    el.edgeL.appendChild(p);
    var l = mk("text", { "class": "bit", x: 0, y: 0 });
    l.appendChild(document.createTextNode(""));
    el.labelL.appendChild(l);
    return { path: p, lab: l };
  }

  function renderTree(k, animate) {
    if (!M) return;
    var L = layoutAt(k);
    var present = L.pos;
    var id;

    /* drop nodes that no longer exist (only happens on rebuild/reset) */
    for (id in els) {
      if (!(id in present)) { els[id].g.remove(); delete els[id]; }
    }
    for (id in edges) {
      var rec0 = els[id];
      if (!rec0 || !(rec0.node.absorbedAt < k)) {
        edges[id].path.remove(); edges[id].lab.remove(); delete edges[id];
      }
    }

    /* create / update */
    M.nodes.forEach(function (n) {
      var p = present[n.id];
      if (!p) return;
      var rec = els[n.id];
      if (!rec) {
        var g = makeNodeEl(n);
        /* a new parent is born between its two children */
        var sx = p.x, sy = p.y;
        if (!n.leaf && els[n.left.id] && els[n.right.id]) {
          sx = (els[n.left.id].cur.x + els[n.right.id].cur.x) / 2;
          sy = (els[n.left.id].cur.y + els[n.right.id].cur.y) / 2;
        }
        rec = els[n.id] = { g: g, cur: { x: sx, y: sy }, from: { x: sx, y: sy }, tgt: { x: p.x, y: p.y }, node: n, fresh: true };
        g.setAttribute("transform", "translate(" + sx + "," + sy + ")");
      }
      rec.from = { x: rec.cur.x, y: rec.cur.y };
      rec.tgt = { x: p.x, y: p.y };

      if (n.absorbedAt < k && !edges[n.id]) edges[n.id] = makeEdgeEl(n.id);
    });

    /* per-step accents */
    Object.keys(els).forEach(function (i) { els[i].g.classList.remove("born", "pick"); });
    Object.keys(edges).forEach(function (i) { edges[i].path.classList.remove("fresh"); });
    if (k > 0 && k <= M.steps.length) {
      var s = M.steps[k - 1];
      if (els[s.p.id]) els[s.p.id].g.classList.add("born");
      if (edges[s.a.id]) edges[s.a.id].path.classList.add("fresh");
      if (edges[s.b.id]) edges[s.b.id].path.classList.add("fresh");
    }
    if (k < M.steps.length) {
      var nx = M.steps[k];
      if (els[nx.a.id]) els[nx.a.id].g.classList.add("pick");
      if (els[nx.b.id]) els[nx.b.id].g.classList.add("pick");
    }

    var dur = (animate && !RM.matches) ? Math.min(420, Math.max(180, +el.speed.value * 0.55)) : 0;
    startTween(dur);
    applyHot();
  }

  function startTween(dur) {
    if (raf) { cancelAnimationFrame(raf); raf = 0; }
    tweenDur = dur; tweenStart = 0;
    if (dur <= 0) { paint(1); return; }
    raf = requestAnimationFrame(function loop(ts) {
      if (!tweenStart) tweenStart = ts;
      var t = Math.min(1, (ts - tweenStart) / tweenDur);
      paint(t < 1 ? 1 - Math.pow(1 - t, 3) : 1);
      if (t < 1) raf = requestAnimationFrame(loop); else raf = 0;
    });
  }

  function paint(t) {
    var id;
    for (id in els) {
      var r = els[id];
      r.cur.x = r.from.x + (r.tgt.x - r.from.x) * t;
      r.cur.y = r.from.y + (r.tgt.y - r.from.y) * t;
      r.g.setAttribute("transform", "translate(" + r.cur.x.toFixed(2) + "," + r.cur.y.toFixed(2) + ")");
      if (r.fresh) {
        r.g.setAttribute("opacity", t >= 1 ? "1" : (0.15 + 0.85 * t).toFixed(3));
        if (t >= 1) r.fresh = false;
      }
    }
    for (id in edges) {
      var child = els[id]; if (!child) continue;
      var par = els[child.node.parent ? child.node.parent.id : -1];
      if (!par) continue;
      var x1 = par.cur.x, y1 = par.cur.y + GEO.r - 1;
      var x2 = child.cur.x, y2 = child.cur.y - (child.node.leaf ? GEO.leafH / 2 : GEO.r) + 1;
      edges[id].path.setAttribute("d", "M" + x1.toFixed(2) + " " + y1.toFixed(2) + "L" + x2.toFixed(2) + " " + y2.toFixed(2));
      var lx = x1 + (x2 - x1) * 0.42, ly = y1 + (y2 - y1) * 0.42;
      var lab = edges[id].lab;
      lab.setAttribute("x", (lx + (x2 < x1 ? -7 : 7)).toFixed(2));
      lab.setAttribute("y", ly.toFixed(2));
      lab.firstChild.nodeValue = child.node.bit;
    }
  }

  /* =======================================================================
     queue strip
     ======================================================================= */
  function renderQueue(k) {
    el.queue.textContent = "";
    var lab = document.createElement("span");
    lab.className = "qlab";
    lab.textContent = k < M.steps.length ? "waiting to merge → lightest two first" : "one tree left";
    el.queue.appendChild(lab);

    var roots = M.nodes.filter(function (n) { return n.createdAt < k && n.absorbedAt >= k; });
    roots.sort(function (a, b) { return a.w - b.w || a.seq - b.seq; });
    roots.forEach(function (n, i) {
      var c = document.createElement("span");
      c.className = "qchip" + (k < M.steps.length && i < 2 ? " next" : "");
      var b = document.createElement("b");
      b.textContent = n.leaf ? glyph(n.ch) : "●";
      c.appendChild(b);
      c.appendChild(document.createTextNode(" " + n.w));
      el.queue.appendChild(c);
    });
  }

  /* =======================================================================
     stats / verdict
     ======================================================================= */
  function renderStats() {
    var withTbl = el.cbTable.checked;
    var enc = M.payload + (withTbl ? M.header : 0);
    var orig = M.origBits;

    $("sChars").innerHTML = fmt(M.len);
    $("sSyms").innerHTML = fmt(M.leaves.length);
    $("sOrig").innerHTML = fmt(orig) + ' <small>bits</small>';
    $("sEnc").innerHTML = fmt(enc) + ' <small>bits</small>';
    $("sAvg").innerHTML = (M.payload / M.len).toFixed(2) + ' <small>bits</small>';
    $("sDepth").innerHTML = fmt(M.maxDepth) + ' <small>bits</small>';

    el.tblCost.textContent = "+" + fmt(M.header) + " bits (" + M.leaves.length + " × 13 + 8)";

    var smaller = enc < orig;
    var same = enc === orig;
    el.verdict.classList.toggle("good", smaller);
    el.verdict.classList.toggle("bad", !smaller && !same);
    el.vbadge.textContent = smaller ? "smaller" : same ? "no change" : "bigger";
    var pct = orig ? Math.abs(orig - enc) / orig * 100 : 0;
    el.vhero.textContent = smaller
      ? pct.toFixed(1) + "% smaller"
      : same ? "same size" : pct.toFixed(1) + "% bigger";
    el.vdetail.textContent = fmt(enc) + " bits vs " + fmt(orig) + " bits"
      + (withTbl ? "  (" + fmt(M.payload) + " data + " + fmt(M.header) + " table)" : "")
      + "  ·  " + (enc / orig).toFixed(3) + "×";

    var mx = Math.max(orig, enc, 1);
    el.mOrig.style.width = (orig / mx * 100).toFixed(2) + "%";
    el.mEnc.style.width = (enc / mx * 100).toFixed(2) + "%";
    el.mOrigV.textContent = fmt(orig) + " b";
    el.mEncV.textContent = fmt(enc) + " b";
  }

  /* =======================================================================
     code table
     ======================================================================= */
  function sortedLeaves() {
    var a = M.leaves.slice();
    var key = SORTS[sortIdx].key;
    if (key === "freq") a.sort(function (x, y) { return y.w - x.w || x.depth - y.depth || (x.ch < y.ch ? -1 : 1); });
    else if (key === "len") a.sort(function (x, y) { return x.depth - y.depth || y.w - x.w || (x.ch < y.ch ? -1 : 1); });
    else a.sort(function (x, y) { return x.ch < y.ch ? -1 : x.ch > y.ch ? 1 : 0; });
    return a;
  }

  var rowByChar = new Map();

  function renderTable() {
    el.ctb.textContent = "";
    rowByChar = new Map();
    var rows = sortedLeaves();
    var maxW = rows.reduce(function (m, n) { return Math.max(m, n.w); }, 1);
    if (rowIdx >= rows.length) rowIdx = 0;

    rows.forEach(function (n, i) {
      var tr = document.createElement("tr");
      tr.dataset.ch = n.ch;

      var td1 = document.createElement("td"); td1.className = "l";
      var btn = document.createElement("button");
      btn.type = "button"; btn.className = "glyphbtn";
      btn.textContent = glyph(n.ch);
      btn.tabIndex = i === rowIdx ? 0 : -1;
      btn.setAttribute("aria-label",
        nameOf(n.ch) + ", appears " + n.w + " time" + (n.w === 1 ? "" : "s") +
        ", code " + n.code.split("").join(" ") + ", " + n.depth + " bits");
      btn.addEventListener("focus", function () { rowIdx = i; retab(); setHot(n.ch, -1); });
      btn.addEventListener("click", function () { setHot(hotChar === n.ch ? null : n.ch, -1); });
      btn.addEventListener("keydown", function (e) { tableKey(e, i, rows); });
      td1.appendChild(btn);
      var ws = wsLabel(n.ch);
      if (ws) { var s = document.createElement("span"); s.className = "wslab"; s.textContent = ws; td1.appendChild(s); }
      tr.appendChild(td1);

      var td2 = document.createElement("td"); td2.textContent = String(n.w); tr.appendChild(td2);

      var td3 = document.createElement("td"); td3.className = "l";
      var bar = document.createElement("span");
      bar.className = "bar";
      bar.style.width = Math.max(3, Math.round(n.w / maxW * 62)) + "px";
      td3.appendChild(bar);
      tr.appendChild(td3);

      var td4 = document.createElement("td"); td4.className = "l code"; td4.textContent = n.code; tr.appendChild(td4);
      var td5 = document.createElement("td"); td5.textContent = String(n.depth); tr.appendChild(td5);

      tr.addEventListener("mouseenter", function () { setHot(n.ch, -1); });
      tr.addEventListener("mouseleave", function () { setHot(null, -1); });
      el.ctb.appendChild(tr);
      rowByChar.set(n.ch, tr);
    });
  }

  function retab() {
    var btns = el.ctb.querySelectorAll(".glyphbtn");
    for (var i = 0; i < btns.length; i++) btns[i].tabIndex = i === rowIdx ? 0 : -1;
  }

  function tableKey(e, i, rows) {
    var next = -1;
    if (e.key === "ArrowDown") next = Math.min(rows.length - 1, i + 1);
    else if (e.key === "ArrowUp") next = Math.max(0, i - 1);
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = rows.length - 1;
    else return;
    e.preventDefault();
    rowIdx = next; retab();
    var btns = el.ctb.querySelectorAll(".glyphbtn");
    if (btns[next]) btns[next].focus();
  }

  /* =======================================================================
     bitstring
     ======================================================================= */
  var symSpans = [];            /* index -> span (rendered chars only) */

  function renderBits() {
    el.bits.textContent = "";
    symSpans = [];
    var shown = Math.min(M.len, MAX_RENDER_CHARS);
    var frag = document.createDocumentFragment();
    for (var i = 0; i < shown; i++) {
      var ch = M.chars[i];
      var leaf = M.codeOf.get(ch);
      var sp = document.createElement("span");
      sp.className = "sym" + (i % 2 ? " alt" : "");
      sp.textContent = leaf.code;
      sp.dataset.i = String(i);
      sp.dataset.ch = ch;
      (function (c, idx, node) {
        node.addEventListener("mouseenter", function () { setHot(c, idx); });
        node.addEventListener("mouseleave", function () { setHot(null, -1); });
      })(ch, i, sp);
      frag.appendChild(sp);
      symSpans.push(sp);
    }
    el.bits.appendChild(frag);

    var shownBits = 0;
    for (var j = 0; j < shown; j++) shownBits += M.codeOf.get(M.chars[j]).depth;
    el.bitsSub.textContent = fmt(M.payload) + (M.payload === 1 ? " bit total" : " bits total");
    if (shown < M.len) {
      el.trunc.hidden = false;
      el.trunc.textContent = "showing the first " + shown + " of " + fmt(M.len) + " characters (" + fmt(shownBits) + " of " + fmt(M.payload) + " bits)";
    } else {
      el.trunc.hidden = true; el.trunc.textContent = "";
    }
    cursor = -1;
  }

  /* =======================================================================
     highlighting
     ======================================================================= */
  function setHot(ch, idx) {
    hotChar = ch; cursor = idx;
    applyHot();
    renderReadout();
  }

  function applyHot() {
    /* table */
    rowByChar.forEach(function (tr, ch) { tr.classList.toggle("hot", ch === hotChar); });
    /* bits */
    for (var i = 0; i < symSpans.length; i++) {
      var sp = symSpans[i];
      sp.classList.toggle("hot", hotChar !== null && sp.dataset.ch === hotChar);
      sp.classList.toggle("cur", i === cursor);
    }
    /* tree */
    var id;
    for (id in els) els[id].g.classList.remove("hot");
    for (id in edges) { edges[id].path.classList.remove("hot"); edges[id].lab.classList.remove("hot"); }
    if (hotChar !== null && M) {
      var n = M.codeOf.get(hotChar);
      while (n) {
        if (els[n.id]) els[n.id].g.classList.add("hot");
        if (edges[n.id]) { edges[n.id].path.classList.add("hot"); edges[n.id].lab.classList.add("hot"); }
        n = n.parent;
      }
    }
  }

  function renderReadout() {
    el.readout.textContent = "";
    if (hotChar === null) {
      var s = document.createElement("span");
      s.textContent = "hover any run of bits — or any row in the table — to see which character it is";
      el.readout.appendChild(s);
      return;
    }
    var n = M.codeOf.get(hotChar);
    var parts = [];
    parts.push(["b", nameOf(hotChar)]);
    if (cursor >= 0) parts.push(["s", "position " + (cursor + 1)]);
    parts.push(["s", "×" + n.w + " in the text"]);
    parts.push(["k", n.code]);
    parts.push(["s", n.depth + (n.depth === 1 ? " bit" : " bits") + " instead of 8"]);
    parts.push(["s", "→ " + fmt(n.w * n.depth) + " bits here vs " + fmt(n.w * 8) + " uncompressed"]);
    parts.forEach(function (p, i) {
      var e2 = document.createElement(p[0] === "b" ? "b" : "span");
      if (p[0] === "k") e2.className = "k";
      e2.textContent = (i ? "· " : "") + p[1];
      el.readout.appendChild(e2);
      el.readout.appendChild(document.createTextNode(" "));
    });
  }

  /* =======================================================================
     playback
     ======================================================================= */
  function setNote(kind, text) {
    el.note.className = "note" + (kind ? " " + kind : "");
    el.noteText.textContent = text;
  }

  function describe(k) {
    if (!M.steps.length) {
      setNote("ok", "one distinct character — no tree to build, and one bit says it all");
      return;
    }
    if (k === 0) {
      setNote("", M.leaves.length + " characters to encode · " + M.steps.length +
        " merges ahead · each step joins the two lightest piles");
      return;
    }
    var s = M.steps[k - 1];
    var an = s.a.leaf ? glyph(s.a.ch) : "node";
    var bn = s.b.leaf ? glyph(s.b.ch) : "node";
    var txt = "merge " + k + "/" + M.steps.length + " · " + an + "(" + s.a.w + ") + " +
              bn + "(" + s.b.w + ") → node(" + s.p.w + ")";
    if (k === M.steps.length) {
      setNote("ok", txt + " · tree complete — root weight " + s.p.w + " = every character");
    } else {
      setNote("info", txt);
    }
  }

  function setStep(k, animate) {
    step = Math.max(0, Math.min(M.steps.length, k));
    renderTree(step, animate);
    renderQueue(step);
    describe(step);
    updateButtons();
    updateSvgDesc();
    updateFades();
  }

  function updateSvgDesc() {
    el.svgD.textContent = step >= M.steps.length
      ? "Completed Huffman tree with " + M.leaves.length + " character leaves, deepest code " + M.maxDepth + " bits."
      : "Huffman forest after " + step + " of " + M.steps.length + " merges.";
  }

  function updateButtons() {
    if (!M) return;
    var atEnd = step >= M.steps.length;
    el.btnStep.disabled = atEnd;
    el.btnEnd.disabled = atEnd;
    el.btnReset.disabled = step === 0 && !playing;
    el.btnPlay.disabled = M.steps.length === 0;
    el.btnPlay.textContent = playing ? "⏸ Pause" : (atEnd ? "↺ Replay build" : "▶ Build");
  }

  function tick() {
    if (!playing || !M) return;
    if (step >= M.steps.length) { stop(); return; }
    setStep(step + 1, true);
    if (step >= M.steps.length) {
      stop();
      el.sr.textContent = "Tree complete. " + M.leaves.length + " characters, longest code " + M.maxDepth + " bits.";
      return;
    }
    timer = setTimeout(tick, +el.speed.value);
  }

  function play() {
    if (!M || !M.steps.length) return;
    if (step >= M.steps.length) setStep(0, false);
    playing = true; updateButtons();
    timer = setTimeout(tick, RM.matches ? 0 : 260);
  }
  function stop() {
    playing = false;
    if (timer) { clearTimeout(timer); timer = null; }
    updateButtons();
  }

  /* =======================================================================
     empty / rebuild
     ======================================================================= */
  function showEmpty() {
    stop();
    clearTree();
    el.svg.setAttribute("width", "320"); el.svg.setAttribute("height", "120");
    el.svg.setAttribute("viewBox", "0 0 320 120");
    var t = document.createElementNS(SVG_NS, "text");
    t.setAttribute("x", "160"); t.setAttribute("y", "66");
    t.setAttribute("class", "rootcap");
    t.appendChild(document.createTextNode("type something above"));
    el.nodeL.appendChild(t);
    el.svgD.textContent = "No tree yet — the text box is empty.";
    el.queue.textContent = "";
    el.ctb.textContent = "";
    el.bits.textContent = "";
    el.trunc.hidden = true;
    el.bitsSub.textContent = "—";
    rowByChar = new Map(); symSpans = []; hotChar = null; cursor = -1;
    setNote("", "no text yet — nothing to compress");
    ["sChars", "sSyms"].forEach(function (i) { $(i).textContent = "0"; });
    $("sOrig").innerHTML = '0 <small>bits</small>';
    $("sEnc").innerHTML = '0 <small>bits</small>';
    $("sAvg").innerHTML = '0 <small>bits</small>';
    $("sDepth").innerHTML = '0 <small>bits</small>';
    el.verdict.className = "verdict";
    el.vbadge.textContent = "idle"; el.vhero.textContent = "—"; el.vdetail.textContent = "";
    el.mOrig.style.width = "0%"; el.mEnc.style.width = "0%";
    el.mOrigV.textContent = "0 b"; el.mEncV.textContent = "0 b";
    el.tblCost.textContent = "+0 bits";
    el.readout.textContent = ""; renderReadout();
    /* updateButtons() bails out while M is null, so reset the play control by hand */
    el.btnPlay.textContent = "▶ Build";
    el.btnPlay.disabled = true; el.btnStep.disabled = true; el.btnEnd.disabled = true; el.btnReset.disabled = true;
  }

  function rebuild(autoplay) {
    var text = el.txt.value;
    el.count.textContent = Array.from(text).length + " / 400";
    el.count.classList.toggle("warn", Array.from(text).length >= 400);

    var m = analyze(text);
    if (!m) { M = null; showEmpty(); return; }
    M = m;
    stop();
    hotChar = null; cursor = -1; rowIdx = 0;
    clearTree();
    fitGeometry();
    renderStats();
    renderTable();
    renderBits();
    renderReadout();
    if (autoplay && !RM.matches && M.steps.length) {
      setStep(0, false);
      play();
    } else {
      setStep(M.steps.length, false);
    }
  }

  /* =======================================================================
     scroll fades
     ======================================================================= */
  function updateFades() {
    var w = el.cwrap;
    var can = w.scrollWidth - w.clientWidth > 2;
    el.shell.classList.toggle("can-l", can && w.scrollLeft > 2);
    el.shell.classList.toggle("can-r", can && w.scrollLeft < w.scrollWidth - w.clientWidth - 2);
  }

  /* =======================================================================
     wiring
     ======================================================================= */
  var debounce = null;
  el.txt.addEventListener("input", function () {
    if (debounce) clearTimeout(debounce);
    debounce = setTimeout(function () { rebuild(false); }, 130);
  });

  document.querySelectorAll("[data-preset]").forEach(function (b) {
    b.addEventListener("click", function () {
      el.txt.value = PRESETS[+b.dataset.preset];
      rebuild(true);
      el.sr.textContent = "Loaded example. " + (M && M.steps.length
        ? "Building the tree, " + M.steps.length + " merges."
        : "Only one distinct character, so there is no tree to build.");
    });
  });

  el.btnPlay.addEventListener("click", function () { if (playing) stop(); else play(); });
  el.btnStep.addEventListener("click", function () { stop(); setStep(step + 1, true); });
  el.btnEnd.addEventListener("click", function () { stop(); setStep(M.steps.length, false); });
  el.btnReset.addEventListener("click", function () { stop(); setStep(0, false); });
  el.speed.addEventListener("change", function () {
    if (playing) { if (timer) clearTimeout(timer); timer = setTimeout(tick, +el.speed.value); }
  });
  el.cbTable.addEventListener("change", function () {
    if (M) { renderStats(); el.sr.textContent = el.cbTable.checked ? "Code table included in the total." : "Code table excluded."; }
  });
  el.sortBtn.addEventListener("click", function () {
    sortIdx = (sortIdx + 1) % SORTS.length;
    el.sortBtn.textContent = "sort: " + SORTS[sortIdx].lab;
    rowIdx = 0;
    if (M) { renderTable(); applyHot(); }
  });

  el.cwrap.addEventListener("scroll", updateFades, { passive: true });
  window.addEventListener("resize", updateFades);

  /* bitstring roving cursor */
  el.bits.addEventListener("keydown", function (e) {
    if (!symSpans.length) return;
    var n = symSpans.length, next = cursor;
    if (e.key === "ArrowRight") next = cursor < 0 ? 0 : Math.min(n - 1, cursor + 1);
    else if (e.key === "ArrowLeft") next = cursor < 0 ? n - 1 : Math.max(0, cursor - 1);
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = n - 1;
    else if (e.key === "Escape") { setHot(null, -1); return; }
    else return;
    e.preventDefault();
    setHot(symSpans[next].dataset.ch, next);
    var sp = symSpans[next];
    if (sp.scrollIntoView) sp.scrollIntoView({ block: "nearest", inline: "nearest" });
    el.sr.textContent = nameOf(symSpans[next].dataset.ch) + ", code " + sp.textContent;
  });
  el.bits.addEventListener("focus", function () {
    if (cursor < 0 && symSpans.length) setHot(symSpans[0].dataset.ch, 0);
  });
  el.bits.addEventListener("blur", function () { setHot(null, -1); });

  /* ---------- boot ---------- */
  el.txt.value = PRESETS[0];
  rebuild(true);
  updateFades();
})();
</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