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.
Source — the code you see is the code that just ran 1217 lines 54.2 KB 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×</option>
<option value="1" selected>1×</option>
<option value="2">2×</option>
<option value="4">4×</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, "&").replace(/</g, "<").replace(/>/g, ">");
}
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 totalNo comments yet. If you ran it, say what happened.