rspace-online/modules/rnetwork/components/folk-power-indices.ts

337 lines
14 KiB
TypeScript

/**
* <folk-power-indices> — 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<string, { label: string; color: string }> = {
"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<string, string>(); // 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 = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
.container { padding: 16px; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.tab {
padding: 6px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); color: #94a3b8;
transition: all 0.2s;
}
.tab:hover { background: rgba(255,255,255,0.1); }
.tab.active { background: ${meta.color}22; border-color: ${meta.color}; color: ${meta.color}; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; }
.metric {
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px;
padding: 12px; text-align: center;
}
.metric-value { font-size: 24px; font-weight: 700; color: #f1f5f9; }
.metric-label { font-size: 11px; color: #64748b; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.gauge-bar { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.1); margin-top: 8px; overflow: hidden; }
.gauge-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
.chart-section { margin-bottom: 24px; }
.section-title { font-size: 13px; font-weight: 600; color: #94a3b8; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
/* Bar chart */
.bar-chart { display: flex; flex-direction: column; gap: 6px; }
.bar-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.bar-label { width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #cbd5e1; text-align: right; flex-shrink: 0; }
.bar-group { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.bar-track { height: 14px; background: rgba(255,255,255,0.04); border-radius: 3px; overflow: hidden; position: relative; }
.bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; display: flex; align-items: center; padding-left: 4px; font-size: 10px; color: rgba(255,255,255,0.8); min-width: 0; }
.bar-fill.weight { background: rgba(148,163,184,0.4); }
.bar-fill.banzhaf { background: ${meta.color}aa; }
.bar-fill.shapley { background: ${meta.color}55; }
.bar-value { font-size: 11px; color: #64748b; width: 48px; text-align: right; flex-shrink: 0; }
/* Scatter plot */
.scatter-container { position: relative; width: 100%; padding-top: 100%; max-width: 400px; max-height: 400px; }
.scatter-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.scatter-point { cursor: pointer; transition: r 0.2s; }
.scatter-point:hover { r: 6; }
/* Legend */
.legend { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #94a3b8; }
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
.loading, .error-msg { text-align: center; padding: 40px; color: #64748b; }
.error-msg { color: #f87171; }
.empty-state { text-align: center; padding: 40px; color: #64748b; }
.tooltip {
position: fixed; z-index: 100; background: #1e293b; border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px; padding: 8px 12px; font-size: 12px; pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: none;
}
</style>
<div class="container">
${this.renderTabs()}
${this.loading ? '<div class="loading">Computing power indices...</div>'
: this.error ? `<div class="error-msg">Error: ${this.error}</div>`
: this.data && this.data.results.length > 0 ? this.renderContent()
: '<div class="empty-state">No delegation data available for power analysis.</div>'}
</div>
<div class="tooltip" id="tooltip"></div>
`;
this.attachEvents();
}
private renderTabs(): string {
return `<div class="tabs">${AUTHORITIES.map(a => {
const m = AUTHORITY_META[a];
return `<div class="tab ${a === this.authority ? 'active' : ''}" data-authority="${a}">${m.label}</div>`;
}).join("")}</div>`;
}
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)}
<div class="chart-section">
<div class="section-title">Power Distribution</div>
${this.renderLegend()}
${this.renderBarChart(d)}
</div>
<div class="chart-section">
<div class="section-title">Power vs Weight (deviation from proportionality)</div>
${this.renderScatter(d)}
</div>
`;
}
private renderMetrics(d: PowerData, effectiveN: string, giniColor: string, hhiColor: string): string {
return `<div class="metrics">
<div class="metric">
<div class="metric-value">${d.playerCount}</div>
<div class="metric-label">Voters</div>
</div>
<div class="metric">
<div class="metric-value">${effectiveN}</div>
<div class="metric-label">Effective Voters</div>
<div class="gauge-bar"><div class="gauge-fill" style="width:${Math.min(100, (1/d.herfindahlIndex/d.playerCount)*100)}%; background:${hhiColor}"></div></div>
</div>
<div class="metric">
<div class="metric-value">${(d.giniCoefficient * 100).toFixed(0)}%</div>
<div class="metric-label">Gini (Power)</div>
<div class="gauge-bar"><div class="gauge-fill" style="width:${d.giniCoefficient*100}%; background:${giniColor}"></div></div>
</div>
<div class="metric">
<div class="metric-value">${(d.herfindahlIndex * 100).toFixed(0)}%</div>
<div class="metric-label">HHI Concentration</div>
<div class="gauge-bar"><div class="gauge-fill" style="width:${d.herfindahlIndex*100}%; background:${hhiColor}"></div></div>
</div>
</div>`;
}
private renderLegend(): string {
const meta = AUTHORITY_META[this.authority];
return `<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background:rgba(148,163,184,0.4)"></div>Raw Weight</div>
<div class="legend-item"><div class="legend-swatch" style="background:${meta.color}aa"></div>Banzhaf Index</div>
<div class="legend-item"><div class="legend-swatch" style="background:${meta.color}55"></div>Shapley-Shubik</div>
</div>`;
}
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 `<div class="bar-chart">${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 `<div class="bar-row" data-did="${r.did}">
<div class="bar-label" title="${name}">${name}</div>
<div class="bar-group">
<div class="bar-track"><div class="bar-fill weight" style="width:${wPct}%">${(r.weight/d.totalWeight*100).toFixed(1)}%</div></div>
<div class="bar-track"><div class="bar-fill banzhaf" style="width:${bPct}%">${(r.banzhaf*100).toFixed(1)}%</div></div>
<div class="bar-track"><div class="bar-fill shapley" style="width:${sPct}%">${(r.shapleyShubik*100).toFixed(1)}%</div></div>
</div>
<div class="bar-value">${(r.banzhaf*100).toFixed(1)}%</div>
</div>`;
}).join("")}</div>`;
}
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 `<circle class="scatter-point" cx="${sx(p.x)}" cy="${sy(p.y)}" r="4" fill="${color}" data-did="${p.did}" />`;
}).join("");
return `<div class="scatter-container" style="max-width:${size}px; padding-top:${size}px;">
<svg class="scatter-svg" viewBox="0 0 ${size} ${size}">
<!-- axes -->
<line x1="${pad}" y1="${size-pad}" x2="${size-pad}" y2="${size-pad}" stroke="rgba(255,255,255,0.15)" />
<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${size-pad}" stroke="rgba(255,255,255,0.15)" />
<!-- diagonal (proportional) -->
<line x1="${sx(0)}" y1="${sy(0)}" x2="${sx(diagEnd)}" y2="${sy(diagEnd)}" stroke="rgba(255,255,255,0.1)" stroke-dasharray="4,4" />
<!-- labels -->
<text x="${size/2}" y="${size-6}" fill="#64748b" font-size="10" text-anchor="middle">Weight %</text>
<text x="10" y="${size/2}" fill="#64748b" font-size="10" text-anchor="middle" transform="rotate(-90 10 ${size/2})">Banzhaf %</text>
${pointsSvg}
</svg>
</div>
<div class="legend" style="margin-top:8px">
<div class="legend-item"><div class="legend-swatch" style="background:#f87171"></div>Overrepresented</div>
<div class="legend-item"><div class="legend-swatch" style="background:#94a3b8"></div>Proportional</div>
<div class="legend-item"><div class="legend-swatch" style="background:#60a5fa"></div>Underrepresented</div>
</div>`;
}
private attachEvents() {
// Tab clicks
this.shadow.querySelectorAll<HTMLElement>(".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<SVGCircleElement>(".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 = `<b>${name}</b><br>Weight: ${(r.weight / this.data.totalWeight * 100).toFixed(1)}%<br>Banzhaf: ${(r.banzhaf * 100).toFixed(1)}%<br>Shapley-Shubik: ${(r.shapleyShubik * 100).toFixed(1)}%<br>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);