aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard/dashboard.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/dashboard/dashboard.html')
-rw-r--r--src/api/dashboard/dashboard.html2198
1 files changed, 2198 insertions, 0 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
new file mode 100644
index 0000000..1b9a8cb
--- /dev/null
+++ b/src/api/dashboard/dashboard.html
@@ -0,0 +1,2198 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>continuum</title>
+<style>
+ :root {
+ --bg: #fafaf9; --fg: #1c1917; --muted: #78716c; --line: #e7e5e4; --panel: #ffffff;
+ --ok: #15803d; --warn: #c2410c; --bad: #b91c1c; --intended: #a8a29e; --self: #1d4ed8;
+ /* How strong the grey under a console message is, pointed at or shown, and under
+ its nodes and link on the map. */
+ --pointed: .12; --shown: .22;
+ /* How strong the halo of a node or link is, pointed at or in focus, and the tint of
+ the console messages exchanged with it. */
+ --halo-hovered: .15; --halo-focused: .35;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1c1917; --fg: #f5f5f4; --muted: #a8a29e; --line: #44403c; --panel: #292524;
+ --ok: #4ade80; --warn: #fb923c; --bad: #f87171; --intended: #57534e; --self: #60a5fa;
+ }
+ }
+ * { box-sizing: border-box; }
+ html { background: var(--bg); }
+ /* The window's height, unless it is way too small: then the page scrolls. */
+ body { margin: 0; background: var(--bg); color: var(--fg);
+ font: 14px/1.4 system-ui, sans-serif;
+ height: 100vh; min-height: 480px; display: flex; flex-direction: column; }
+ header { display: flex; gap: 1em; align-items: baseline; padding: 12px 16px;
+ border-bottom: 1px solid var(--line); flex-wrap: wrap; }
+ header h1 { font-size: 16px; margin: 0; }
+ #connection { margin-left: auto; color: var(--muted); }
+ #clock-warning, #peers .clock-off { color: var(--warn); }
+ #connection.live { color: var(--ok); }
+ #connection.down { color: var(--bad); }
+ /* A dot in the status's colour that breathes once with every event. */
+ #dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
+ background: currentColor; margin-right: 6px; vertical-align: 1px; opacity: .45; }
+ #dot.pulse { animation: breath .9s ease-in-out; }
+ @keyframes breath {
+ 0% { opacity: .45; transform: scale(1); }
+ 30% { opacity: 1; transform: scale(1.35); box-shadow: 0 0 6px currentColor; }
+ 100% { opacity: .45; transform: scale(1); }
+ }
+ @media (prefers-reduced-motion: reduce) { #dot.pulse { animation-duration: .01s; } }
+ body.stale main { opacity: .55; }
+ /* Swarm on the left, the full height; peers (as tall as their rows, up to 8) above
+ the figures (the rest) on the right. */
+ /* The columns and rows as dragged (`--columns`, `--rows`, set by `sizes`). */
+ main { flex: 1; min-height: 0; display: grid; gap: 16px; padding: 16px;
+ grid-template-columns: var(--columns, minmax(0, 3fr) minmax(0, 2fr));
+ grid-template-rows: var(--rows, auto minmax(0, .4fr) minmax(0, .4fr) minmax(0, .2fr));
+ grid-template-areas: "map peers" "map figures" "map document" "map console"; }
+ @media (max-width: 900px) {
+ body { height: auto; }
+ /* Stacked: each box as tall as set here, a collapsed one as its title. */
+ main { grid-template-columns: minmax(0, 1fr); grid-template-rows: none;
+ grid-auto-rows: auto; grid-template-areas: "map" "peers" "figures" "document" "console"; }
+ #map, #figures-box, #document-box { height: 70vh; }
+ #console-box { height: 40vh; }
+ main > section.collapsed { height: auto; }
+ }
+ section { background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
+ padding: 12px; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
+ section h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
+ color: var(--muted); margin: 0 0 8px; flex: none; }
+ section h2 .what { text-transform: none; letter-spacing: 0; color: var(--fg); }
+ /* Collapsed: the title only, and nothing drawn in it (`busy`). */
+ button.collapse { font: inherit; padding: 0 4px 0 0; color: var(--muted); background: none;
+ border: none; cursor: pointer; }
+ button.collapse:hover { color: var(--fg); }
+ section.collapsed > :not(h2) { display: none; }
+ section.collapsed { align-self: start; }
+ section.collapsed h2 { margin: 0; }
+ section.collapsed h2 .toggle { display: none; }
+ /* The right column dragged off to the side: the map takes the width. */
+ @media (min-width: 901px) {
+ main.right-hidden > section:not(#map) { display: none; }
+ /* The right column all collapsed: its titles at the top, a hint below them. */
+ main.right-shut { grid-template-rows: auto auto auto auto minmax(0, 1fr);
+ grid-template-areas: "map peers" "map figures" "map document" "map console" "map hint"; }
+ main.right-shut #column-hint { display: block; }
+ }
+ #column-hint { grid-area: hint; display: none; font-size: 12px; }
+ section h2 .what .id { color: var(--muted); font-weight: normal; word-break: break-all; }
+ /* The space between boxes, where they are resized. */
+ main.resize-columns, main.resize-columns * { cursor: col-resize; }
+ main.resize-rows, main.resize-rows * { cursor: row-resize; }
+ /* The peers dragged to a height: the table fills it, and scrolls. */
+ main.peers-sized #peers-scroll { flex: 1; max-height: none !important; }
+ #map { grid-area: map; }
+ #peers-box { grid-area: peers; }
+ #figures-box { grid-area: figures; }
+ #document-box { grid-area: document; }
+ #console-box { grid-area: console; }
+ #console-box .scroll { flex: 1; }
+ .console { font-family: ui-monospace, monospace; font-size: 12px; white-space: pre-wrap; }
+ .console .when { color: var(--muted); }
+ /* Room on the left for the bar of a related message, so it covers no text. */
+ .console > div { cursor: pointer; border-left: 3px solid transparent; padding-left: 4px; }
+ /* Of one exchange with the message pointed at or shown (`related`): a lighter grey, and
+ the bar. */
+ /* Exchanged with the node or over the link pointed at on the map, or in focus: the
+ colour of its halo; in focus wins. */
+ .console > div.peer-hovered {
+ background: color-mix(in srgb, var(--self) calc(var(--halo-hovered) * 100%), transparent); }
+ .console > div.peer-focused {
+ background: color-mix(in srgb, var(--self) calc(var(--halo-focused) * 100%), transparent); }
+ .console > div.related { background: rgba(127, 127, 127, .08); border-left-color: var(--self); }
+ .console > div:hover { background: rgb(127 127 127 / var(--pointed)); }
+ .console > div.focused { background: rgb(127 127 127 / var(--shown)); }
+ /* The message takes the box's width and scrolls with it, not in a box of its own. */
+ #stats pre.message { margin: 4px 0 0; font-size: 12px; white-space: pre-wrap;
+ max-height: none; overflow: visible; }
+ svg { flex: 1; min-height: 0; width: 100%; display: block; cursor: default; }
+ /* Dragged to pan or to move a node, also by touch. */
+ /* Dragged, not selected: neither its labels' text nor the picture itself. */
+ #graph { touch-action: none; user-select: none; -webkit-user-select: none; }
+ #graph.dragging, #graph.dragging * { cursor: grabbing; }
+ svg circle { cursor: pointer; }
+ /* Labels on an underlay of their own shape, so they read where they lie on a line. */
+ #graph text { paint-order: stroke; stroke: var(--panel); stroke-width: 4px;
+ stroke-linejoin: round; }
+ .legend { color: var(--muted); font-size: 12px; display: flex; gap: 1em; flex-wrap: wrap;
+ flex: none; }
+ .legend span::before { content: ""; display: inline-block; width: 18px; height: 0;
+ vertical-align: middle; margin-right: 4px; border-top: 2px solid var(--colour, currentColor); }
+ .legend .intended::before { border-top-style: dashed; --colour: var(--intended); }
+ .legend .used::before { border-top-width: 3px; --colour: var(--muted); }
+ .legend .other::before { border-top-width: 1px; --colour: var(--muted); }
+ .legend .direct::before { --colour: var(--muted); }
+ .legend .relayed::before { border-top-style: dashed; --colour: var(--muted); }
+ .legend .dormant::before { border-top-width: 1.5px; --colour: var(--muted);
+ box-shadow: 0 0 0 2.5px color-mix(in srgb, var(--bad) 50%, transparent); }
+ .legend .silent::before { border-top-style: dotted; --colour: var(--muted); }
+ .legend .hint::before { display: none; }
+ /* A kind of message: a dot in its colour, as on the map. */
+ .legend .message::before { width: 8px; height: 8px; border: none; border-radius: 50%;
+ background: var(--colour); }
+ /* The dots' size, right after their legend, no taller than its text. */
+ .legend .dot-size { display: inline-flex; align-items: center; gap: 4px; }
+ .legend .dot-size::before { display: none; }
+ .legend .dot-size input { width: 6em; height: 1em; margin: 0; }
+ /* After the slider, as tall; the kinds and the slider greyed while the dots are off. */
+ .legend button.toggle { float: none; margin-left: 0; line-height: 1em; }
+ .legend.off .message, .legend.off .dot-size { opacity: .45; }
+ #legends { flex: none; }
+ /* Hides the legend, and shows it again. */
+ button.toggle { font: inherit; font-size: 12px; text-transform: none; letter-spacing: 0;
+ float: right; margin-left: 6px; padding: 0 6px; color: var(--muted); background: none;
+ border: 1px solid var(--line); border-radius: 3px; cursor: pointer; }
+ button.toggle:hover { color: var(--fg); border-color: var(--muted); }
+ /* Nodes: circles, as on the map. */
+ .legend .node::before { width: 10px; height: 10px; border: 1.5px solid var(--fg);
+ border-radius: 50%; background: var(--fg); }
+ .legend .node.silent::before { background: var(--panel); }
+ .legend .node.self::before { border-color: var(--self); background: var(--self); }
+ .legend .node.dormant-node::before { border: 2.5px solid var(--bad); }
+ .legend .node.clock-off::before { width: 14px; height: 14px; background: none;
+ border: 1.5px dashed var(--warn); }
+ .legend .node.focus::before { width: 14px; height: 14px; background: var(--self);
+ border: none; opacity: .35; }
+ .scroll { overflow: auto; min-height: 0; }
+ #figures-box .scroll, #document-box .scroll { flex: 1; }
+ .tree pre { margin: 2px 0 4px 1.1em; white-space: pre-wrap; word-break: break-all;
+ max-height: 20em; overflow: auto; }
+ .note { color: var(--muted); font-style: italic; }
+ .error { color: var(--bad); }
+ .tree button.add { font: inherit; line-height: 1; padding: 0 4px; margin-left: 4px;
+ color: var(--muted); background: none; border: 1px solid var(--line); border-radius: 3px;
+ cursor: pointer; }
+ .tree button.add:hover { color: var(--fg); border-color: var(--muted); }
+ .tree .nested { padding-left: 1.1em; }
+ .editor input.extra[type=text] { width: 12em; margin-bottom: 2px; display: block; }
+ .editor { margin: 2px 0 4px; }
+ .tree details > .editor { margin-left: 1.1em; }
+ .editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg);
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; }
+ .editor button { font: inherit; }
+ .editor button.delete { color: #fff; background: var(--bad); border: 1px solid var(--bad);
+ border-radius: 3px; }
+ .tree details.editing > :not(summary):not(.editor) { display: none; }
+ .tree .leaf[title] { cursor: text; }
+ table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
+ th, td { text-align: left; padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line);
+ white-space: nowrap; }
+ th { color: var(--muted); font-weight: 500; position: sticky; top: 0;
+ background: var(--panel); }
+ #peers tr[data-peer] { cursor: pointer; }
+ #peers tr.focused td { background: var(--line); }
+ .id { font-family: ui-monospace, monospace; font-size: 12px; }
+ /* A peer's status (not the legend's "dormant"). */
+ #peers .active { color: var(--ok); } #peers .dormant { color: var(--bad); }
+ #peers .unknown { color: var(--muted); }
+ .tree { font-family: ui-monospace, monospace; font-size: 12px; }
+ /* Every entry indented alike: a branch's triangle sits in the indentation, left of
+ where the text of every entry begins. */
+ .tree details, .tree .leaf { position: relative; padding-left: 1.1em; }
+ .tree summary { cursor: pointer; list-style: none; }
+ .tree summary::-webkit-details-marker { display: none; }
+ .tree summary::before { content: "▸"; position: absolute; left: .1em; color: var(--muted); }
+ .tree details[open] > summary::before { content: "▾"; }
+ .tree details > .note { padding-left: 1.1em; }
+ .key { color: var(--muted); }
+</style>
+</head>
+<body>
+<header>
+ <h1>continuum</h1>
+ <span id="node" class="id"></span>
+ <span id="clock-warning"></span>
+ <span id="connection"><span id="dot"></span><span id="connection-text">connecting…</span></span>
+</header>
+<main>
+ <section id="map">
+ <h2>Swarm <button id="legend-toggle" class="toggle">hide legend</button>
+ <button id="rtt-toggle" class="toggle">layout: static</button>
+ <button id="links-toggle" class="toggle">links: live</button></h2>
+ <div id="legends">
+ <div class="legend">
+ <span class="node self">this node</span>
+ <span class="node">reports its links</span>
+ <span class="node silent">reports none (no LINKS_REPLY)</span>
+ <span class="node dormant-node">dormant (to this node)</span>
+ <span class="node clock-off">clock off the swarm's</span>
+ <span class="node focus">in focus (click a node)</span>
+ </div>
+ <div class="legend" id="legend"></div>
+ <div class="legend" id="messages-legend"><button id="flows-toggle"
+ class="toggle">messages: on</button></div>
+ <div class="legend">
+ <span class="hint">drag to pan, wheel or pinch to zoom, turn with two fingers;
+ drag a node to move it, double-click it to pin it there or let it go</span>
+ </div>
+ </div>
+ <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g></g></svg>
+ </section>
+ <section id="peers-box">
+ <h2>Peers</h2>
+ <div class="scroll" id="peers-scroll"><table id="peers"></table></div>
+ </section>
+ <section id="figures-box">
+ <h2><span id="figures-title">Figures</span></h2>
+ <div class="scroll"><div id="stats" class="tree"></div></div>
+ </section>
+ <section id="document-box">
+ <h2>Document</h2>
+ <div class="scroll"><div id="document" class="tree"></div></div>
+ </section>
+ <div id="column-hint" class="note">Drag the space left of these boxes to the right to
+ hide the column.</div>
+ <section id="console-box">
+ <h2>Console</h2>
+ <div class="scroll" id="console-scroll"><div id="console" class="console"></div></div>
+ </section>
+</main>
+<script>
+"use strict";
+// Which page this is (the node fills it in); a snapshot naming another came from
+// another build: reloaded then, to get its page.
+const PAGE_VERSION = "__PAGE_VERSION__";
+// The state as the node published it, and when it did (the last event).
+let state = null;
+let stateAt = 0;
+
+// Fields that grow by themselves: the node's list (`GROWING` and `is_age` in its
+// dashboard), filled in when it serves the page.
+const GROWING = new Set(__GROWING__);
+const isAge = key => key === "secsAgo" || key.endsWith("SecsAgo") || GROWING.has(key);
+const elapsed = () => Math.floor((Date.now() - stateAt) / 1000);
+const aged = (key, value) =>
+ isAge(key) && typeof value === "number" ? value + elapsed() : value;
+
+// RFC 7386.
+function mergePatch(target, patch) {
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return patch;
+ const result = target !== null && typeof target === "object" && !Array.isArray(target)
+ ? target : {};
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === null) delete result[key];
+ else result[key] = mergePatch(result[key], value);
+ }
+ return result;
+}
+
+function el(tag, attrs, ...children) {
+ const node = document.createElement(tag);
+ for (const [key, value] of Object.entries(attrs || {})) node.setAttribute(key, value);
+ for (const child of children) {
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
+ }
+ return node;
+}
+const SVG = "http://www.w3.org/2000/svg";
+function svg(tag, attrs) {
+ const node = document.createElementNS(SVG, tag);
+ for (const [key, value] of Object.entries(attrs || {})) node.setAttribute(key, value);
+ return node;
+}
+
+const short = id => id.length > 14 ? id.slice(0, 6) + "…" + id.slice(-6) : id;
+function name(id) {
+ const member = state.graph.members[id];
+ return (member && member.alias) || (state.peers.peers[id] || {}).alias || short(id);
+}
+// Milliseconds as signed seconds: "+5.0 s".
+const seconds = ms => (ms < 0 ? "−" : "+") + (Math.abs(ms) / 1000).toFixed(1) + " s";
+// Whether `clock` (a peer's, `/peers`) differs from this node's beyond the limit for sure.
+function clockOff(clock) {
+ const limit = (state.stats.clock || {}).limitMs || 0;
+ return !!clock && limit > 0 && Math.abs(clock.offsetMs) > clock.withinMs + limit;
+}
+function age(secs) {
+ if (secs === null || secs === undefined) return "";
+ if (secs < 120) return secs + " s";
+ if (secs < 7200) return Math.floor(secs / 60) + " min";
+ if (secs < 172800) return Math.floor(secs / 3600) + " h";
+ return Math.floor(secs / 86400) + " d";
+}
+
+// What is in focus, its figures shown: a node's ID, or a link's pair key (`pairKey`,
+// two IDs). null: this node's (`/admin/stats`).
+let focused = (() => {
+ try { return decodeURIComponent(location.hash.slice(1)) || null; } catch { return null; }
+})();
+const pairKey = (a, b) => a < b ? a + " " + b : b + " " + a;
+const isPair = key => key !== null && key.includes(" ");
+function focus(id) {
+ focused = id === null || id === state.node.id ? null : id;
+ if (shownMessage !== null) {
+ shownMessage = null;
+ document.querySelectorAll("#console > div.focused").forEach(line => line.classList.remove("focused"));
+ markRelated(null);
+ }
+ // In the URL too, so a reload (a new version) keeps it.
+ try {
+ history.replaceState(null, "", focused === null ? location.pathname
+ : "#" + encodeURIComponent(focused));
+ } catch { /* not where history works */ }
+ render();
+ markPeerMessages();
+}
+
+// --- Peers ---
+function renderPeers() {
+ const table = document.getElementById("peers");
+ const rows = [el("tr", {}, ...["peer", "status", "seen", "via", "rtt", "clock", "neighbour",
+ "same epoch"]
+ .map(h => el("th", {}, h)))];
+ for (const [id, peer] of Object.entries(state.peers.peers)) {
+ const destination = peer.destination;
+ const path = destination && destination.paths && destination.paths[0];
+ const rtt = path && typeof path.rttMs === "number" ? path.rttMs.toFixed(1) + " ms" : "";
+ const via = destination
+ ? destination.address + (destination.connection ? " (" + destination.connection + ")" : "")
+ : "";
+ const row = el("tr", { "data-peer": id, class: id === focused ? "focused" : "" },
+ el("td", { title: id }, peer.alias || el("span", { class: "id" }, short(id))),
+ // How long dormant is about how long unseen ("seen"): in the tooltip only.
+ el("td", { class: peer.status, title: peer.status === "dormant"
+ ? "dormant for " + age(aged("dormantForSecs", peer.dormantForSecs)) : "" }, peer.status),
+ el("td", {}, age(aged("lastSeenSecsAgo", peer.lastSeenSecsAgo))),
+ el("td", { class: "id" }, via),
+ el("td", {}, rtt),
+ // Its clock against this node's, flagged beyond the limit (`--clock-skew-warn`).
+ el("td", { class: clockOff(peer.clock) ? "clock-off" : "", title: peer.clock
+ ? "ahead of this node's by " + peer.clock.offsetMs + " ms, give or take " +
+ peer.clock.withinMs + " ms" : "" }, peer.clock ? seconds(peer.clock.offsetMs) : ""),
+ el("td", {}, peer.neighbour ? "yes" : ""),
+ el("td", {}, peer.sameEpoch === null || peer.sameEpoch === undefined
+ ? "" : peer.sameEpoch ? "yes" : "no"));
+ // On press: the table may be drawn anew (ages) before a click would complete.
+ row.addEventListener("pointerdown", () => focus(id === focused ? null : id));
+ rows.push(row);
+ }
+ table.replaceChildren(...rows);
+ // As tall as the rows, up to 8 of them (and the header); then it scrolls.
+ const scroll = document.getElementById("peers-scroll");
+ scroll.style.maxHeight = rows.length > 9
+ ? (rows[8].offsetTop + rows[8].offsetHeight) + "px" : "";
+}
+
+// --- Figures: a tree, its open branches kept across renders ---
+const open = new Set(["stats", "peer", "links", "from", "to", "paths", "paths.destination"]);
+function tree(key, value, path) {
+ // Empty: nothing to open.
+ if (value !== null && typeof value === "object" && Object.keys(value).length === 0) {
+ return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "),
+ Array.isArray(value) ? "[]" : "{}");
+ }
+ if (value !== null && typeof value === "object") {
+ const details = el("details", {});
+ if (open.has(path)) details.open = true;
+ details.addEventListener("toggle", () => {
+ if (details.open) open.add(path); else open.delete(path);
+ });
+ const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
+ const summary = el("summary", {}, el("span", { class: "key" }, key),
+ Array.isArray(value) ? " [" + value.length + "]" : "");
+ // Opened or closed on press, as the peers' rows are focused: the tree is drawn anew
+ // every second (ages), and a click whose press and release land on two drawings is
+ // lost. Keys (Enter, space) still click.
+ summary.addEventListener("pointerdown", event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ details.open = !details.open;
+ });
+ summary.addEventListener("click", event => {
+ if (event.detail !== 0) event.preventDefault();
+ });
+ details.append(summary);
+ for (const [k, v] of entries) details.append(tree(k, v, path + "." + k));
+ return details;
+ }
+ return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "),
+ JSON.stringify(aged(key, value)));
+}
+function renderStats() {
+ const title = document.getElementById("figures-title");
+ const what = el("span", { class: "what" });
+ let shown;
+ const stats = document.getElementById("stats");
+ if (shownMessage !== null) {
+ const message = shownMessage;
+ // Drawn once: a message doesn't change, and drawing it anew would scroll it back up.
+ if (stats.dataset.message === String(message.seq)) return;
+ stats.dataset.message = String(message.seq);
+ what.textContent = message.type + " " + WAYS[message.way] + " " + name(message.peer);
+ const field = (key, value) => el("div", { class: "leaf" },
+ el("span", { class: "key" }, key + ": "), value);
+ title.replaceChildren("Message: ", what);
+ document.getElementById("stats").replaceChildren(
+ field("at", clockTime(new Date(message.at))),
+ field(message.way === "received" ? "from" : "to", message.peer),
+ field("way", message.way),
+ field("type", message.type),
+ field("bytes", String(message.bytes)),
+ el("pre", { class: "message" }, message.text));
+ return;
+ }
+ delete stats.dataset.message;
+ if (isPair(focused)) {
+ const [a, b] = focused.split(" ");
+ what.textContent = name(a) + " – " + name(b);
+ what.title = focused;
+ const listed = (x, y) => (state.graph.intended[x] || []).includes(y);
+ shown = [el("div", { class: "leaf" }, el("span", { class: "key" }, "intended: "),
+ listed(a, b) || listed(b, a) ? "yes (member list)" : "no")];
+ // Each end's view of it, as it reported it.
+ for (const [x, y, path] of [[a, b, "from"], [b, a, "to"]]) {
+ const report = state.graph.measured[x];
+ const link = report && report.links.find(link => link.peer === y);
+ if (link) {
+ shown.push(tree(name(x) + " reports", { secsAgo: report.secsAgo, ...link }, path));
+ } else {
+ shown.push(el("div", { class: "key leaf" }, name(x) + (report
+ ? " reports no link to " + name(y) + "." : " reports no links.")));
+ }
+ }
+ // Where this node is an end: the link itself, as `/peers` has the other end (where
+ // messages go, the paths, the addresses). The whole entry is the node's focus.
+ const self = state.node.id;
+ const other = a === self ? b : b === self ? a : null;
+ const peer = other !== null && state.peers.peers[other];
+ if (peer) {
+ shown.push(tree("this node's paths", {
+ destination: peer.destination, addresses: peer.addresses }, "paths"));
+ }
+ title.replaceChildren("Link: ", what);
+ document.getElementById("stats").replaceChildren(...shown);
+ return;
+ }
+ if (focused === null) {
+ what.textContent = name(state.node.id) + " (this node)";
+ shown = [tree("stats", state.stats, "stats")];
+ } else {
+ // Its alias (if it has one) and its node ID, in full.
+ const alias = (state.graph.members[focused] || {}).alias ||
+ (state.peers.peers[focused] || {}).alias;
+ what.replaceChildren(...(alias ? [alias, " "] : []), el("span", { class: "id" }, focused));
+ const peer = state.peers.peers[focused];
+ const report = state.graph.measured[focused];
+ shown = [];
+ if (peer) shown.push(tree("peer", peer, "peer"));
+ else shown.push(el("div", { class: "key leaf" }, "Not a peer of this node."));
+ if (report) shown.push(tree("links", report, "links"));
+ }
+ title.replaceChildren(focused === null ? "Figures: " : "Peer: ", what);
+ document.getElementById("stats").replaceChildren(...shown);
+}
+
+// --- Swarm map: a small force layout, positions kept across renders ---
+const positions = new Map();
+// Nodes put somewhere by hand (dragged): the layout leaves them there.
+const pinned = new Set();
+const transportColour = new Map([["mycelium", "#2563eb"], ["libp2p", "#9333ea"],
+ ["tor", "#0891b2"]]);
+const SPARE = ["#ca8a04", "#db2777", "#65a30d"];
+function colourOf(transport) {
+ if (!transport) return "var(--muted)";
+ if (!transportColour.has(transport)) {
+ transportColour.set(transport, SPARE[(transportColour.size - 3) % SPARE.length]);
+ }
+ return transportColour.get(transport);
+}
+
+function renderLegend(measured) {
+ const transports = new Set();
+ for (const { link } of measured.values()) {
+ transports.add(link.transport || "");
+ for (const other of link.others || []) transports.add(other.transport);
+ }
+ const items = [el("span", { class: "intended" }, "intended (member list)")];
+ for (const transport of [...transports].sort()) {
+ const item = el("span", {}, transport || "unknown transport");
+ item.style.setProperty("--colour", colourOf(transport || null));
+ items.push(item);
+ }
+ items.push(el("span", { class: "used" }, "in use"),
+ el("span", { class: "other" }, "other transport"),
+ el("span", { class: "direct" }, "direct"),
+ el("span", { class: "relayed" }, "relayed"),
+ el("span", { class: "dormant" }, "dormant or down"),
+ el("span", { class: "silent" }, "an end reports no links"));
+ document.getElementById("legend").replaceChildren(...items);
+}
+
+function edges() {
+ const intended = new Map();
+ for (const [a, neighbours] of Object.entries(state.graph.intended)) {
+ for (const b of neighbours) {
+ intended.set(pairKey(a, b), [a, b]);
+ }
+ }
+ // One measured edge per pair: a report with an RTT, then the fresher.
+ const measured = new Map();
+ for (const [a, report] of Object.entries(state.graph.measured)) {
+ for (const link of report.links) {
+ const key = pairKey(a, link.peer);
+ const known = measured.get(key);
+ const timed = typeof link.rttMs === "number";
+ const knownTimed = known && typeof known.link.rttMs === "number";
+ if (!known || (timed && !knownTimed) ||
+ (timed === knownTimed && report.secsAgo < known.secsAgo)) {
+ measured.set(key, { a, b: link.peer, link, secsAgo: report.secsAgo,
+ dormant: link.dormant || (known && known.dormant),
+ down: link.down || (known && known.down) });
+ } else {
+ if (link.dormant) known.dormant = true;
+ if (link.down) known.down = true;
+ }
+ }
+ }
+ // Dormant, or down, if either end says so, whichever report is drawn.
+ for (const edge of measured.values()) {
+ if ((edge.dormant && !edge.link.dormant) || (edge.down && !edge.link.down)) {
+ edge.link = { ...edge.link, dormant: !!edge.dormant, down: !!edge.down };
+ }
+ }
+ return { intended, measured };
+}
+
+// How long a link is, ideally, for `count` nodes on a map of that size.
+function idealLength(count, width, height) {
+ return Math.min(width, height) / Math.max(2, Math.sqrt(count) * 1.5);
+}
+// `pairs`: [a, b, length, loose], the length the link pulls towards (else
+// `idealLength`), and whether it is loose (`lengths`).
+// How hard a loose link pulls, against a measured one.
+const LOOSE = .1;
+// `only`, if given: the nodes laid out; the others stay where they are.
+function layout(nodes, pairs, width, height, only) {
+ for (const id of nodes) {
+ if (!positions.has(id)) {
+ positions.set(id, { x: width / 2 + (Math.random() - .5) * width / 2,
+ y: height / 2 + (Math.random() - .5) * height / 2 });
+ }
+ }
+ for (let round = 0; round < 120; round++) relax(nodes, pairs, width, height, 1, 10, only);
+}
+// One round of the layout: every node moved by `gain` of the forces on it, at most
+// `most` px. Returns how far the furthest moved. A gain of 1 overshoots where a node
+// has several links (the pulls add up): it gets close fast, then swings about its
+// place; well below 1, it comes to rest. `only`, if given: the nodes that move.
+function relax(nodes, pairs, width, height, gain, most, only) {
+ const ideal = idealLength(nodes.length, width, height);
+ {
+ const force = new Map(nodes.map(id => [id, { x: 0, y: 0 }]));
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ const p = positions.get(nodes[i]), q = positions.get(nodes[j]);
+ const dx = p.x - q.x, dy = p.y - q.y, d2 = Math.max(dx * dx + dy * dy, 1);
+ const push = ideal * ideal / d2;
+ force.get(nodes[i]).x += dx * push / 10; force.get(nodes[i]).y += dy * push / 10;
+ force.get(nodes[j]).x -= dx * push / 10; force.get(nodes[j]).y -= dy * push / 10;
+ }
+ }
+ for (const [a, b, length, loose] of pairs) {
+ const p = positions.get(a), q = positions.get(b);
+ if (!p || !q) continue;
+ const dx = q.x - p.x, dy = q.y - p.y, d = Math.max(Math.hypot(dx, dy), 1);
+ const stretch = d - (length || ideal);
+ // A loose link pulls a little, and never pushes.
+ const pull = loose ? Math.max(0, stretch) / d / 4 * LOOSE : stretch / d / 4;
+ force.get(a).x += dx * pull; force.get(a).y += dy * pull;
+ force.get(b).x -= dx * pull; force.get(b).y -= dy * pull;
+ }
+ let moved = 0;
+ for (const id of nodes) {
+ if (pinned.has(id) || id === grabbed || (only && !only.has(id))) continue;
+ const p = positions.get(id), f = force.get(id);
+ const fx = f.x * gain, fy = f.y * gain;
+ const x = Math.min(width - 40, Math.max(40, p.x + Math.max(-most, Math.min(most, fx))));
+ const y = Math.min(height - 20, Math.max(20, p.y + Math.max(-most, Math.min(most, fy))));
+ moved = Math.max(moved, Math.abs(x - p.x), Math.abs(y - p.y));
+ p.x = x; p.y = y;
+ }
+ return moved;
+ }
+}
+
+// The layout (`rtt-toggle`, kept in this browser): static by default, nodes moving only
+// when laid out anew (members or links come or go); or dynamic, resized by round trip:
+// each measured link's length grows with the square root of its round trip against
+// the median one's, within bounds. Then, when the lengths change, the map moves to
+// them a little every frame (`settle`), not at once.
+let byRtt = false;
+try { byRtt = localStorage.getItem("continuum-by-rtt") === "1"; } catch (_) {}
+// One spring per pair of nodes, intended or measured or both: [a, b, length, loose].
+// Measured links shape the map; a link only intended (nothing measured backs it) is
+// `loose`: it gives way to them, pulling only a little where stretched and never pushing
+// where they hold its ends closer (`LOOSE`).
+function lengths(intended, measured, count, width, height) {
+ const ideal = idealLength(count, width, height);
+ const rtts = [...measured.values()].map(edge => edge.link.rttMs)
+ .filter(rtt => typeof rtt === "number" && rtt > 0).sort((x, y) => x - y);
+ const median = rtts[Math.floor(rtts.length / 2)];
+ const pairs = new Map([...intended].map(([key, [a, b]]) => [key, [a, b, ideal, true]]));
+ for (const [key, { a, b, link }] of measured) {
+ const rtt = link.rttMs;
+ const length = byRtt && median && typeof rtt === "number" && rtt > 0
+ ? ideal * Math.min(2.5, Math.max(.4, Math.sqrt(rtt / median))) : ideal;
+ pairs.set(key, [a, b, Math.round(length), false]);
+ }
+ return [...pairs.values()];
+}
+// What `settle` moves the map towards: the latest render's nodes and lengths.
+let settling = false, settleTo = null, lastLengths = "";
+// Nodes let go (`unpin`) on a static map: the only ones `settle` moves there, to
+// their places among the others.
+const freed = new Set();
+function settle() {
+ if (settling) return;
+ settling = true;
+ let frames = 0;
+ const step = () => {
+ // Static again: where they are now (but those let go).
+ if (!byRtt && freed.size === 0) { settling = false; return; }
+ const { nodes, pairs, width, height } = settleTo;
+ // Damped: the swings of the layout's rounds die down instead of going on.
+ const moved = relax(nodes, pairs, width, height, .15, 2, byRtt ? null : freed);
+ renderGraph();
+ if (moved > .1 && ++frames < 600 && !document.hidden) requestAnimationFrame(step);
+ else {
+ settling = false;
+ freed.clear();
+ // Hidden: on again with the next render.
+ if (document.hidden) lastLengths = "";
+ }
+ };
+ requestAnimationFrame(step);
+}
+
+// The node or link (its pair key) under the pointer, if any. Kept here rather than
+// left to :hover: its halo is an element of its own, drawn behind it.
+let hovered = null;
+function hover(id) {
+ if (hovered === id) return;
+ hovered = id;
+ if (state) renderGraph();
+ markPeerMessages();
+}
+function hoverable(element, id) {
+ element.addEventListener("mouseenter", () => hover(id));
+ element.addEventListener("mouseleave", () => hover(null));
+}
+
+// The map's elements by what they show (a key), kept across renders and updated in
+// place: the one under the pointer stays itself.
+const drawn = new Map();
+// The element `key` names, with exactly `attrs`; `made` sets up a new one (its
+// listeners).
+function drawing(key, tag, attrs, made) {
+ let node = drawn.get(key);
+ if (!node) {
+ node = svg(tag);
+ if (made) made(node);
+ drawn.set(key, node);
+ }
+ for (const attr of node.getAttributeNames()) if (!(attr in attrs)) node.removeAttribute(attr);
+ for (const [attr, value] of Object.entries(attrs)) {
+ if (node.getAttribute(attr) !== String(value)) node.setAttribute(attr, value);
+ }
+ return node;
+}
+// A label's `attrs`, turned back where the map is turned (`view`): upright, about where
+// it is anchored.
+function upright(attrs) {
+ if (!view.turn) return attrs;
+ return { ...attrs, transform: "rotate(" + -degrees(view.turn) + " " + attrs.x + " " +
+ attrs.y + ")" };
+}
+function text(node, content) {
+ if (node.textContent !== content) node.textContent = content;
+}
+// A label on the map: its text, breathing once when it changed (not when new), as the
+// connection's dot does: its underlay, in the shape of the new text, glows and fades.
+// An animation of its own, not a class: every render sets the label's attributes.
+const calm = matchMedia("(prefers-reduced-motion: reduce)");
+function label(node, content) {
+ if (node.textContent === content) return;
+ const before = node.textContent;
+ node.textContent = content;
+ if (!before || calm.matches || !node.animate) return;
+ const colours = getComputedStyle(document.documentElement);
+ const panel = colours.getPropertyValue("--panel").trim();
+ const glow = "color-mix(in srgb, " + colours.getPropertyValue("--self").trim() + " 45%, " +
+ panel + ")";
+ node.animate([
+ { stroke: panel, strokeWidth: "4px" },
+ { stroke: glow, strokeWidth: "7px", offset: .3 },
+ { stroke: panel, strokeWidth: "4px" },
+ ], { duration: 900, easing: "ease-in-out" });
+}
+// A tooltip whose text changes under the pointer goes and comes back (the ages in it
+// change on every render): its new text waits until the pointer leaves.
+const tooltipsDue = new WeakMap();
+function tooltip(node, content) {
+ let title = node.firstElementChild;
+ if (!title) node.append(title = svg("title"));
+ if (title.textContent === content || !title.textContent || !node.matches(":hover")) {
+ tooltipsDue.delete(node);
+ text(title, content);
+ return;
+ }
+ if (!tooltipsDue.has(node)) {
+ node.addEventListener("mouseleave", () => {
+ const due = tooltipsDue.get(node);
+ tooltipsDue.delete(node);
+ if (due !== undefined) text(title, due);
+ }, { once: true });
+ }
+ tooltipsDue.set(node, content);
+}
+// `root` showing `children`, in order: the others go, and only what is out of place
+// moves.
+function show(root, children) {
+ const shown = new Set(children);
+ for (const [key, node] of drawn) {
+ if (!shown.has(node)) { node.remove(); drawn.delete(key); }
+ }
+ let at = root.firstChild;
+ for (const node of children) {
+ if (node === at) at = at.nextSibling; else root.insertBefore(node, at);
+ }
+}
+
+let lastShape = "";
+// The whole map laid out again with the next render (the window or the box resized),
+// not only nodes new to it.
+let relayout = true;
+function layoutAgain() {
+ relayout = true;
+ lastShape = "";
+}
+function renderGraph() {
+ if (document.getElementById("map").classList.contains("collapsed")) return;
+ const root = document.getElementById("graph");
+ const width = root.clientWidth || 800, height = root.clientHeight || 560;
+ root.setAttribute("viewBox", "0 0 " + width + " " + height);
+ const nodes = Object.keys(state.graph.members).sort();
+ const { intended, measured } = edges();
+ renderLegend(measured);
+ const pairs = lengths(intended, measured, nodes.length, width, height);
+ // Laid out when who's there or who links to whom changed: only the nodes new to the
+ // map, the others staying where they are (links that came, after the first reports,
+ // don't move them); all of them at first, and when the map was resized.
+ const shape = nodes.join(",") + "|" + [...intended.keys(), ...measured.keys()].sort().join(",");
+ if (shape !== lastShape) {
+ for (const id of [...positions.keys()]) {
+ if (!nodes.includes(id)) { positions.delete(id); pinned.delete(id); }
+ }
+ const fresh = new Set(nodes.filter(id => !positions.has(id)));
+ if (relayout) {
+ layout(nodes, pairs, width, height);
+ // Dynamic: settled at once, not moving into place before one's eyes.
+ if (byRtt) {
+ for (let round = 0; round < 3000; round++) {
+ if (relax(nodes, pairs, width, height, .15, 2) <= .1) break;
+ }
+ lastLengths = pairs.map(pair => pair.join(" ")).join(",");
+ }
+ } else if (fresh.size > 0) {
+ layout(nodes, pairs, width, height, fresh);
+ }
+ relayout = false;
+ lastShape = shape;
+ }
+ settleTo = { nodes, pairs, width, height };
+ const wanted = pairs.map(pair => pair.join(" ")).join(",");
+ if (byRtt && wanted !== lastLengths) {
+ lastLengths = wanted;
+ settle();
+ }
+ const children = [];
+ // Under everything: the nodes and the link of the message shown in the console, and
+ // of the one pointed at, each in the grey its line has there (the shown one's where
+ // both mark the same), in its own shape (the lines of a link as they bow, a node's
+ // circle). A group per grey, so where its shapes overlap it is no darker.
+ const marking = new Map();
+ for (const [message, how] of [[pointedMessage, "pointed"], [shownMessage, "shown"]]) {
+ if (message === null) continue;
+ for (const key of [state.node.id, message.peer, pairKey(state.node.id, message.peer)]) {
+ marking.set(key, how);
+ }
+ }
+ const marks = Object.fromEntries(["pointed", "shown"].map(how => [how,
+ drawing("marks " + how, "g", { "pointer-events": "none",
+ style: "opacity: var(--" + how + ")" })]));
+ const marked = { pointed: [], shown: [] };
+ // A shape under `key` (a node's ID, a link's pair key), if it is marked.
+ const mark = (key, tag, attrs) => {
+ const how = marking.get(key);
+ if (how) marked[how].push(svg(tag, attrs));
+ };
+ const markLine = (key, d, width) => mark(key, "path", { d, fill: "none",
+ stroke: "rgb(127 127 127)", "stroke-width": width + 8, "stroke-linecap": "round" });
+ children.push(marks.pointed, marks.shown);
+ // Behind a link: a halo while it's hovered or in focus (`width`: of its lines).
+ const halo = (key, p, q, width) => {
+ if (key !== hovered && key !== focused) return;
+ children.push(drawing("halo " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y,
+ stroke: "var(--self)",
+ style: "stroke-opacity: var(" + (key === focused ? "--halo-focused" : "--halo-hovered") + ")",
+ "stroke-width": width + 8, "stroke-linecap": "round" }));
+ };
+ // Over a link: an invisible, wider line to point at and click.
+ const hits = [];
+ // The links' round trips.
+ const labels = [];
+ const hit = (key, p, q, width) => {
+ const line = drawing("hit " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y,
+ stroke: "transparent", "stroke-width": width + 8, cursor: "pointer" }, line => {
+ // Dragged, it pans, as beside the nodes; a click, it is in focus.
+ line.addEventListener("pointerdown", event => { event.stopPropagation(); pan(event, () => focus(key)); });
+ hoverable(line, key);
+ });
+ hits.push(line);
+ return line;
+ };
+ for (const [key, [a, b]] of intended) {
+ if (measured.has(key)) continue;
+ const p = positions.get(a), q = positions.get(b);
+ halo(key, p, q, 1);
+ markLine(key, "M " + p.x + " " + p.y + " L " + q.x + " " + q.y, 1);
+ children.push(drawing("intended " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y,
+ stroke: "var(--intended)", "stroke-dasharray": "4 4", "stroke-width": 1 }));
+ tooltip(hit(key, p, q, 1), name(a) + " – " + name(b) + ": intended (member list), not measured");
+ }
+ const ms = rtt => rtt < 10 ? rtt.toFixed(1) : String(Math.round(rtt));
+ for (const [key, { a, b, link, secsAgo }] of measured) {
+ const p = positions.get(a), q = positions.get(b);
+ if (!p || !q) continue;
+ // The transport in use thick, in the middle; the others thin, beside it.
+ const lines = [{ transport: link.transport, rttMs: link.rttMs, used: true }];
+ for (const other of link.others || []) lines.push({ ...other, used: false });
+ // As wide as the lines side by side.
+ const width = 3 + 12 * Math.ceil((lines.length - 1) / 2);
+ halo(key, p, q, width);
+ // It covers the lines: their descriptions are its tooltip.
+ const covering = hit(key, p, q, width);
+ const descriptions = [];
+ const length = Math.max(Math.hypot(q.x - p.x, q.y - p.y), 1);
+ const nx = -(q.y - p.y) / length, ny = (q.x - p.x) / length;
+ // An end that reports no links (any more): what's drawn may be out of date.
+ const silent = !(a in state.graph.measured) || !(b in state.graph.measured);
+ // Dormant, or down (no path answers, whatever its connection was): a red underlay
+ // as wide as the lines, as a dormant node's red outline.
+ const broken = link.dormant || link.down;
+ if (broken) {
+ children.push(drawing("underlay " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y,
+ stroke: "var(--bad)", "stroke-opacity": .5, "stroke-width": width + 4,
+ "stroke-linecap": "round" }));
+ }
+ // The one in use drawn last: on top of the others where they meet, at the nodes.
+ const drawnLines = [];
+ lines.forEach((line, i) => {
+ // 0, then +1, -1, +2, -2 … steps of 6 px to the side, at the middle: from node to
+ // node, each bowing out that far (a quadratic curve, its control point twice as
+ // far out); `aside(t)`, how far out at `t` along.
+ const side = i === 0 ? 0 : (i % 2 ? 1 : -1) * Math.ceil(i / 2) * 6;
+ const aside = t => 4 * t * (1 - t) * side;
+ const cx = (p.x + q.x) / 2 + nx * side * 2, cy = (p.y + q.y) / 2 + ny * side * 2;
+ const attrs = { d: "M " + p.x + " " + p.y + " Q " + cx + " " + cy + " " + q.x + " " + q.y,
+ fill: "none", stroke: colourOf(line.transport),
+ // Narrow where dormant or down (over its underlay).
+ "stroke-width": broken ? (line.used ? 1.5 : 0.8) : line.used ? 3 : 1.2 };
+ // Dotted where an end reports nothing, dashed where relayed, solid where direct.
+ if (silent) {
+ attrs["stroke-dasharray"] = line.used ? "1 5" : "1 3";
+ attrs["stroke-linecap"] = "round";
+ } else if (line.used ? link.relayed : line.relayed) {
+ attrs["stroke-dasharray"] = "7 4";
+ }
+ markLine(key, attrs.d, attrs["stroke-width"]);
+ const drawnLine = drawing("path " + key + " " + i, "path", attrs);
+ const description = name(a) + " – " + name(b) +
+ (line.transport ? " via " + line.transport : "") + (line.used ? " (in use)" : "") +
+ ((line.used ? link.relayed : line.relayed) ? ", relayed" : "") + (link.dormant ? ", dormant" : "") +
+ (line.used && link.down ? ", down" : "") +
+ (silent ? ", one end reports no links" : "") +
+ (typeof line.rttMs === "number" ? ", " + line.rttMs.toFixed(1) + " ms" : "") +
+ ", measured " + age(secsAgo + elapsed()) + " ago";
+ tooltip(drawnLine, description);
+ descriptions.push(description);
+ drawnLines.push(drawnLine);
+ if (typeof line.rttMs === "number") {
+ // Near the end that measured it (`a`, whose report is drawn), clear of its
+ // node and name, and short of the middle: the one in use larger, the others
+ // closer still, beside theirs.
+ const along = Math.min(length / 2, Math.max(36, length * .3));
+ const t = (line.used ? along : Math.max(24, along * .6)) / length;
+ const out = line.used ? -4 : Math.sign(side) * 9;
+ const rtt = drawing("rtt " + key + " " + i, "text", upright({
+ x: p.x + (q.x - p.x) * t + nx * (aside(t) + out),
+ y: p.y + (q.y - p.y) * t + ny * (aside(t) + out) + (line.used ? 0 : 3),
+ "text-anchor": "middle", "font-size": line.used ? 11 : 9,
+ fill: line.used ? "var(--fg)" : "var(--muted)" }));
+ label(rtt, ms(line.rttMs));
+ labels.push(rtt);
+ }
+ });
+ children.push(...drawnLines.reverse());
+ tooltip(covering, descriptions.join("\n"));
+ }
+ // Above every line, so none crosses them.
+ children.push(...labels);
+ // Above the lines and their labels, below the nodes.
+ children.push(...hits);
+ for (const id of nodes) {
+ const p = positions.get(id), member = state.graph.members[id];
+ const reported = id in state.graph.measured;
+ const shown = focused === null ? member.self : id === focused;
+ // A link in focus: both ends ringed.
+ // A halo as links have: light under the pointer, stronger in focus.
+ if (shown || id === hovered) {
+ children.push(drawing("ring " + id, "circle", { cx: p.x, cy: p.y, r: 13,
+ fill: "var(--self)", stroke: "none",
+ style: "fill-opacity: var(" + (shown ? "--halo-focused" : "--halo-hovered") + ")" }));
+ }
+ const pressed = element => {
+ element.addEventListener("pointerdown", event => { event.stopPropagation(); drag(event, id); });
+ hoverable(element, id);
+ };
+ // A clock off the swarm's: a dashed orange ring.
+ if (((state.graph.clocks || {})[id] || {}).off) {
+ children.push(drawing("clock " + id, "circle", { cx: p.x, cy: p.y, r: 11, fill: "none",
+ stroke: "var(--warn)", "stroke-width": 1.5, "stroke-dasharray": "3 2" }));
+ }
+ // Dormant as this node sees it (`/peers`): a red outline.
+ const dormant = (state.peers.peers[id] || {}).status === "dormant";
+ const radius = member.self ? 8 : 6;
+ mark(id, "circle", { cx: p.x, cy: p.y, r: radius + 6, fill: "rgb(127 127 127)" });
+ const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, r: radius,
+ fill: member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)",
+ stroke: member.self ? "var(--self)" : dormant ? "var(--bad)" : "var(--fg)",
+ "stroke-width": dormant ? 2.5 : 1.5 }, pressed);
+ // Its clock against the swarm's, as the members' reports chain it (the node's).
+ const clock = ((state.graph.clocks || {})[id]);
+ tooltip(circle, id + (reported ? "" : " (no links reported)") +
+ (dormant ? " (dormant)" : "") +
+ (clock && clock.off ? " (clock " + seconds(clock.offsetMs) + " off the swarm's, ±" +
+ clock.withinMs + " ms)" : ""));
+ // Pinned (double-clicked): a dot in the middle.
+ const pin = pinned.has(id) ? [drawing("pin " + id, "circle", { cx: p.x, cy: p.y, r: 2,
+ fill: member.self || reported ? "var(--panel)" : "var(--fg)",
+ "pointer-events": "none" })] : [];
+ const nameLabel = drawing("name " + id, "text", upright({ x: p.x + 10, y: p.y + 4,
+ "font-size": 12, fill: "var(--fg)", cursor: "pointer" }), pressed);
+ label(nameLabel, name(id));
+ children.push(circle, ...pin, nameLabel);
+ }
+ marks.pointed.replaceChildren(...marked.pointed);
+ marks.shown.replaceChildren(...marked.shown);
+ show(document.getElementById("view"), children);
+}
+
+// --- Document: read a level at a time (`/store?shallow=true`) as it is opened, and
+// read again where the node says it changed (`document` events) ---
+// What is open, by path (as JSON): the first level to begin with.
+const documentOpen = new Set([JSON.stringify([])]);
+// The open branches read, by path (as JSON): to read again on a change.
+const documentBranches = new Map();
+function storeUrl(path, shallow) {
+ const where = path.length ? "../store/" + path.map(encodeURIComponent).join("/") : "../store";
+ return where + (shallow ? "?shallow=true" : "");
+}
+// Store paths split at "/": a key with one (or an empty key) can't be read on its own.
+const addressable = path => path.every(segment => segment !== "" && !segment.includes("/"));
+// Either is within the other.
+const related = (a, b) => a.length <= b.length
+ ? a.every((segment, i) => segment === b[i]) : related(b, a);
+function describe(summary) {
+ switch (summary.type) {
+ case "map": return "{" + summary.size + (summary.size === 1 ? " key}" : " keys}");
+ case "list": return "[" + summary.size + (summary.size === 1 ? " item]" : " items]");
+ case "text": return "text, " + summary.size + " characters";
+ default: return summary.type + ("size" in summary ? ", " + summary.size + " bytes" : "");
+ }
+}
+async function readStore(path, shallow) {
+ const response = await fetch(storeUrl(path, shallow), { headers: { Accept: "application/json" } });
+ if (!response.ok) throw new Error("HTTP " + response.status);
+ return response.json();
+}
+// An empty map, list or text: shown as it is, nothing to open.
+const EMPTY = { map: "{}", list: "[]", text: "\"\"" };
+const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" };
+
+// --- Editing: a double click on an entry turns it into a field holding its contents;
+// saved with PUT /store/<path> (the whole value, objects too) ---
+// A string is edited as text (sent as text/plain), anything else as JSON; "JSON"
+// switches. `done(saved, response)` when saved or cancelled.
+// `options` for adding rather than replacing: `extra`, a short field before the value
+// (`{ label, initial, hint }`: the key, or the index); `check(extra)`, an error text if
+// it won't do; `send(body, type, extra)`, the request (PUT to `path` without).
+function editor(path, value, done, options = {}) {
+ const message = el("span", { class: "note" });
+ const fail = text => { message.className = "error"; message.textContent = text; };
+ const asText = typeof value === "string";
+ const text = asText ? value : JSON.stringify(value, null, 2);
+ const field = el("textarea", { spellcheck: "false",
+ rows: String(Math.max(2, Math.min(20, text.split("\n").length + 1))) });
+ field.value = text;
+ const json = el("input", { type: "checkbox" });
+ json.checked = !asText;
+ json.addEventListener("change", () => {
+ if (json.checked) { field.value = JSON.stringify(field.value); return; }
+ try {
+ const parsed = JSON.parse(field.value);
+ if (typeof parsed !== "string") throw new Error("not a string");
+ field.value = parsed;
+ } catch {
+ json.checked = true;
+ fail("Only a JSON string can be edited as text.");
+ }
+ });
+ const extra = options.extra
+ ? el("input", { type: "text", spellcheck: "false", class: "extra",
+ placeholder: options.extra.label, title: options.extra.hint || options.extra.label })
+ : null;
+ if (extra) extra.value = options.extra.initial || "";
+ const send = options.send || ((body, type) => fetch(storeUrl(path, false),
+ { method: "PUT", body, headers: { "Content-Type": type } }));
+ const save = el("button", { type: "button" }, options.extra ? "Add" : "Save");
+ const cancel = el("button", { type: "button" }, "Cancel");
+ const submit = async () => {
+ const body = field.value;
+ const problem = extra && options.check ? options.check(extra.value) : null;
+ if (problem) { fail(problem); return; }
+ if (json.checked) {
+ try { JSON.parse(body); } catch (error) {
+ fail("Not JSON: " + error.message); return;
+ }
+ }
+ save.disabled = true;
+ message.className = "note";
+ message.textContent = "Saving…";
+ try {
+ const response = await send(body, json.checked ? "application/json" : "text/plain",
+ extra ? extra.value : null);
+ if (!response.ok) {
+ // The node says why (`{"error": …}`), where it does.
+ let why = response.status === 403 ? "not allowed" : "";
+ try { why = (await response.json()).error || why; } catch { /* no body */ }
+ throw new Error("HTTP " + response.status + (why ? ": " + why : ""));
+ }
+ done(true, response);
+ } catch (error) {
+ save.disabled = false;
+ fail("Not saved: " + error.message);
+ }
+ };
+ save.addEventListener("click", submit);
+ cancel.addEventListener("click", () => done(false));
+ // Editing what's there: it can be deleted too (DELETE /store/<path>), after a second
+ // click to be sure, not too soon after the first: a double-click doesn't delete.
+ const remove = options.extra ? null : el("button", { type: "button", class: "delete" },
+ "Delete");
+ let sure = null, askedAt = 0;
+ remove?.addEventListener("click", async () => {
+ if (sure === null) {
+ remove.textContent = "Delete: sure?";
+ askedAt = performance.now();
+ sure = setTimeout(() => { sure = null; remove.textContent = "Delete"; }, 3000);
+ return;
+ }
+ if (performance.now() - askedAt < 500) return;
+ clearTimeout(sure);
+ remove.disabled = save.disabled = true;
+ message.className = "note";
+ message.textContent = "Deleting…";
+ try {
+ const response = await fetch(storeUrl(path, false), { method: "DELETE" });
+ if (!response.ok) {
+ let why = response.status === 403 ? "not allowed" : "";
+ try { why = (await response.json()).error || why; } catch { /* no body */ }
+ throw new Error("HTTP " + response.status + (why ? ": " + why : ""));
+ }
+ done("deleted", response);
+ } catch (error) {
+ sure = null;
+ remove.textContent = "Delete";
+ remove.disabled = save.disabled = false;
+ fail("Not deleted: " + error.message);
+ }
+ });
+ // Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does.
+ const keys = (input, line) => input.addEventListener("keydown", event => {
+ if (event.key === "Escape") { event.preventDefault(); done(false); }
+ else if (event.key === "Enter" && (line || event.ctrlKey || event.metaKey)) {
+ event.preventDefault(); submit();
+ }
+ });
+ keys(field, false);
+ if (extra) keys(extra, true);
+ setTimeout(() => (extra && !extra.value ? extra : field).focus());
+ return el("div", { class: "editor" }, ...(extra ? [extra] : []), field,
+ el("div", {}, save, " ", cancel, " ", ...(remove ? [remove, " "] : []),
+ el("label", {}, json, " JSON"), " ",
+ el("span", { class: "note" }, "Ctrl+Enter saves, Esc cancels"), " ", message));
+}
+
+// Store paths name keys below the root; the root itself isn't written whole.
+const editable = path => path.length > 0 && addressable(path);
+// Branches whose reading again waited for an edit in them to end, by path (as JSON).
+const documentDeferred = new Set();
+// After an edit: what changed is read again (the node's `document` event follows),
+// and what was waiting for the edit; `opened`, what it made, shown open. `saved`:
+// true, false (cancelled), or "deleted" (then its parent is read again).
+function edited(path, saved, opened = path) {
+ if (saved === "deleted") path = path.slice(0, -1);
+ else if (saved) documentOpen.add(JSON.stringify(opened));
+ release();
+ const waiting = [...documentDeferred].map(id => JSON.parse(id));
+ documentDeferred.clear();
+ if (saved || waiting.length) documentChanged({ paths: saved ? [path, ...waiting] : waiting });
+}
+// Whether `element` holds a field being edited: left as it is until it's done.
+const editing = element => element.querySelector(".editor") !== null;
+
+// Adding to a map (a key and its value, PUT /store/<path>/<key>) or a list (an index
+// and the value, POST /store/<path>/<index>: the items from there on move up; -1 is the
+// end), without writing it whole: a "+" on the row, the field below it. `known()`: a
+// map's keys as last read, so an existing one isn't overwritten.
+function addButton(kind, path, known, place) {
+ const map = kind === "map";
+ const button = el("button", { type: "button", class: "add",
+ title: map ? "Add a key" : "Insert an item" }, "+");
+ button.addEventListener("click", event => {
+ // Not a click on the row: it would open or close it.
+ event.preventDefault();
+ event.stopPropagation();
+ const area = place();
+ if (editing(area)) return;
+ const form = editor(path, "", async (saved, response) => {
+ form.remove();
+ let added = path;
+ if (saved) {
+ if (map) added = [...path, form.dataset.extra];
+ else {
+ try { added = [...path, String((await response.json()).index)]; } catch { /* path */ }
+ }
+ }
+ edited(path, saved, added);
+ }, {
+ extra: map
+ ? { label: "key", initial: "" }
+ : { label: "index", initial: "-1", hint: "where: 0 is the first; -1 the end" },
+ check: extra => {
+ form.dataset.extra = extra;
+ return map
+ ? (extra === "" ? "A key, please."
+ : extra.includes("/") ? "A key with \"/\" can't be named in a path."
+ : known().includes(extra) ? "\"" + extra + "\" exists already: edit it instead."
+ : null)
+ : (/^(-1|\d+)$/.test(extra.trim()) ? null : "The index: 0 or more, or -1 for the end.");
+ },
+ send: (body, type, extra) => fetch(storeUrl([...path, map ? extra : extra.trim()], false),
+ { method: map ? "PUT" : "POST", body, headers: { "Content-Type": type } }),
+ });
+ area.prepend(form);
+ });
+ return button;
+}
+
+function editableLeaf(key, shown, value, path) {
+ const leaf = el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), shown);
+ if (!editable(path)) return leaf;
+ leaf.title = "Double-click to edit";
+ leaf.addEventListener("dblclick", event => {
+ if (editing(leaf)) return;
+ event.preventDefault();
+ window.getSelection().removeAllRanges();
+ const before = [...leaf.childNodes];
+ leaf.replaceChildren(before[0], editor(path, value, saved => {
+ leaf.replaceChildren(...before);
+ edited(path, saved);
+ }));
+ });
+ return leaf;
+}
+
+// An entry that changed in the document breathes once, as the map's labels do: its
+// row glows and fades.
+function glow(entry) {
+ if (calm.matches || !entry.animate) return;
+ const row = entry.tagName === "DETAILS" ? entry.querySelector(":scope > summary")
+ : entry.classList.contains("leaf") ? entry : entry.firstElementChild || entry;
+ const colour = getComputedStyle(document.documentElement).getPropertyValue("--self").trim();
+ row.animate([
+ { backgroundColor: "transparent" },
+ { backgroundColor: "color-mix(in srgb, " + colour + " 30%, transparent)", offset: .3 },
+ { backgroundColor: "transparent" },
+ ], { duration: 900, easing: "ease-in-out" });
+}
+
+// One entry: a short scalar as it is; anything else a branch read when opened. A
+// branch's entries are kept across reads, so what's open inside stays open.
+function documentEntry(key, summary, path) {
+ if (summary.size === 0 && summary.type in EMPTY) {
+ const leaf = editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path);
+ // An empty map or list takes its first entry here: "+" on its row, the field below.
+ if ((summary.type === "map" || summary.type === "list") && editable(path)) {
+ const below = el("div", { class: "nested" });
+ leaf.append(" ", addButton(summary.type, path, () => [], () => below));
+ return el("div", {}, leaf, below);
+ }
+ return leaf;
+ }
+ if ("value" in summary) {
+ return editableLeaf(key, JSON.stringify(summary.value), summary.value, path);
+ }
+ const details = el("details", {});
+ const described = document.createTextNode(describe(summary));
+ const head = el("summary", {}, el("span", { class: "key" }, key), " ", described);
+ details.append(head);
+ // A double click edits the whole of it: read, then shown in a field.
+ if (editable(path)) {
+ head.title = "Double-click to edit";
+ head.addEventListener("mousedown", event => { if (event.detail > 1) event.preventDefault(); });
+ head.addEventListener("dblclick", async event => {
+ event.preventDefault();
+ if (details.querySelector(":scope > .editor")) return;
+ let value;
+ try { value = await readStore(path, false); } catch (error) {
+ described.textContent = describe(summary) + " (not read: " + error.message + ")";
+ return;
+ }
+ details.open = true;
+ details.classList.add("editing");
+ head.after(editor(path, value, saved => {
+ details.querySelector(":scope > .editor").remove();
+ details.classList.remove("editing");
+ edited(path, saved);
+ }));
+ });
+ }
+ // A newer summary from the parent: a branch of the same kind stays as it is.
+ details.updated = newer => {
+ summary = newer;
+ described.textContent = describe(newer);
+ };
+ details.sameKind = newer => !("value" in newer) && newer.type === summary.type;
+ if (!addressable(path)) {
+ details.append(el("div", { class: "note" }, "Its path can't be read on its own (a key with \"/\")."));
+ return details;
+ }
+ const id = JSON.stringify(path);
+ const children = new Map();
+ const browsed = summary.type === "map" || summary.type === "list";
+ // Adding: on the row, the field just below it (the branch opened for it).
+ if (browsed && (editable(path) || path.length === 0)) {
+ head.append(" ", addButton(summary.type, path, () => [...children.keys()], () => {
+ details.open = true;
+ return { prepend: form => head.after(form), querySelector: q => details.querySelector(":scope > " + q) };
+ }));
+ }
+ let read = false;
+ // Read once at least: what's new or changed from then on breathes (`glow`).
+ let filled = false;
+ const fill = async () => {
+ // Being edited as a whole: read again once that's done.
+ if (details.classList.contains("editing")) { documentDeferred.add(id); return; }
+ try {
+ if (browsed) {
+ const shallow = await readStore(path, true);
+ details.updated(shallow);
+ const entries = shallow.entries || {};
+ // A list's items by index (the node sends them sorted as text).
+ const keys = Object.keys(entries);
+ if (summary.type === "list") keys.sort((a, b) => Number(a) - Number(b));
+ for (const [k, child] of children) {
+ if (k in entries) continue;
+ // Being edited: kept until that's done.
+ if (editing(child.element)) { documentDeferred.add(id); continue; }
+ child.element.remove();
+ children.delete(k);
+ }
+ for (const k of keys) {
+ const newer = entries[k];
+ const known = children.get(k);
+ if (known && JSON.stringify(known.summary) === JSON.stringify(newer)) continue;
+ if (known && editing(known.element)) { documentDeferred.add(id); continue; }
+ if (known && known.element.sameKind && known.element.sameKind(newer)) {
+ known.element.updated(newer);
+ known.summary = newer;
+ glow(known.element);
+ continue;
+ }
+ const element = documentEntry(k, newer, [...path, k]);
+ if (known) known.element.replaceWith(element);
+ children.set(k, { element, summary: newer });
+ if (filled) glow(element);
+ }
+ // In order; moving keeps their state.
+ details.querySelectorAll(":scope > .note").forEach(note => note.remove());
+ if (!children.size) details.append(el("div", { class: "note" }, "empty"));
+ for (const k of keys) if (children.has(k)) details.append(children.get(k).element);
+ } else {
+ const value = await readStore(path, false);
+ const shown = el("pre", {}, typeof value === "string" ? value : JSON.stringify(value, null, 2));
+ const before = details.querySelector(":scope > pre");
+ if (before) before.replaceWith(shown); else details.append(shown);
+ if (before && before.textContent !== shown.textContent) glow(shown);
+ }
+ filled = true;
+ } catch (error) {
+ details.querySelectorAll(":scope > .note").forEach(note => note.remove());
+ details.append(el("div", { class: "note error" }, "Not read: " + error.message));
+ }
+ };
+ const open = () => {
+ documentOpen.add(id);
+ documentBranches.set(id, { path, element: details, refresh: fill });
+ if (!read) { read = true; fill(); }
+ };
+ details.addEventListener("toggle", () => {
+ if (details.open) open();
+ else { documentOpen.delete(id); documentBranches.delete(id); }
+ });
+ if (documentOpen.has(id)) { details.open = true; open(); }
+ return details;
+}
+// The node says what changed (paths), or that anything may have (`all`).
+// Changes waiting while text in the document box is selected (`busy`): merged, applied
+// after. A field being edited holds only what it is in (`editing`, `documentDeferred`).
+let documentPending = null;
+function documentChanged(change) {
+ if (busy(document.getElementById("document-box"))) {
+ documentPending = documentPending === null ? change
+ : documentPending.all || change.all ? { all: true }
+ : { paths: [...documentPending.paths, ...change.paths] };
+ return;
+ }
+ for (const [id, branch] of documentBranches) {
+ if (!branch.element.isConnected) { documentBranches.delete(id); continue; }
+ if (change.all || change.paths.some(path => related(path, branch.path))) branch.refresh();
+ }
+}
+document.getElementById("document").replaceChildren(
+ documentEntry("store", { type: "map", size: "…" }, []));
+
+// A box is busy while text in it is selected: drawing it anew would lose the selection,
+// so it waits (`held`) until it's gone.
+// Collapsed, or not shown at all (the right column dragged off): drawn when opened
+// (`release`).
+function hidden(box) {
+ return box.classList.contains("collapsed") || box.getClientRects().length === 0;
+}
+function busy(box) {
+ if (hidden(box)) return true;
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || !selection.rangeCount) return false;
+ return box.contains(selection.getRangeAt(0).commonAncestorContainer);
+}
+let held = false;
+function render(ages = false) {
+ if (!state) return;
+ document.getElementById("node").textContent =
+ (state.node.alias ? state.node.alias + " · " : "") + state.node.id;
+ const skew = state.stats.clock;
+ document.getElementById("clock-warning").textContent = skew && skew.off
+ ? "clock " + seconds(skew.offsetMs) + " off its peers'" : "";
+ showLinksToggle();
+ held = false;
+ const boxes = [["peers-box", renderPeers], ["figures-box", renderStats]];
+ // Its ages are in tooltips only: drawn on events, not every second.
+ if (!ages) boxes.push(["map", renderGraph]);
+ for (const [box, draw] of boxes) {
+ if (busy(document.getElementById(box))) held = true; else draw();
+ }
+}
+// What waited for a box to be free: drawn, or applied, now.
+function release() {
+ if (held) render();
+ if (documentPending !== null && !busy(document.getElementById("document-box"))) {
+ const change = documentPending;
+ documentPending = null;
+ documentChanged(change);
+ }
+}
+document.addEventListener("selectionchange", release);
+
+// The legend, hidden and shown again; the map takes the room.
+const legendToggle = document.getElementById("legend-toggle");
+legendToggle.addEventListener("click", () => {
+ const legends = document.getElementById("legends");
+ legends.hidden = !legends.hidden;
+ legendToggle.textContent = legends.hidden ? "show legend" : "hide legend";
+ if (state) renderGraph();
+});
+// The members' links subscribed to (`live`: each tells of a change as it happens), or
+// only asked for every 30 s; the node's choice, for every page (`POST
+// /admin/dashboard/links`), shown as the state has it.
+const linksToggle = document.getElementById("links-toggle");
+function showLinksToggle() {
+ const live = !state || state.graph.live !== false;
+ linksToggle.textContent = live ? "links: live" : "links: every 30 s";
+ linksToggle.title = live
+ ? "The members tell of their links' changes as they happen. Click to only ask them every 30 s (fewer messages; for every page on this node)"
+ : "The members are asked for their links every 30 s. Click to have them tell of changes as they happen (for every page on this node)";
+}
+linksToggle.addEventListener("click", () => {
+ const live = !state || state.graph.live !== false;
+ fetch("dashboard/links?live=" + !live, { method: "POST" }).catch(() => {});
+});
+// Links as long as their round trips, and back.
+const rttToggle = document.getElementById("rtt-toggle");
+function showRttToggle() {
+ rttToggle.textContent = byRtt ? "layout: dynamic" : "layout: static";
+ rttToggle.title = "Click for " + (byRtt ? "a static layout"
+ : "a dynamic one: links as long as their round trips");
+}
+showRttToggle();
+rttToggle.addEventListener("click", () => {
+ byRtt = !byRtt;
+ try { localStorage.setItem("continuum-by-rtt", byRtt ? "1" : "0"); } catch (_) {}
+ showRttToggle();
+ // To the lengths afresh, when resized again.
+ lastLengths = "";
+ if (state) renderGraph();
+});
+
+// --- Pan, zoom and turn: the map's group moved, scaled and turned (`turn`, in
+// radians). Lines, labels and nodes scale with it, as on a map; labels stay upright
+// (`upright`). ---
+const graph = document.getElementById("graph");
+const view = { x: 0, y: 0, k: 1, turn: 0 };
+const degrees = radians => radians * 180 / Math.PI;
+function moveView() {
+ document.getElementById("view").setAttribute("transform",
+ "translate(" + view.x + " " + view.y + ") rotate(" + degrees(view.turn) + ") scale(" +
+ view.k + ")");
+}
+// `[x, y]` turned by `angle`.
+function turned(x, y, angle) {
+ const c = Math.cos(angle), s = Math.sin(angle);
+ return [x * c - y * s, x * s + y * c];
+}
+// Where the pointer is, in the map's own units (its `viewBox` is its size).
+function pointer(event) {
+ const box = graph.getBoundingClientRect();
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
+}
+// How far it zooms: from a quarter to eight times.
+const zoomed = k => Math.min(8, Math.max(.25, k));
+// The wheel zooms around the pointer.
+graph.addEventListener("wheel", event => {
+ event.preventDefault();
+ const at = pointer(event);
+ const k = zoomed(view.k * Math.exp(-event.deltaY * .002));
+ view.x = at.x - (at.x - view.x) * k / view.k;
+ view.y = at.y - (at.y - view.y) * k / view.k;
+ view.k = k;
+ moveView();
+}, { passive: false });
+// A press on the map: dragged (more than a few pixels), it `move`s by as much as the
+// pointer did; let go where pressed, it's a `click`.
+let press = null;
+function pressing(event, move, click) {
+ press = { from: pointer(event), moved: false, move, click };
+ graph.setPointerCapture(event.pointerId);
+}
+// Fingers on the map, by pointer. Two pinch: the map zooms around the point between
+// them, moves with it, and turns as they do. A press (`pressing`) is given up when the
+// second comes.
+const touching = new Map();
+let pinch = null;
+const between = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
+// From the first two fingers as they are now.
+function pinchFrom() {
+ const [one, two] = touching.values();
+ pinch = { view: { ...view }, at: between(one, two),
+ distance: Math.max(Math.hypot(two.x - one.x, two.y - one.y), 1),
+ angle: Math.atan2(two.y - one.y, two.x - one.x) };
+}
+function pinched() {
+ const [one, two] = touching.values();
+ const at = between(one, two);
+ const k = zoomed(pinch.view.k * Math.hypot(two.x - one.x, two.y - one.y) / pinch.distance);
+ const turn = pinch.view.turn + Math.atan2(two.y - one.y, two.x - one.x) - pinch.angle;
+ // What was between the fingers (on the map, unturned and unscaled) stays between them.
+ const [x, y] = turned(pinch.at.x - pinch.view.x, pinch.at.y - pinch.view.y, -pinch.view.turn);
+ const [dx, dy] = turned(x * k / pinch.view.k, y * k / pinch.view.k, turn);
+ Object.assign(view, { x: at.x - dx, y: at.y - dy, k, turn });
+ moveView();
+ // Its labels upright again.
+ if (state) renderGraph();
+}
+// Before the map's own handlers (a node's included): a second finger takes over.
+graph.addEventListener("pointerdown", event => {
+ if (event.pointerType !== "touch") return;
+ touching.set(event.pointerId, pointer(event));
+ graph.setPointerCapture(event.pointerId);
+ if (touching.size < 2) return;
+ press = null;
+ graph.classList.remove("dragging");
+ pinchFrom();
+ event.stopImmediatePropagation();
+}, true);
+// A finger lifted: the others pinch on from where they are, or (one left) do nothing
+// until lifted too.
+function untouched(event) {
+ if (!touching.delete(event.pointerId)) return;
+ if (touching.size >= 2) pinchFrom(); else pinch = null;
+}
+graph.addEventListener("pointermove", event => {
+ if (touching.has(event.pointerId)) touching.set(event.pointerId, pointer(event));
+ if (pinch) { pinched(); return; }
+ if (!press) return;
+ const at = pointer(event);
+ const dx = at.x - press.from.x, dy = at.y - press.from.y;
+ if (!press.moved && Math.hypot(dx, dy) < 4) return;
+ press.moved = true;
+ graph.classList.add("dragging");
+ press.move(dx, dy);
+});
+graph.addEventListener("pointerup", event => {
+ untouched(event);
+ const click = press && !press.moved ? press.click : null;
+ press = null;
+ graph.classList.remove("dragging");
+ dropped();
+ if (click) click();
+});
+graph.addEventListener("pointercancel", event => {
+ untouched(event);
+ press = null;
+ graph.classList.remove("dragging");
+ dropped();
+});
+// The map dragged: it pans; a click, `click`.
+function pan(event, click) {
+ const from = { ...view };
+ pressing(event, (dx, dy) => {
+ view.x = from.x + dx;
+ view.y = from.y + dy;
+ moveView();
+ }, click);
+}
+// Beside the nodes: dragged, it pans; a click, back to this node.
+graph.addEventListener("pointerdown", event => {
+ if (event.target !== graph) return;
+ pan(event, () => focus(null));
+});
+// Not the browser's drag and drop of the picture.
+graph.addEventListener("dragstart", event => event.preventDefault());
+// A node: dragged, it moves, held where the pointer is (`grabbed`) until let go; a click,
+// it is in focus; a second one soon after pins it where it is (`pinned`: the layout
+// leaves it there), or lets it go if pinned (`unpin`), and so does a second press soon
+// after that drags it (pinned where it's dropped). Told apart here: the map holds the
+// pointer while pressed (`pressing`), so the browser's clicks and double-clicks go to
+// the map, not the node.
+let lastNodeClick = { id: null, at: 0 };
+const DOUBLE_CLICK_MS = 400;
+let grabbed = null;
+// Of the press under way on a node: whether it's the second of a double-click, and
+// whether it moved the node.
+let secondPress = false, dragMoved = false;
+function drag(event, id) {
+ const p = positions.get(id), from = { x: p.x, y: p.y };
+ grabbed = id;
+ secondPress = lastNodeClick.id === id && performance.now() - lastNodeClick.at < DOUBLE_CLICK_MS;
+ dragMoved = false;
+ pressing(event, (dx, dy) => {
+ dragMoved = true;
+ // The pointer's move, on the map (unturned and unscaled).
+ const [x, y] = turned(dx, dy, -view.turn);
+ p.x = from.x + x / view.k;
+ p.y = from.y + y / view.k;
+ renderGraph();
+ // Dynamic: the others follow it at once.
+ if (byRtt) settle();
+ }, () => {
+ const now = performance.now();
+ if (lastNodeClick.id === id && now - lastNodeClick.at < DOUBLE_CLICK_MS) {
+ lastNodeClick = { id: null, at: now };
+ if (pinned.has(id)) unpin(id); else pin(id);
+ } else {
+ lastNodeClick = { id, at: now };
+ focus(id);
+ }
+ });
+}
+// A dragged node let go of: dynamic, it moves to its place among the others again;
+// dragged by a double-click's second press, its pin toggled.
+function dropped() {
+ if (grabbed === null) return;
+ const id = grabbed;
+ grabbed = null;
+ if (dragMoved && secondPress) {
+ lastNodeClick = { id: null, at: 0 };
+ if (pinned.has(id)) unpin(id); else pin(id);
+ return;
+ }
+ if (byRtt) settle();
+}
+// A node pinned where it is: the layout leaves it there.
+function pin(id) {
+ pinned.add(id);
+ renderGraph();
+}
+// A node let go: it moves to its place among the others, smoothly (`settle`); on a
+// static map, only it moves.
+function unpin(id) {
+ if (!pinned.delete(id)) return;
+ freed.add(id);
+ renderGraph();
+ settle();
+}
+// Beside the nodes, or off the map: nothing hovered (the element left may be gone).
+graph.addEventListener("mousemove", event => { if (event.target === graph) hover(null); });
+graph.addEventListener("mouseleave", () => hover(null));
+
+// --- Messages on the map: this node's traffic with each peer (`/peers`, `traffic`),
+// as it grew since the last update, drawn as dots running along the link the way the
+// messages went. Counts come with each update (every 2 s), so the dots are spread
+// over the next 2 s, a few at most per link and way: a picture of the traffic, not a
+// record of it. Kept above everything else on the map (`flows`, after what `show`
+// places). ---
+const FLOW_MS = 700, FLOW_SPREAD_MS = 2000, FLOWS_AT_MOST = 6;
+// Shown or not (`flows-toggle`, kept in this browser; shown at first).
+let flowsShown = true;
+try { flowsShown = localStorage.getItem("continuum-flows") !== "0"; } catch (_) {}
+const flowsToggle = document.getElementById("flows-toggle");
+function showFlowsToggle() {
+ flowsToggle.textContent = flowsShown ? "messages: on" : "messages: off";
+ flowsToggle.title = "Click to " + (flowsShown ? "hide" : "show") +
+ " the messages running along this node's links";
+}
+showFlowsToggle();
+flowsToggle.addEventListener("click", () => {
+ flowsShown = !flowsShown;
+ try { localStorage.setItem("continuum-flows", flowsShown ? "1" : "0"); } catch (_) {}
+ showFlowsToggle();
+ showMessagesLegend();
+ if (!flowsShown) {
+ flows.length = 0;
+ document.getElementById("flows").replaceChildren();
+ }
+});
+let lastTraffic = null;
+const flows = [];
+let flowFrame = null;
+// Messages and bytes by kind, of a peer's `traffic.sent` or `.received`.
+function messages(byKind) {
+ return Object.fromEntries(Object.entries(byKind || {})
+ .map(([kind, counter]) => [kind, { messages: counter.messages, bytes: counter.bytes }]));
+}
+// The colour of a kind of message (`traffic`'s kinds), on the map and in the console;
+// the replies as their requests.
+const KIND_COLOURS = [["sync", "#ea580c"], ["blob", "#a16207"], ["hello", "#16a34a"],
+ ["status", "#e11d48"], ["identity", "#0d9488"], ["paths", "var(--muted)"],
+ ["links", "#db2777"]];
+const kindColour = kind => (KIND_COLOURS.find(([name]) => kind === name ||
+ kind === name + "Reply") || [null, "var(--fg)"])[1];
+// How large the dots are drawn, times `dotRadius` (the slider after their legend, kept
+// in this browser).
+let dotScale = 2;
+try { dotScale = Number(localStorage.getItem("continuum-dot-scale")) || 2; } catch (_) {}
+dotScale = Math.min(4, Math.max(1.25, dotScale));
+let dotSlider = null;
+function showMessagesLegend() {
+ const legend = document.getElementById("messages-legend");
+ legend.classList.toggle("off", !flowsShown);
+ if (dotSlider === null) {
+ const slider = dotSlider = el("input", { type: "range", min: "1.25", max: "4", step: ".25",
+ title: "How large the dots are" });
+ slider.value = String(dotScale);
+ slider.addEventListener("input", () => {
+ dotScale = Number(slider.value);
+ try { localStorage.setItem("continuum-dot-scale", String(dotScale)); } catch (_) {}
+ });
+ // Before the toggle, which ends the line.
+ legend.prepend(...KIND_COLOURS.map(([kind, colour]) =>
+ el("span", { class: "message", style: "--colour: " + colour },
+ kind === "paths" ? "paths (pings)" : kind)),
+ el("label", { class: "dot-size" }, "size", slider));
+ }
+ dotSlider.disabled = !flowsShown;
+}
+showMessagesLegend();
+// A dot's radius for a message of `bytes`: grows with the size's logarithm, from 1.5
+// (a ping) to 8.
+const dotRadius = bytes => Math.min(8, Math.max(1.5, 1.5 + 1.1 * Math.log10(1 + bytes / 32)));
+// The dots for messages that grew by `kinds` (kind: messages, bytes): `FLOWS_AT_MOST`
+// at most, shared out by kind (one each at least, the busiest first), each as large as
+// its kind's messages on average; interleaved.
+function dotsFor(kinds, total) {
+ const byKind = Object.entries(kinds).sort(([, a], [, b]) => b.messages - a.messages)
+ .map(([kind, grown]) => ({ kind, bytes: grown.bytes / grown.messages,
+ count: Math.min(grown.messages, Math.max(1, Math.round(FLOWS_AT_MOST * grown.messages / total))) }));
+ const dots = [];
+ for (let round = 0; dots.length < FLOWS_AT_MOST && byKind.some(k => k.count > round); round++) {
+ for (const kind of byKind) {
+ if (kind.count > round && dots.length < FLOWS_AT_MOST) dots.push(kind);
+ }
+ }
+ return dots;
+}
+// The messages exchanged with each peer since the last update, on the map (`flows`, if
+// shown).
+function trafficArrived() {
+ if (!state) return;
+ const self = state.node.id, now = performance.now();
+ const dots = flowsShown && !calm.matches && !document.hidden &&
+ !document.getElementById("map").classList.contains("collapsed");
+ const current = new Map();
+ for (const [id, peer] of Object.entries(state.peers.peers)) {
+ if (peer.traffic) {
+ current.set(id, { sent: messages(peer.traffic.sent),
+ received: messages(peer.traffic.received) });
+ }
+ }
+ if (lastTraffic) {
+ for (const [id, counts] of current) {
+ const before = lastTraffic.get(id);
+ if (!before) continue;
+ for (const [way, from, to] of [["sent", self, id], ["received", id, self]]) {
+ const kinds = {};
+ let total = 0;
+ for (const [kind, count] of Object.entries(counts[way])) {
+ const was = before[way][kind] || { messages: 0, bytes: 0 };
+ const grown = Math.max(0, count.messages - was.messages);
+ if (grown > 0) {
+ kinds[kind] = { messages: grown, bytes: Math.max(0, count.bytes - was.bytes) };
+ total += grown;
+ }
+ }
+ if (total === 0) continue;
+ if (!dots) continue;
+ const shown = dotsFor(kinds, total);
+ shown.forEach((dot, i) => {
+ flows.push({ from, to, start: now + i * FLOW_SPREAD_MS / shown.length,
+ kind: dot.kind, r: dotRadius(dot.bytes) });
+ });
+ }
+ }
+ }
+ lastTraffic = current;
+ if (flows.length > 0 && flowFrame === null) flowFrame = requestAnimationFrame(drawFlows);
+}
+
+// --- Console: the messages this node exchanged with its peers, one by one, as the
+// node logs them (`messages` events, `traffic::MessageLog`), the latest at the bottom,
+// `CONSOLE_LINES` at most. Scrolled along while at the bottom. A click on one shows it
+// in the figures box (`shownMessage`). ---
+const CONSOLE_LINES = 500;
+// A time of day on the 24-hour clock, whatever the browser's locale: 18:04:05.123.
+const clockTime = date => [date.getHours(), date.getMinutes(), date.getSeconds()]
+ .map(part => String(part).padStart(2, "0")).join(":") + "." +
+ String(date.getMilliseconds()).padStart(3, "0");
+const size = bytes => bytes < 1024 ? bytes + " B" : bytes < 1048576
+ ? (bytes / 1024).toFixed(1) + " KiB" : (bytes / 1048576).toFixed(1) + " MiB";
+// The messages, kept while the box is collapsed and drawn when it's opened
+// (`showConsole`).
+const consoleMessages = [];
+// The message shown in the figures box instead of what is in focus; null: none.
+let shownMessage = null;
+const WAYS = { sent: "→", received: "←", refused: "↛" };
+// The exchange marked in the console (a message's `related`): the one of the message
+// pointed at, else of the one shown; null: none.
+let relatedMarked = null;
+// The message pointed at in the console, if any: its nodes and link are marked on the
+// map (`renderGraph`), else the shown one's.
+let pointedMessage = null;
+function pointAt(message) {
+ pointedMessage = message;
+ markRelated(message ? message.related : shownRelated());
+ if (state) renderGraph();
+}
+function markRelated(key) {
+ relatedMarked = key || null;
+ for (const line of document.getElementById("console").children) {
+ line.classList.toggle("related", relatedMarked !== null && line.dataset.related === relatedMarked);
+ }
+}
+const shownRelated = () => shownMessage && shownMessage.related;
+// The peer whose messages `key` (a node, or a link's pair key) stands for: the node,
+// or the other end of one of this node's links; null: none (this node itself, whose
+// every message it would be, or a link between others).
+function peerOf(key) {
+ if (key === null || !state) return null;
+ const self = state.node.id;
+ if (!isPair(key)) return key === self ? null : key;
+ const [a, b] = key.split(" ");
+ return a === self ? b : b === self ? a : null;
+}
+// A console line's tint for the node or link pointed at on the map, or in focus.
+function peerClasses(peer) {
+ return (peer === peerOf(focused) ? " peer-focused" : "") +
+ (peer === peerOf(hovered) ? " peer-hovered" : "");
+}
+function markPeerMessages() {
+ const focusedPeer = peerOf(focused), hoveredPeer = peerOf(hovered);
+ for (const line of document.getElementById("console").children) {
+ line.classList.toggle("peer-focused", focusedPeer !== null && line.dataset.peer === focusedPeer);
+ line.classList.toggle("peer-hovered", hoveredPeer !== null && line.dataset.peer === hoveredPeer);
+ }
+}
+// A line: when, which way, with whom, the type (in its kind's colour) and the size.
+function consoleLine(message) {
+ const line = el("div", { "data-seq": String(message.seq),
+ "data-related": message.related || "",
+ "data-peer": message.peer,
+ class: (shownMessage && shownMessage.seq === message.seq ? "focused" : "") +
+ (relatedMarked !== null && message.related === relatedMarked ? " related" : "") +
+ peerClasses(message.peer),
+ title: message.way === "refused" ? "refused: the transport wouldn't send it" : "" },
+ el("span", { class: "when" }, clockTime(new Date(message.at)) + " "),
+ WAYS[message.way] + " " + name(message.peer) + " ",
+ el("span", { style: "color: " + kindColour(message.counted) }, message.type),
+ " " + size(message.bytes));
+ // On press, as the peers' rows: lines come and go under the pointer.
+ line.addEventListener("pointerdown", event => {
+ if (event.button !== 0) return;
+ showMessage(shownMessage && shownMessage.seq === message.seq ? null : message);
+ });
+ // The messages of its exchange marked while pointed at; then the shown one's again.
+ line.addEventListener("pointerenter", () => pointAt(message));
+ line.addEventListener("pointerleave", () => pointAt(null));
+ return line;
+}
+function showMessage(message) {
+ shownMessage = message;
+ const log = document.getElementById("console");
+ for (const line of log.children) {
+ line.classList.toggle("focused", message !== null && line.dataset.seq === String(message.seq));
+ }
+ markRelated(message !== null ? message.related : null);
+ render();
+}
+function messagesArrived(messages) {
+ consoleMessages.push(...messages);
+ consoleMessages.splice(0, Math.max(0, consoleMessages.length - CONSOLE_LINES));
+ if (hidden(document.getElementById("console-box"))) return;
+ const log = document.getElementById("console");
+ const scroll = document.getElementById("console-scroll");
+ const following = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 4;
+ log.append(...messages.slice(-CONSOLE_LINES).map(consoleLine));
+ while (log.childElementCount > CONSOLE_LINES) log.firstElementChild.remove();
+ if (following) scroll.scrollTop = scroll.scrollHeight;
+}
+function showConsole() {
+ document.getElementById("console").replaceChildren(...consoleMessages.map(consoleLine));
+ const scroll = document.getElementById("console-scroll");
+ scroll.scrollTop = scroll.scrollHeight;
+}
+function drawFlows() {
+ flowFrame = null;
+ const layer = document.getElementById("flows");
+ const now = performance.now();
+ for (let i = flows.length - 1; i >= 0; i--) {
+ if (now > flows[i].start + FLOW_MS) flows.splice(i, 1);
+ }
+ const dots = [];
+ for (const flow of flows) {
+ const p = positions.get(flow.from), q = positions.get(flow.to);
+ if (now < flow.start || !p || !q) continue;
+ const t = (now - flow.start) / FLOW_MS;
+ dots.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, r: flow.r, kind: flow.kind,
+ opacity: Math.sin(Math.PI * t) });
+ }
+ while (layer.children.length < dots.length) layer.append(svg("circle"));
+ while (layer.children.length > dots.length) layer.lastChild.remove();
+ dots.forEach((dot, i) => {
+ const circle = layer.children[i];
+ circle.setAttribute("cx", dot.x);
+ circle.setAttribute("cy", dot.y);
+ circle.setAttribute("r", (dot.r * dotScale).toFixed(1));
+ circle.setAttribute("fill", kindColour(dot.kind));
+ circle.setAttribute("opacity", dot.opacity.toFixed(2));
+ });
+ if (flows.length > 0) flowFrame = requestAnimationFrame(drawFlows);
+}
+
+// --- Events ---
+const connection = document.getElementById("connection");
+const dot = document.getElementById("dot");
+// One breath per event from the node.
+function pulse() {
+ dot.classList.remove("pulse");
+ void dot.offsetWidth; // restarts the animation
+ dot.classList.add("pulse");
+}
+function status(text, live) {
+ document.getElementById("connection-text").textContent = text;
+ connection.className = live ? "live" : "down";
+ document.body.classList.toggle("stale", !live && state !== null);
+}
+// While not live, what is shown stays as it was last (ages don't count on).
+let live = false;
+let events = null;
+// The next attempt, while one is waited for: a connect now replaces it.
+let retry = null;
+function connect() {
+ clearTimeout(retry);
+ retry = null;
+ events = new EventSource("dashboard/events");
+ events.addEventListener("snapshot", event => {
+ const snapshot = JSON.parse(event.data);
+ if (snapshot.page !== PAGE_VERSION) {
+ events.close();
+ status("new version; reloading…", false);
+ location.reload();
+ return;
+ }
+ // Changes while not connected went unseen: what is open is read again.
+ if (state !== null) documentChanged({ all: true });
+ state = snapshot; stateAt = Date.now(); live = true;
+ status("live", true); pulse(); render(); trafficArrived();
+ });
+ events.addEventListener("patch", event => {
+ if (!state) return;
+ state = mergePatch(state, JSON.parse(event.data)); stateAt = Date.now(); pulse(); render();
+ trafficArrived();
+ });
+ events.addEventListener("document", event => { pulse(); documentChanged(JSON.parse(event.data)); });
+ events.addEventListener("messages", event => messagesArrived(JSON.parse(event.data)));
+ // The node stops: it says so, and ends the stream. Back once it is.
+ events.addEventListener("offline", () => {
+ events.close(); live = false;
+ status("node stopped; reconnecting…", false);
+ retry = setTimeout(connect, 3000);
+ });
+ events.addEventListener("error", () => {
+ // Each attempt that failed: a breath too, so it shows it keeps trying.
+ pulse();
+ live = false;
+ if (events.readyState === EventSource.CLOSED) {
+ // Given up (an HTTP error, e.g. the node is up but not serving the page yet).
+ status("disconnected; retrying…", false);
+ retry = setTimeout(connect, 5000);
+ } else {
+ // EventSource reconnects by itself; the node sends a snapshot first again.
+ status("reconnecting…", false);
+ }
+ });
+}
+connect();
+// A page not shown (another tab, a minimised window) is paused: hidden for a while, it
+// closes its stream, so the node stops building updates and asking members for their
+// links for it; shown again, it reconnects. Briefly hidden, nothing changes.
+const PAUSE_AFTER = 30000;
+let paused = false;
+let pausing = null;
+document.addEventListener("visibilitychange", () => {
+ if (document.hidden) {
+ pausing = setTimeout(() => {
+ pausing = null;
+ paused = true;
+ clearTimeout(retry);
+ retry = null;
+ if (events) events.close();
+ live = false;
+ status("paused while hidden", false);
+ }, PAUSE_AFTER);
+ } else {
+ clearTimeout(pausing);
+ pausing = null;
+ if (paused) {
+ paused = false;
+ status("reconnecting…", false);
+ connect();
+ }
+ }
+});
+// A click on the status: live, everything now (a tick on the node, the members asked
+// again, what's open of the document read again); otherwise, reconnecting now.
+connection.title = "Click to refresh now";
+connection.style.cursor = "pointer";
+connection.addEventListener("click", () => {
+ pulse();
+ if (live) {
+ fetch("dashboard/refresh", { method: "POST" }).catch(() => {});
+ documentChanged({ all: true });
+ } else {
+ if (events) events.close();
+ status("reconnecting…", false);
+ connect();
+ }
+});
+// Ages count on between events; nothing else changes without one.
+setInterval(() => { if (live) render(true); }, 1000);
+window.addEventListener("resize", () => { layoutAgain(); render(); });
+
+// --- Sizes: the space between boxes dragged to resize them, double-clicked to reset;
+// kept in this browser ---
+const main = document.querySelector("main");
+// The map's share of the width (1: the right column dragged off to the side, hidden);
+// the peers' height in px; the console's share of what the peers leave, and the
+// figures' share of the rest. Null: as laid out by default.
+let sizes = { columns: null, peers: null, split: null, console: null };
+try { sizes = { ...sizes, ...JSON.parse(localStorage.getItem("continuum-sizes") || "{}") }; }
+catch (_) {}
+function applySizes() {
+ const style = main.style;
+ const shut = id => document.getElementById(id).classList.contains("collapsed");
+ const right = ["peers-box", "figures-box", "document-box", "console-box"];
+ const wasHidden = main.classList.contains("right-hidden");
+ main.classList.toggle("right-hidden", sizes.columns === 1);
+ main.classList.toggle("right-shut", sizes.columns !== 1 && right.every(shut));
+ // What's collapsed gives its room to what isn't: the map collapsed, the right column
+ // takes the width but the map's title. The right column all collapsed keeps its
+ // width, resized as ever (dragged to the side, it goes).
+ const mapShut = shut("map"), rightShut = right.every(shut);
+ if (sizes.columns === 1) style.setProperty("--columns", "minmax(0, 1fr) 0");
+ else if (mapShut && !rightShut) style.setProperty("--columns", "max-content minmax(0, 1fr)");
+ else if (sizes.columns === null) style.removeProperty("--columns");
+ else style.setProperty("--columns", "minmax(0, " + sizes.columns + "fr) minmax(0, " +
+ (1 - sizes.columns) + "fr)");
+ // Back: what waited is drawn.
+ if (wasHidden && sizes.columns !== 1) {
+ if (!hidden(document.getElementById("console-box"))) showConsole();
+ release();
+ }
+ // A collapsed box's row as tall as its title; the open ones share the rest, as they
+ // did, and the peers take it all when they're the only one open. Their fractions
+ // add up to 1: fewer would leave room unused (CSS grid gives fractions adding up to
+ // less than 1 only that part of the room).
+ const split = sizes.split === null ? .5 : sizes.split;
+ const consoleShare = sizes.console === null ? .2 : sizes.console;
+ const rest = 1 - consoleShare;
+ const fractions = { "figures-box": split * rest, "document-box": (1 - split) * rest,
+ "console-box": consoleShare };
+ const openShare = Object.keys(fractions).filter(id => !shut(id))
+ .reduce((sum, id) => sum + fractions[id], 0);
+ const share = id => shut(id) ? "auto" : "minmax(0, " + fractions[id] / openShare + "fr)";
+ const peersOnly = !shut("peers-box") && right.slice(1).every(shut);
+ const peers = shut("peers-box") ? "auto" : peersOnly ? "minmax(0, 1fr)"
+ : sizes.peers === null ? "auto" : sizes.peers + "px";
+ style.setProperty("--rows", peers + " " + share("figures-box") + " " +
+ share("document-box") + " " + share("console-box"));
+ main.classList.toggle("peers-sized", sizes.peers !== null || peersOnly);
+ try { localStorage.setItem("continuum-sizes", JSON.stringify(sizes)); } catch (_) {}
+}
+const box = id => document.getElementById(id).getBoundingClientRect();
+// Which space between boxes `event` is over, if any: "columns", "peers" (below the
+// peers), "split" (between figures and document) or "console" (above the console).
+// Only side by side; with the right column hidden, right of the map: "columns".
+function gapAt(event) {
+ if (event.target !== main) return null;
+ if (sizes.columns === 1 && main.classList.contains("right-hidden")) {
+ return event.clientX >= box("map").right ? "columns" : null;
+ }
+ const map = box("map"), peers = box("peers-box"), figures = box("figures-box"),
+ documentBox = box("document-box"), consoleBox = box("console-box");
+ if (map.right >= peers.left) return null;
+ if (event.clientX >= map.right && event.clientX <= peers.left) return "columns";
+ if (event.clientX < peers.left || event.clientX > peers.right) return null;
+ if (event.clientY >= peers.bottom && event.clientY <= figures.top) return "peers";
+ if (event.clientY >= figures.bottom && event.clientY <= documentBox.top) return "split";
+ if (event.clientY >= documentBox.bottom && event.clientY <= consoleBox.top) return "console";
+ return null;
+}
+function resizeTo(gap, event) {
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
+ // The space between boxes (`main`'s gap), half of it each side.
+ const half = parseFloat(getComputedStyle(main).columnGap) / 2;
+ if (gap === "columns") {
+ // Across the whole width: the right column's right edge is `main`'s, inside.
+ const map = box("map");
+ const right = main.getBoundingClientRect().right -
+ parseFloat(getComputedStyle(main).paddingRight);
+ const share = (event.clientX - half - map.left) / (right - map.left - 2 * half);
+ // Dragged to the side: hidden.
+ sizes.columns = share > .9 ? 1 : clamp(share, .2, .8);
+ } else if (gap === "peers") {
+ const peers = box("peers-box"), consoleBox = box("console-box");
+ sizes.peers = Math.round(clamp(event.clientY - half - peers.top, 60,
+ consoleBox.bottom - peers.top - 6 * half - 200));
+ } else if (gap === "console") {
+ const figures = box("figures-box"), consoleBox = box("console-box");
+ sizes.console = clamp((consoleBox.bottom - event.clientY - half) /
+ (consoleBox.bottom - figures.top - 4 * half), .05, .8);
+ } else {
+ const figures = box("figures-box"), documentBox = box("document-box");
+ sizes.split = clamp((event.clientY - half - figures.top) /
+ (documentBox.bottom - figures.top - 2 * half), .1, .9);
+ }
+ applySizes();
+}
+let resizing = null;
+let resizeFrame = null;
+main.addEventListener("pointermove", event => {
+ if (resizing) {
+ resizeTo(resizing, event);
+ // The map's view box follows its size: drawn once a frame.
+ if (resizeFrame === null) resizeFrame = requestAnimationFrame(() => {
+ resizeFrame = null;
+ render();
+ });
+ return;
+ }
+ const gap = gapAt(event);
+ main.classList.toggle("resize-columns", gap === "columns");
+ main.classList.toggle("resize-rows", gap === "peers" || gap === "split" || gap === "console");
+});
+main.addEventListener("pointerdown", event => {
+ resizing = gapAt(event);
+ if (!resizing) return;
+ event.preventDefault();
+ main.setPointerCapture(event.pointerId);
+});
+const resized = () => {
+ if (!resizing) return;
+ resizing = null;
+ main.classList.remove("resize-columns", "resize-rows");
+ layoutAgain();
+ render();
+};
+main.addEventListener("pointerup", resized);
+main.addEventListener("pointercancel", resized);
+// --- Boxes collapsed to their titles, and opened again; kept in this browser. A
+// collapsed box isn't drawn (`busy`), so it costs nothing while the node updates. ---
+const collapsed = new Set();
+try {
+ for (const id of JSON.parse(localStorage.getItem("continuum-collapsed") || "[]")) {
+ collapsed.add(id);
+ }
+} catch (_) {}
+for (const section of document.querySelectorAll("main > section")) {
+ const button = el("button", { class: "collapse" });
+ section.querySelector("h2").prepend(button);
+ const show = () => {
+ const shut = collapsed.has(section.id);
+ section.classList.toggle("collapsed", shut);
+ button.textContent = shut ? "▸" : "▾";
+ button.title = shut ? "Open" : "Collapse";
+ };
+ show();
+ button.addEventListener("click", () => {
+ if (collapsed.has(section.id)) collapsed.delete(section.id); else collapsed.add(section.id);
+ try { localStorage.setItem("continuum-collapsed", JSON.stringify([...collapsed])); }
+ catch (_) {}
+ show();
+ applySizes();
+ if (collapsed.has(section.id)) return;
+ if (section.id === "console-box") showConsole();
+ if (section.id === "map") layoutAgain();
+ release();
+ render();
+ });
+}
+applySizes();
+main.addEventListener("dblclick", event => {
+ const gap = gapAt(event);
+ if (!gap) return;
+ sizes[gap] = null;
+ applySizes();
+ layoutAgain();
+ render();
+});
+</script>
+</body>
+</html>