diff options
| author | tv <tv@krebsco.de> | 2026-10-31 05:58:57 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-31 05:58:57 +0100 |
| commit | 7de1cdb03db420cfbb5c563fd933ec9d183e657d (patch) | |
| tree | 75294159dcf54e5c097dfb6f2a1637849fd90834 /src | |
| parent | 179f408b2a90b7ba5971ac178c357920d94af176 (diff) | |
dashboard: add pan and zoom
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 68 |
1 files changed, 64 insertions, 4 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 9f191be..1c03892 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -60,6 +60,9 @@ #figures-box { grid-area: figures; } #document-box { grid-area: document; } svg { flex: 1; min-height: 0; width: 100%; display: block; cursor: default; } + /* Dragged to pan, also by touch. */ + #graph { touch-action: none; } + #graph.panning { cursor: grabbing; } svg circle { cursor: pointer; } .legend { color: var(--muted); font-size: 12px; display: flex; gap: 1em; flex-wrap: wrap; flex: none; } @@ -73,6 +76,8 @@ .legend .dormant::before { border-top-width: 1.5px; --colour: var(--muted); box-shadow: 0 0 0 2.5px color-mix(in srgb, var(--bad) 50%, transparent); } .legend .silent::before { border-top-style: dotted; --colour: var(--muted); } + .legend .hint { margin-left: auto; } + .legend .hint::before { display: none; } /* Nodes: circles, as on the map. */ .legend .node::before { width: 10px; height: 10px; border: 1.5px solid var(--fg); border-radius: 50%; background: var(--fg); } @@ -136,9 +141,10 @@ <span class="node silent">reports none (no LINKS_REPLY)</span> <span class="node dormant-node">dormant (to this node)</span> <span class="node focus">in focus (click a node)</span> + <span class="hint">drag to pan, wheel to zoom, double-click to reset</span> </div> <div class="legend" id="legend"></div> - <svg id="graph"></svg> + <svg id="graph"><g id="view"></g></svg> </section> <section id="peers-box"> <h2>Peers</h2> @@ -617,7 +623,7 @@ function renderGraph() { text(label, name(id)); children.push(circle, label); } - show(root, children); + show(document.getElementById("view"), children); } // --- Document: read a level at a time (`/store?shallow=true`) as it is opened, and @@ -982,9 +988,63 @@ function release() { } document.addEventListener("selectionchange", release); -// A click beside the nodes: back to this node. +// --- Pan and zoom: the map's group moved and scaled. Lines, labels and nodes scale +// with it, as on a map. --- const graph = document.getElementById("graph"); -graph.addEventListener("pointerdown", event => { if (event.target === graph) focus(null); }); +const view = { x: 0, y: 0, k: 1 }; +function moveView() { + document.getElementById("view").setAttribute("transform", + "translate(" + view.x + " " + view.y + ") scale(" + view.k + ")"); +} +// Where the pointer is, in the map's own units (its `viewBox` is its size). +function pointer(event) { + const box = graph.getBoundingClientRect(); + return { x: event.clientX - box.left, y: event.clientY - box.top }; +} +// The wheel zooms around the pointer. +graph.addEventListener("wheel", event => { + event.preventDefault(); + const at = pointer(event); + const k = Math.min(8, Math.max(.25, view.k * Math.exp(-event.deltaY * .002))); + view.x = at.x - (at.x - view.x) * k / view.k; + view.y = at.y - (at.y - view.y) * k / view.k; + view.k = k; + moveView(); +}, { passive: false }); +// Pressed beside the nodes: dragged, it pans; let go where pressed (a click), back to +// this node. +let panning = null; +graph.addEventListener("pointerdown", event => { + if (event.target !== graph) return; + const at = pointer(event); + panning = { from: at, view: { ...view }, moved: false }; + graph.setPointerCapture(event.pointerId); +}); +graph.addEventListener("pointermove", event => { + if (!panning) return; + const at = pointer(event); + const dx = at.x - panning.from.x, dy = at.y - panning.from.y; + if (!panning.moved && Math.hypot(dx, dy) < 4) return; + panning.moved = true; + graph.classList.add("panning"); + view.x = panning.view.x + dx; + view.y = panning.view.y + dy; + moveView(); +}); +const panned = () => { + const click = panning && !panning.moved; + panning = null; + graph.classList.remove("panning"); + if (click) focus(null); +}; +graph.addEventListener("pointerup", panned); +graph.addEventListener("pointercancel", () => { if (panning) panning.moved = true; panned(); }); +// A double-click beside the nodes: the whole map again. +graph.addEventListener("dblclick", event => { + if (event.target !== graph) return; + Object.assign(view, { x: 0, y: 0, k: 1 }); + moveView(); +}); // Beside the nodes, or off the map: nothing hovered (the element left may be gone). graph.addEventListener("mousemove", event => { if (event.target === graph) hover(null); }); graph.addEventListener("mouseleave", () => hover(null)); |
