aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 00:14:08 +0100
committertv <tv@krebsco.de>2026-11-07 00:14:08 +0100
commit06dca76de30422a9cfdb45a7879c83b3f91cc1e4 (patch)
tree80ca470bfe3cd458d1c6943f85247d0de488d71f
parentdf40d6d2aed2d1a3b5c02a0a2e14c7c234d035ef (diff)
dashboard: polish remake rings
-rw-r--r--docs/dashboard.md9
-rw-r--r--src/api/dashboard/dashboard.html55
-rw-r--r--src/remake/mod.rs18
3 files changed, 65 insertions, 17 deletions
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 091e9f7..24713a8 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -80,9 +80,12 @@ Every member of the swarm, by alias (or a shortened node ID):
around it), or voted for one, while that remake is open: voting, or committed until
the member switched. A small amber breath as a remake is proposed (its coordinator)
and as each vote comes; a short pop once it's decided, green if committed, red if
- aborted (or expired), and the nodes turn back. Committed, the coordinator's ring
- counts down this node's wait to switch (`switchDelay`): from the top, it goes
- counterclockwise, and is gone as this node switches. Of the remakes in this node's document:
+ aborted (or expired), and the nodes turn back. While votes are collected
+ (`settle`), the coordinator's ring fills: empty at first, it grows counterclockwise
+ from the top. Committed, it counts down this node's wait to switch (`switchDelay`):
+ whole at first, its end goes back counterclockwise to the top, and it is gone as this
+ node switches. Both start and end at the top of the screen, however the map is turned;
+ a wait mostly over before the page saw it is shown whole, only faster. Of the remakes in this node's document:
every member's vote shows, as it reaches this node.
* **A golden glow**, growing from it and fading, once: it just entered a new epoch (a
remake, [`remakes.md`](remakes.md)). For every member that reports its links (they
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 771b49b..06bf267 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -1371,12 +1371,12 @@ function renderGraph() {
// In a remake (`inRemakes`): amber; its coordinator also ringed.
const voting = amber.has(id);
if (coordinating.has(id)) {
- // Counting down once committed (`countdowns`): from the top, as a clock's hand.
+ // Filling while it settles, counting down once committed (`countdowns`): both
+ // from the top of the screen, however the map is turned.
const dash = countdownDash(id, radius + 4);
children.push(drawing("coordinator " + id, "circle", { cx: p.x, cy: p.y, r: radius + 4,
fill: "none", stroke: AMBER, "stroke-width": 1, "pointer-events": "none",
- ...(dash ? { "stroke-dasharray": dash,
- transform: "rotate(-90 " + p.x + " " + p.y + ")" } : {}) }));
+ ...(dash ? { "stroke-dasharray": dash, transform: countdownTurn(id, p) } : {}) }));
if (dash && countdownFrame === null) countdownFrame = requestAnimationFrame(tickCountdowns);
}
const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, r: radius,
@@ -2253,12 +2253,22 @@ function remakesArrived() {
countdowns.clear();
for (const remake of remakes) {
seen.add(remake.id);
- // Committed: this node's wait to switch, as of now (re-anchored with every update).
- if (remake.phase === "committed" && typeof remake.switchInMs === "number" &&
- remake.switchDelayMs > 0) {
- countdowns.set(remake.coordinator, { until: performance.now() + remake.switchInMs,
- total: remake.switchDelayMs });
+ // Settling: how long its votes are collected; committed: this node's wait to switch.
+ // Anchored when first seen, not with every update (each moved it by however old the
+ // update was: jumps, with short waits), and from there on whole: it starts and ends
+ // at the top, even where most of the wait was over before this page saw it.
+ const [kind, left] = remake.phase === "proposed" && typeof remake.settleInMs === "number"
+ ? ["settle", remake.settleInMs]
+ : remake.phase === "committed" && typeof remake.switchInMs === "number" &&
+ remake.switchDelayMs > 0 ? ["switch", remake.switchInMs] : [null, 0];
+ const key = remake.id + " " + kind;
+ if (kind && !anchors.has(key)) {
+ anchors.set(key, { until: performance.now() + left, total: Math.max(1, left) });
}
+ // Committed and over (it switches any moment): an empty ring, not a whole one again.
+ const anchor = anchors.get(key) ||
+ (remake.phase === "committed" && anchors.get(remake.id + " switch"));
+ if (anchor) countdowns.set(remake.coordinator, { ...anchor, kind: kind || "switch" });
const was = knownRemakes.get(remake.id);
if (remakesKnown) {
const breath = id => breathe(id, { fill: AMBER, rim: AMBER, to: 20, ms: 900, opacity: .45 });
@@ -2287,20 +2297,39 @@ function remakesArrived() {
}
knownRemakes.delete(id);
}
+ for (const key of anchors.keys()) if (!seen.has(key.split(" ")[0])) anchors.delete(key);
remakesKnown = true;
}
-// Committed remakes' coordinators, by node ID: when this node switches (by
-// `performance.now()`) and the whole wait (ms). Their ring counts down: a full circle
-// from the top, going counterclockwise as the wait runs out, gone as the epoch switches.
+// Open remakes' coordinators, by node ID: when the wait ends (by `performance.now()`),
+// the whole wait (ms), and which: `settle` (votes collected) or `switch` (committed,
+// this node's wait to switch). The ring of one settling fills: empty at first, growing
+// counterclockwise from the top; once committed it counts down: a full circle from the
+// top, its end going back counterclockwise as the wait runs out, gone as the epoch
+// switches. Both meet at the top, so the one hands over to the other there.
const countdowns = new Map();
+// When each wait was first seen (`remakesArrived`), by remake ID and kind.
+const anchors = new Map();
let countdownFrame = null;
-// The ring's dashes for `id` (radius `r`), if it counts down: the part left.
+// The ring's dashes for `id` (radius `r`), if it counts: the part filled, or left.
function countdownDash(id, r) {
const countdown = countdowns.get(id);
if (!countdown) return null;
const left = Math.min(1, Math.max(0, (countdown.until - performance.now()) / countdown.total));
const around = 2 * Math.PI * r;
- return (around * left).toFixed(1) + " " + around.toFixed(1);
+ const shown = countdown.kind === "settle" ? 1 - left : left;
+ return (around * shown).toFixed(1) + " " + around.toFixed(1);
+}
+// Where the ring of `id` (at `p`) starts, and which way it goes: a circle's stroke
+// starts at its right and goes clockwise; turned a quarter back (and back from the
+// map's own turn, `view`), it starts at the top of the screen. Settling, mirrored
+// about the upright through its centre: counterclockwise from there.
+function countdownTurn(id, p) {
+ const turn = "rotate(" + (-90 - degrees(view.turn)) + " " + p.x + " " + p.y + ")";
+ if ((countdowns.get(id) || {}).kind !== "settle") return turn;
+ const a = degrees(view.turn);
+ // The mirror about the screen's upright through p, in the map's turned frame.
+ return "rotate(" + -a + " " + p.x + " " + p.y + ") translate(" + 2 * p.x + " 0) scale(-1 1) rotate(" +
+ a + " " + p.x + " " + p.y + ") " + turn;
}
// Every frame while a ring counts down: its dashes, and nothing else drawn anew.
function tickCountdowns() {
diff --git a/src/remake/mod.rs b/src/remake/mod.rs
index b16b8a3..67fa77b 100644
--- a/src/remake/mod.rs
+++ b/src/remake/mod.rs
@@ -94,12 +94,19 @@ pub(crate) struct Current {
pub(crate) switch_delay_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) switch_in_ms: Option<u64>,
+ /// Proposed: how long votes are collected (`Proposal::settle_ms`), and how much of
+ /// that is left by this node's clock (ms; 0 once past).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) settle_ms: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) settle_in_ms: Option<u64>,
}
/// The remakes of `epoch` in `doc`, with their phase at `now` (expiry by `grace`, this
/// node's `dormant_after`, for records without their own: `Record::phase`), and for one
/// committed, this node's wait to switch (`remake`: when it saw the commit; as
-/// `switch::check` waits: the proposal's delay, or a quarter of `grace`).
+/// `switch::check` waits: the proposal's delay, or a quarter of `grace`); for one
+/// proposed, how long it settles and how much of that is left.
#[cfg_attr(not(feature = "dashboard"), allow(dead_code))]
pub(crate) fn current(
remake: &RemakeState,
@@ -120,7 +127,16 @@ pub(crate) fn current(
let seen = remake.seen_for(&id)?;
Some(delay.saturating_sub(seen.as_millis() as u64))
});
+ let settling = phase == records::Phase::Proposed;
Current {
+ settle_ms: settling.then_some(record.proposal.settle_ms),
+ settle_in_ms: settling.then(|| {
+ record
+ .proposal
+ .settle_until
+ .as_u64()
+ .saturating_sub(now.as_u64())
+ }),
phase,
coordinator: record.proposal.coordinator.clone(),
voters: record.votes.keys().cloned().collect(),