$sloprun.dev

Consensus Visualizer

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

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

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

Five machines keep one shared list of changes with no boss and no shared clock, running a real implementation of the Raft consensus algorithm — leader election, heartbeats, log replication, and majority commit — drawn live as it happens. It opens mid-story: a scripted intro elects a leader, strands a write on a machine that goes quiet before anyone else copies it, and shows that entry being thrown away when the machine comes back, while everything a majority agreed on survives untouched. Then the controls are yours. Pause the leader and watch the countdown rings drain until someone calls a vote; pause three of the five and watch writes stop turning green, because there is no majority left to agree.

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

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1217 lines 54.2 KB index.html
demos/raft-consensus/index.html
<!doctype html>
<html lang="en" data-theme="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Consensus Visualizer</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
: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); }
/* ── end token block ──────────────────────────────────────────────────────── */

:root { --danger-soft: #FBEBEA; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --danger-soft: #382A2E; } }
:root[data-theme="dark"] { --danger-soft: #382A2E; }

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; min-height: 100vh; overflow-x: hidden; }
.sr { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
      clip:rect(0 0 0 0); white-space:nowrap; border:0; }

.wrap { max-width: 1240px; margin: 0 auto; padding: 20px 16px 26px; }

header h1 { margin: 0 0 5px; font-size: 19px; font-weight: 650; letter-spacing: -.01em; }
header p  { margin: 0; font-size: 13.5px; line-height: 1.55; color: var(--muted); max-width: 74ch; }
header p b { color: var(--ink); font-weight: 600; }
header p .mm { font: 600 12.5px var(--font-mono); color: var(--ink); }

/* ── controls ─────────────────────────────────────────────────────────────── */
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin:16px 0 10px; }
.grow { flex: 1 1 8px; }
.btn { font: 600 13px/1 var(--font-sans); color: var(--ink); background: var(--surface);
       border: 1px solid var(--line); border-radius: 8px; padding: 9px 12px;
       display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
       white-space: nowrap; transition: border-color .12s, color .12s, background .12s; }
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
.btn.primary:hover:not(:disabled) { filter: brightness(1.08); color: var(--surface); }
.btn.sm { padding: 6px 9px; font-size: 12px; border-radius: 7px; }
.btn .gl { font-size: 11px; line-height: 1; }
.btn .kb { font: 600 10.5px var(--font-mono); opacity: .7; }

.sel { display:inline-flex; align-items:center; gap:6px;
       font: 600 10.5px var(--font-mono); letter-spacing:.09em; text-transform: uppercase;
       color: var(--muted); }
.sel select { font: 600 12px var(--font-mono); color: var(--ink); background: var(--surface);
       border: 1px solid var(--line); border-radius: 7px; padding: 7px 8px; cursor: pointer; }
.sel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── status strip ─────────────────────────────────────────────────────────── */
.strip { display:flex; flex-wrap:wrap; align-items:center; gap:7px 14px;
  font: 500 11.5px var(--font-mono); color: var(--muted);
  border: 1px solid var(--line); background: var(--surface);
  border-radius: var(--radius); padding: 9px 12px; }
.strip b { color: var(--ink); font-weight: 600; }
.pill { font: 650 11px var(--font-mono); letter-spacing:.09em; padding: 3px 8px;
  border-radius: 999px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
.pill[data-s="lead"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.pill[data-s="vote"] { color: var(--accent); border-color: var(--accent); background: transparent; }
.pill[data-s="none"] { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }
b.flash { color: var(--run); }
.strip .mode { color: var(--accent); }
.strip .warnup { color: var(--danger); }

/* ── banner / narrator ────────────────────────────────────────────────────── */
.banner { margin: 10px 0 0; border: 1px solid var(--line); border-left-width: 3px;
  border-left-color: var(--accent); background: var(--accent-soft); border-radius: 8px;
  padding: 10px 12px; font-size: 13.5px; line-height: 1.5; display: flex; gap: 9px; align-items: baseline; }
.banner .tag { font: 650 10px var(--font-mono); letter-spacing: .1em; text-transform: uppercase;
  color: var(--accent); flex: 0 0 auto; }
.banner .tx { color: var(--ink); }
.banner .tx .mm { font: 600 12.5px var(--font-mono); }
.banner[data-k="warn"] { border-left-color: var(--danger); background: var(--danger-soft); }
.banner[data-k="warn"] .tag { color: var(--danger); }
.banner[data-k="idle"] { border-left-color: var(--line); background: var(--surface); }
.banner[data-k="idle"] .tag { color: var(--muted); }

/* ── stage ────────────────────────────────────────────────────────────────── */
.stage { display: grid; grid-template-columns: minmax(0,1fr) 270px; gap: 14px; margin-top: 12px; align-items: start; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); } }

.canvas { border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); overflow: hidden; }
.board { display: block; width: 100%; height: auto; }
.legend { display:flex; flex-wrap:wrap; gap: 5px 13px; padding: 8px 12px 10px;
  border-top: 1px solid var(--line); }
.legend span { display:inline-flex; align-items:center; gap:5px;
  font: 500 10.5px var(--font-mono); color: var(--muted); white-space: nowrap; }
.sw { width: 10px; height: 10px; border-radius: 2px; border: 1.5px solid var(--line); background: transparent; flex:0 0 auto; }
.sw.com { background: var(--run); border-color: var(--run); }
.sw.rep { background: var(--accent-soft); border-color: var(--accent); }
.sw.bad { background: var(--danger-soft); border-color: var(--danger); }
.sw.dot { border-radius: 999px; width: 9px; height: 9px; background: var(--muted); border-color: var(--muted); }
.sw.dot.hollow { background: transparent; border-color: var(--accent); }
.sw.dot.ent { background: var(--accent); border-color: var(--accent); }

/* ── side panel ───────────────────────────────────────────────────────────── */
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
  padding: 12px; display: flex; flex-direction: column; gap: 11px; }
.panel h2 { margin: 0; font: 650 10.5px var(--font-mono); letter-spacing: .11em;
  text-transform: uppercase; color: var(--muted); }
.sep { border: 0; border-top: 1px solid var(--line); margin: 1px 0; }
ul.nodes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
ul.nodes li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 7px;
  border: 1px solid var(--line); border-radius: 8px; padding: 5px 5px 5px 8px; background: var(--bg); }
ul.nodes li .nid { font: 650 11.5px var(--font-mono); color: var(--ink); }
ul.nodes li .rp { font: 650 9.5px var(--font-mono); letter-spacing:.08em; color: var(--muted);
  border: 1px solid var(--line); border-radius: 999px; padding: 2px 6px; justify-self: start; }
