aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-31 05:58:57 +0100
committertv <tv@krebsco.de>2026-10-31 05:58:57 +0100
commitf276d57a7e3e7f92286867d0c7808e35aed31334 (patch)
tree458c40b7406a88707592b7ddf2ae7d28670f1ac0
parentb119c3bc22bb8b10148fa3699a5d4e445adf8931 (diff)
dashboard: add pan and zoom
-rw-r--r--src/api/dashboard/dashboard.html68
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));