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