899 lines
33 KiB
TypeScript
899 lines
33 KiB
TypeScript
/**
|
||
* <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.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 `<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);
|