From 587fd07d3896087f9c5562b3a8c3f9e92e814797 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 01:16:02 +0200 Subject: [PATCH] =?UTF-8?q?stage-exp-d=20explore:=20JS=20=E2=80=94=20cross?= =?UTF-8?q?-jump=20navigation=20+=20verify=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/psyc/cockpit/static/federation_explore.js | 780 ++++++++++++++++++ 1 file changed, 780 insertions(+) create mode 100644 src/psyc/cockpit/static/federation_explore.js diff --git a/src/psyc/cockpit/static/federation_explore.js b/src/psyc/cockpit/static/federation_explore.js new file mode 100644 index 0000000..20be78a --- /dev/null +++ b/src/psyc/cockpit/static/federation_explore.js @@ -0,0 +1,780 @@ +/* psyc — federation explorer (public, transparency view). + * + * Forked from federation_network.js, adapted for the public surface: + * • data source is /federation/explore/data (signed, CORS-enabled) + * • clicking a peer opens a walk-to-peer card with a primary CTA + * that full-page-navigates to that peer's own /federation/explore + * • the transparency log can be re-verified live from the page + * • inbound vouches (who vouches for THIS node) get their own section + * • severity/IOC-type breakdowns are intentionally NOT surfaced — + * those stay admin-only to avoid sector-leaking via the public page + * + * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop. + */ + +(function () { + "use strict"; + + const svg = document.getElementById("federation-network-graph"); + const loadingEl = document.getElementById("fn-loading"); + const errorEl = document.getElementById("fn-error"); + const tooltipEl = document.getElementById("fn-tooltip"); + const walkEl = document.getElementById("fe-walk"); + const directCountEl = document.getElementById("fe-direct-count"); + const transitiveCountEl = document.getElementById("fe-transitive-count"); + const kpiPeers = document.getElementById("fe-kpi-peers"); + const kpiVouchesOut = document.getElementById("fe-kpi-vouches-out"); + const kpiVouchesIn = document.getElementById("fe-kpi-vouches-in"); + const kpiSignals = document.getElementById("fe-kpi-signals"); + const kpiCorroboration = document.getElementById("fe-kpi-corroboration"); + const kpiTranslog = document.getElementById("fe-kpi-translog"); + const kpiVerify = document.getElementById("fe-kpi-verify"); + const verifyBtn = document.getElementById("fe-verify-btn"); + const verifyResult = document.getElementById("fe-verify-result"); + const vouchesInList = document.getElementById("fe-vouches-in-list"); + const vouchesInCountEl = document.getElementById("fe-vouches-in-count"); + const settings = window.PSYC_EXPLORE || {}; + + if (!svg) return; + + // ---------- shared escape ----------------------------------------------- + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + function shortFp(fp) { + if (!fp) return "—"; + if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8); + return fp; + } + function fmtAge(iso) { + if (!iso) return "—"; + const ts = new Date(iso); + if (isNaN(ts.getTime())) return "—"; + const secs = Math.floor((Date.now() - ts.getTime()) / 1000); + if (secs < 0) return "just now"; + if (secs < 60) return secs + "s ago"; + if (secs < 3600) return Math.floor(secs / 60) + "m ago"; + if (secs < 86400) return Math.floor(secs / 3600) + "h ago"; + return Math.floor(secs / 86400) + "d ago"; + } + + fetch("/federation/explore/data", { credentials: "omit" }) + .then(r => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(data => { + if (loadingEl) loadingEl.style.display = "none"; + render(data); + }) + .catch(err => { + if (loadingEl) loadingEl.style.display = "none"; + if (errorEl) { + errorEl.style.display = "block"; + errorEl.textContent = "✗ failed to load explore payload: " + err.message; + } + }); + + // ---------- verify button — fetch /federation/log/verify --------------- + if (verifyBtn) { + verifyBtn.addEventListener("click", () => { + verifyBtn.disabled = true; + verifyResult.textContent = "verifying…"; + verifyResult.classList.remove("fe-verify-ok", "fe-verify-bad"); + fetch("/federation/log/verify", { credentials: "omit" }) + .then(r => r.json().then(b => ({ status: r.status, body: b }))) + .then(({ status, body }) => { + if (status === 200 && body.verified != null) { + verifyResult.textContent = "✓ verified " + body.verified + " entries · head " + (body.head_hash || "").slice(0, 12) + "…"; + verifyResult.classList.add("fe-verify-ok"); + if (kpiVerify) { + kpiVerify.textContent = "✓ ok"; + kpiVerify.classList.add("fe-verify-ok"); + kpiVerify.classList.remove("fe-verify-bad"); + } + } else { + verifyResult.textContent = "✗ " + (body.error || "chain invalid"); + verifyResult.classList.add("fe-verify-bad"); + if (kpiVerify) { + kpiVerify.textContent = "✗ broken"; + kpiVerify.classList.add("fe-verify-bad"); + kpiVerify.classList.remove("fe-verify-ok"); + } + } + }) + .catch(err => { + verifyResult.textContent = "✗ fetch failed: " + err.message; + verifyResult.classList.add("fe-verify-bad"); + }) + .finally(() => { verifyBtn.disabled = false; }); + }); + } + + function render(data) { + const node = data.node || {}; + const selfFp = data.fingerprint || node.fingerprint || ""; + const peersData = data.peers || []; + const transitiveData = data.transitive_peers || []; + const vouchesIn = data.vouches_in || []; + const vouchesOut = data.vouches_out || data.vouches || []; + + // ---------- KPI strip ------------------------------------------------ + if (kpiPeers) kpiPeers.textContent = String(node.peer_count != null ? node.peer_count : peersData.length); + if (kpiVouchesOut) kpiVouchesOut.textContent = String(node.vouches_out_count != null ? node.vouches_out_count : vouchesOut.length); + if (kpiVouchesIn) kpiVouchesIn.textContent = String(node.vouches_in_count != null ? node.vouches_in_count : vouchesIn.length); + if (kpiSignals) kpiSignals.textContent = String(node.signals_count_24h || 0); + if (kpiCorroboration) kpiCorroboration.textContent = String(node.corroboration_count_24h || 0); + if (kpiTranslog) kpiTranslog.textContent = String(node.translog_entry_count || 0); + if (kpiVerify) kpiVerify.textContent = "unverified"; + + if (directCountEl) directCountEl.textContent = String(peersData.length); + if (transitiveCountEl) transitiveCountEl.textContent = String(transitiveData.length); + + // ---------- node + edge model ---------------------------------------- + // The explore payload doesn't ship edges directly; we derive them from + // the vouches + per-peer signal counts so the graph reads the same way + // the admin view does. + const peerByFp = Object.create(null); + const nodes = []; + + // Self at the center. + const selfNode = { + id: selfFp, fp: selfFp, + domain: settings.selfDomain || node.domain || "", + label: settings.selfDomain || (node.domain || "self"), + status: "self", + is_self: true, + distance: 0, + stats: null, + r: 38, + intensity: 1, + x: 0, y: 0, vx: 0, vy: 0, fixed: false, + }; + nodes.push(selfNode); + peerByFp[selfFp] = selfNode; + + // Max signal count for log-intensity normalization. + let maxSig = 0; + for (const p of peersData) { + if ((p.signal_count_24h || 0) > maxSig) maxSig = p.signal_count_24h || 0; + } + + for (const p of peersData) { + const fp = p.fingerprint; + if (!fp || fp === selfFp) continue; + const sig = p.signal_count_24h || 0; + let intensity = 1; + if (maxSig > 0) { + const num = Math.log2(sig + 1); + const den = Math.log2(maxSig + 1) || 1; + intensity = 0.20 + 0.80 * (num / den); + } + const n = { + id: fp, fp, + domain: p.domain || "", + label: p.domain || shortFp(fp), + status: "trusted", + is_self: false, + distance: 1, + stats: p, + r: 16, + intensity, + x: 0, y: 0, vx: 0, vy: 0, fixed: false, + }; + nodes.push(n); + peerByFp[fp] = n; + } + + for (const t of transitiveData) { + const fp = t.fingerprint; + if (!fp || peerByFp[fp]) continue; + const n = { + id: fp, fp, + domain: t.domain || "", + label: t.domain || shortFp(fp), + status: "unknown", + is_self: false, + distance: 2, + stats: null, + via: t.via_peer_fingerprint || "", + r: 9, + intensity: 0.7, + x: 0, y: 0, vx: 0, vy: 0, fixed: false, + }; + nodes.push(n); + peerByFp[fp] = n; + } + + // Edges. Per-peer signal counts → signal edges; outbound vouches → + // vouch edges; vouches_in → bidirectional vouch edges; transitive + // "via" → knows edges. + const edges = []; + for (const p of peersData) { + const fp = p.fingerprint; + if (!fp || fp === selfFp) continue; + if ((p.signal_count_24h || 0) > 0) { + edges.push({ + source: fp, target: selfFp, kind: "signal", + weight: p.signal_count_24h, label: p.signal_count_24h + " signals/24h", + bidirectional: false, + }); + } + } + // Outbound vouches. + const outbound = new Set(); + for (const v of vouchesOut) { + const tgt = v.target_fingerprint; + if (!tgt || !peerByFp[tgt]) continue; + outbound.add(tgt); + edges.push({ + source: selfFp, target: tgt, kind: "vouch", + weight: 1, label: "vouched", bidirectional: false, + }); + } + // Inbound vouches — collapse onto existing outbound where possible. + for (const v of vouchesIn) { + const src = v.voucher_fingerprint; + if (!src || !peerByFp[src]) continue; + if (outbound.has(src)) { + const existing = edges.find(e => e.kind === "vouch" + && e.source === selfFp && e.target === src); + if (existing) { + existing.bidirectional = true; + existing.label = "vouched ↔"; + continue; + } + } + edges.push({ + source: src, target: selfFp, kind: "vouch", + weight: 1, label: "vouches us", bidirectional: false, + }); + } + // Transitive "knows" edges. + for (const t of transitiveData) { + const parent = t.via_peer_fingerprint; + if (!parent || !peerByFp[parent] || !peerByFp[t.fingerprint]) continue; + edges.push({ + source: parent, target: t.fingerprint, kind: "knows", + weight: 0.5, label: "knows", bidirectional: false, + }); + } + + // ---------- viewport + seeding --------------------------------------- + function viewport() { + const W = svg.clientWidth || 900; + const H = parseInt(getComputedStyle(svg).height, 10) || 560; + return { W, H }; + } + let { W, H } = viewport(); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + + (function seed() { + const cx = W / 2, cy = H / 2; + nodes.forEach((n, i) => { + if (n.is_self) { n.x = cx; n.y = cy; return; } + const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22; + const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2; + n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20; + n.y = cy + ring * Math.sin(ang) + (Math.random() - 0.5) * 20; + }); + })(); + + const REPULSION = 1500; + const SPRING_K = 0.035; + const SPRING_REST_BASE = 110; + const DAMP = 0.82; + const CENTER_PULL = 0.005; + + function tick() { + for (let i = 0; i < nodes.length; i++) { + const a = nodes[i]; + for (let j = i + 1; j < nodes.length; j++) { + const b = nodes[j]; + const dx = b.x - a.x, dy = b.y - a.y; + const d2 = dx * dx + dy * dy + 0.1; + const d = Math.sqrt(d2); + const f = REPULSION / d2; + const fx = (dx / d) * f, fy = (dy / d) * f; + if (!a.fixed) { a.vx -= fx; a.vy -= fy; } + if (!b.fixed) { b.vx += fx; b.vy += fy; } + } + } + for (const e of edges) { + const a = peerByFp[e.source], b = peerByFp[e.target]; + if (!a || !b) continue; + const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE; + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.sqrt(dx * dx + dy * dy) + 0.1; + const f = (d - rest) * SPRING_K; + const fx = (dx / d) * f, fy = (dy / d) * f; + if (!a.fixed) { a.vx += fx; a.vy += fy; } + if (!b.fixed) { b.vx -= fx; b.vy -= fy; } + } + for (const n of nodes) { + if (n.fixed) { n.vx = 0; n.vy = 0; continue; } + n.vx += (W / 2 - n.x) * CENTER_PULL; + n.vy += (H / 2 - n.y) * CENTER_PULL; + n.vx *= DAMP; n.vy *= DAMP; + n.x += n.vx; n.y += n.vy; + n.x = Math.max(n.r, Math.min(W - n.r, n.x)); + n.y = Math.max(n.r, Math.min(H - n.r, n.y)); + } + } + for (let i = 0; i < 280; i++) tick(); + + // ---------- render SVG groups ---------------------------------------- + const ns = "http://www.w3.org/2000/svg"; + const edgesG = document.createElementNS(ns, "g"); + const nodesG = document.createElementNS(ns, "g"); + edgesG.setAttribute("class", "fn-edges"); + nodesG.setAttribute("class", "fn-nodes"); + svg.appendChild(edgesG); + svg.appendChild(nodesG); + + const edgeEls = edges.map(e => { + const grp = document.createElementNS(ns, "g"); + grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind); + grp.dataset.source = e.source; + grp.dataset.target = e.target; + const ln = document.createElementNS(ns, "line"); + ln.setAttribute("class", "fn-edge"); + if (e.kind === "signal") { + const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8); + ln.setAttribute("stroke-width", w.toFixed(2)); + } + grp.appendChild(ln); + if (e.label) { + const lbl = document.createElementNS(ns, "text"); + lbl.setAttribute("class", "fn-edge-label"); + lbl.textContent = e.label; + grp.appendChild(lbl); + } + edgesG.appendChild(grp); + return { line: ln, label: grp.querySelector("text"), grp }; + }); + + function _classFor(n) { + if (n.is_self) return "fn-node fn-self"; + const dist = n.distance >= 2 ? " fn-distance-2" : " fn-distance-1"; + return "fn-node fn-status-" + n.status + dist; + } + + const nodeEls = nodes.map(n => { + const g = document.createElementNS(ns, "g"); + g.setAttribute("class", _classFor(n)); + g.dataset.fp = n.fp; + + let shape; + if (n.is_self) { + const sz = n.r; + shape = document.createElementNS(ns, "rect"); + shape.setAttribute("x", -sz); shape.setAttribute("y", -sz); + shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2); + shape.setAttribute("rx", 10); shape.setAttribute("ry", 10); + g.appendChild(shape); + } else { + shape = document.createElementNS(ns, "circle"); + shape.setAttribute("r", n.r); + shape.setAttribute("fill-opacity", n.intensity.toFixed(2)); + g.appendChild(shape); + } + + const text = document.createElementNS(ns, "text"); + text.setAttribute("class", "fn-label"); + text.setAttribute("dy", n.r + 13); + text.textContent = n.label; + g.appendChild(text); + + if (!n.is_self) { + const sub = document.createElementNS(ns, "text"); + sub.setAttribute("class", "fn-sublabel"); + sub.setAttribute("dy", n.r + 24); + sub.textContent = n.fp.slice(0, 8) + "…"; + g.appendChild(sub); + + if (n.stats) { + const badge = document.createElementNS(ns, "text"); + badge.setAttribute("class", "fn-stat-badge"); + badge.setAttribute("dy", n.r + 36); + badge.textContent = + "↓ " + (n.stats.signal_count_24h || 0) + + " · ⚡ " + (n.stats.quorum_contribution_24h || 0); + g.appendChild(badge); + } + } + + const title = document.createElementNS(ns, "title"); + title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp); + g.appendChild(title); + + nodesG.appendChild(g); + return g; + }); + + function paint() { + for (let i = 0; i < edges.length; i++) { + const e = edges[i]; + const a = peerByFp[e.source], b = peerByFp[e.target]; + if (!a || !b) continue; + const els = edgeEls[i]; + els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y); + els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y); + if (els.label) { + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3); + } + } + for (let i = 0; i < nodes.length; i++) { + nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`); + } + } + paint(); + + // ---------- tooltip -------------------------------------------------- + function showTooltip(n, clientX, clientY) { + if (!tooltipEl) return; + const rows = []; + rows.push(`
${esc(n.domain || n.label || n.fp.slice(0,12))}
`); + if (n.is_self) { + rows.push(`
roleself
`); + rows.push(`
peers${node.peer_count || 0}
`); + rows.push(`
signals 24h${node.signals_count_24h || 0}
`); + } else if (n.distance >= 2) { + rows.push(`
distance2 hops (transitive)
`); + if (n.via) { + const parent = peerByFp[n.via]; + const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via); + rows.push(`
via${esc(via)}
`); + } + rows.push(`
tipclick to walk →
`); + } else { + const s = n.stats || {}; + rows.push(`
statustrusted
`); + rows.push(`
signals 24h${s.signal_count_24h || 0}
`); + rows.push(`
quorum hits${s.quorum_contribution_24h || 0}
`); + rows.push(`
last seen${esc(fmtAge(s.last_seen))}
`); + rows.push(`
tipclick to walk →
`); + } + tooltipEl.innerHTML = rows.join(""); + tooltipEl.classList.add("is-visible"); + positionTooltip(clientX, clientY); + } + function positionTooltip(clientX, clientY) { + if (!tooltipEl) return; + const parent = svg.parentElement; + if (!parent) return; + const rect = parent.getBoundingClientRect(); + let x = clientX - rect.left + 14; + let y = clientY - rect.top + 14; + const tw = tooltipEl.offsetWidth || 240; + const th = tooltipEl.offsetHeight || 100; + if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14; + if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14; + tooltipEl.style.left = x + "px"; + tooltipEl.style.top = y + "px"; + } + function hideTooltip() { + if (tooltipEl) tooltipEl.classList.remove("is-visible"); + } + + // ---------- drag + click + hover ------------------------------------ + let dragging = null, dragOffset = { x: 0, y: 0 }; + let pressedNode = null, pressedAt = null, moved = false; + let energyBudget = 40; + function svgPoint(clientX, clientY) { + const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY; + return pt.matrixTransform(svg.getScreenCTM().inverse()); + } + nodeEls.forEach((g, i) => { + const n = nodes[i]; + g.addEventListener("mousedown", ev => { + ev.preventDefault(); + pressedNode = n; + pressedAt = { x: ev.clientX, y: ev.clientY }; + moved = false; + dragging = n; + const p = svgPoint(ev.clientX, ev.clientY); + dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y; + if (currentLayout === "force") dragging.fixed = true; + g.classList.add("dragging"); + }); + g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY)); + g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY)); + g.addEventListener("mouseleave", hideTooltip); + }); + document.addEventListener("mousemove", ev => { + if (pressedAt) { + const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y; + if (dx * dx + dy * dy > 16) moved = true; + } + if (!dragging) return; + const p = svgPoint(ev.clientX, ev.clientY); + dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y; + dragging.vx = 0; dragging.vy = 0; + energyBudget = 80; + }); + document.addEventListener("mouseup", () => { + if (dragging) { + const g = nodesG.querySelector(`[data-fp="${CSS.escape(dragging.fp)}"]`); + if (g) g.classList.remove("dragging"); + if (currentLayout === "force") dragging.fixed = false; + dragging = null; + } + if (pressedNode && !moved) selectNode(pressedNode); + pressedNode = null; pressedAt = null; + }); + + // ---------- walk-to-peer card --------------------------------------- + function selectNode(n) { + nodeEls.forEach(el => el.classList.remove("selected")); + const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`); + if (me) me.classList.add("selected"); + renderWalk(n); + } + function jumpToFp(fp) { + const t = peerByFp[fp]; + if (!t) return; + selectNode(t); + // Scroll the graph stage into view so the user sees the highlight. + svg.scrollIntoView({ behavior: "smooth", block: "center" }); + } + + function vouchersFor(fp) { + // Inbound vouches naming `fp`. Right now we only have inbound vouches + // for SELF in the public payload; for any other peer we don't see + // who-vouches-for-them from this page. + if (fp === selfFp) return vouchesIn.map(v => v.voucher_fingerprint); + return []; + } + + function renderWalk(n) { + if (!walkEl) return; + const isSelf = n.is_self; + const isTransitive = n.distance >= 2; + const stats = n.stats || {}; + + const targetDomain = n.domain || (isSelf ? settings.selfDomain : ""); + const peerHref = targetDomain + ? `https://${targetDomain}/federation/explore` + : ""; + + const statsHtml = []; + if (isSelf) { + statsHtml.push(`peers ${node.peer_count || 0}`); + statsHtml.push(`signals 24h ${node.signals_count_24h || 0}`); + statsHtml.push(`corroborations ${node.corroboration_count_24h || 0}`); + statsHtml.push(`translog ${node.translog_entry_count || 0} entries`); + } else if (isTransitive) { + const parent = peerByFp[n.via]; + const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via); + statsHtml.push(`distance 2 hops`); + statsHtml.push(`learned via ${esc(via)}`); + statsHtml.push(`stats — (peer-side only)`); + } else { + statsHtml.push(`status trusted`); + statsHtml.push(`signals 24h ${stats.signal_count_24h || 0}`); + statsHtml.push(`cases / iocs 24h ${stats.cases_24h || 0} / ${stats.iocs_24h || 0}`); + statsHtml.push(`quorum hits ${stats.quorum_contribution_24h || 0}`); + statsHtml.push(`last seen ${esc(fmtAge(stats.last_seen))}`); + } + + const cta = peerHref + ? `View this peer's federation ` + : `no public address known`; + + walkEl.innerHTML = ` +
+
+

${esc(n.domain || n.label || shortFp(n.fp))}

+
${esc(n.fp)}
+
${statsHtml.join("")}
+
+ ${cta} +
`; + } + + // ---------- inbound vouches list ------------------------------------ + function renderVouchesIn() { + if (!vouchesInList) return; + if (vouchesInCountEl) vouchesInCountEl.textContent = String(vouchesIn.length); + if (!vouchesIn.length) { + vouchesInList.innerHTML = `
  • no inbound vouches yet
  • `; + return; + } + const items = vouchesIn.map(v => { + const fp = v.voucher_fingerprint || ""; + const peer = peerByFp[fp]; + const label = peer ? (peer.domain || shortFp(fp)) : shortFp(fp); + const clickable = peer ? `data-jump="${esc(fp)}"` : `data-jump=""`; + return `
  • + + + ${esc(fp)} + + ${esc(v.issued_at || "")} +
  • `; + }).join(""); + vouchesInList.innerHTML = items; + vouchesInList.querySelectorAll(".fn-fp-jump").forEach(btn => { + btn.addEventListener("click", () => { + const fp = btn.getAttribute("data-jump") || ""; + if (fp) jumpToFp(fp); + }); + }); + } + renderVouchesIn(); + + // ---------- copy buttons on the static page ------------------------- + document.querySelectorAll(".fn-copy-btn").forEach(btn => { + btn.addEventListener("click", () => { + const v = btn.getAttribute("data-copy") || ""; + if (!v) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(v).catch(() => {}); + } + const t = btn.textContent; + btn.textContent = "copied"; + setTimeout(() => { btn.textContent = t; }, 1100); + }); + }); + + // ---------- idle animation ------------------------------------------ + function loop() { + let moving = false; + for (const n of nodes) { + if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; } + } + if (moving || energyBudget > 0 || dragging) { + tick(); paint(); + if (energyBudget > 0) energyBudget--; + } + requestAnimationFrame(loop); + } + loop(); + + // ---------- edge liveness + flow toggle ----------------------------- + edges.forEach((e, i) => { + const ln = edgeEls[i].line; + if (e.kind === "signal") ln.classList.add("alive"); + if (e.kind === "knows") ln.classList.add("dim"); + }); + const flowToggle = document.getElementById("fn-flow"); + function applyFlowToggle() { + svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked)); + } + applyFlowToggle(); + if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle); + + // ---------- layout modes -------------------------------------------- + function unfix() { for (const n of nodes) n.fixed = false; } + function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } } + + function applyForce() { + unfix(); + for (const n of nodes) { + if (n.is_self) { n.x = W / 2; n.y = H / 2; n.fixed = true; continue; } + n.vx = (Math.random() - 0.5) * 5; + n.vy = (Math.random() - 0.5) * 5; + } + energyBudget = 300; + } + + function applyHierarchical() { + const self = nodes.find(n => n.is_self); + const direct = nodes.filter(n => !n.is_self && n.distance === 1); + const transitive = nodes.filter(n => n.distance >= 2); + if (self) { self.x = W / 2; self.y = 70; self.fixed = true; } + direct.forEach((n, i) => { + n.x = W * (i + 1) / (direct.length + 1); + n.y = H * 0.42; + n.fixed = true; + }); + const tCount = transitive.length || 1; + transitive.forEach((n, i) => { + n.x = W * (i + 1) / (tCount + 1); + n.y = H * 0.78; + n.fixed = true; + }); + clearVel(); paint(); + } + + function applyRadial() { + const self = nodes.find(n => n.is_self); + const direct = nodes.filter(n => !n.is_self && n.distance === 1); + const transitive = nodes.filter(n => n.distance >= 2); + const R1 = Math.min(W, H) * 0.22; + const R2 = Math.min(W, H) * 0.40; + if (self) { self.x = W / 2; self.y = H / 2; self.fixed = true; } + const dCount = direct.length || 1; + direct.forEach((n, i) => { + const a = (i / dCount) * Math.PI * 2 - Math.PI / 2; + n.x = W / 2 + R1 * Math.cos(a); + n.y = H / 2 + R1 * Math.sin(a); + n.fixed = true; + }); + const tCount = transitive.length || 1; + transitive.forEach((n, i) => { + const a = (i / tCount) * Math.PI * 2 - Math.PI / 2; + n.x = W / 2 + R2 * Math.cos(a); + n.y = H / 2 + R2 * Math.sin(a); + n.fixed = true; + }); + clearVel(); paint(); + } + + const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial }; + let currentLayout = "force"; + const selfNodeRef = nodes.find(n => n.is_self); + if (selfNodeRef) { selfNodeRef.x = W / 2; selfNodeRef.y = H / 2; selfNodeRef.fixed = true; } + + document.querySelectorAll(".topo-layout").forEach(btn => { + btn.addEventListener("click", () => { + const mode = btn.dataset.layout; + if (!LAYOUTS[mode] || mode === currentLayout) return; + document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn)); + currentLayout = mode; + LAYOUTS[mode](); + }); + }); + + const resetBtn = document.getElementById("fn-reset"); + if (resetBtn) { + resetBtn.addEventListener("click", () => { + if (currentLayout === "force") { + for (const n of nodes) { + if (n.is_self) continue; + n.vx = (Math.random() - 0.5) * 6; + n.vy = (Math.random() - 0.5) * 6; + } + energyBudget = 200; + } else { + LAYOUTS[currentLayout](); + } + }); + } + + // ---------- wheel zoom + resize ------------------------------------- + let zoom = 1, panX = 0, panY = 0; + svg.addEventListener("wheel", ev => { + ev.preventDefault(); + const delta = ev.deltaY > 0 ? 1.1 : 0.9; + zoom = Math.max(0.3, Math.min(2.5, zoom * delta)); + const vw = W / zoom, vh = H / zoom; + svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`); + }, { passive: false }); + window.addEventListener("resize", () => { + const v = viewport(); + W = v.W; H = v.H; + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + energyBudget = 60; + }); + + // ---------- focus-peer query param ---------------------------------- + // ?peer= auto-selects that peer in the graph so deep links work. + if (settings.focusPeer) { + const target = nodes.find(n => n.domain && n.domain === settings.focusPeer); + if (target) selectNode(target); + } + } +})();