/** * — 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; revokedAt: number | null; } 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 SANKEY_AUTHORITY_DISPLAY: Record = { "gov-ops": { label: "Gov", color: "#a78bfa" }, "fin-ops": { label: "Econ", color: "#10b981" }, "dev-ops": { label: "Tech", color: "#3b82f6" }, }; // Demo DAO members const DEMO_MEMBERS = [ { did: "demo:alice", name: "Alice" }, { did: "demo:bob", name: "Bob" }, { did: "demo:carol", name: "Carol" }, { did: "demo:dave", name: "Dave" }, { did: "demo:eve", name: "Eve" }, { did: "demo:frank", name: "Frank" }, { did: "demo:grace", name: "Grace" }, { did: "demo:heidi", name: "Heidi" }, { did: "demo:ivan", name: "Ivan" }, { did: "demo:judy", name: "Judy" }, ]; // Per-delegator color palette for flow bands const DELEGATOR_PALETTE = [ "#7c3aed", "#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#818cf8", "#6d28d9", "#4f46e5", "#7e22ce", "#5b21b6", "#4338ca", "#9333ea", ]; 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; private hoveredFlowId: string | null = null; private hoveredNodeDid: string | null = null; private _demoTimer: ReturnType | null = null; private _demoIdCounter = 0; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } private _delegationsHandler: ((e: Event) => void) | null = null; connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.authority = this.getAttribute("authority") || "gov-ops"; this.render(); if (this.space === "demo") { this.initDemoSimulation(); } else { this.loadData(); // Listen for cross-component sync this._delegationsHandler = () => this.loadData(); document.addEventListener("delegations-updated", this._delegationsHandler); } } disconnectedCallback() { if (this._delegationsHandler) { document.removeEventListener("delegations-updated", this._delegationsHandler); this._delegationsHandler = null; } if (this._demoTimer) { clearInterval(this._demoTimer); this._demoTimer = null; } } 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 delegations (including revoked), user directory, and trust events in parallel const [delegRes, usersRes, eventsRes] = await Promise.all([ fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}&include_revoked=true`), fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`), fetch(`${authBase}/api/trust/events?space=${encodeURIComponent(this.space)}&limit=200`).catch(() => null), ]); 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: d.state || "active", createdAt: d.createdAt || Date.now(), revokedAt: d.revokedAt || null, }); } } 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)!; } } // Load trust events for sparklines if (eventsRes && eventsRes.ok) { const evtData = await eventsRes.json(); this.events = evtData.events || []; } } catch { this.error = "Failed to load delegation data"; } this.loading = false; this.render(); } // ── Demo simulation ────────────────────────────────────────── private demoNextId(): string { return `demo-${++this._demoIdCounter}`; } private demoRand(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } /** Total outbound weight for a given did+authority (active only) */ private demoOutboundWeight(did: string, authority: string): number { return this.flows .filter(f => f.fromDid === did && f.authority === authority && f.state === "active") .reduce((s, f) => s + f.weight, 0); } /** Generate initial demo delegations and start the mutation loop */ private initDemoSimulation() { const now = Date.now(); this.flows = []; this.events = []; // Seed ~12 initial delegations spread across all 3 verticals const authorities: string[] = ["gov-ops", "fin-ops", "dev-ops"]; for (const auth of authorities) { // Each authority gets 3-5 initial delegations const count = 3 + Math.floor(Math.random() * 3); for (let i = 0; i < count; i++) { const from = this.demoRand(DEMO_MEMBERS); let to = this.demoRand(DEMO_MEMBERS); // No self-delegation while (to.did === from.did) to = this.demoRand(DEMO_MEMBERS); // Skip if this exact pair already exists for this authority if (this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth)) continue; // Weight between 0.05 and 0.40 — capped so total outbound stays <= 1.0 const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth); if (maxAvailable < 0.05) continue; const weight = Math.min(0.05 + Math.random() * 0.35, maxAvailable); const createdAt = now - Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // within last week this.flows.push({ id: this.demoNextId(), fromDid: from.did, fromName: from.name, toDid: to.did, toName: to.name, authority: auth, weight: Math.round(weight * 100) / 100, state: "active", createdAt, revokedAt: null, }); this.events.push({ id: this.demoNextId(), sourceDid: from.did, targetDid: to.did, eventType: "delegate", authority: auth, weightDelta: weight, createdAt, }); } } this.loading = false; this.render(); // Mutate every 2.5–4s this._demoTimer = setInterval(() => this.demoTick(), 2500 + Math.random() * 1500); } /** Single mutation step — add, adjust, revoke, or reactivate a delegation */ private demoTick() { const now = Date.now(); const active = this.flows.filter(f => f.state === "active"); const revoked = this.flows.filter(f => f.state === "revoked"); const roll = Math.random(); if (roll < 0.35 || active.length < 4) { // — Add a new delegation — const auth = this.demoRand(["gov-ops", "fin-ops", "dev-ops"] as string[]); const from = this.demoRand(DEMO_MEMBERS); let to = this.demoRand(DEMO_MEMBERS); let attempts = 0; while ((to.did === from.did || this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth && f.state === "active")) && attempts < 20) { to = this.demoRand(DEMO_MEMBERS); attempts++; } if (to.did === from.did) return; // couldn't find a valid pair const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth); if (maxAvailable < 0.05) return; const weight = Math.min(0.05 + Math.random() * 0.30, maxAvailable); const rounded = Math.round(weight * 100) / 100; this.flows.push({ id: this.demoNextId(), fromDid: from.did, fromName: from.name, toDid: to.did, toName: to.name, authority: auth, weight: rounded, state: "active", createdAt: now, revokedAt: null, }); this.events.push({ id: this.demoNextId(), sourceDid: from.did, targetDid: to.did, eventType: "delegate", authority: auth, weightDelta: rounded, createdAt: now, }); } else if (roll < 0.65 && active.length > 0) { // — Adjust weight of an existing delegation — const flow = this.demoRand(active); const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority) + flow.weight; const delta = (Math.random() - 0.4) * 0.15; // slight bias toward increase const newWeight = Math.max(0.03, Math.min(maxAvailable, flow.weight + delta)); const rounded = Math.round(newWeight * 100) / 100; const actualDelta = rounded - flow.weight; flow.weight = rounded; this.events.push({ id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid, eventType: "adjust", authority: flow.authority, weightDelta: actualDelta, createdAt: now, }); } else if (roll < 0.85 && active.length > 4) { // — Revoke a delegation — const flow = this.demoRand(active); flow.state = "revoked"; flow.revokedAt = now; this.events.push({ id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid, eventType: "revoke", authority: flow.authority, weightDelta: -flow.weight, createdAt: now, }); } else if (revoked.length > 0) { // — Reactivate a revoked delegation — const flow = this.demoRand(revoked); const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority); if (maxAvailable < flow.weight) return; // can't fit flow.state = "active"; flow.revokedAt = null; flow.createdAt = now; // reset so it shows as recent this.events.push({ id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid, eventType: "delegate", authority: flow.authority, weightDelta: flow.weight, createdAt: now, }); } // Cap events list to prevent unbounded growth if (this.events.length > 300) this.events = this.events.slice(-200); // Clear hover state to avoid stale references, then re-render this.hoveredFlowId = null; this.hoveredNodeDid = null; this.render(); } // ── Data filtering ────────────────────────────────────────── private getFilteredFlows(): DelegationFlow[] { let filtered = this.flows.filter(f => f.authority === this.authority); // Time slider: revocation-aware filtering // Active at time T = createdAt <= T AND (state=active OR revokedAt > T) 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 && (f.state === "active" || (f.revokedAt != null && f.revokedAt > cutoff)) ); } else { // At "Now" (100%), only show active flows filtered = filtered.filter(f => f.state === "active"); } 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" : ""}.
`; } // --- True Sankey layout with stacked flow ports --- const MIN_BAND = 6; // minimum band thickness in px const WEIGHT_SCALE = 80; // scale factor: weight 1.0 = 80px band const NODE_W = 14; const NODE_GAP = 12; // vertical gap between nodes const LABEL_W = 110; // space for text labels on each side const W = 620; const leftX = LABEL_W; const rightX = W - LABEL_W; // Collect unique delegators and delegates const delegators = [...new Set(flows.map(f => f.fromDid))]; const delegates = [...new Set(flows.map(f => f.toDid))]; // Assign colors per delegator const delegatorColor = new Map(); delegators.forEach((did, i) => { delegatorColor.set(did, DELEGATOR_PALETTE[i % DELEGATOR_PALETTE.length]); }); // Compute node heights based on total weight const leftTotals = new Map(); const rightTotals = new Map(); for (const did of delegators) { leftTotals.set(did, flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0)); } for (const did of delegates) { rightTotals.set(did, flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0)); } const nodeHeight = (total: number) => Math.max(MIN_BAND, total * WEIGHT_SCALE); const bandThickness = (weight: number) => Math.max(MIN_BAND * 0.5, weight * WEIGHT_SCALE); // Compute total height needed for each side const leftTotalH = delegators.reduce((s, did) => s + nodeHeight(leftTotals.get(did)!), 0) + (delegators.length - 1) * NODE_GAP; const rightTotalH = delegates.reduce((s, did) => s + nodeHeight(rightTotals.get(did)!), 0) + (delegates.length - 1) * NODE_GAP; const H = Math.max(240, Math.max(leftTotalH, rightTotalH) + 60); // Position nodes: center the column vertically, stack nodes top-to-bottom const leftNodeY = new Map(); // top-edge Y of each left node const rightNodeY = new Map(); let curY = (H - leftTotalH) / 2; for (const did of delegators) { leftNodeY.set(did, curY); curY += nodeHeight(leftTotals.get(did)!) + NODE_GAP; } curY = (H - rightTotalH) / 2; for (const did of delegates) { rightNodeY.set(did, curY); curY += nodeHeight(rightTotals.get(did)!) + NODE_GAP; } // --- Stacked port allocation --- // Track how much of each node's height has been consumed by flow connections const leftPortOffset = new Map(); // cumulative offset from node top const rightPortOffset = new Map(); for (const did of delegators) leftPortOffset.set(did, 0); for (const did of delegates) rightPortOffset.set(did, 0); // Sort flows by delegator order then delegate order for consistent stacking const sortedFlows = [...flows].sort((a, b) => { const ai = delegators.indexOf(a.fromDid); const bi = delegators.indexOf(b.fromDid); if (ai !== bi) return ai - bi; return delegates.indexOf(a.toDid) - delegates.indexOf(b.toDid); }); // Build flow band paths (filled bezier area between two curves) const flowBands: string[] = []; const flowTooltips: string[] = []; const particles: string[] = []; const midX = (leftX + NODE_W + rightX) / 2; for (const f of sortedFlows) { const thickness = bandThickness(f.weight); const color = delegatorColor.get(f.fromDid) || "#7c3aed"; const gradRef = `url(#grad-${delegators.indexOf(f.fromDid)})`; // Left side: source port position const lTop = leftNodeY.get(f.fromDid)!; const lOffset = leftPortOffset.get(f.fromDid)!; const srcY1 = lTop + lOffset; const srcY2 = srcY1 + thickness; leftPortOffset.set(f.fromDid, lOffset + thickness); // Right side: target port position const rTop = rightNodeY.get(f.toDid)!; const rOffset = rightPortOffset.get(f.toDid)!; const tgtY1 = rTop + rOffset; const tgtY2 = tgtY1 + thickness; rightPortOffset.set(f.toDid, rOffset + thickness); const sx = leftX + NODE_W; const tx = rightX; const isHovered = this.hoveredFlowId === f.id; const opacity = this.hoveredFlowId ? (isHovered ? 0.85 : 0.15) : 0.55; // Filled bezier band: top curve left-to-right, bottom curve right-to-left const bandPath = [ `M ${sx} ${srcY1}`, `C ${midX} ${srcY1}, ${midX} ${tgtY1}, ${tx} ${tgtY1}`, `L ${tx} ${tgtY2}`, `C ${midX} ${tgtY2}, ${midX} ${srcY2}, ${sx} ${srcY2}`, "Z", ].join(" "); // Center-line path for particles const srcMid = (srcY1 + srcY2) / 2; const tgtMid = (tgtY1 + tgtY2) / 2; const centerPath = `M ${sx} ${srcMid} C ${midX} ${srcMid}, ${midX} ${tgtMid}, ${tx} ${tgtMid}`; const fromName = flows.find(fl => fl.fromDid === f.fromDid)?.fromName || f.fromDid.slice(0, 12); const toName = flows.find(fl => fl.toDid === f.toDid)?.toName || f.toDid.slice(0, 12); flowBands.push(` `); // Invisible wider hit area for hover flowTooltips.push(` ${this.esc(fromName)} → ${this.esc(toName)}: ${Math.round(f.weight * 100)}% `); // Weight label on band (only if thick enough) if (thickness >= 14) { const labelX = midX; const labelY = ((srcY1 + srcY2) / 2 + (tgtY1 + tgtY2) / 2) / 2 + 3; flowBands.push(` ${Math.round(f.weight * 100)}% `); } // Animated particles along center-line if (this.animationEnabled) { const duration = 2.5 + Math.random() * 1.5; const delay = Math.random() * duration; const particleR = Math.max(1.5, thickness * 0.12); particles.push(` `); } } // Build per-delegator gradients const gradients = delegators.map((did, i) => { const color = delegatorColor.get(did)!; return ` `; }).join(""); // --- Weight rankings: all unique people ranked by received trust --- const allDids = new Set(); flows.forEach(f => { allDids.add(f.fromDid); allDids.add(f.toDid); }); const receivedWeight = new Map(); allDids.forEach(did => receivedWeight.set(did, 0)); flows.forEach(f => receivedWeight.set(f.toDid, (receivedWeight.get(f.toDid) || 0) + f.weight)); const ranked = [...receivedWeight.entries()].sort((a, b) => b[1] - a[1]); const rankOf = new Map(); ranked.forEach(([did], i) => rankOf.set(did, i + 1)); // Name lookup helper const nameOf = (did: string, side: "from" | "to"): string => { const f = side === "from" ? flows.find(fl => fl.fromDid === did) : flows.find(fl => fl.toDid === did); if (side === "from") return f?.fromName || did.slice(0, 8); return f?.toName || did.slice(0, 8); }; // Left nodes (delegators) const leftNodes = delegators.map(did => { const y = leftNodeY.get(did)!; const total = leftTotals.get(did)!; const h = nodeHeight(total); const name = nameOf(did, "from"); const color = delegatorColor.get(did)!; const midY = y + h / 2; const rank = rankOf.get(did) || 0; const recW = receivedWeight.get(did) || 0; return ` ${this.esc(name)} ${Math.round(total * 100)}% out ${recW > 0 ? `#${rank} (${Math.round(recW * 100)}% in)` : ""} `; }).join(""); // Right nodes (delegates) — sorted by rank const rightNodes = delegates.map(did => { const y = rightNodeY.get(did)!; const total = rightTotals.get(did)!; const h = nodeHeight(total); const name = nameOf(did, "to"); const midY = y + h / 2; const rank = rankOf.get(did) || 0; // Sparkline const sparkline = this.renderSparkline(did, 30); // Rank badge const rankColor = rank <= 3 ? "#fbbf24" : "#a78bfa"; return ` #${rank} ${this.esc(name)} ${Math.round(total * 100)}% received ${sparkline ? `${sparkline}` : ""} `; }).join(""); return ` ${gradients} ${flowBands.join("")} ${flowTooltips.join("")} ${particles.join("")} ${leftNodes} ${rightNodes} `; } /** Render a tiny sparkline SVG (60x16) showing recent weight trend from trust events */ private renderSparkline(did: string, days: number): string { const now = Date.now(); const cutoff = now - days * 24 * 60 * 60 * 1000; // Prefer trust events with weightDelta for accurate sparklines const relevantEvents = this.events.filter(e => e.targetDid === did && (e.authority === this.authority || e.authority === null) && e.createdAt > cutoff && e.weightDelta != null ); if (relevantEvents.length >= 2) { // Build cumulative weight from events const sorted = [...relevantEvents].sort((a, b) => a.createdAt - b.createdAt); const points: Array<{ t: number; w: number }> = []; let cumulative = 0; for (const evt of sorted) { cumulative += (evt.weightDelta || 0); points.push({ t: evt.createdAt, w: Math.max(0, 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 ``; } // Fallback: use delegation creation timestamps const relevant = this.flows.filter(f => f.toDid === did && f.authority === this.authority && f.createdAt > cutoff); if (relevant.length < 2) return ""; 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 ${this.space === "demo" ? `LIVE DEMO` : ""}
${SANKEY_AUTHORITIES.map(a => ``).join("")}
${this.loading ? `
Loading flows...
` : `
${this.renderSankey()}
History: ${this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"}
Band width = weight
Trust received (hover)
Trust given (hover)
`} `; 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(); }); // Flow band hover — highlight individual flows (only when no node is hovered) this.shadow.querySelectorAll(".flow-band, .flow-hit").forEach(el => { el.addEventListener("mouseenter", () => { if (this.hoveredNodeDid) return; // node hover takes priority const id = (el as HTMLElement).dataset.flowId; if (id && this.hoveredFlowId !== id) { this.hoveredFlowId = id; this.updateHighlights(); } }); el.addEventListener("mouseleave", () => { if (this.hoveredNodeDid) return; if (this.hoveredFlowId) { this.hoveredFlowId = null; this.updateHighlights(); } }); }); // Node hover — highlight inbound (teal) vs outbound (amber) flows this.shadow.querySelectorAll(".sankey-node").forEach(el => { el.addEventListener("mouseenter", () => { const did = (el as HTMLElement).dataset.nodeDid; if (did && this.hoveredNodeDid !== did) { this.hoveredNodeDid = did; this.hoveredFlowId = null; this.updateHighlights(); } }); el.addEventListener("mouseleave", () => { if (this.hoveredNodeDid) { this.hoveredNodeDid = null; this.updateHighlights(); } }); }); } // Inbound = trust flowing INTO the hovered node (teal) // Outbound = trust flowing FROM the hovered node (amber) private static readonly COLOR_INBOUND = "#10b981"; private static readonly COLOR_OUTBOUND = "#f59e0b"; /** Update all flow band visual states for hover (node or flow) */ private updateHighlights() { const hNode = this.hoveredNodeDid; const hFlow = this.hoveredFlowId; const active = !!(hNode || hFlow); this.shadow.querySelectorAll(".flow-band").forEach(band => { const el = band as SVGElement; const ds = (band as HTMLElement).dataset; const origFill = ds.origFill || ""; if (hNode) { // Node hover mode: color by direction const isInbound = ds.toDid === hNode; const isOutbound = ds.fromDid === hNode; if (isInbound) { el.setAttribute("fill", FolkTrustSankey.COLOR_INBOUND); el.setAttribute("opacity", "0.8"); el.setAttribute("stroke", FolkTrustSankey.COLOR_INBOUND); } else if (isOutbound) { el.setAttribute("fill", FolkTrustSankey.COLOR_OUTBOUND); el.setAttribute("opacity", "0.8"); el.setAttribute("stroke", FolkTrustSankey.COLOR_OUTBOUND); } else { el.setAttribute("fill", origFill); el.setAttribute("opacity", "0.08"); el.setAttribute("stroke", "none"); } } else if (hFlow) { // Single flow hover mode el.setAttribute("fill", origFill); el.setAttribute("opacity", ds.flowId === hFlow ? "0.85" : "0.15"); } else { // No hover — restore defaults el.setAttribute("fill", origFill); el.setAttribute("opacity", "0.55"); } }); // Dim/show band weight labels this.shadow.querySelectorAll(".flow-layer text").forEach(t => { (t as SVGElement).setAttribute("opacity", active ? "0.1" : "0.7"); }); // Highlight/dim node groups — keep connected nodes visible if (hNode) { // Build set of DIDs connected to the hovered node const connectedDids = new Set([hNode]); this.shadow.querySelectorAll(".flow-band").forEach(band => { const ds = (band as HTMLElement).dataset; if (ds.fromDid === hNode) connectedDids.add(ds.toDid || ""); if (ds.toDid === hNode) connectedDids.add(ds.fromDid || ""); }); this.shadow.querySelectorAll(".sankey-node").forEach(g => { const did = (g as HTMLElement).dataset.nodeDid || ""; if (did === hNode) { (g as SVGElement).style.opacity = "1"; } else if (connectedDids.has(did)) { (g as SVGElement).style.opacity = "0.85"; } else { (g as SVGElement).style.opacity = "0.3"; } }); } else { this.shadow.querySelectorAll(".sankey-node").forEach(g => { (g as SVGElement).style.opacity = "1"; }); } } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-trust-sankey", FolkTrustSankey);