/** * — animated SVG sankey river visualization. * Vanilla web component port of rfunds-online BudgetRiver.tsx. */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick, DEFAULT_CONFIG } from "../lib/simulation"; import { demoNodes } from "../lib/presets"; // ─── Layout types ─────────────────────────────────────── interface RiverLayout { sources: SourceLayout[]; funnels: FunnelLayout[]; outcomes: OutcomeLayout[]; sourceWaterfalls: WaterfallLayout[]; overflowBranches: BranchLayout[]; spendingWaterfalls: WaterfallLayout[]; width: number; height: number; } interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; } interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; riverWidth: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; } interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; poolWidth: number; fillPercent: number; } interface WaterfallLayout { id: string; sourceId: string; targetId: string; label: string; percentage: number; x: number; xSource: number; yStart: number; yEnd: number; width: number; riverEndWidth: number; farEndWidth: number; direction: "inflow" | "outflow"; color: string; flowAmount: number; } interface BranchLayout { sourceId: string; targetId: string; percentage: number; x1: number; y1: number; x2: number; y2: number; width: number; color: string; } // ─── Constants ─────────────────────────────────────────── const LAYER_HEIGHT = 160; const WATERFALL_HEIGHT = 120; const GAP = 40; const MIN_RIVER_WIDTH = 24; const MAX_RIVER_WIDTH = 100; const MIN_WATERFALL_WIDTH = 4; const SEGMENT_LENGTH = 200; const POOL_WIDTH = 100; const POOL_HEIGHT = 60; const SOURCE_HEIGHT = 40; const COLORS = { sourceWaterfall: "#10b981", riverHealthy: ["#0ea5e9", "#06b6d4"], riverOverflow: ["#f59e0b", "#fbbf24"], riverCritical: ["#ef4444", "#f87171"], riverSufficient: ["#fbbf24", "#10b981"], overflowBranch: "#f59e0b", spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"], outcomePool: "#3b82f6", goldenGlow: "#fbbf24", bg: "#0f172a", text: "#e2e8f0", textMuted: "#94a3b8", }; function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] { const totalPct = percentages.reduce((s, p) => s + p, 0); if (totalPct === 0) return percentages.map(() => minWidth); let widths = percentages.map((p) => (p / totalPct) * totalAvailable); const belowMin = widths.filter((w) => w < minWidth); if (belowMin.length > 0 && belowMin.length < widths.length) { const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0); const aboveMinTotal = widths.filter((w) => w >= minWidth).reduce((s, w) => s + w, 0); widths = widths.map((w) => { if (w < minWidth) return minWidth; return Math.max(minWidth, w - (w / aboveMinTotal) * deficit); }); } return widths; } // ─── Layout engine (faithful port) ────────────────────── function computeLayout(nodes: FlowNode[]): RiverLayout { const funnelNodes = nodes.filter((n) => n.type === "funnel"); const outcomeNodes = nodes.filter((n) => n.type === "outcome"); const sourceNodes = nodes.filter((n) => n.type === "source"); const overflowTargets = new Set(); const spendingTargets = new Set(); funnelNodes.forEach((n) => { const data = n.data as FunnelNodeData; data.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId)); data.spendingAllocations?.forEach((a) => spendingTargets.add(a.targetId)); }); const rootFunnels = funnelNodes.filter((n) => !overflowTargets.has(n.id)); const funnelLayers = new Map(); rootFunnels.forEach((n) => funnelLayers.set(n.id, 0)); const queue = [...rootFunnels]; while (queue.length > 0) { const current = queue.shift()!; const data = current.data as FunnelNodeData; const parentLayer = funnelLayers.get(current.id) ?? 0; data.overflowAllocations?.forEach((a) => { const child = funnelNodes.find((n) => n.id === a.targetId); if (child && !funnelLayers.has(child.id)) { funnelLayers.set(child.id, parentLayer + 1); queue.push(child); } }); } const layerGroups = new Map(); funnelNodes.forEach((n) => { const layer = funnelLayers.get(n.id) ?? 0; if (!layerGroups.has(layer)) layerGroups.set(layer, []); layerGroups.get(layer)!.push(n); }); const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0); const sourceLayerY = GAP; const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP; const funnelLayouts: FunnelLayout[] = []; for (let layer = 0; layer <= maxLayer; layer++) { const layerNodes = layerGroups.get(layer) || []; const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP); const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2; layerNodes.forEach((n, i) => { const data = n.data as FunnelNodeData; const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1)); const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH); const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2); const status: "healthy" | "overflow" | "critical" = data.currentValue > data.maxThreshold ? "overflow" : data.currentValue < data.minThreshold ? "critical" : "healthy"; funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, riverWidth, segmentLength: SEGMENT_LENGTH, layer, status, sufficiency: computeSufficiencyState(data) }); }); } // Source layouts const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { const data = n.data as SourceNodeData; const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP; return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (120 + GAP), y: sourceLayerY, width: 120 }; }); // Source waterfalls const inflowsByFunnel = new Map(); sourceNodes.forEach((sn) => { const data = sn.data as SourceNodeData; data.targetAllocations?.forEach((alloc, i) => { const flowAmount = (alloc.percentage / 100) * data.flowRate; if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []); inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage }); }); }); const sourceWaterfalls: WaterfallLayout[] = []; sourceNodes.forEach((sn) => { const data = sn.data as SourceNodeData; const sourceLayout = sourceLayouts.find((s) => s.id === sn.id); if (!sourceLayout) return; data.targetAllocations?.forEach((alloc, allocIdx) => { const targetLayout = funnelLayouts.find((f) => f.id === alloc.targetId); if (!targetLayout) return; const flowAmount = (alloc.percentage / 100) * data.flowRate; const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || []; const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0); const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1; const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth); const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8); const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx); const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); const startX = targetLayout.x + targetLayout.segmentLength * 0.15; let offsetX = 0; for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k]; const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2; const sourceCenterX = sourceLayout.x + sourceLayout.width / 2; sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "inflow", color: COLORS.sourceWaterfall, flowAmount }); }); }); // Implicit waterfalls for root funnels without source nodes if (sourceNodes.length === 0) { rootFunnels.forEach((rn) => { const data = rn.data as FunnelNodeData; if (data.inflowRate <= 0) return; const layout = funnelLayouts.find((f) => f.id === rn.id); if (!layout) return; sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, width: layout.riverWidth, riverEndWidth: layout.riverWidth, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate }); }); } // Overflow branches const overflowBranches: BranchLayout[] = []; funnelNodes.forEach((n) => { const data = n.data as FunnelNodeData; const parentLayout = funnelLayouts.find((f) => f.id === n.id); if (!parentLayout) return; data.overflowAllocations?.forEach((alloc) => { const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId); if (!childLayout) return; const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth); overflowBranches.push({ sourceId: n.id, targetId: alloc.targetId, percentage: alloc.percentage, x1: parentLayout.x + parentLayout.segmentLength, y1: parentLayout.y + parentLayout.riverWidth / 2, x2: childLayout.x, y2: childLayout.y + childLayout.riverWidth / 2, width, color: alloc.color || COLORS.overflowBranch }); }); }); // Outcome layouts const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT; const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP; const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => { const data = n.data as OutcomeNodeData; const fillPercent = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent }; }); // Spending waterfalls const spendingWaterfalls: WaterfallLayout[] = []; funnelNodes.forEach((n) => { const data = n.data as FunnelNodeData; const parentLayout = funnelLayouts.find((f) => f.id === n.id); if (!parentLayout) return; const allocations = data.spendingAllocations || []; if (allocations.length === 0) return; const percentages = allocations.map((a) => a.percentage); const slotWidths = distributeWidths(percentages, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH); const startX = parentLayout.x + parentLayout.segmentLength * 0.15; let offsetX = 0; allocations.forEach((alloc, i) => { const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId); if (!outcomeLayout) return; const riverEndWidth = riverEndWidths[i]; const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6); const riverCenterX = startX + offsetX + slotWidths[i] / 2; offsetX += slotWidths[i]; const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2; spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "outflow", color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1) }); }); }); // Compute bounds and normalize const allX = [...funnelLayouts.map((f) => f.x), ...funnelLayouts.map((f) => f.x + f.segmentLength), ...outcomeLayouts.map((o) => o.x), ...outcomeLayouts.map((o) => o.x + o.poolWidth), ...sourceLayouts.map((s) => s.x), ...sourceLayouts.map((s) => s.x + s.width)]; const allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY]; const minX = Math.min(...allX, -100); const maxX = Math.max(...allX, 100); const maxY = Math.max(...allY, 400); const padding = 80; const offsetXGlobal = -minX + padding; const offsetYGlobal = padding; funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; }); outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; }); sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += offsetYGlobal; }); sourceWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; }); overflowBranches.forEach((b) => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal; }); spendingWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; }); return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding }; } // ─── SVG Rendering ────────────────────────────────────── function renderWaterfall(wf: WaterfallLayout): string { const isInflow = wf.direction === "inflow"; const height = wf.yEnd - wf.yStart; if (height <= 0) return ""; const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth; const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth; const topCx = isInflow ? wf.xSource : wf.x; const bottomCx = isInflow ? wf.x : wf.xSource; const cpFrac1 = isInflow ? 0.55 : 0.2; const cpFrac2 = isInflow ? 0.75 : 0.45; const cpY1 = wf.yStart + height * cpFrac1; const cpY2 = wf.yStart + height * cpFrac2; const tl = topCx - topWidth / 2; const tr = topCx + topWidth / 2; const bl = bottomCx - bottomWidth / 2; const br = bottomCx + bottomWidth / 2; const shapePath = `M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd} L ${br} ${wf.yEnd} C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart} Z`; const clipId = `sankey-clip-${wf.id}`; const gradId = `sankey-grad-${wf.id}`; const pathMinX = Math.min(tl, bl) - 5; const pathMaxW = Math.max(topWidth, bottomWidth) + 10; return ` ${[0, 1, 2].map((i) => ``).join("")} `; } function renderBranch(b: BranchLayout): string { const dx = b.x2 - b.x1; const dy = b.y2 - b.y1; const cpx = b.x1 + dx * 0.5; const halfW = b.width / 2; return ` ${b.percentage}%`; } function renderSource(s: SourceLayout): string { return ` ${esc(s.label)} $${s.flowRate.toLocaleString()}/mo`; } function renderFunnel(f: FunnelLayout): string { const colors = f.status === "overflow" ? COLORS.riverOverflow : f.status === "critical" ? COLORS.riverCritical : f.sufficiency === "sufficient" || f.sufficiency === "abundant" ? COLORS.riverSufficient : COLORS.riverHealthy; const gradId = `river-grad-${f.id}`; const fillRatio = f.data.currentValue / (f.data.maxCapacity || 1); const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold; const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant"; return ` ${isSufficient ? `` : ""} ${[0, 1, 2].map((i) => ``).join("")} ${esc(f.label)} $${Math.floor(f.data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${isSufficient ? "\u2728" : ""} `; } function renderOutcome(o: OutcomeLayout): string { const filled = (o.fillPercent / 100) * POOL_HEIGHT; const color = o.data.status === "completed" ? "#10b981" : o.data.status === "blocked" ? "#ef4444" : "#3b82f6"; return ` ${filled > 5 ? `` : ""} ${esc(o.label)} ${Math.round(o.fillPercent)}%`; } function renderSufficiencyBadge(score: number, x: number, y: number): string { const pct = Math.round(score * 100); const color = pct >= 90 ? COLORS.goldenGlow : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444"; const circumference = 2 * Math.PI * 18; const dashoffset = circumference * (1 - score); return ` ${pct}% ENOUGH `; } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // ─── Web Component ────────────────────────────────────── class FolkBudgetRiver extends HTMLElement { private shadow: ShadowRoot; private nodes: FlowNode[] = []; private simulating = false; private simTimer: ReturnType | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } static get observedAttributes() { return ["simulate"]; } connectedCallback() { this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))]; this.simulating = this.getAttribute("simulate") === "true"; this.render(); if (this.simulating) this.startSimulation(); } disconnectedCallback() { this.stopSimulation(); } attributeChangedCallback(name: string, _: string, newVal: string) { if (name === "simulate") { this.simulating = newVal === "true"; if (this.simulating) this.startSimulation(); else this.stopSimulation(); } } setNodes(nodes: FlowNode[]) { this.nodes = nodes; this.render(); } private startSimulation() { if (this.simTimer) return; this.simTimer = setInterval(() => { this.nodes = simulateTick(this.nodes, DEFAULT_CONFIG); this.render(); }, 500); } private stopSimulation() { if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; } } private render() { const layout = computeLayout(this.nodes); const score = computeSystemSufficiency(this.nodes); this.shadow.innerHTML = `
${layout.sourceWaterfalls.map(renderWaterfall).join("")} ${layout.spendingWaterfalls.map(renderWaterfall).join("")} ${layout.overflowBranches.map(renderBranch).join("")} ${layout.sources.map(renderSource).join("")} ${layout.funnels.map(renderFunnel).join("")} ${layout.outcomes.map(renderOutcome).join("")} ${renderSufficiencyBadge(score, layout.width - 70, 10)}
Inflow
Healthy
Overflow
Critical
Spending
Sufficient
`; this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => { this.simulating = !this.simulating; if (this.simulating) this.startSimulation(); else this.stopSimulation(); this.render(); }); } } customElements.define("folk-budget-river", FolkBudgetRiver);