ul.nodes li[data-r="leader"] { border-color: var(--accent); }
ul.nodes li[data-r="leader"] .rp { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
ul.nodes li[data-r="candidate"] .rp { color: var(--accent); border-color: var(--accent); }
ul.nodes li[data-r="down"] { border-color: var(--danger); background: var(--danger-soft); }
ul.nodes li[data-r="down"] .rp { color: var(--danger); border-color: var(--danger); }
ul.nodes li .nm { font: 500 10.5px var(--font-mono); color: var(--muted); justify-self: end; }
.kv { display: flex; flex-wrap: wrap; gap: 5px; }
.kv .chip { font: 500 11px var(--font-mono); color: var(--ink); background: var(--run-soft);
  border: 1px solid var(--run); border-radius: 6px; padding: 3px 7px; }
.kv .none { font: 500 11px var(--font-mono); color: var(--muted); }
.hint { margin: 0; padding-left: 16px; font-size: 12.5px; line-height: 1.6; color: var(--muted); }
.hint li { margin-bottom: 5px; }
.hint b { color: var(--ink); font-weight: 600; }
.hint .mm { font: 600 11.5px var(--font-mono); color: var(--ink); }

/* ── svg graph ────────────────────────────────────────────────────────────── */
.link { stroke: var(--line); stroke-width: 1.2; opacity: .8; }
.link.spoke { stroke: var(--accent); opacity: .32; stroke-width: 1.4; }
.nd { cursor: pointer; outline: none; }
.nd .disc { fill: var(--surface); stroke: var(--line); stroke-width: 2; transition: fill .18s, stroke .18s; }
.nd .num  { fill: var(--ink); font: 650 19px var(--font-mono); text-anchor: middle; }
.nd .ring { fill: none; stroke: var(--muted); stroke-width: 3.5; opacity: .5; }
.nd .card { fill: var(--bg); stroke: var(--line); stroke-width: 1; }
.nd .role { font: 650 10.5px var(--font-mono); letter-spacing: .09em; text-anchor: middle; fill: var(--muted); }
.nd .meta { font: 500 9.5px var(--font-mono); text-anchor: middle; fill: var(--muted); }
.nd .foc  { fill: transparent; stroke: var(--accent); stroke-width: 2.5; opacity: 0; }
.nd:focus .foc { opacity: 1; }
.nd:focus:not(:focus-visible) .foc { opacity: 0; }
.nd:focus-visible .foc { opacity: 1; }
.nd:hover .disc { stroke: var(--accent); }
.nd.leader .disc { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 3; }
.nd.leader .role, .nd.leader .num { fill: var(--accent); }
.nd.leader .ring { stroke: var(--accent); opacity: .95; }
.nd.leader .card { stroke: var(--accent); }
.nd.candidate .disc { stroke: var(--accent); stroke-width: 2.5; stroke-dasharray: 7 4; }
.nd.candidate .role { fill: var(--accent); }
.nd.candidate .ring { stroke: var(--accent); opacity: .9; }
.nd.down .disc { fill: var(--danger-soft); stroke: var(--danger); stroke-width: 2; stroke-dasharray: 4 4; }
.nd.down .num  { fill: var(--danger); opacity: .75; }
.nd.down .role { fill: var(--danger); }
.nd.down .card { stroke: var(--danger); }
.nd.down .ring { opacity: 0; }
.sq { stroke-width: 1; }
.sq.empty { fill: transparent; stroke: var(--line); }
.sq.rep   { fill: var(--accent-soft); stroke: var(--accent); }
.sq.com   { fill: var(--run); stroke: var(--run); }
.sq.bad   { fill: var(--danger-soft); stroke: var(--danger); }
.vp { fill: var(--accent); }
.vp.off { fill: var(--line); }
.msg { stroke-width: 0; }
.msg.rv   { fill: transparent; stroke: var(--accent); stroke-width: 1.7; }
.msg.rvr  { fill: var(--accent); }
.msg.rvr.no { fill: var(--muted); }
.msg.ae   { fill: var(--muted); }
.msg.ae.ent { fill: var(--accent); }
.msg.aer  { fill: var(--accent); opacity: .85; }
.msg.aer.bad { fill: var(--danger); opacity: 1; }
.pulse { fill: none; stroke: var(--accent); stroke-width: 2; }
.ct-lab { font: 650 9px var(--font-mono); letter-spacing: .14em; text-anchor: middle; fill: var(--muted); }
.ct-num { font: 650 23px var(--font-mono); text-anchor: middle; fill: var(--accent); }
.ct-sub { font: 500 9.5px var(--font-mono); text-anchor: middle; fill: var(--muted); }
.lay-narrow .nd .num { font-size: 15px; }
.lay-narrow .nd .role { font-size: 10px; }
.lay-narrow .nd .meta { font-size: 9px; }
.lay-narrow .ct-num { font-size: 19px; }
.lay-narrow .ct-sub { font-size: 8.5px; }
.lay-narrow .ct-lab { font-size: 8px; }

/* ── event log ────────────────────────────────────────────────────────────── */
.logwrap { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); overflow: hidden; }
.loghd { display:flex; align-items:center; justify-content:space-between; gap:10px;
  padding: 8px 12px; border-bottom: 1px solid var(--line); }
.loghd h2 { margin:0; font: 650 10.5px var(--font-mono); letter-spacing:.11em;
  text-transform: uppercase; color: var(--muted); }
.loghd .sub { font: 500 11px var(--font-mono); color: var(--muted); opacity: .92; }
.log { margin: 0; padding: 8px 12px 11px; max-height: 210px; overflow-y: auto; }
.log:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ln { display: grid; grid-template-columns: 48px 14px minmax(0,1fr); gap: 8px;
  font-size: 12.5px; line-height: 1.65; color: var(--ink); padding: 1px 0; }
.ln .t { font: 500 11px var(--font-mono); color: var(--muted); opacity: .9; }
.ln .i { font: 600 11px var(--font-mono); color: var(--muted); text-align: center; }
.ln .x { color: var(--muted); }
.ln .mm { font: 600 11.5px var(--font-mono); color: var(--ink); }
.ln.ok .i { color: var(--run); }
.ln.ok .x { color: var(--ink); }
.ln.ok .x .mm { color: var(--run); }
.ln.lead .i, .ln.write .i, .ln.up .i { color: var(--accent); }
.ln.lead .x .mm { color: var(--accent); }
.ln.warn .i, .ln.down .i { color: var(--danger); }
.ln.warn .x, .ln.down .x { color: var(--ink); }
.ln.warn .x .mm, .ln.down .x .mm { color: var(--danger); }
.ln.you { }
.ln.you .i { color: var(--accent); }
.ln.deny, .ln.vote { color: var(--muted); }
.ln.deny .mm, .ln.vote .mm { color: var(--muted); }

