From 03f6af0ef3b6917f07861f32283d8b1fd885c238 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:29 +0100 Subject: dashboard: message flashes messages: off / dots / flash: a message expands at its own speed and leaves an afterglow fading out in the direction it went. --- docs/dashboard.md | 8 ++- src/api/dashboard/dashboard.html | 124 +++++++++++++++++++++++++++++---------- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index 322abc6..2e482e8 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -144,8 +144,12 @@ along its links too, from its messages of each 2 s (read from it with the page's as its links are): those with this node are drawn from this node's counts, and between two subscribed nodes from what the sender sent, so each message once. Links between other members show none. -`messages: on` after the sliders hides them (`messages: off` shows them again; the -legend and the sliders greyed meanwhile); the choice is kept in the browser. +`messages: dots` after the sliders switches to `messages: flash`: each message crosses +its link as fast as it did (half the link's round trip, as the map has it; 50 ms where +none is measured), a streak widening to its head, which then draws back into where it +went as it fades (0.7 s); the `speed` slider is greyed then. Another click, +`messages: off`, hides them (the legend and the sliders greyed), and the next shows +dots again; the choice is kept in the browser. A label on the map that changes, a round trip or a node's name, breathes once: it glows around its new text and fades (not where the system asks for reduced motion). diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 17ff44b..ee8627c 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -329,7 +329,7 @@
+ class="toggle">messages: dots
drag to pan, wheel or pinch to zoom, turn with two fingers; drag a node to move it, middle-click it to pin it there or let it go, @@ -339,7 +339,7 @@
- +
@@ -2712,29 +2712,55 @@ graph.addEventListener("mouseleave", () => hover(null)); // (`remoteMessagesArrived`), 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. All at one speed (`flowSpeed`), so over a longer link they arrive -// later. Kept above everything else on the map (`flows`, after what `show` places). --- +// record of it. As dots, all at one speed (`flowSpeed`), so over a longer link they +// arrive later; as flashes, each crosses its link as fast as the message did (half the +// link's round trip) and leaves an afterglow that draws back into where it went +// (`FLASH_GLOW_MS`). Kept above everything else on the map (`flows`, after what `show` +// places). --- const 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 (_) {} +// A flash's afterglow, after it arrived; and how long it takes over a link whose round +// trip isn't known. +const FLASH_GLOW_MS = 700, FLASH_UNKNOWN_MS = 50; +// How they are shown (`flows-toggle`, kept in this browser; dots at first): `dots`, +// `flash` or `off`. +const FLOW_MODES = ["dots", "flash", "off"]; +let flowsMode = "dots"; +try { + const kept = localStorage.getItem("continuum-flows"); + flowsMode = FLOW_MODES.includes(kept) ? kept : kept === "0" ? "off" : "dots"; +} catch (_) {} +let flowsShown = flowsMode !== "off"; 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"; + const next = FLOW_MODES[(FLOW_MODES.indexOf(flowsMode) + 1) % FLOW_MODES.length]; + flowsToggle.textContent = "messages: " + flowsMode; + flowsToggle.title = "Click for " + (next === "off" ? "none" + : next === "dots" ? "dots running along the links" + : "flashes: each as fast as the message, with an afterglow toward where it went"); } showFlowsToggle(); flowsToggle.addEventListener("click", () => { - flowsShown = !flowsShown; - try { localStorage.setItem("continuum-flows", flowsShown ? "1" : "0"); } catch (_) {} + flowsMode = FLOW_MODES[(FLOW_MODES.indexOf(flowsMode) + 1) % FLOW_MODES.length]; + flowsShown = flowsMode !== "off"; + try { localStorage.setItem("continuum-flows", flowsMode); } catch (_) {} showFlowsToggle(); showMessagesLegend(); if (!flowsShown) { flows.length = 0; - document.getElementById("flows").replaceChildren(); + document.getElementById("flow-dots").replaceChildren(); + document.getElementById("flow-streaks").replaceChildren(); } }); +// How long a message takes from `from` to `to`: half the round trip either end +// measured, as the map has it (`FLASH_UNKNOWN_MS` if neither did). +function oneWayMs(from, to) { + for (const [a, b] of [[from, to], [to, from]]) { + const report = state && state.graph.measured[a]; + const link = report && (report.links || []).find(link => link.peer === b); + if (link && typeof link.rttMs === "number") return link.rttMs / 2; + } + return FLASH_UNKNOWN_MS; +} let lastTraffic = null; // When the last update's traffic came, by the wall clock (it runs on while the computer // sleeps, unlike `performance.now()` on some systems). @@ -2775,7 +2801,7 @@ function showMessagesLegend() { try { localStorage.setItem("continuum-dot-scale", String(dotScale)); } catch (_) {} }); const speed = speedSlider = el("input", { type: "range", min: ".1", max: ".9", step: ".1", - title: "How fast the dots run" }); + title: "How fast the dots run (flashes run as fast as the messages did)" }); speed.value = String(flowSpeed); speed.addEventListener("input", () => { flowSpeed = Number(speed.value); @@ -2788,7 +2814,8 @@ function showMessagesLegend() { el("label", { class: "slider" }, "size", slider), el("label", { class: "slider" }, "speed", speed)); } - dotSlider.disabled = speedSlider.disabled = !flowsShown; + dotSlider.disabled = !flowsShown; + speedSlider.disabled = flowsMode !== "dots"; } showMessagesLegend(); // A dot's radius for a message of `bytes`: grows with the size's logarithm, from 1.5 @@ -3155,37 +3182,72 @@ function showConsole() { } function drawFlows() { flowFrame = null; - const layer = document.getElementById("flows"); const now = performance.now(); - // How far along its link each is (0 to 1), as long as the link is now: arrived, or - // its link gone, it goes. - const along = flow => { + // Where each is: as a dot, how far along its link (0 to 1) as long as the link is now; + // as a flash, its head and its afterglow's tail (each 0 to 1) and how bright. Arrived + // (a flash: its afterglow gone), or its link gone, it goes. + const where = flow => { const p = positions.get(flow.from), q = positions.get(flow.to); if (!p || !q) return null; const length = Math.max(Math.hypot(q.x - p.x, q.y - p.y), 1); - return { p, q, t: (now - flow.start) * flowSpeed / length }; + if (flowsMode === "flash") { + const travel = flow.travel || (flow.travel = Math.max(1, oneWayMs(flow.from, flow.to))); + const age = now - flow.start, glow = Math.max(0, (age - travel) / FLASH_GLOW_MS); + return { p, q, length, done: glow >= 1, head: Math.min(1, age / travel), + tail: 1 - (1 - Math.min(1, glow)) ** 2, opacity: 1 - Math.min(1, glow) }; + } + const t = (now - flow.start) * flowSpeed / length; + return { p, q, length, done: t >= 1, t }; }; for (let i = flows.length - 1; i >= 0; i--) { - const at = along(flows[i]); - if (at === null || at.t >= 1) flows.splice(i, 1); + const at = where(flows[i]); + if (at === null || at.done) flows.splice(i, 1); } - const dots = []; + const dots = [], streaks = []; for (const flow of flows) { if (now < flow.start) continue; - const { p, q, t } = along(flow); - 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) }); + const at = where(flow); + const { p, q } = at; + const r = flow.r * dotScale, colour = kindColour(flow.kind); + if (flowsMode === "flash") { + // From the tail, a point, widening to the head, rounded: brightest where the + // message is, drawing back toward it as it fades. + const ux = (q.x - p.x) / at.length, uy = (q.y - p.y) / at.length; + const hx = p.x + (q.x - p.x) * at.head, hy = p.y + (q.y - p.y) * at.head; + const tx = p.x + (q.x - p.x) * Math.min(at.tail, at.head); + const ty = p.y + (q.y - p.y) * Math.min(at.tail, at.head); + const w = r * .8, nx = -uy * w, ny = ux * w; + const f = n => n.toFixed(1); + streaks.push({ colour, opacity: at.opacity, d: "M" + f(tx) + " " + f(ty) + + "L" + f(hx + nx) + " " + f(hy + ny) + + "A" + f(w) + " " + f(w) + " 0 0 0 " + f(hx - nx) + " " + f(hy - ny) + "Z" }); + } else { + dots.push({ x: p.x + (q.x - p.x) * at.t, y: p.y + (q.y - p.y) * at.t, r, colour, + opacity: Math.sin(Math.PI * at.t) }); + } } - while (layer.children.length < dots.length) layer.append(svg("circle")); - while (layer.children.length > dots.length) layer.lastChild.remove(); + const layer = (id, count, tag) => { + const group = document.getElementById(id); + while (group.children.length < count) group.append(svg(tag)); + while (group.children.length > count) group.lastChild.remove(); + return group; + }; + const dotLayer = layer("flow-dots", dots.length, "circle"); dots.forEach((dot, i) => { - const circle = layer.children[i]; + const circle = dotLayer.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("r", dot.r.toFixed(1)); + circle.setAttribute("fill", dot.colour); circle.setAttribute("opacity", dot.opacity.toFixed(2)); }); + const streakLayer = layer("flow-streaks", streaks.length, "path"); + streaks.forEach((streak, i) => { + const path = streakLayer.children[i]; + path.setAttribute("d", streak.d); + path.setAttribute("fill", streak.colour); + path.setAttribute("opacity", streak.opacity.toFixed(2)); + }); if (flows.length > 0) flowFrame = requestAnimationFrame(drawFlows); } -- cgit v1.3.1