stage-exp-d explore: JS — cross-jump navigation + verify button

This commit is contained in:
m17hr1l
2026-06-07 01:16:02 +02:00
parent ca6ba83950
commit 587fd07d38

View File

@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`);
if (n.is_self) {
rows.push(`<div class="fn-tooltip-row"><span class="k">role</span><span class="v">self</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${node.peer_count || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${node.signals_count_24h || 0}</span></div>`);
} else if (n.distance >= 2) {
rows.push(`<div class="fn-tooltip-row"><span class="k">distance</span><span class="v">2 hops (transitive)</span></div>`);
if (n.via) {
const parent = peerByFp[n.via];
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
rows.push(`<div class="fn-tooltip-row"><span class="k">via</span><span class="v">${esc(via)}</span></div>`);
}
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
} else {
const s = n.stats || {};
rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">trusted</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signal_count_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum hits</span><span class="v">${s.quorum_contribution_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(fmtAge(s.last_seen))}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
}
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(`<span><span class="k">peers</span> <span class="v">${node.peer_count || 0}</span></span>`);
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${node.signals_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">corroborations</span> <span class="v">${node.corroboration_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">translog</span> <span class="v">${node.translog_entry_count || 0} entries</span></span>`);
} else if (isTransitive) {
const parent = peerByFp[n.via];
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
statsHtml.push(`<span><span class="k">distance</span> <span class="v">2 hops</span></span>`);
statsHtml.push(`<span><span class="k">learned via</span> <span class="v">${esc(via)}</span></span>`);
statsHtml.push(`<span><span class="k">stats</span> <span class="v">— (peer-side only)</span></span>`);
} else {
statsHtml.push(`<span><span class="k">status</span> <span class="v">trusted</span></span>`);
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${stats.signal_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">cases / iocs 24h</span> <span class="v">${stats.cases_24h || 0} / ${stats.iocs_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">quorum hits</span> <span class="v">${stats.quorum_contribution_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">last seen</span> <span class="v">${esc(fmtAge(stats.last_seen))}</span></span>`);
}
const cta = peerHref
? `<a class="fe-walk-cta" href="${esc(peerHref)}">View this peer's federation <span aria-hidden="true">→</span></a>`
: `<span class="fe-walk-cta fe-walk-cta-disabled" title="no public domain on file for this peer">no public address known</span>`;
walkEl.innerHTML = `
<div class="fe-walk-card">
<div class="fe-walk-card-body">
<h3 class="fe-walk-card-title">${esc(n.domain || n.label || shortFp(n.fp))}</h3>
<div class="fe-walk-card-fp">${esc(n.fp)}</div>
<div class="fe-walk-card-stats">${statsHtml.join("")}</div>
</div>
${cta}
</div>`;
}
// ---------- inbound vouches list ------------------------------------
function renderVouchesIn() {
if (!vouchesInList) return;
if (vouchesInCountEl) vouchesInCountEl.textContent = String(vouchesIn.length);
if (!vouchesIn.length) {
vouchesInList.innerHTML = `<li class="fe-vouches-in-empty">no inbound vouches yet</li>`;
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 `<li>
<span class="fp">
<button type="button" class="fn-fp-jump" ${clickable}>${esc(label)}</button>
<code style="margin-left:8px;color:var(--muted);font-size:11px;">${esc(fp)}</code>
</span>
<span class="ts">${esc(v.issued_at || "")}</span>
</li>`;
}).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=<domain> 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);
}
}
})();