aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/dashboard/dashboard.html104
1 files changed, 94 insertions, 10 deletions
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 @@
</header>
<main>
<section id="map">
- <h2>Swarm <button id="legend-toggle" class="toggle">hide legend</button></h2>
+ <h2>Swarm <button id="legend-toggle" class="toggle">hide legend</button>
+ <button id="rtt-toggle" class="toggle">layout: static</button></h2>
<div id="legends">
<div class="legend">
<span class="node self">this node</span>
@@ -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