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

390 lines
14 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;
}
interface TrustEvent {
id: string;
sourceDid: string;
targetDid: string;
eventType: string;
authority: string | null;
weightDelta: number | null;
createdAt: number;
}
const SANKEY_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
const FLOW_COLOR = "#a78bfa";
class FolkTrustSankey extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private authority = "gov-ops";
private flows: DelegationFlow[] = [];
private events: TrustEvent[] = [];
private loading = true;
private error = "";
private timeSliderValue = 100; // 0-100, percentage of history
private animationEnabled = true;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.authority = this.getAttribute("authority") || "gov-ops";
this.render();
this.loadData();
}
private getAuthBase(): string {
return this.getAttribute("auth-url") || "";
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
return match ? match[0] : "";
}
private getAuthHeaders(): Record<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 all space-level delegations and user directory in parallel
const [delegRes, usersRes] = await Promise.all([
fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}`),
fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`),
]);
const allFlows: DelegationFlow[] = [];
if (delegRes.ok) {
const data = await delegRes.json();
for (const d of data.delegations || []) {
allFlows.push({
id: d.id,
fromDid: d.from,
fromName: d.from.slice(0, 12) + "...",
toDid: d.to,
toName: d.to.slice(0, 12) + "...",
authority: d.authority,
weight: d.weight,
state: "active",
createdAt: Date.now(),
});
}
}
this.flows = allFlows;
// Resolve user display names
if (usersRes.ok) {
const userData = await usersRes.json();
const nameMap = new Map<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)!;
}
}
} catch {
this.error = "Failed to load delegation data";
}
this.loading = false;
this.render();
}
private getFilteredFlows(): DelegationFlow[] {
let filtered = this.flows.filter(f => f.authority === this.authority && f.state === "active");
// Time slider: filter flows created before the time cutoff
if (this.timeSliderValue < 100 && filtered.length > 0) {
const times = filtered.map(f => f.createdAt).sort((a, b) => a - b);
const earliest = times[0];
const latest = Date.now();
const cutoff = earliest + (latest - earliest) * (this.timeSliderValue / 100);
filtered = filtered.filter(f => f.createdAt <= cutoff);
}
return filtered;
}
private renderSankey(): string {
const flows = this.getFilteredFlows();
if (flows.length === 0) {
return `<div class="empty">No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.</div>`;
}
const W = 600, H = Math.max(300, flows.length * 40 + 60);
const leftX = 120, rightX = W - 120;
const nodeW = 16;
// Collect unique delegators and delegates
const delegators = [...new Set(flows.map(f => f.fromDid))];
const delegates = [...new Set(flows.map(f => f.toDid))];
// Position nodes vertically
const leftH = H - 40;
const rightH = H - 40;
const leftPositions = new Map<string, number>();
const rightPositions = new Map<string, number>();
delegators.forEach((did, i) => {
leftPositions.set(did, 20 + (leftH * (i + 0.5)) / delegators.length);
});
delegates.forEach((did, i) => {
rightPositions.set(did, 20 + (rightH * (i + 0.5)) / delegates.length);
});
// Build SVG
const flowPaths: string[] = [];
const particles: string[] = [];
for (let i = 0; i < flows.length; i++) {
const f = flows[i];
const y1 = leftPositions.get(f.fromDid)!;
const y2 = rightPositions.get(f.toDid)!;
const thickness = Math.max(2, f.weight * 20);
const midX = (leftX + nodeW + rightX - nodeW) / 2;
// Bezier path
const path = `M ${leftX + nodeW} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${rightX - nodeW} ${y2}`;
flowPaths.push(`
<path d="${path}" fill="none" stroke="${FLOW_COLOR}" stroke-width="${thickness}" opacity="0.3"/>
<path d="${path}" fill="none" stroke="url(#flow-gradient)" stroke-width="${thickness}" opacity="0.6"/>
`);
// Animated particles
if (this.animationEnabled) {
const duration = 3 + Math.random() * 2;
const delay = Math.random() * duration;
particles.push(`
<circle r="${Math.max(2, thickness * 0.4)}" fill="${FLOW_COLOR}" opacity="0.8">
<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${path}"/>
</circle>
`);
}
}
// Left nodes (delegators)
const leftNodes = delegators.map(did => {
const y = leftPositions.get(did)!;
const name = flows.find(f => f.fromDid === did)?.fromName || did.slice(0, 8);
const total = flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0);
const h = Math.max(12, total * 40);
return `
<rect x="${leftX}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#7c3aed" opacity="0.8"/>
<text x="${leftX - 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="500">${this.esc(name)}</text>
<text x="${leftX - 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}%</text>
`;
}).join("");
// Right nodes (delegates)
const rightNodes = delegates.map(did => {
const y = rightPositions.get(did)!;
const name = flows.find(f => f.toDid === did)?.toName || did.slice(0, 8);
const total = flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0);
const h = Math.max(12, total * 40);
// Sparkline: recent weight changes (last 30 days)
const sparkline = this.renderSparkline(did, 30);
return `
<rect x="${rightX - nodeW}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#a78bfa" opacity="0.8"/>
<text x="${rightX + 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="500">${this.esc(name)}</text>
<text x="${rightX + 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
${sparkline ? `<g transform="translate(${rightX + 6}, ${y + 20})">${sparkline}</g>` : ""}
`;
}).join("");
return `
<svg width="100%" viewBox="0 0 ${W} ${H}" class="sankey-svg">
<defs>
<linearGradient id="flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7c3aed" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#a78bfa" stop-opacity="0.4"/>
</linearGradient>
</defs>
<g class="flow-layer">${flowPaths.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 */
private renderSparkline(did: string, days: number): string {
// Use delegation creation timestamps as data points
const now = Date.now();
const cutoff = now - days * 24 * 60 * 60 * 1000;
const relevant = this.flows.filter(f => f.toDid === did && f.authority === this.authority && f.createdAt > cutoff);
if (relevant.length < 2) return "";
// Build cumulative weight over time
const sorted = [...relevant].sort((a, b) => a.createdAt - b.createdAt);
const points: Array<{ t: number; w: number }> = [];
let cumulative = 0;
for (const f of sorted) {
cumulative += f.weight;
points.push({ t: f.createdAt, w: cumulative });
}
const w = 50, h = 12;
const tMin = cutoff, tMax = now;
const wMax = Math.max(...points.map(p => p.w), 0.01);
const pathData = points.map((p, i) => {
const x = ((p.t - tMin) / (tMax - tMin)) * w;
const y = h - (p.w / wMax) * h;
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
}).join(" ");
return `<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; }
.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; }
.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>
<div class="authority-filter">
${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${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:#7c3aed"></span> Delegators</div>
<div class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span> Delegates</div>
<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa)"></span> Flow (width = weight)</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();
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-trust-sankey", FolkTrustSankey);