footer { margin-top: 18px; font: 500 11.5px var(--font-mono); color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
@media (max-width: 520px) {
  .wrap { padding: 16px 12px 22px; }
  .btn { padding: 8px 10px; font-size: 12.5px; }
  .bar { gap: 6px; }
  .ln { grid-template-columns: 42px 13px minmax(0,1fr); gap: 6px; font-size: 12px; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Consensus Visualizer</h1>
    <p>Five machines keep the same list of changes, with no shared clock and no boss.
       They run <b>Raft</b>: one machine is elected <b>leader</b>, it sends a heartbeat to
       everyone else, and a change only counts as <b>agreed</b> once a majority — 3 of the 5 —
       have written it down. Pause the leader and watch the rest notice the silence, hold a
       vote, and carry on without losing a thing.</p>
  </header>

  <div class="bar">
    <button class="btn primary" id="write" type="button">
      <span class="gl" aria-hidden="true">+</span>Submit a write <span class="kb" aria-hidden="true">W</span></button>
    <button class="btn" id="killlead" type="button">Pause the leader</button>
    <button class="btn" id="resumeall" type="button">Resume all</button>
    <button class="btn" id="freeze" type="button" aria-pressed="false">
      <span class="gl" aria-hidden="true">❙❙</span><span id="freezelbl">Freeze clock</span></button>
    <span class="grow"></span>
    <label class="sel" for="speed">speed
      <select id="speed">
        <option value="0.5">0.5&times;</option>
        <option value="1" selected>1&times;</option>
        <option value="2">2&times;</option>
        <option value="4">4&times;</option>
      </select>
    </label>
    <button class="btn" id="replay" type="button">Replay intro</button>
    <button class="btn" id="reset" type="button">Reset cluster</button>
  </div>

  <div class="strip" id="strip">
    <span class="pill" id="pill" data-s="lead">LEADER node 1</span>
    <span>term <b id="sterm">3</b></span>
    <span>agreed entries <b id="scommit">3</b></span>
    <span>running <b id="sup">5/5</b></span>
    <span>majority <b>3</b></span>
    <span>clock <b id="sclock">0.0s</b></span>
    <span class="grow"></span>
    <span class="mode" id="mode">intro playing — touch any control to take over</span>
  </div>

  <p class="sr" id="live" role="status" aria-live="polite"></p>

  <div class="banner" id="banner" data-k="info">
    <span class="tag" id="btag">intro</span>
    <span class="tx" id="btx">Starting up…</span>
  </div>

  <div class="stage">
    <div class="canvas">
      <svg class="board lay-wide" id="board" viewBox="0 0 700 462"
           role="group" aria-label="Cluster of five machines. Each machine can be focused with Tab and paused with Enter.">
        <g id="links"></g>
        <g id="pulses"></g>
        <g id="msgs"></g>
        <g id="center"></g>
        <g id="ndlayer"></g>
      </svg>
      <div class="legend">
        <span><i class="sw com"></i>agreed by a majority</span>
        <span><i class="sw rep"></i>written down, not yet agreed</span>
        <span><i class="sw bad"></i>disagrees with the leader</span>
        <span><i class="sw dot"></i>heartbeat</span>
        <span><i class="sw dot ent"></i>carrying entries</span>
        <span><i class="sw dot hollow"></i>asking for a vote</span>
        <span id="winlab">entries 1–8</span>
      </div>
    </div>

    <aside class="panel">
      <h2>Machines</h2>
      <ul class="nodes" id="nodelist"></ul>
      <hr class="sep">
      <h2>What everyone agrees on</h2>
      <div class="kv" id="kv"><span class="none">nothing yet</span></div>
      <hr class="sep">
      <h2>Try this</h2>
      <ul class="hint">
        <li>Hit <b>Submit a write</b> a few times fast — entries queue up behind one heartbeat.</li>
        <li><b>Pause the leader</b>, then watch the countdown rings: whoever empties first calls a vote.</li>
        <li>Pause a follower, submit 3 writes, then resume it — the leader tops it back up.</li>
        <li>Pause <b>3 of the 5</b>: writes stop turning green, because no majority is left.</li>
      </ul>
    </aside>
  </div>

  <div class="logwrap">
    <div class="loghd">
      <h2>Event log</h2>
      <span class="sub" id="logsub">newest first</span>
    </div>
    <div class="log" id="log" role="log" aria-live="off" tabindex="0"></div>
  </div>

  <footer>demo · sloprun.dev</footer>
</div>

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

/* ── theme bridge ───────────────────────────────────────────────────────── */
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 SVGNS = "http://www.w3.org/2000/svg";
var $ = function (id) { return document.getElementById(id); };
var mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
var reduce = mqReduce.matches;
if (mqReduce.addEventListener) mqReduce.addEventListener("change", function (e) { reduce = e.matches; });
else if (mqReduce.addListener) mqReduce.addListener(function (e) { reduce = e.matches; });

/* ── cluster constants (simulated milliseconds) ─────────────────────────── */
var N = 5, MAJ = 3;
var HB = 700;              /* leader heartbeat period                        */
var ELO = 2200, ELHI = 3600; /* randomized election timeout window           */
var LATLO = 220, LATHI = 400; /* one-way network delay                       */

/* ── deterministic rng so the intro always reads the same ───────────────── */
var seed = 0;
function srand(s) { seed = s >>> 0; }
function rng() {
  seed = (seed + 0x6D2B79F5) >>> 0;
  var t = seed;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}

/* ── state ──────────────────────────────────────────────────────────────── */
var nodes = [], msgs = [], pulses = [], pending = [];
var now = 0, msgSeq = 0, entrySeq = 0, writeIdx = 0;
var frozen = false, speed = 1;
var scripted = true, script = [], scriptIdx = 0;
var pendingWarned = false;
var newLines = [], logCount = 0;

var WRITES = [
  "set fruit = mango", "set mood = calm", "set level = 3", "set song = rain",
  "set color = amber", "set pet = cat", "set count = 12", "set drink = cocoa",
  "set city = oslo", "set light = warm", "set fruit = pear", "set level = 9"
];
function nextWrite() { var w = WRITES[writeIdx % WRITES.length]; writeIdx++; return w; }

function mkNode(id) {
  return { id: id, role: "follower", term: 1, votedFor: null, log: [], commit: 0,
           next: [], match: [], votes: null, down: false, leaderId: null,
           eldl: 0, elTotal: ELHI, hbdl: 0, repairing: [], behind: false };
}
function lastTerm(n) { return n.log.length ? n.log[n.log.length - 1].term : 0; }
function termAt(n, i) { return i <= 0 ? 0 : (i <= n.log.length ? n.log[i - 1].term : -1); }
function resetEl(n) { n.elTotal = ELO + rng() * (ELHI - ELO); n.eldl = now + n.elTotal; }
function leaderNode() {
  var best = null;
  for (var i = 0; i < N; i++) {
    var n = nodes[i];
    if (!n.down && n.role === "leader" && (!best || n.term > best.term)) best = n;
  }
  return best;
}
function upCount() { var c = 0; for (var i = 0; i < N; i++) if (!nodes[i].down) c++; return c; }
function refNode() {
  var l = leaderNode();
  if (l) return l;
  var best = nodes[0];
  for (var i = 1; i < N; i++) if (nodes[i].log.length > best.log.length) best = nodes[i];
  return best;
}
function maxTerm() { var t = 0; for (var i = 0; i < N; i++) t = Math.max(t, nodes[i].term); return t; }

/* ── event log ──────────────────────────────────────────────────────────── */
function esc(s) {
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function mm(s) { return '<b class="mm">' + esc(s) + "</b>"; }
function nd(i) { return mm("node " + (i + 1)); }
var ICON = { ok: "✓", lead: "▲", vote: "·", deny: "·", down: "✕",
             up: "↺", write: "→", warn: "!", you: "◆", info: "·" };
function say(kind, html) {
  newLines.push({ t: now, kind: kind, html: html });
  if (kind === "ok" || kind === "lead" || kind === "down" || kind === "warn") {
    $("live").textContent = html.replace(/<[^>]*>/g, "");
  }
}

/* ── network ────────────────────────────────────────────────────────────── */
function send(m) {
  if (nodes[m.from].down) return;
  m.id = ++msgSeq;
  m.t0 = now;
  m.t1 = now + LATLO + rng() * (LATHI - LATLO);
  msgs.push(m);
}
function deliver(m) {
  if (nodes[m.to].down || nodes[m.from].down) return;   /* dropped */
  var n = nodes[m.to];
  if (m.type === "rv") onRequestVote(n, m);
  else if (m.type === "rvr") onVoteReply(n, m);
  else if (m.type === "ae") onAppend(n, m);
  else if (m.type === "aer") onAppendReply(n, m);
}

/* ── raft ───────────────────────────────────────────────────────────────── */
function stepDown(n, term, why) {
  var wasLeader = n.role === "leader";
  n.term = term; n.votedFor = null;
  if (n.role !== "follower") {
    n.role = "follower";
    if (wasLeader) say("warn", nd(n.id) + " hears about a newer round (" + mm("term " + term) +
      ") — it stops leading and becomes a follower");
  }
  n.leaderId = null;
  resetEl(n);
}

function startElection(n) {
  n.term += 1;
  n.role = "candidate";
  n.votedFor = n.id;
  n.votes = {}; n.votes[n.id] = 1;
  n.leaderId = null;
  resetEl(n);
  say("lead", nd(n.id) + " heard nothing from a leader in time → it asks the others to make it " +
      "leader for " + mm("term " + n.term));
  for (var i = 0; i < N; i++) if (i !== n.id) {
    send({ type: "rv", from: n.id, to: i, term: n.term,
           lastIdx: n.log.length, lastTerm: lastTerm(n) });
  }
}

function onRequestVote(n, m) {
  if (m.term > n.term) stepDown(n, m.term);
  var granted = false, why = "";
  if (m.term < n.term) {
    why = "that round is older than its own (" + mm("term " + n.term) + ")";
  } else if (n.votedFor !== null && n.votedFor !== m.from) {
    why = "it already voted for " + nd(n.votedFor) + " this round";
  } else if (!(m.lastTerm > lastTerm(n) ||
              (m.lastTerm === lastTerm(n) && m.lastIdx >= n.log.length))) {
    why = "the asker's list is behind its own";
  } else {
    granted = true; n.votedFor = m.from; resetEl(n);
  }
  if (granted) say("vote", nd(n.id) + " votes for " + nd(m.from) + " in " + mm("term " + m.term));
  else say("deny", nd(n.id) + " turns down " + nd(m.from) + " — " + why);
  send({ type: "rvr", from: n.id, to: m.from, term: n.term, granted: granted });
}

function onVoteReply(n, m) {
  if (m.term > n.term) { stepDown(n, m.term); return; }
  if (n.role !== "candidate" || m.term !== n.term) return;
  if (!m.granted) return;
  n.votes[m.from] = 1;
  var c = 0, k;
  for (k in n.votes) if (n.votes[k]) c++;
  if (c >= MAJ) becomeLeader(n, c);
}

function becomeLeader(n, votes) {
  n.role = "leader";
  n.leaderId = n.id;
  n.next = []; n.match = []; n.repairing = [];
  for (var i = 0; i < N; i++) { n.next[i] = n.log.length + 1; n.match[i] = 0; n.repairing[i] = false; }
  n.log.push({ term: n.term, cmd: "— node " + (n.id + 1) + " takes over —",
               noop: true, id: ++entrySeq });
  n.match[n.id] = n.log.length;
  n.hbdl = now;
  say("lead", nd(n.id) + " got " + mm(votes + " of " + N) + " votes — it is the leader for " +
      mm("term " + n.term) + " and writes a marker entry so everyone lines up");
  if (scripted) narrate("New leader, new round. Nothing was lost — the old entries are still there, " +
                        "and the fresh leader tops the list up with a marker entry.");
}

function sendAE(l, f) {
  var prev = l.next[f] - 1;
  if (prev < 0) prev = 0;
  var ents = l.log.slice(prev).map(function (e) {
    return { term: e.term, cmd: e.cmd, id: e.id, noop: e.noop };
  });
  send({ type: "ae", from: l.id, to: f, term: l.term, prevIdx: prev,
         prevTerm: termAt(l, prev), entries: ents, lc: l.commit });
}

function onAppend(n, m) {
  if (m.term < n.term) {
    send({ type: "aer", from: n.id, to: m.from, term: n.term, ok: false, match: 0 });
    return;
  }
  if (m.term > n.term) { n.term = m.term; n.votedFor = null; }
  if (n.role === "candidate") {
    say("vote", nd(n.id) + " sees that " + nd(m.from) + " already leads " + mm("term " + m.term) +
        " — it drops out and follows");
  }
  n.role = "follower";
  n.leaderId = m.from;
  resetEl(n);

  if (m.prevIdx > n.log.length || termAt(n, m.prevIdx) !== m.prevTerm) {
    send({ type: "aer", from: n.id, to: m.from, term: n.term, ok: false, match: 0 });
    return;
  }
  var dropped = 0, added = 0;
  for (var i = 0; i < m.entries.length; i++) {
    var idx = m.prevIdx + 1 + i;
    if (n.log.length >= idx && n.log[idx - 1].term !== m.entries[i].term) {
      dropped += n.log.length - (idx - 1);
      n.log.length = idx - 1;
    }
    if (n.log.length < idx) { n.log.push(m.entries[i]); added++; }
  }
  if (dropped) {
    say("warn", nd(n.id) + " was holding " + mm(dropped + (dropped === 1 ? " entry" : " entries")) +
        " that never reached a majority — " + (dropped === 1 ? "it is" : "they are") +
        " thrown away, and the leader's copy wins");
  }
  if (n.behind && added) {
    n.behind = false;
    say("up", nd(n.id) + " caught up — it copied " + mm(added + (added === 1 ? " entry" : " entries")) +
        " from " + nd(m.from) + " and now matches");
  }
  var lastNew = m.prevIdx + m.entries.length;
  if (m.lc > n.commit) n.commit = Math.min(m.lc, lastNew);
  send({ type: "aer", from: n.id, to: m.from, term: n.term, ok: true, match: lastNew });
}

function onAppendReply(l, m) {
  if (m.term > l.term) { stepDown(l, m.term); return; }
  if (l.role !== "leader" || m.term !== l.term) return;
  var f = m.from;
  if (m.ok) {
    if (m.match > l.match[f]) l.match[f] = m.match;
    l.next[f] = l.match[f] + 1;
    if (l.repairing[f] && l.match[f] >= l.log.length) {
      l.repairing[f] = false;
      nodes[f].behind = false;
      say("up", nd(l.id) + " finished refilling " + nd(f) + " — both lists end at " +
          mm(l.log.length + " entries"));
    }
    advanceCommit(l);
  } else {
    if (!l.repairing[f]) {
      l.repairing[f] = true;
      say("up", nd(l.id) + " and " + nd(f) + " disagree about where the list ends — " +
          "the leader rewinds and sends the missing part");
    }
    if (l.next[f] > 1) l.next[f] -= 1;
    sendAE(l, f);
  }
}

function advanceCommit(l) {
  var before = l.commit;
  for (var idx = l.log.length; idx > l.commit; idx--) {
    if (l.log[idx - 1].term !== l.term) continue;
    var c = 1;
    for (var i = 0; i < N; i++) if (i !== l.id && l.match[i] >= idx) c++;
    if (c >= MAJ) { l.commit = idx; break; }
  }
  if (l.commit > before) {
    for (var k = before + 1; k <= l.commit; k++) {
      var cnt = 1;
      for (var j = 0; j < N; j++) if (j !== l.id && l.match[j] >= k) cnt++;
      var e = l.log[k - 1];
      if (e.noop) {
        say("ok", mm("entry " + k) + " (the marker) is on " + mm(cnt + " of " + N) + " — agreed");
      } else {
        say("ok", mm("entry " + k) + " " + mm(e.cmd) + " is on " + mm(cnt + " of " + N) +
            " machines — agreed, it can never be undone");
      }
    }
    flashCommit();
  }
}

/* ── client writes ──────────────────────────────────────────────────────── */
function submitWrite(cmd) { pending.push(cmd); pendingWarned = false; }

function drainPending() {
  if (!pending.length) return;
  var l = leaderNode();
  if (!l) {
    if (!pendingWarned) {
      pendingWarned = true;
      if (upCount() < MAJ) {
        say("warn", "only " + mm(upCount() + " of " + N) + " machines are running — a write needs " +
            mm(MAJ) + " to agree, so it has to wait");
      } else {
        say("warn", "no leader right now — your write waits for the vote to finish");
      }
    }
    return;
  }
  while (pending.length) {
    var cmd = pending.shift();
    l.log.push({ term: l.term, cmd: cmd, id: ++entrySeq });
    say("write", "you asked " + nd(l.id) + " to store " + mm(cmd) + " — it becomes " +
        mm("entry " + l.log.length) + " and goes out to everyone");
  }
  l.match[l.id] = l.log.length;
  if (!reduce) pulses.push({ node: l.id, t0: now });
  for (var i = 0; i < N; i++) if (i !== l.id) sendAE(l, i);
  l.hbdl = now + HB;
}

/* ── pause / resume ─────────────────────────────────────────────────────── */
function pauseNode(i, quiet) {
  var n = nodes[i];
  if (n.down) return;
  n.down = true;
  var wasLeader = n.role === "leader";
  n.role = "follower";
  n.leaderId = null;
  say("down", nd(i) + (wasLeader ? " (the leader)" : "") + " is paused — nothing gets in or out");
  if (upCount() < MAJ) {
    say("warn", "only " + mm(upCount() + " of " + N) + " machines left — that is short of the " +
        mm(MAJ + " needed") + ", so nothing new can be agreed");
  }
  if (!quiet) checkTakeover();
}
function resumeNode(i, quiet) {
  var n = nodes[i];
  if (!n.down) return;
  n.down = false;
  n.role = "follower";
  n.votes = null;
  n.behind = true;
  resetEl(n);
  say("up", nd(i) + " is back, still holding " + mm(n.log.length + " entries") +
      " — the leader will bring it up to date");
  if (!quiet) checkTakeover();
}
function toggleNode(i) { if (nodes[i].down) resumeNode(i); else pauseNode(i); }

/* ── simulation step ────────────────────────────────────────────────────── */
function step(dt) {
  now += dt;
  for (var i = msgs.length - 1; i >= 0; i--) {
    if (msgs[i].t1 <= now) { var m = msgs.splice(i, 1)[0]; deliver(m); }
  }
  for (var k = 0; k < N; k++) {
    var n = nodes[k];
    if (n.down) continue;
    if (n.role === "leader") {
      if (now >= n.hbdl) {
        n.hbdl = now + HB;
        if (!reduce) pulses.push({ node: n.id, t0: now });
        for (var f = 0; f < N; f++) if (f !== n.id) sendAE(n, f);
      }
    } else if (now >= n.eldl) {
      startElection(n);
    }
  }
  drainPending();
}

/* ── scripted intro ─────────────────────────────────────────────────────── */
function narrate(text, kind) {
  $("banner").setAttribute("data-k", kind || "info");
  $("btag").textContent = kind === "warn" ? "heads up" : (scripted ? "intro" : "note");
  $("btx").innerHTML = text;
}
function hasLeader() { return !!leaderNode(); }

function buildScript() {
  script = [
    { t: 300, fn: function () {
        narrate("Five machines, one shared list. " + mm("node 1") + " is the leader: the amber ring " +
                "is its heartbeat, sent every " + mm("700 ms") + " so nobody starts a vote."); } },
    { t: 2600, fn: function () {
        narrate("A client write arrives. Watch it travel out, come back acknowledged, and turn " +
                "green once " + mm("3 of 5") + " machines have it written down.");
        submitWrite(nextWrite()); } },
    { t: 6000, fn: function () {
        narrate("Now the leader takes one more write — and goes quiet in the same breath, before " +
                "anyone else copies it. That entry is stranded on " + mm("node 1") + " alone.", "warn");
        submitWrite(nextWrite());
        drainPending();
        pauseNode(0, true); } },
    { t: 8300, fn: function () {
        narrate("No heartbeats, so the countdown rings empty. Whoever runs out first asks the others " +
                "to vote for it — and each machine votes at most once per round, so only one can win."); } },
    { t: 13000, cond: hasLeader, fn: function () {
        narrate("The new leader takes a write of its own — same list, new round.");
        submitWrite(nextWrite()); } },
    { t: 16800, cond: hasLeader, fn: function () {
        narrate(mm("node 1") + " comes back. Its stranded entry never reached a majority, so it is " +
                "dropped and replaced by the leader's copy — but every entry that did reach a " +
                "majority is still exactly where it was.");
        resumeNode(0, true); } },
    { t: 21500, fn: function () { endIntro(); } }
  ];
  scriptIdx = 0;
}
function runScript() {
  while (scripted && scriptIdx < script.length) {
    var s = script[scriptIdx];
    if (now < s.t) break;
    if (s.cond && !s.cond()) break;
    scriptIdx++;
    s.fn();
  }
}
function endIntro() {
  scripted = false;
  script = []; scriptIdx = 0;
  $("mode").textContent = "you have the controls";
  $("mode").className = "";
  narrate("Over to you: submit writes, pause the leader, or pause " + mm("3 of 5") +
          " and watch agreement stop dead.", "idle");
}
function checkTakeover() {
  if (!scripted) return;
  scripted = false; script = []; scriptIdx = 0;
  $("mode").textContent = "you have the controls";
  $("mode").className = "";
  narrate("You took the controls mid-intro — the cluster keeps running exactly as it was.", "idle");
  say("you", "you took the controls");
}

/* ── layout ─────────────────────────────────────────────────────────────── */
var LAY = {
  wide:   { vb: "0 0 700 462", cx: 350, cy: 214, R: 136, r: 36, ring: 44,
            cardY: 44, cardW: 108, cardH: 50, roleY: 16, metaY: 29, sqY: 34,
            sqW: 10, sqH: 8, sqGap: 3, nsq: 8, pulse: 34, dotS: 3.6, dotB: 5.2 },
  narrow: { vb: "0 0 340 336", cx: 170, cy: 164, R: 106, r: 25, ring: 32,
            cardY: 32, cardW: 80, cardH: 42, roleY: 14, metaY: 25, sqY: 29,
            sqW: 9, sqH: 7, sqGap: 2, nsq: 6, pulse: 24, dotS: 3.2, dotB: 4.6 }
};
var L = LAY.wide, layName = "wide", pos = [];
function computePos() {
  pos = [];
  var ang = [-90, -18, 54, 126, 198];
  for (var i = 0; i < N; i++) {
    var a = ang[i] * Math.PI / 180;
    pos.push({ x: L.cx + L.R * Math.cos(a), y: L.cy + L.R * Math.sin(a) });
  }
}

/* ── build the board ────────────────────────────────────────────────────── */
var board = $("board"), gLinks = $("links"), gPulses = $("pulses"), gMsgs = $("msgs"),
    gCenter = $("center"), gNodes = $("ndlayer");
var nodeEls = [], linkEls = {}, msgEls = {}, pulseEls = {}, ctEls = {};
var prevTxt = {};

function el(tag, attrs) {
  var e = document.createElementNS(SVGNS, tag);
  for (var k in attrs) e.setAttribute(k, attrs[k]);
  return e;
}
function setTxt(e, key, v) { if (prevTxt[key] !== v) { prevTxt[key] = v; e.textContent = v; } }
function setCls(e, v) { if (e.getAttribute("class") !== v) e.setAttribute("class", v); }

function buildBoard() {
  computePos();
  board.setAttribute("viewBox", L.vb);
  board.setAttribute("class", "board lay-" + layName);
  gLinks.textContent = ""; gPulses.textContent = ""; gMsgs.textContent = "";
  pulses.length = 0;
  gCenter.textContent = ""; gNodes.textContent = "";
  nodeEls = []; linkEls = {}; msgEls = {}; pulseEls = {}; prevTxt = {};

  var i, j;
  for (i = 0; i < N; i++) for (j = i + 1; j < N; j++) {
    var ln = el("line", { x1: pos[i].x, y1: pos[i].y, x2: pos[j].x, y2: pos[j].y, "class": "link" });
    gLinks.appendChild(ln);
    linkEls[i + "-" + j] = ln;
  }

  ctEls.lab = el("text", { x: L.cx, y: L.cy - (layName === "wide" ? 26 : 22), "class": "ct-lab" });
  ctEls.lab.textContent = "ROUND";
  ctEls.num = el("text", { x: L.cx, y: L.cy + 2, "class": "ct-num" });
  ctEls.sub = el("text", { x: L.cx, y: L.cy + (layName === "wide" ? 22 : 18), "class": "ct-sub" });
  ctEls.sub.textContent = layName === "wide" ? "3 of 5 must agree" : "3 of 5 agree";
  gCenter.appendChild(ctEls.lab); gCenter.appendChild(ctEls.num); gCenter.appendChild(ctEls.sub);

  for (i = 0; i < N; i++) {
    var p = pos[i];
    var g = el("g", { "class": "nd", tabindex: "0", role: "button" });
    var refs = { g: g, sq: [] };
    refs.foc = el("rect", { "class": "foc", x: p.x - L.cardW / 2 - 4, y: p.y - L.ring - 6,
      width: L.cardW + 8, height: L.ring + 6 + L.cardY + L.cardH + 6, rx: 12 });
    refs.ring = el("circle", { "class": "ring", cx: p.x, cy: p.y, r: L.ring,
      transform: "rotate(-90 " + p.x + " " + p.y + ")" });
    refs.disc = el("circle", { "class": "disc", cx: p.x, cy: p.y, r: L.r });
    refs.num = el("text", { "class": "num", x: p.x, y: p.y + (layName === "wide" ? 7 : 5.5) });
    refs.num.textContent = String(i + 1);
    refs.card = el("rect", { "class": "card", x: p.x - L.cardW / 2, y: p.y + L.cardY,
      width: L.cardW, height: L.cardH, rx: 8 });
    refs.role = el("text", { "class": "role", x: p.x, y: p.y + L.cardY + L.roleY });
    refs.meta = el("text", { "class": "meta", x: p.x, y: p.y + L.cardY + L.metaY });
    g.appendChild(refs.foc); g.appendChild(refs.ring); g.appendChild(refs.disc);
    g.appendChild(refs.num); g.appendChild(refs.card); g.appendChild(refs.role);
    g.appendChild(refs.meta);
    var tot = L.nsq * (L.sqW + L.sqGap) - L.sqGap;
    for (j = 0; j < L.nsq; j++) {
      var r = el("rect", { "class": "sq empty", x: p.x - tot / 2 + j * (L.sqW + L.sqGap),
        y: p.y + L.cardY + L.sqY, width: L.sqW, height: L.sqH, rx: 1.5 });
      var ti = document.createElementNS(SVGNS, "title");
      r.appendChild(ti);
      r.__t = ti;
      g.appendChild(r);
      refs.sq.push(r);
    }
    (function (idx) {
      g.addEventListener("click", function () { toggleNode(idx); });
      g.addEventListener("keydown", function (ev) {
        if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
          ev.preventDefault(); toggleNode(idx);
        }
      });
    })(i);
    gNodes.appendChild(g);
    nodeEls.push(refs);
  }
  var C = 2 * Math.PI * L.ring;
  board.__C = C;
}

function pickLayout() {
  var w = board.parentNode.clientWidth || 700;
  var want = w < 520 ? "narrow" : "wide";
  if (want !== layName) { layName = want; L = LAY[want]; buildBoard(); }
}

/* ── node list rows ─────────────────────────────────────────────────────── */
var rowEls = [];
function buildRows() {
  var ul = $("nodelist");
  ul.textContent = "";
  rowEls = [];
  for (var i = 0; i < N; i++) {
    var li = document.createElement("li");
    var nid = document.createElement("span"); nid.className = "nid"; nid.textContent = "node " + (i + 1);
    var rp = document.createElement("span"); rp.className = "rp";
    var nm = document.createElement("span"); nm.className = "nm";
    var b = document.createElement("button"); b.className = "btn sm"; b.type = "button";
    (function (idx) { b.addEventListener("click", function () { toggleNode(idx); }); })(i);
    li.appendChild(nid); li.appendChild(rp); li.appendChild(nm); li.appendChild(b);
    ul.appendChild(li);
    rowEls.push({ li: li, rp: rp, nm: nm, b: b });
  }
}

/* ── render ─────────────────────────────────────────────────────────────── */
var flashT = 0;
function flashCommit() { flashT = 620; $("scommit").classList.add("flash"); }

function render(dtReal) {
  var i, j, n;
  var lead = leaderNode();
  var ref = refNode();
  var up = upCount();

  /* strip */
  var pill = $("pill");
  if (lead) { setTxt(pill, "pill", "LEADER node " + (lead.id + 1)); pill.setAttribute("data-s", "lead"); }
  else {
    var cand = false;
    for (i = 0; i < N; i++) if (!nodes[i].down && nodes[i].role === "candidate") cand = true;
    setTxt(pill, "pill", cand ? "VOTE IN PROGRESS" : "NO LEADER");
    pill.setAttribute("data-s", cand ? "vote" : "none");
  }
  setTxt($("sterm"), "term", String(maxTerm()));
  setTxt($("scommit"), "commit", String(ref.commit));
  setTxt($("sup"), "up", up + "/" + N);
  $("sup").className = up < MAJ ? "warnup" : "";
  setTxt($("sclock"), "clock", (now / 1000).toFixed(1) + "s");

  if (flashT > 0) { flashT -= dtReal; if (flashT <= 0) $("scommit").classList.remove("flash"); }

  /* center */
  setTxt(ctEls.num, "ctnum", String(maxTerm()));

  /* links */
  var hub = lead ? lead.id : -1;
  if (hub < 0) for (i = 0; i < N; i++) if (!nodes[i].down && nodes[i].role === "candidate") hub = nodes[i].id;
  for (i = 0; i < N; i++) for (j = i + 1; j < N; j++) {
    var spoke = (i === hub || j === hub) && !nodes[i].down && !nodes[j].down;
    setCls(linkEls[i + "-" + j], spoke ? "link spoke" : "link");
  }

  /* nodes */
  var maxLen = 0;
  for (i = 0; i < N; i++) maxLen = Math.max(maxLen, nodes[i].log.length);
  var start = Math.max(1, maxLen - L.nsq + 1);
  setTxt($("winlab"), "winlab", maxLen ? ("showing entries " + start + "–" + Math.max(maxLen, start)) : "no entries yet");

  var C = board.__C;
  for (i = 0; i < N; i++) {
    n = nodes[i];
    var e = nodeEls[i];
    var cls = "nd " + (n.down ? "down" : n.role);
    setCls(e.g, cls);

    var roleTxt = n.down ? "PAUSED" : (n.role === "leader" ? "LEADER" :
      (n.role === "candidate" ? "CANDIDATE" : "FOLLOWER"));
    setTxt(e.role, "role" + i, roleTxt);

    var meta;
    if (n.role === "candidate" && !n.down) {
      var v = 0, kk; for (kk in n.votes) if (n.votes[kk]) v++;
      meta = "term " + n.term + " · " + v + "/" + N + " votes";
    } else {
      meta = "term " + n.term + " · " + n.log.length + " entries";
    }
    setTxt(e.meta, "meta" + i, meta);

    /* countdown / heartbeat ring */
    var frac;
    if (n.down) frac = 0;
    else if (n.role === "leader") frac = Math.max(0, Math.min(1, (n.hbdl - now) / HB));
    else frac = Math.max(0, Math.min(1, (n.eldl - now) / n.elTotal));
    if (reduce) frac = Math.round(frac * 8) / 8;
    e.ring.setAttribute("stroke-dasharray", (frac * C).toFixed(1) + " " + C.toFixed(1));

    /* log squares */
    for (j = 0; j < L.nsq; j++) {
      var idx = start + j, sq = e.sq[j], sc, tt;
      if (idx > n.log.length) { sc = "sq empty"; tt = "entry " + idx + " · not here yet"; }
      else {
        var ent = n.log[idx - 1];
        if (ref !== n && idx <= ref.log.length && ref.log[idx - 1].term !== ent.term) {
          sc = "sq bad";
          tt = "entry " + idx + " · term " + ent.term + " · " + ent.cmd + " · conflicts with the leader";
        } else if (idx <= n.commit) {
          sc = "sq com"; tt = "entry " + idx + " · term " + ent.term + " · " + ent.cmd + " · agreed";
        } else {
          sc = "sq rep"; tt = "entry " + idx + " · term " + ent.term + " · " + ent.cmd + " · not yet agreed";
        }
      }
      setCls(sq, sc);
      if (sq.__t.textContent !== tt) sq.__t.textContent = tt;
    }

    var lab = "node " + (i + 1) + ", " + roleTxt.toLowerCase() + ", " + meta +
      ". Press Enter to " + (n.down ? "resume" : "pause") + " it.";
    if (e.g.getAttribute("aria-label") !== lab) e.g.setAttribute("aria-label", lab);

    /* side rows */
    var row = rowEls[i];
    var rk = n.down ? "down" : n.role;
    if (row.li.getAttribute("data-r") !== rk) row.li.setAttribute("data-r", rk);
    setTxt(row.rp, "rp" + i, roleTxt);
    setTxt(row.nm, "nm" + i, "t" + n.term + " · " + n.log.length + "e");
    setTxt(row.b, "rb" + i, n.down ? "Resume" : "Pause");
    var bl = (n.down ? "Resume" : "Pause") + " node " + (i + 1);
    if (row.b.getAttribute("aria-label") !== bl) row.b.setAttribute("aria-label", bl);
  }

  /* messages */
  var seen = {};
  for (i = 0; i < msgs.length; i++) {
    var m = msgs[i];
    seen[m.id] = 1;
    var me = msgEls[m.id];
    if (!me) {
      var mc = "msg ";
      if (m.type === "rv") mc += "rv";
      else if (m.type === "rvr") mc += "rvr" + (m.granted ? "" : " no");
      else if (m.type === "ae") mc += "ae" + (m.entries.length ? " ent" : "");
      else mc += "aer" + (m.ok ? "" : " bad");
      var rr = (m.type === "ae" && m.entries.length) ? L.dotB : L.dotS;
      me = el("circle", { "class": mc, r: rr });
      gMsgs.appendChild(me);
      msgEls[m.id] = me;
    }
    var a = pos[m.from], b = pos[m.to];
    var dx = b.x - a.x, dy = b.y - a.y, len = Math.sqrt(dx * dx + dy * dy) || 1;
    var ux = dx / len, uy = dy / len;
    var trim = L.r + 5;
    var ax = a.x + ux * trim, ay = a.y + uy * trim;
    var bx = b.x - ux * trim, by = b.y - uy * trim;
    var off = layName === "wide" ? 7 : 5;
    var px = -uy * off, py = ux * off;
    var t = (now - m.t0) / (m.t1 - m.t0);
    if (t < 0) t = 0; else if (t > 1) t = 1;
    if (reduce) t = Math.round(t * 4) / 4;
    me.setAttribute("cx", (ax + (bx - ax) * t + px).toFixed(1));
    me.setAttribute("cy", (ay + (by - ay) * t + py).toFixed(1));
  }
  for (var mid in msgEls) if (!seen[mid]) { gMsgs.removeChild(msgEls[mid]); delete msgEls[mid]; }

  /* heartbeat pulses */
  for (i = pulses.length - 1; i >= 0; i--) {
    var pu = pulses[i], age = (now - pu.t0) / 620;
    if (age >= 1 || nodes[pu.node].down) {
      if (pu.el && pu.el.parentNode) pu.el.parentNode.removeChild(pu.el);
      pulses.splice(i, 1);
      continue;
    }
    if (!pu.el) {
      pu.el = el("circle", { "class": "pulse", cx: pos[pu.node].x, cy: pos[pu.node].y });
      gPulses.appendChild(pu.el);
    }
    pu.el.setAttribute("r", (L.r + age * L.pulse).toFixed(1));
    pu.el.setAttribute("opacity", (0.55 * (1 - age)).toFixed(3));
  }

  /* agreed state */
  var kv = {}, order = [];
  for (i = 0; i < ref.commit && i < ref.log.length; i++) {
    var c = ref.log[i].cmd;
    var mt = /^set\s+(\S+)\s*=\s*(.+)$/.exec(c);
    if (mt) { if (!(mt[1] in kv)) order.push(mt[1]); kv[mt[1]] = mt[2]; }
  }
  var kvs = order.map(function (k) { return k + "=" + kv[k]; }).join("|");
  if (prevTxt.kv !== kvs) {
    prevTxt.kv = kvs;
    var box = $("kv");
    box.textContent = "";
    if (!order.length) {
      var s = document.createElement("span"); s.className = "none";
      s.textContent = "nothing yet"; box.appendChild(s);
    } else {
      order.forEach(function (k) {
        var s = document.createElement("span"); s.className = "chip";
        s.textContent = k + " = " + kv[k]; box.appendChild(s);
      });
    }
  }

  /* buttons */
  var kb = $("killlead");
  if (kb.disabled !== !lead) kb.disabled = !lead;
  var anyDown = false;
  for (i = 0; i < N; i++) if (nodes[i].down) anyDown = true;
  if ($("resumeall").disabled !== !anyDown) $("resumeall").disabled = !anyDown;

  /* log */
  if (newLines.length) {
    var log = $("log");
    for (i = 0; i < newLines.length; i++) {
      var ln = newLines[i];
      var d = document.createElement("div");
      d.className = "ln " + ln.kind;
      d.innerHTML = '<span class="t">' + (ln.t / 1000).toFixed(1) + 's</span>' +
                    '<span class="i" aria-hidden="true">' + (ICON[ln.kind] || "·") + "</span>" +
                    '<span class="x">' + ln.html + "</span>";
      log.insertBefore(d, log.firstChild);
      logCount++;
    }
    newLines.length = 0;
    while (logCount > 140 && log.lastChild) { log.removeChild(log.lastChild); logCount--; }
  }
}

/* ── cluster setup ──────────────────────────────────────────────────────── */
function initCluster() {
  srand(20260813);
  nodes = []; msgs = []; pulses = []; pending = [];
  now = 0; msgSeq = 0; entrySeq = 3; writeIdx = 0; pendingWarned = false;
  var seedLog = [
    { term: 1, cmd: "set color = teal", id: 1 },
    { term: 2, cmd: "set count = 7", id: 2 },
    { term: 3, cmd: "set city = lisbon", id: 3 }
  ];
  var i;
  for (i = 0; i < N; i++) {
    var n = mkNode(i);
    n.term = 3;
    n.votedFor = 0;
    n.log = seedLog.map(function (e) { return { term: e.term, cmd: e.cmd, id: e.id }; });
    n.commit = 3;
    n.leaderId = 0;
    resetEl(n);
    nodes.push(n);
  }
  var l = nodes[0];
  l.role = "leader";
  for (i = 0; i < N; i++) { l.next[i] = 4; l.match[i] = 3; l.repairing[i] = false; }
  l.hbdl = now + 200;
  gPulses.textContent = ""; gMsgs.textContent = ""; msgEls = {}; prevTxt = {};
  pulses.length = 0;
  $("log").textContent = ""; logCount = 0; newLines.length = 0;
  say("info", "cluster is up — " + nd(0) + " leads " + mm("term 3") + ", and " + mm("3 entries") +
      " are already agreed on all " + mm("5 machines"));
}

function startIntro() {
  initCluster();
  scripted = true;
  buildScript();
  $("mode").textContent = "intro playing — touch any control to take over";
  $("mode").className = "mode";
  narrate("Starting up…");
}
function hardReset() {
  initCluster();
  scripted = false; script = []; scriptIdx = 0;
  $("mode").textContent = "you have the controls";
  $("mode").className = "";
  narrate("Fresh cluster, back to " + mm("term 3") + ". Submit writes, or pause a machine and " +
          "watch the others cope.", "idle");
}

/* ── controls ───────────────────────────────────────────────────────────── */
$("write").addEventListener("click", function () {
  checkTakeover();
  submitWrite(nextWrite());
});
$("killlead").addEventListener("click", function () {
  checkTakeover();
  var l = leaderNode();
  if (l) pauseNode(l.id, true);
});
$("resumeall").addEventListener("click", function () {
  checkTakeover();
  for (var i = 0; i < N; i++) if (nodes[i].down) resumeNode(i, true);
});
$("freeze").addEventListener("click", function () {
  frozen = !frozen;
  this.setAttribute("aria-pressed", frozen ? "true" : "false");
  $("freezelbl").textContent = frozen ? "Start clock" : "Freeze clock";
  if (frozen) { checkTakeover(); say("you", "clock frozen — nothing moves until you start it again"); }
  else say("you", "clock running again");
});
$("speed").addEventListener("change", function () { speed = parseFloat(this.value) || 1; });
$("replay").addEventListener("click", function () { startIntro(); });
$("reset").addEventListener("click", function () { hardReset(); });

document.addEventListener("keydown", function (ev) {
  var t = ev.target, tag = t && t.tagName ? t.tagName.toLowerCase() : "";
  if (tag === "input" || tag === "select" || tag === "textarea") return;
  if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
  if (ev.key === "w" || ev.key === "W") { ev.preventDefault(); checkTakeover(); submitWrite(nextWrite()); }
});

/* ── main loop ──────────────────────────────────────────────────────────── */
var last = 0, lastRender = -1e9;
function frame(ts) {
  requestAnimationFrame(frame);
  if (!last) last = ts;
  var dtr = ts - last;
  last = ts;
  if (dtr > 60) dtr = 60;
  if (dtr < 0) dtr = 0;
  if (!frozen) {
    step(dtr * speed);
    runScript();
  }
  var ri = reduce ? 150 : 0;
  if (ts - lastRender >= ri) { render(ts - lastRender); lastRender = ts; }
}

/* ── boot ───────────────────────────────────────────────────────────────── */
buildRows();
pickLayout();
buildBoard();
startIntro();
render(16);
requestAnimationFrame(frame);

var rt = 0;
window.addEventListener("resize", function () {
  clearTimeout(rt);
  rt = setTimeout(function () { pickLayout(); }, 120);
});

})();
</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