From 542478309f175aa6bdab951073ac24c4f94788c5 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 31 Oct 2026 22:24:27 +0100 Subject: dashboard: allow RTT to determine edge lengths --- src/api/dashboard/dashboard.html | 104 +++++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index ec80e31..ff55da4 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -90,7 +90,7 @@ #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; padding: 0 6px; color: var(--muted); background: none; + 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. */ @@ -154,7 +154,8 @@
-

Swarm

+

Swarm +

this node @@ -453,6 +454,11 @@ function edges() { 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], the length the link pulls towards (else `idealLength`). function layout(nodes, pairs, width, height) { for (const id of nodes) { if (!positions.has(id)) { @@ -460,8 +466,15 @@ function layout(nodes, pairs, width, height) { y: height / 2 + (Math.random() - .5) * height / 2 }); } } - const ideal = Math.min(width, height) / Math.max(2, Math.sqrt(nodes.length) * 1.5); - for (let round = 0; round < 120; round++) { + for (let round = 0; round < 120; round++) relax(nodes, pairs, width, height, 1, 10); +} +// 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. +function relax(nodes, pairs, width, height, gain, most) { + 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++) { @@ -472,23 +485,72 @@ function layout(nodes, pairs, width, height) { force.get(nodes[j]).x -= dx * push / 10; force.get(nodes[j]).y -= dy * push / 10; } } - for (const [a, b] of pairs) { + for (const [a, b, length] 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 pull = (d - ideal) / d / 4; + const pull = (d - (length || ideal)) / 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)) continue; - const p = positions.get(id), f = force.get(id); - p.x = Math.min(width - 40, Math.max(40, p.x + Math.max(-10, Math.min(10, f.x)))); - p.y = Math.min(height - 20, Math.max(20, p.y + Math.max(-10, Math.min(10, f.y)))); + 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 (_) {} +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 = [...intended.values()].map(([a, b]) => [a, b, ideal]); + for (const { a, b, link } of measured.values()) { + 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.push([a, b, Math.round(length)]); + } + return pairs; +} +// What `settle` moves the map towards: the latest render's nodes and lengths. +let settling = false, settleTo = null, lastLengths = ""; +function settle() { + if (settling) return; + settling = true; + let frames = 0; + const step = () => { + // Static again: where they are now. + if (!byRtt) { 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); + renderGraph(); + if (moved > .1 && ++frames < 600 && !document.hidden) requestAnimationFrame(step); + else { + settling = false; + // 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; @@ -571,7 +633,7 @@ function renderGraph() { const nodes = Object.keys(state.graph.members).sort(); const { intended, measured } = edges(); renderLegend(measured); - const pairs = [...intended.values(), ...[...measured.values()].map(m => [m.a, m.b])]; + const pairs = lengths(intended, measured, nodes.length, width, height); // Laid out anew only when who's there or who links to whom changed. const shape = nodes.join(",") + "|" + [...intended.keys(), ...measured.keys()].sort().join(","); if (shape !== lastShape) { @@ -581,6 +643,12 @@ function renderGraph() { layout(nodes, pairs, width, height); lastShape = shape; } + settleTo = { nodes, pairs, width, height }; + const wanted = pairs.map(pair => pair.join(" ")).join(","); + if (byRtt && wanted !== lastLengths) { + lastLengths = wanted; + settle(); + } const children = []; // Behind a link: a halo while it's hovered or in focus (`width`: of its lines). const halo = (key, p, q, width) => { @@ -1097,6 +1165,22 @@ legendToggle.addEventListener("click", () => { legendToggle.textContent = legends.hidden ? "show legend" : "hide legend"; if (state) renderGraph(); }); +// 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 -- cgit v1.3.1