/** * — Sankey diagram of delegation trust flows. * * Left column: delegators. Right column: delegates. * Bezier curves between them, width proportional to delegation weight. * Animated flow particles, authority filter, time slider for history playback, * and per-flow trend sparklines. */ interface DelegationFlow { id: string; fromDid: string; fromName: string; toDid: string; toName: string; authority: string; weight: number; state: string; createdAt: number; } interface TrustEvent { id: string; sourceDid: string; targetDid: string; eventType: string; authority: string | null; weightDelta: number | null; createdAt: number; } const SANKEY_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const; const FLOW_COLOR = "#a78bfa"; class FolkTrustSankey extends HTMLElement { private shadow: ShadowRoot; private space = ""; private authority = "gov-ops"; private flows: DelegationFlow[] = []; private events: TrustEvent[] = []; private loading = true; private error = ""; private timeSliderValue = 100; // 0-100, percentage of history private animationEnabled = true; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.authority = this.getAttribute("authority") || "gov-ops"; this.render(); this.loadData(); } private getAuthBase(): string { return this.getAttribute("auth-url") || ""; } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnetwork/); return match ? match[0] : ""; } private getAuthHeaders(): Record { const token = localStorage.getItem("encryptid_session"); if (!token) return {}; return { Authorization: `Bearer ${token}` }; } private async loadData() { const authBase = this.getAuthBase(); const apiBase = this.getApiBase(); try { // Fetch all space-level delegations and user directory in parallel const [delegRes, usersRes] = await Promise.all([ fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}`), fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`), ]); const allFlows: DelegationFlow[] = []; if (delegRes.ok) { const data = await delegRes.json(); for (const d of data.delegations || []) { allFlows.push({ id: d.id, fromDid: d.from, fromName: d.from.slice(0, 12) + "...", toDid: d.to, toName: d.to.slice(0, 12) + "...", authority: d.authority, weight: d.weight, state: "active", createdAt: Date.now(), }); } } this.flows = allFlows; // Resolve user display names if (usersRes.ok) { const userData = await usersRes.json(); const nameMap = new Map(); for (const u of userData.users || []) { nameMap.set(u.did, u.displayName || u.username); } for (const f of this.flows) { if (nameMap.has(f.fromDid)) f.fromName = nameMap.get(f.fromDid)!; if (nameMap.has(f.toDid)) f.toName = nameMap.get(f.toDid)!; } } } catch { this.error = "Failed to load delegation data"; } this.loading = false; this.render(); } private getFilteredFlows(): DelegationFlow[] { let filtered = this.flows.filter(f => f.authority === this.authority && f.state === "active"); // Time slider: filter flows created before the time cutoff if (this.timeSliderValue < 100 && filtered.length > 0) { const times = filtered.map(f => f.createdAt).sort((a, b) => a - b); const earliest = times[0]; const latest = Date.now(); const cutoff = earliest + (latest - earliest) * (this.timeSliderValue / 100); filtered = filtered.filter(f => f.createdAt <= cutoff); } return filtered; } private renderSankey(): string { const flows = this.getFilteredFlows(); if (flows.length === 0) { return `
No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.
`; } const W = 600, H = Math.max(300, flows.length * 40 + 60); const leftX = 120, rightX = W - 120; const nodeW = 16; // Collect unique delegators and delegates const delegators = [...new Set(flows.map(f => f.fromDid))]; const delegates = [...new Set(flows.map(f => f.toDid))]; // Position nodes vertically const leftH = H - 40; const rightH = H - 40; const leftPositions = new Map(); const rightPositions = new Map(); delegators.forEach((did, i) => { leftPositions.set(did, 20 + (leftH * (i + 0.5)) / delegators.length); }); delegates.forEach((did, i) => { rightPositions.set(did, 20 + (rightH * (i + 0.5)) / delegates.length); }); // Build SVG const flowPaths: string[] = []; const particles: string[] = []; for (let i = 0; i < flows.length; i++) { const f = flows[i]; const y1 = leftPositions.get(f.fromDid)!; const y2 = rightPositions.get(f.toDid)!; const thickness = Math.max(2, f.weight * 20); const midX = (leftX + nodeW + rightX - nodeW) / 2; // Bezier path const path = `M ${leftX + nodeW} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${rightX - nodeW} ${y2}`; flowPaths.push(` `); // Animated particles if (this.animationEnabled) { const duration = 3 + Math.random() * 2; const delay = Math.random() * duration; particles.push(` `); } } // Left nodes (delegators) const leftNodes = delegators.map(did => { const y = leftPositions.get(did)!; const name = flows.find(f => f.fromDid === did)?.fromName || did.slice(0, 8); const total = flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0); const h = Math.max(12, total * 40); return ` ${this.esc(name)} ${Math.round(total * 100)}% `; }).join(""); // Right nodes (delegates) const rightNodes = delegates.map(did => { const y = rightPositions.get(did)!; const name = flows.find(f => f.toDid === did)?.toName || did.slice(0, 8); const total = flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0); const h = Math.max(12, total * 40); // Sparkline: recent weight changes (last 30 days) const sparkline = this.renderSparkline(did, 30); return ` ${this.esc(name)} ${Math.round(total * 100)}% received ${sparkline ? `${sparkline}` : ""} `; }).join(""); return ` ${flowPaths.join("")} ${particles.join("")} ${leftNodes} ${rightNodes} `; } /** Render a tiny sparkline SVG (60x16) showing recent weight trend */ private renderSparkline(did: string, days: number): string { // Use delegation creation timestamps as data points const now = Date.now(); const cutoff = now - days * 24 * 60 * 60 * 1000; const relevant = this.flows.filter(f => f.toDid === did && f.authority === this.authority && f.createdAt > cutoff); if (relevant.length < 2) return ""; // Build cumulative weight over time const sorted = [...relevant].sort((a, b) => a.createdAt - b.createdAt); const points: Array<{ t: number; w: number }> = []; let cumulative = 0; for (const f of sorted) { cumulative += f.weight; points.push({ t: f.createdAt, w: cumulative }); } const w = 50, h = 12; const tMin = cutoff, tMax = now; const wMax = Math.max(...points.map(p => p.w), 0.01); const pathData = points.map((p, i) => { const x = ((p.t - tMin) / (tMax - tMin)) * w; const y = h - (p.w / wMax) * h; return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`; }).join(" "); return ``; } private render() { this.shadow.innerHTML = `
Delegation Flows
${SANKEY_AUTHORITIES.map(a => ``).join("")}
${this.loading ? `
Loading flows...
` : `
${this.renderSankey()}
History: ${this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"}
Delegators
Delegates
Flow (width = weight)
`} `; this.attachListeners(); } private attachListeners() { // Authority filter this.shadow.querySelectorAll("[data-authority]").forEach(el => { el.addEventListener("click", () => { this.authority = (el as HTMLElement).dataset.authority!; this.render(); }); }); // Time slider this.shadow.getElementById("time-slider")?.addEventListener("input", (e) => { this.timeSliderValue = parseInt((e.target as HTMLInputElement).value); const label = this.shadow.getElementById("time-value"); if (label) label.textContent = this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"; // Debounce re-render clearTimeout((this as any)._sliderTimer); (this as any)._sliderTimer = setTimeout(() => this.render(), 100); }); // Toggle animation this.shadow.getElementById("toggle-animation")?.addEventListener("click", () => { this.animationEnabled = !this.animationEnabled; this.render(); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-trust-sankey", FolkTrustSankey);