513 lines
14 KiB
TypeScript
513 lines
14 KiB
TypeScript
/**
|
|
* folk-gov-sankey — Governance Flow Visualizer
|
|
*
|
|
* Auto-discovers all connected governance shapes via arrow graph traversal,
|
|
* renders an SVG Sankey diagram with animated flow curves, tooltips, and
|
|
* a color-coded legend. Purely visual — no ports.
|
|
*/
|
|
|
|
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
|
|
const HEADER_COLOR = "#7c3aed";
|
|
|
|
// Gov shape tag names recognized by the visualizer
|
|
const GOV_TAG_NAMES = new Set([
|
|
"FOLK-GOV-BINARY",
|
|
"FOLK-GOV-THRESHOLD",
|
|
"FOLK-GOV-KNOB",
|
|
"FOLK-GOV-PROJECT",
|
|
"FOLK-GOV-AMENDMENT",
|
|
"FOLK-GOV-QUADRATIC",
|
|
"FOLK-GOV-CONVICTION",
|
|
"FOLK-GOV-MULTISIG",
|
|
]);
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
"FOLK-GOV-BINARY": "#7c3aed",
|
|
"FOLK-GOV-THRESHOLD": "#0891b2",
|
|
"FOLK-GOV-KNOB": "#b45309",
|
|
"FOLK-GOV-PROJECT": "#1d4ed8",
|
|
"FOLK-GOV-AMENDMENT": "#be185d",
|
|
"FOLK-GOV-QUADRATIC": "#14b8a6",
|
|
"FOLK-GOV-CONVICTION": "#d97706",
|
|
"FOLK-GOV-MULTISIG": "#6366f1",
|
|
};
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
"FOLK-GOV-BINARY": "Binary",
|
|
"FOLK-GOV-THRESHOLD": "Threshold",
|
|
"FOLK-GOV-KNOB": "Knob",
|
|
"FOLK-GOV-PROJECT": "Project",
|
|
"FOLK-GOV-AMENDMENT": "Amendment",
|
|
"FOLK-GOV-QUADRATIC": "Quadratic",
|
|
"FOLK-GOV-CONVICTION": "Conviction",
|
|
"FOLK-GOV-MULTISIG": "Multisig",
|
|
};
|
|
|
|
interface SankeyNode {
|
|
id: string;
|
|
tagName: string;
|
|
title: string;
|
|
satisfied: boolean;
|
|
column: number; // 0 = leftmost
|
|
row: number;
|
|
}
|
|
|
|
interface SankeyFlow {
|
|
sourceId: string;
|
|
targetId: string;
|
|
}
|
|
|
|
const styles = css`
|
|
:host {
|
|
background: var(--rs-bg-surface, #1e293b);
|
|
border-radius: 10px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
|
min-width: 340px;
|
|
min-height: 240px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: ${HEADER_COLOR};
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: move;
|
|
border-radius: 10px 10px 0 0;
|
|
}
|
|
|
|
.header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.header-actions button {
|
|
background: transparent;
|
|
border: none;
|
|
color: white;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.header-actions button:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 12px;
|
|
gap: 8px;
|
|
overflow: auto;
|
|
max-height: calc(100% - 36px);
|
|
}
|
|
|
|
.title-input {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
width: 100%;
|
|
outline: none;
|
|
}
|
|
|
|
.title-input::placeholder {
|
|
color: var(--rs-text-muted, #64748b);
|
|
}
|
|
|
|
.summary {
|
|
font-size: 11px;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
text-align: center;
|
|
}
|
|
|
|
.sankey-area svg {
|
|
width: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 9px;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.no-data {
|
|
font-size: 11px;
|
|
color: var(--rs-text-muted, #475569);
|
|
font-style: italic;
|
|
text-align: center;
|
|
padding: 24px 0;
|
|
}
|
|
|
|
@keyframes flow-dash {
|
|
to { stroke-dashoffset: -20; }
|
|
}
|
|
`;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-gov-sankey": FolkGovSankey;
|
|
}
|
|
}
|
|
|
|
export class FolkGovSankey extends FolkShape {
|
|
static override tagName = "folk-gov-sankey";
|
|
|
|
static {
|
|
const sheet = new CSSStyleSheet();
|
|
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
|
|
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
|
|
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
|
this.styles = sheet;
|
|
}
|
|
|
|
#title = "Governance Flow";
|
|
#pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
#lastHash = "";
|
|
|
|
// DOM refs
|
|
#titleEl!: HTMLInputElement;
|
|
#summaryEl!: HTMLElement;
|
|
#sankeyEl!: HTMLElement;
|
|
#legendEl!: HTMLElement;
|
|
|
|
get title() { return this.#title; }
|
|
set title(v: string) {
|
|
this.#title = v;
|
|
if (this.#titleEl) this.#titleEl.value = v;
|
|
}
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;";
|
|
wrapper.innerHTML = html`
|
|
<div class="header" data-drag>
|
|
<span class="header-title">📊 Sankey</span>
|
|
<span class="header-actions">
|
|
<button class="close-btn">×</button>
|
|
</span>
|
|
</div>
|
|
<div class="body">
|
|
<input class="title-input" type="text" placeholder="Flow visualizer title..." />
|
|
<div class="summary"></div>
|
|
<div class="sankey-area"></div>
|
|
<div class="legend"></div>
|
|
</div>
|
|
`;
|
|
|
|
const slot = root.querySelector("slot");
|
|
const container = slot?.parentElement as HTMLElement;
|
|
if (container) container.replaceWith(wrapper);
|
|
|
|
// Cache refs
|
|
this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement;
|
|
this.#summaryEl = wrapper.querySelector(".summary") as HTMLElement;
|
|
this.#sankeyEl = wrapper.querySelector(".sankey-area") as HTMLElement;
|
|
this.#legendEl = wrapper.querySelector(".legend") as HTMLElement;
|
|
|
|
// Set initial values
|
|
this.#titleEl.value = this.#title;
|
|
|
|
// Wire events
|
|
this.#titleEl.addEventListener("input", (e) => {
|
|
e.stopPropagation();
|
|
this.#title = this.#titleEl.value;
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
// Prevent drag on inputs
|
|
for (const el of wrapper.querySelectorAll("input, button")) {
|
|
el.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
}
|
|
|
|
// Poll every 3 seconds
|
|
this.#pollInterval = setInterval(() => this.#discover(), 3000);
|
|
requestAnimationFrame(() => this.#discover());
|
|
|
|
return root;
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
if (this.#pollInterval) {
|
|
clearInterval(this.#pollInterval);
|
|
this.#pollInterval = null;
|
|
}
|
|
}
|
|
|
|
#discover() {
|
|
const arrows = document.querySelectorAll("folk-arrow");
|
|
const nodes = new Map<string, SankeyNode>();
|
|
const flows: SankeyFlow[] = [];
|
|
|
|
// Collect all gov shapes connected by arrows
|
|
for (const arrow of arrows) {
|
|
const a = arrow as any;
|
|
const sourceId = a.sourceId;
|
|
const targetId = a.targetId;
|
|
if (!sourceId || !targetId) continue;
|
|
|
|
// Skip self
|
|
if (sourceId === this.id || targetId === this.id) continue;
|
|
|
|
const sourceEl = document.getElementById(sourceId) as any;
|
|
const targetEl = document.getElementById(targetId) as any;
|
|
if (!sourceEl || !targetEl) continue;
|
|
|
|
const srcTag = sourceEl.tagName?.toUpperCase();
|
|
const tgtTag = targetEl.tagName?.toUpperCase();
|
|
|
|
const srcIsGov = GOV_TAG_NAMES.has(srcTag);
|
|
const tgtIsGov = GOV_TAG_NAMES.has(tgtTag);
|
|
|
|
if (!srcIsGov && !tgtIsGov) continue;
|
|
|
|
if (srcIsGov && !nodes.has(sourceId)) {
|
|
const portVal = sourceEl.getPortValue?.("gate-out");
|
|
nodes.set(sourceId, {
|
|
id: sourceId,
|
|
tagName: srcTag,
|
|
title: sourceEl.title || srcTag,
|
|
satisfied: portVal?.satisfied === true,
|
|
column: 0,
|
|
row: 0,
|
|
});
|
|
}
|
|
|
|
if (tgtIsGov && !nodes.has(targetId)) {
|
|
const portVal = targetEl.getPortValue?.("gate-out") || targetEl.getPortValue?.("circuit-out");
|
|
nodes.set(targetId, {
|
|
id: targetId,
|
|
tagName: tgtTag,
|
|
title: targetEl.title || tgtTag,
|
|
satisfied: portVal?.satisfied === true || portVal?.status === "completed",
|
|
column: 0,
|
|
row: 0,
|
|
});
|
|
}
|
|
|
|
if (srcIsGov && tgtIsGov) {
|
|
flows.push({ sourceId, targetId });
|
|
}
|
|
}
|
|
|
|
// Hash-based skip
|
|
const hash = [...nodes.keys()].sort().join(",") + "|" +
|
|
flows.map(f => `${f.sourceId}->${f.targetId}`).sort().join(",") +
|
|
"|" + [...nodes.values()].map(n => n.satisfied ? "1" : "0").join("");
|
|
if (hash === this.#lastHash) return;
|
|
this.#lastHash = hash;
|
|
|
|
this.#layout(nodes, flows);
|
|
this.#renderSankey(nodes, flows);
|
|
}
|
|
|
|
#layout(nodes: Map<string, SankeyNode>, flows: SankeyFlow[]) {
|
|
if (nodes.size === 0) return;
|
|
|
|
// Build adjacency for topological column assignment
|
|
const outEdges = new Map<string, string[]>();
|
|
const inDegree = new Map<string, number>();
|
|
for (const n of nodes.keys()) {
|
|
outEdges.set(n, []);
|
|
inDegree.set(n, 0);
|
|
}
|
|
for (const f of flows) {
|
|
if (nodes.has(f.sourceId) && nodes.has(f.targetId)) {
|
|
outEdges.get(f.sourceId)!.push(f.targetId);
|
|
inDegree.set(f.targetId, (inDegree.get(f.targetId) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
// BFS topological layering
|
|
const queue: string[] = [];
|
|
for (const [id, deg] of inDegree) {
|
|
if (deg === 0) queue.push(id);
|
|
}
|
|
|
|
const visited = new Set<string>();
|
|
while (queue.length > 0) {
|
|
const id = queue.shift()!;
|
|
if (visited.has(id)) continue;
|
|
visited.add(id);
|
|
|
|
for (const next of outEdges.get(id) || []) {
|
|
const parentCol = nodes.get(id)!.column;
|
|
const node = nodes.get(next)!;
|
|
node.column = Math.max(node.column, parentCol + 1);
|
|
const newDeg = (inDegree.get(next) || 1) - 1;
|
|
inDegree.set(next, newDeg);
|
|
if (newDeg <= 0) queue.push(next);
|
|
}
|
|
}
|
|
|
|
// Assign rows within each column
|
|
const columns = new Map<number, string[]>();
|
|
for (const [id, node] of nodes) {
|
|
const col = node.column;
|
|
if (!columns.has(col)) columns.set(col, []);
|
|
columns.get(col)!.push(id);
|
|
}
|
|
for (const [, ids] of columns) {
|
|
ids.forEach((id, i) => {
|
|
nodes.get(id)!.row = i;
|
|
});
|
|
}
|
|
}
|
|
|
|
#renderSankey(nodes: Map<string, SankeyNode>, flows: SankeyFlow[]) {
|
|
if (nodes.size === 0) {
|
|
if (this.#summaryEl) this.#summaryEl.textContent = "";
|
|
if (this.#sankeyEl) this.#sankeyEl.innerHTML = `<div class="no-data">Drop near gov shapes to visualize flows</div>`;
|
|
if (this.#legendEl) this.#legendEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
// Summary
|
|
if (this.#summaryEl) {
|
|
this.#summaryEl.textContent = `${nodes.size} shapes, ${flows.length} flows`;
|
|
}
|
|
|
|
// Calculate dimensions
|
|
const maxCol = Math.max(...[...nodes.values()].map(n => n.column));
|
|
const columns = new Map<number, SankeyNode[]>();
|
|
for (const n of nodes.values()) {
|
|
if (!columns.has(n.column)) columns.set(n.column, []);
|
|
columns.get(n.column)!.push(n);
|
|
}
|
|
const maxRows = Math.max(...[...columns.values()].map(c => c.length));
|
|
|
|
const NODE_W = 80;
|
|
const NODE_H = 28;
|
|
const COL_GAP = 60;
|
|
const ROW_GAP = 12;
|
|
const PAD = 16;
|
|
|
|
const W = PAD * 2 + (maxCol + 1) * NODE_W + maxCol * COL_GAP;
|
|
const H = PAD * 2 + maxRows * NODE_H + (maxRows - 1) * ROW_GAP;
|
|
|
|
const nodeX = (col: number) => PAD + col * (NODE_W + COL_GAP);
|
|
const nodeY = (col: number, row: number) => {
|
|
const colNodes = columns.get(col) || [];
|
|
const totalH = colNodes.length * NODE_H + (colNodes.length - 1) * ROW_GAP;
|
|
const offsetY = (H - totalH) / 2;
|
|
return offsetY + row * (NODE_H + ROW_GAP);
|
|
};
|
|
|
|
let svg = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
|
|
|
|
// Flows (Bezier curves)
|
|
for (const f of flows) {
|
|
const src = nodes.get(f.sourceId);
|
|
const tgt = nodes.get(f.targetId);
|
|
if (!src || !tgt) continue;
|
|
|
|
const sx = nodeX(src.column) + NODE_W;
|
|
const sy = nodeY(src.column, src.row) + NODE_H / 2;
|
|
const tx = nodeX(tgt.column);
|
|
const ty = nodeY(tgt.column, tgt.row) + NODE_H / 2;
|
|
const cx1 = sx + (tx - sx) * 0.4;
|
|
const cx2 = tx - (tx - sx) * 0.4;
|
|
|
|
const color = TYPE_COLORS[src.tagName] || "#94a3b8";
|
|
|
|
// Background curve
|
|
svg += `<path d="M${sx},${sy} C${cx1},${sy} ${cx2},${ty} ${tx},${ty}" fill="none" stroke="${color}" stroke-width="3" opacity="0.15"/>`;
|
|
// Animated dash curve
|
|
svg += `<path d="M${sx},${sy} C${cx1},${sy} ${cx2},${ty} ${tx},${ty}" fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="6,4" opacity="0.6" style="animation:flow-dash 1.5s linear infinite"/>`;
|
|
}
|
|
|
|
// Nodes
|
|
for (const n of nodes.values()) {
|
|
const x = nodeX(n.column);
|
|
const y = nodeY(n.column, n.row);
|
|
const color = TYPE_COLORS[n.tagName] || "#94a3b8";
|
|
const fillOpacity = n.satisfied ? "0.25" : "0.1";
|
|
|
|
svg += `<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="6" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`;
|
|
|
|
// Satisfied glow
|
|
if (n.satisfied) {
|
|
svg += `<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="6" fill="none" stroke="#22c55e" stroke-width="1" opacity="0.5"/>`;
|
|
}
|
|
|
|
// Label (truncated)
|
|
const label = n.title.length > 12 ? n.title.slice(0, 11) + "..." : n.title;
|
|
svg += `<text x="${x + NODE_W / 2}" y="${y + NODE_H / 2 + 3}" text-anchor="middle" font-size="8" fill="${color}" font-weight="600" font-family="system-ui">${this.#escapeXml(label)}</text>`;
|
|
|
|
// Tooltip title
|
|
svg += `<title>${this.#escapeXml(n.title)} (${TYPE_LABELS[n.tagName] || n.tagName}) - ${n.satisfied ? "Satisfied" : "Waiting"}</title>`;
|
|
}
|
|
|
|
svg += "</svg>";
|
|
if (this.#sankeyEl) this.#sankeyEl.innerHTML = svg;
|
|
|
|
// Legend
|
|
if (this.#legendEl) {
|
|
const usedTypes = new Set([...nodes.values()].map(n => n.tagName));
|
|
this.#legendEl.innerHTML = [...usedTypes].map(t => {
|
|
const color = TYPE_COLORS[t] || "#94a3b8";
|
|
const label = TYPE_LABELS[t] || t;
|
|
return `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`;
|
|
}).join("");
|
|
}
|
|
}
|
|
|
|
#escapeXml(text: string): string {
|
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
override toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-gov-sankey",
|
|
title: this.#title,
|
|
};
|
|
}
|
|
|
|
static override fromData(data: Record<string, any>): FolkGovSankey {
|
|
const shape = FolkShape.fromData.call(this, data) as FolkGovSankey;
|
|
if (data.title !== undefined) shape.title = data.title;
|
|
return shape;
|
|
}
|
|
|
|
override applyData(data: Record<string, any>): void {
|
|
super.applyData(data);
|
|
if (data.title !== undefined && data.title !== this.#title) this.title = data.title;
|
|
}
|
|
}
|