/** * — Coalitional power analysis visualization. * * Shows Banzhaf power index, Shapley-Shubik power index, raw voting weight, * Gini coefficient, HHI concentration, and a power-vs-weight scatter plot. * Fetches from EncryptID /api/power-indices endpoint. */ interface PowerResult { did: string; weight: number; banzhaf: number; shapleyShubik: number; swingCount: number; pivotalCount: number; label?: string; } interface PowerData { space: string; authority: string; totalWeight: number; playerCount: number; giniCoefficient: number; herfindahlIndex: number; lastComputed: number; results: PowerResult[]; } const AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const; const AUTHORITY_META: Record = { "gov-ops": { label: "Governance", color: "#a78bfa" }, "fin-ops": { label: "Economic", color: "#10b981" }, "dev-ops": { label: "Technical", color: "#3b82f6" }, }; const ENCRYPTID_URL = (typeof window !== "undefined" && (window as any).__ENCRYPTID_URL) || ""; class FolkPowerIndices extends HTMLElement { private shadow: ShadowRoot; private space = ""; private authority = "gov-ops"; private data: PowerData | null = null; private loading = true; private error = ""; private userMap = new Map(); // did → display name constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } static get observedAttributes() { return ["space", "authority"]; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "space" && val !== this.space) { this.space = val; this.fetchData(); } if (name === "authority" && val !== this.authority) { this.authority = val; this.fetchData(); } } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.authority = this.getAttribute("authority") || "gov-ops"; this.fetchData(); } private async fetchData() { this.loading = true; this.error = ""; this.render(); try { // Fetch user directory for name resolution const usersUrl = ENCRYPTID_URL ? `${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(this.space)}` : `/api/power-indices/users?space=${encodeURIComponent(this.space)}`; try { const uRes = await fetch(ENCRYPTID_URL ? usersUrl : `/rnetwork/api/users?space=${encodeURIComponent(this.space)}`); if (uRes.ok) { const uData = await uRes.json(); for (const u of uData.users || []) { this.userMap.set(u.did, u.displayName || u.username || u.did.slice(0, 12)); } } } catch { /* best-effort */ } const piUrl = ENCRYPTID_URL ? `${ENCRYPTID_URL}/api/power-indices?space=${encodeURIComponent(this.space)}&authority=${encodeURIComponent(this.authority)}` : `/rnetwork/api/power-indices?space=${encodeURIComponent(this.space)}&authority=${encodeURIComponent(this.authority)}`; const res = await fetch(piUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); this.data = await res.json(); this.loading = false; } catch (e) { this.error = (e as Error).message; this.loading = false; } this.render(); } private render() { const meta = AUTHORITY_META[this.authority] || { label: this.authority, color: "#888" }; this.shadow.innerHTML = `
${this.renderTabs()} ${this.loading ? '
Computing power indices...
' : this.error ? `
Error: ${this.error}
` : this.data && this.data.results.length > 0 ? this.renderContent() : '
No delegation data available for power analysis.
'}
`; this.attachEvents(); } private renderTabs(): string { return `
${AUTHORITIES.map(a => { const m = AUTHORITY_META[a]; return `
${m.label}
`; }).join("")}
`; } private renderContent(): string { if (!this.data) return ""; const d = this.data; const effectiveN = d.herfindahlIndex > 0 ? (1 / d.herfindahlIndex).toFixed(1) : "—"; const giniColor = d.giniCoefficient > 0.6 ? "#f87171" : d.giniCoefficient > 0.3 ? "#fbbf24" : "#34d399"; const hhiColor = d.herfindahlIndex > 0.25 ? "#f87171" : d.herfindahlIndex > 0.15 ? "#fbbf24" : "#34d399"; return ` ${this.renderMetrics(d, effectiveN, giniColor, hhiColor)}
Power Distribution
${this.renderLegend()} ${this.renderBarChart(d)}
Power vs Weight (deviation from proportionality)
${this.renderScatter(d)}
`; } private renderMetrics(d: PowerData, effectiveN: string, giniColor: string, hhiColor: string): string { return `
${d.playerCount}
Voters
${effectiveN}
Effective Voters
${(d.giniCoefficient * 100).toFixed(0)}%
Gini (Power)
${(d.herfindahlIndex * 100).toFixed(0)}%
HHI Concentration
`; } private renderLegend(): string { const meta = AUTHORITY_META[this.authority]; return `
Raw Weight
Banzhaf Index
Shapley-Shubik
`; } private renderBarChart(d: PowerData): string { const maxVal = Math.max(...d.results.map(r => Math.max(r.weight / d.totalWeight, r.banzhaf, r.shapleyShubik)), 0.01); const top = d.results.slice(0, 20); // show top 20 return `
${top.map(r => { const name = this.userMap.get(r.did) || r.label || r.did.slice(0, 16); const wPct = ((r.weight / d.totalWeight) / maxVal * 100).toFixed(1); const bPct = (r.banzhaf / maxVal * 100).toFixed(1); const sPct = (r.shapleyShubik / maxVal * 100).toFixed(1); return `
${name}
${(r.weight/d.totalWeight*100).toFixed(1)}%
${(r.banzhaf*100).toFixed(1)}%
${(r.shapleyShubik*100).toFixed(1)}%
${(r.banzhaf*100).toFixed(1)}%
`; }).join("")}
`; } private renderScatter(d: PowerData): string { // SVG scatter: X = weight%, Y = Banzhaf% const pad = 40; const size = 300; const meta = AUTHORITY_META[this.authority]; const points = d.results.map(r => ({ x: r.weight / d.totalWeight, y: r.banzhaf, did: r.did, })); const maxX = Math.max(...points.map(p => p.x), 0.01) * 1.1; const maxY = Math.max(...points.map(p => p.y), 0.01) * 1.1; const sx = (v: number) => pad + (v / maxX) * (size - pad * 2); const sy = (v: number) => size - pad - (v / maxY) * (size - pad * 2); // Diagonal line (proportional power) const diagEnd = Math.min(maxX, maxY); const pointsSvg = points.map(p => { const overpower = p.y > p.x / d.totalWeight * d.playerCount; // more power than weight would suggest const color = p.y > (p.x / d.totalWeight) * 1.2 ? "#f87171" : p.y < (p.x / d.totalWeight) * 0.8 ? "#60a5fa" : "#94a3b8"; return ``; }).join(""); return `
Weight % Banzhaf % ${pointsSvg}
Overrepresented
Proportional
Underrepresented
`; } private attachEvents() { // Tab clicks this.shadow.querySelectorAll(".tab").forEach(tab => { tab.addEventListener("click", () => { const auth = tab.dataset.authority; if (auth && auth !== this.authority) { this.authority = auth; this.setAttribute("authority", auth); this.fetchData(); } }); }); // Tooltip on scatter points const tooltip = this.shadow.getElementById("tooltip"); this.shadow.querySelectorAll(".scatter-point").forEach(pt => { pt.addEventListener("mouseenter", (e) => { if (!tooltip || !this.data) return; const did = pt.dataset.did || ""; const r = this.data.results.find(x => x.did === did); if (!r) return; const name = this.userMap.get(did) || did.slice(0, 16); tooltip.innerHTML = `${name}
Weight: ${(r.weight / this.data.totalWeight * 100).toFixed(1)}%
Banzhaf: ${(r.banzhaf * 100).toFixed(1)}%
Shapley-Shubik: ${(r.shapleyShubik * 100).toFixed(1)}%
Swings: ${r.swingCount}`; tooltip.style.display = "block"; tooltip.style.left = `${(e as MouseEvent).clientX + 12}px`; tooltip.style.top = `${(e as MouseEvent).clientY - 10}px`; }); pt.addEventListener("mouseleave", () => { if (tooltip) tooltip.style.display = "none"; }); }); } } customElements.define("folk-power-indices", FolkPowerIndices); export {};