/** * 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 = { "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 = { "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 | 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`
📊 Sankey
`; 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(); 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, flows: SankeyFlow[]) { if (nodes.size === 0) return; // Build adjacency for topological column assignment const outEdges = new Map(); const inDegree = new Map(); 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(); 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(); 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, flows: SankeyFlow[]) { if (nodes.size === 0) { if (this.#summaryEl) this.#summaryEl.textContent = ""; if (this.#sankeyEl) this.#sankeyEl.innerHTML = `
Drop near gov shapes to visualize flows
`; 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(); 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 = ``; // 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 += ``; // Animated dash curve svg += ``; } // 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 += ``; // Satisfied glow if (n.satisfied) { svg += ``; } // Label (truncated) const label = n.title.length > 12 ? n.title.slice(0, 11) + "..." : n.title; svg += `${this.#escapeXml(label)}`; // Tooltip title svg += `${this.#escapeXml(n.title)} (${TYPE_LABELS[n.tagName] || n.tagName}) - ${n.satisfied ? "Satisfied" : "Waiting"}`; } 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 `
${label}
`; }).join(""); } } #escapeXml(text: string): string { return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } override toJSON() { return { ...super.toJSON(), type: "folk-gov-sankey", title: this.#title, }; } static override fromData(data: Record): FolkGovSankey { const shape = FolkShape.fromData.call(this, data) as FolkGovSankey; if (data.title !== undefined) shape.title = data.title; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.title !== undefined && data.title !== this.#title) this.title = data.title; } }