390 lines
14 KiB
TypeScript
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);
|