From 2364e6f54809390485c39a41e6f48e663903c9da Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 31 Oct 2026 22:33:21 +0100 Subject: dashboard: show pins and let go --- src/api/dashboard/dashboard.html | 49 +++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 13 deletions(-) (limited to 'src/api/dashboard/dashboard.html') diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index ff55da4..7fb7ef8 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -471,8 +471,8 @@ function layout(nodes, pairs, width, height) { // 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) { +// 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 }])); @@ -495,7 +495,7 @@ function relax(nodes, pairs, width, height, gain, most) { } let moved = 0; for (const id of nodes) { - if (pinned.has(id)) continue; + if (pinned.has(id) || (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)))); @@ -530,20 +530,24 @@ function lengths(intended, measured, count, width, height) { } // 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. - if (!byRtt) { settling = false; return; } + // 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); + 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 = ""; } @@ -764,7 +768,6 @@ function renderGraph() { } const pressed = element => { element.addEventListener("pointerdown", event => { event.stopPropagation(); drag(event, id); }); - element.addEventListener("dblclick", () => unpin(id)); hoverable(element, id); }; // A clock off the swarm's: a dashed orange ring. @@ -784,10 +787,14 @@ function renderGraph() { (dormant ? " (dormant)" : "") + (clock && clock.off ? " (clock " + seconds(clock.offsetMs) + " off the swarm's, ±" + clock.withinMs + " ms)" : "")); + // Pinned (dragged there): 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 label = drawing("name " + id, "text", upright({ x: p.x + 10, y: p.y + 4, "font-size": 12, fill: "var(--fg)", cursor: "pointer" }), pressed); text(label, name(id)); - children.push(circle, label); + children.push(circle, ...pin, label); } show(document.getElementById("view"), children); } @@ -1299,7 +1306,11 @@ graph.addEventListener("pointerdown", event => { }, () => focus(null)); }); // A node: dragged, it stays where it is put (`pinned`), and the layout places the others -// around it; a click, it is in focus. +// around it; a click, it is in focus; a second one soon after, it's let go (`unpin`). +// 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; function drag(event, id) { const p = positions.get(id), from = { x: p.x, y: p.y }; pressing(event, (dx, dy) => { @@ -1309,17 +1320,29 @@ function drag(event, id) { p.y = from.y + y / view.k; pinned.add(id); renderGraph(); - }, () => focus(id)); + }, () => { + const now = performance.now(); + if (lastNodeClick.id === id && now - lastNodeClick.at < DOUBLE_CLICK_MS) { + lastNodeClick = { id: null, at: now }; + unpin(id); + } else { + lastNodeClick = { id, at: now }; + focus(id); + } + }); } -// A node let go: the layout places it again. +// 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; - lastShape = ""; + freed.add(id); renderGraph(); + settle(); } // A double-click beside the nodes: the whole map again. graph.addEventListener("dblclick", event => { - if (event.target !== graph) return; + // Not one on a node (`drag`), which the browser gives the map. + if (event.target !== graph || performance.now() - lastNodeClick.at < DOUBLE_CLICK_MS) return; Object.assign(view, { x: 0, y: 0, k: 1, turn: 0 }); moveView(); if (state) renderGraph(); -- cgit v1.3.1