rspace-online/modules/rnetwork/components/folk-trust-sankey.ts

899 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-trust-sankey> — 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<string, { label: string; color: string }> = {
"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<typeof setInterval> | 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<string, string> {
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<string, string>();
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<T>(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.54s
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 `<div class="empty">No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.</div>`;
}
// --- 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<string, string>();
delegators.forEach((did, i) => {
delegatorColor.set(did, DELEGATOR_PALETTE[i % DELEGATOR_PALETTE.length]);
});
// Compute node heights based on total weight
const leftTotals = new Map<string, number>();
const rightTotals = new Map<string, number>();
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<string, number>(); // top-edge Y of each left node
const rightNodeY = new Map<string, number>();
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<string, number>(); // cumulative offset from node top
const rightPortOffset = new Map<string, number>();
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(`
<path class="flow-band" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
data-orig-fill="${gradRef}" d="${bandPath}"
fill="${gradRef}" opacity="${opacity}"
stroke="${color}" stroke-width="0.5" stroke-opacity="0.3"/>
`);
// Invisible wider hit area for hover
flowTooltips.push(`
<path class="flow-hit" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
d="${centerPath}" fill="none" stroke="transparent" stroke-width="${Math.max(thickness, 12)}">
<title>${this.esc(fromName)}${this.esc(toName)}: ${Math.round(f.weight * 100)}%</title>
</path>
`);
// 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(`
<text x="${labelX}" y="${labelY}" fill="#fff" font-size="9" font-weight="600"
text-anchor="middle" opacity="${this.hoveredFlowId ? (isHovered ? 0.9 : 0.1) : 0.7}"
pointer-events="none">${Math.round(f.weight * 100)}%</text>
`);
}
// 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(`
<circle r="${particleR}" fill="#fff" opacity="0.6">
<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${centerPath}"/>
</circle>
`);
}
}
// Build per-delegator gradients
const gradients = delegators.map((did, i) => {
const color = delegatorColor.get(did)!;
return `<linearGradient id="grad-${i}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${color}" stop-opacity="0.8"/>
<stop offset="50%" stop-color="${color}" stop-opacity="0.5"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.35"/>
</linearGradient>`;
}).join("");
// --- Weight rankings: all unique people ranked by received trust ---
const allDids = new Set<string>();
flows.forEach(f => { allDids.add(f.fromDid); allDids.add(f.toDid); });
const receivedWeight = new Map<string, number>();
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<string, number>();
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 `
<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
<rect x="${leftX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="${color}" opacity="0.9"/>
<text x="${leftX - 8}" y="${midY + 1}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="600" dominant-baseline="middle">${this.esc(name)}</text>
<text x="${leftX - 8}" y="${midY + 13}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}% out</text>
${recW > 0 ? `<text x="${leftX - 8}" y="${midY + 23}" fill="#10b981" font-size="8" text-anchor="end">#${rank} (${Math.round(recW * 100)}% in)</text>` : ""}
</g>`;
}).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 `
<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
<rect x="${rightX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="#a78bfa" opacity="0.9"/>
<text x="${rightX + NODE_W + 8}" y="${midY - 4}" fill="${rankColor}" font-size="9" text-anchor="start" font-weight="700">#${rank}</text>
<text x="${rightX + NODE_W + 8}" y="${midY + 8}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="600">${this.esc(name)}</text>
<text x="${rightX + NODE_W + 8}" y="${midY + 20}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
${sparkline ? `<g transform="translate(${rightX + NODE_W + 8}, ${midY + 24})">${sparkline}</g>` : ""}
</g>`;
}).join("");
return `
<svg width="100%" viewBox="0 0 ${W} ${H}" class="sankey-svg">
<defs>${gradients}</defs>
<g class="flow-layer">${flowBands.join("")}</g>
<g class="hit-layer">${flowTooltips.join("")}</g>
<g class="particle-layer">${particles.join("")}</g>
<g class="left-nodes">${leftNodes}</g>
<g class="right-nodes">${rightNodes}</g>
</svg>
`;
}
/** 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 `<path d="${pathData}" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.6"/>`;
}
// 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 `<path d="${pathData}" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.6"/>`;
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
.sankey-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.sankey-title { font-size: 15px; font-weight: 600; }
.demo-badge {
font-size: 9px; font-weight: 700; letter-spacing: 0.06em;
padding: 2px 8px; border-radius: 4px;
background: rgba(239, 68, 68, 0.15); color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
animation: demo-pulse 2s ease-in-out infinite;
}
@keyframes demo-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.authority-filter {
display: flex; gap: 4px; flex-wrap: wrap;
}
.authority-btn {
padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-input-border);
background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer;
font-size: 11px; text-transform: capitalize;
}
.authority-btn:hover { border-color: var(--rs-border-strong); }
.authority-btn.active { border-color: #a78bfa; color: #a78bfa; background: rgba(167, 139, 250, 0.1); }
.sankey-container {
background: var(--rs-canvas-bg, #0a0a0f); border: 1px solid var(--rs-border);
border-radius: 12px; padding: 16px; overflow-x: auto;
}
.sankey-svg { display: block; min-height: 200px; }
.flow-band { transition: opacity 0.2s, fill 0.2s; cursor: pointer; }
.flow-hit { cursor: pointer; }
.sankey-node { transition: opacity 0.2s; }
.time-slider {
display: flex; align-items: center; gap: 10px; margin-top: 12px;
}
.time-label { font-size: 11px; color: var(--rs-text-muted); min-width: 80px; }
.time-range { flex: 1; accent-color: #a78bfa; }
.time-value { font-size: 11px; color: var(--rs-text-muted); min-width: 30px; text-align: right; }
.controls { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.toggle-btn {
padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-input-border);
background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer; font-size: 11px;
}
.toggle-btn.active { border-color: #a78bfa; color: #a78bfa; }
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }
.loading { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }
.legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-muted); }
.legend-dot { width: 10px; height: 4px; border-radius: 2px; }
</style>
<div class="sankey-header">
<span class="sankey-title">Delegation Flows</span>
${this.space === "demo" ? `<span class="demo-badge">LIVE DEMO</span>` : ""}
<div class="authority-filter">
${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${SANKEY_AUTHORITY_DISPLAY[a]?.label || a}</button>`).join("")}
</div>
</div>
${this.loading ? `<div class="loading">Loading flows...</div>` : `
<div class="sankey-container">
${this.renderSankey()}
</div>
<div class="time-slider">
<span class="time-label">History:</span>
<input type="range" class="time-range" id="time-slider" min="0" max="100" value="${this.timeSliderValue}">
<span class="time-value" id="time-value">${this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"}</span>
</div>
<div class="controls">
<button class="toggle-btn ${this.animationEnabled ? "active" : ""}" id="toggle-animation">
${this.animationEnabled ? "Pause" : "Play"} particles
</button>
</div>
<div class="legend">
<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa);width:24px"></span> Band width = weight</div>
<div class="legend-item"><span class="legend-dot" style="background:#10b981"></span> Trust received (hover)</div>
<div class="legend-item"><span class="legend-dot" style="background:#f59e0b"></span> Trust given (hover)</div>
</div>
`}
`;
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<string>([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);