/** * — animated SVG sankey river visualization. * Pure renderer: receives nodes via setNodes() or falls back to demo data. * Parent component (folk-flows-app) handles data fetching and mapping. * * Visual vocabulary: * Source → tap/faucet with draggable valve handle * Funnel → trapezoid vessel with water fill + overflow lips * Branch → pipe from vessel lip to downstream vessel * Outcome → pool (unchanged) */ 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; maxSourceFlowRate: number; } interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; valveAngle: number; sourceType: string; } interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; vesselWidth: number; vesselHeight: number; vesselBottomWidth: number; fillLevel: number; overflowLevel: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; leftOverflowPipes: OverflowPipe[]; rightOverflowPipes: OverflowPipe[]; } interface OverflowPipe { targetId: string; percentage: number; lipY: number; lipX: number; side: "left" | "right"; flowAmount: number; isActive: boolean; } 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; side: "left" | "right"; isActive: boolean; flowAmount: number; } // ─── Constants ─────────────────────────────────────────── const TAP_WIDTH = 140; const TAP_HEIGHT = 100; const VALVE_RADIUS = 18; const HANDLE_LENGTH = 24; const VESSEL_WIDTH = 200; const VESSEL_HEIGHT = 140; const VESSEL_BOTTOM_WIDTH = 100; const OVERFLOW_LIP_WIDTH = 20; const DRAIN_WIDTH = 24; const LAYER_HEIGHT = 240; const WATERFALL_HEIGHT = 160; const GAP = 60; const MIN_RIVER_WIDTH = 24; const MAX_RIVER_WIDTH = 100; const MIN_WATERFALL_WIDTH = 4; const SEGMENT_LENGTH = 220; const POOL_WIDTH = 110; const POOL_HEIGHT = 65; const SOURCE_HEIGHT = TAP_HEIGHT; const MAX_PIPE_WIDTH = 20; 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", metal: ["#64748b", "#94a3b8", "#64748b"], water: "#38bdf8", bg: "var(--rs-bg-page)", surface: "var(--rs-bg-surface)", surfaceRaised: "var(--rs-bg-surface-raised)", text: "var(--rs-text-primary)", textMuted: "var(--rs-text-secondary)", sourceTypeColors: { card: "#10b981", safe_wallet: "#8b5cf6", ridentity: "#3b82f6", metamask: "#f59e0b", unconfigured: "#64748b" } as Record, }; 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; } /** Interpolate left/right edges of trapezoid vessel at a given Y fraction (0=top, 1=bottom) */ function vesselEdgesAtY(x: number, topW: number, bottomW: number, height: number, yFrac: number): { left: number; right: number } { const halfTop = topW / 2; const halfBottom = bottomW / 2; const cx = x + topW / 2; const halfAtY = halfTop + (halfBottom - halfTop) * yFrac; return { left: cx - halfAtY, right: cx + halfAtY }; } // ─── Layout engine ────────────────────────────────────── 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 maxSourceFlowRate = Math.max(1, ...sourceNodes.map((n) => (n.data as SourceNodeData).flowRate)); 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 * VESSEL_WIDTH + (layerNodes.length - 1) * GAP * 2; layerNodes.forEach((n, i) => { const data = n.data as FunnelNodeData; const inflow = data.inflowRate || 0; const fillLevel = Math.min(1, data.currentValue / (data.maxCapacity || 1)); const overflowLevel = data.maxThreshold / (data.maxCapacity || 1); const x = -totalWidth / 2 + i * (VESSEL_WIDTH + GAP * 2); const status: "healthy" | "overflow" | "critical" = data.currentValue > data.maxThreshold ? "overflow" : data.currentValue < data.minThreshold ? "critical" : "healthy"; // Distribute overflow allocations to left/right lips const overflows = data.overflowAllocations || []; const leftOverflowPipes: OverflowPipe[] = []; const rightOverflowPipes: OverflowPipe[] = []; const lipY = layerY + VESSEL_HEIGHT * (1 - overflowLevel); const edges = vesselEdgesAtY(x, VESSEL_WIDTH, VESSEL_BOTTOM_WIDTH, VESSEL_HEIGHT, 1 - overflowLevel); overflows.forEach((alloc, idx) => { const isActive = data.currentValue > data.maxThreshold; const excess = isActive ? data.currentValue - data.maxThreshold : 0; const flowAmt = excess * (alloc.percentage / 100); const pipe: OverflowPipe = { targetId: alloc.targetId, percentage: alloc.percentage, lipY, lipX: idx % 2 === 0 ? edges.left : edges.right, side: idx % 2 === 0 ? "left" : "right", flowAmount: flowAmt, isActive, }; if (pipe.side === "left") leftOverflowPipes.push(pipe); else rightOverflowPipes.push(pipe); }); funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, vesselWidth: VESSEL_WIDTH, vesselHeight: VESSEL_HEIGHT, vesselBottomWidth: VESSEL_BOTTOM_WIDTH, fillLevel, overflowLevel, layer, status, sufficiency: computeSufficiencyState(data), leftOverflowPipes, rightOverflowPipes, }); }); } // Source layouts const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { const data = n.data as SourceNodeData; const totalWidth = sourceNodes.length * TAP_WIDTH + (sourceNodes.length - 1) * GAP; const valveAngle = (data.flowRate / maxSourceFlowRate) * 90; return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (TAP_WIDTH + GAP), y: sourceLayerY, width: TAP_WIDTH, valveAngle, sourceType: data.sourceType || "unconfigured" }; }); // 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 targetTopWidth = targetLayout.vesselWidth; const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetTopWidth * 0.4); const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.5); // Target: top-center of vessel const targetCenterX = targetLayout.x + targetTopWidth / 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: targetCenterX, 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; const cx = layout.x + layout.vesselWidth / 2; const w = Math.max(MIN_WATERFALL_WIDTH, layout.vesselWidth * 0.3); sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: cx, xSource: cx, yStart: GAP, yEnd: layout.y, width: w, riverEndWidth: w, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, w * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate }); }); } // Overflow branches — from lip positions to target vessel top const overflowBranches: BranchLayout[] = []; funnelNodes.forEach((n) => { const data = n.data as FunnelNodeData; const parentLayout = funnelLayouts.find((f) => f.id === n.id); if (!parentLayout) return; const allPipes = [...parentLayout.leftOverflowPipes, ...parentLayout.rightOverflowPipes]; allPipes.forEach((pipe) => { const childLayout = funnelLayouts.find((f) => f.id === pipe.targetId); if (!childLayout) return; const width = Math.max(MIN_WATERFALL_WIDTH, (pipe.percentage / 100) * MAX_PIPE_WIDTH); const targetCx = childLayout.x + childLayout.vesselWidth / 2; overflowBranches.push({ sourceId: n.id, targetId: pipe.targetId, percentage: pipe.percentage, x1: pipe.lipX, y1: pipe.lipY, x2: targetCx, y2: childLayout.y, width, color: data.overflowAllocations?.find((a) => a.targetId === pipe.targetId)?.color || COLORS.overflowBranch, side: pipe.side, isActive: pipe.isActive, flowAmount: pipe.flowAmount, }); }); }); // 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 — from vessel bottom drain to outcome pools 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, DRAIN_WIDTH * 2, MIN_WATERFALL_WIDTH); const drainCx = parentLayout.x + parentLayout.vesselWidth / 2; const startX = drainCx - DRAIN_WIDTH; let offsetX = 0; allocations.forEach((alloc, i) => { const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId); if (!outcomeLayout) return; const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, slotWidths[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 + VESSEL_HEIGHT + 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.vesselWidth), ...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 + VESSEL_HEIGHT), ...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 = 100; const offsetXGlobal = -minX + padding; const offsetYGlobal = padding; funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; f.leftOverflowPipes.forEach((p) => { p.lipX += offsetXGlobal; p.lipY += offsetYGlobal; }); f.rightOverflowPipes.forEach((p) => { p.lipX += offsetXGlobal; p.lipY += 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, maxSourceFlowRate }; } // ─── 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 glowId = `sankey-glow-${wf.id}`; const pathMinX = Math.min(tl, bl) - 5; const pathMaxW = Math.max(topWidth, bottomWidth) + 10; const spinePath = `M ${topCx} ${wf.yStart} C ${topCx} ${cpY1}, ${bottomCx} ${cpY2}, ${bottomCx} ${wf.yEnd}`; const entryCx = isInflow ? bottomCx : topCx; const entryY = isInflow ? wf.yEnd : wf.yStart; const entryWidth = isInflow ? bottomWidth : topWidth; const exitCx = isInflow ? topCx : bottomCx; const exitY = isInflow ? wf.yStart : wf.yEnd; const labelX = (topCx + bottomCx) / 2; const labelY = wf.yStart + height * 0.45; const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`; return ` ${[0, 1, 2, 3, 4].map((i) => ``).join("")} ${flowLabel}/mo`; } function renderBranch(b: BranchLayout): string { const dx = b.x2 - b.x1; const dy = b.y2 - b.y1; const halfW = b.width / 2; // Pipe exits horizontally from lip, then curves down to target const exitX = b.side === "left" ? b.x1 - 40 : b.x1 + 40; const cp1x = exitX; const cp1y = b.y1; const cp2x = b.x2; const cp2y = b.y1 + (b.y2 - b.y1) * 0.3; const spinePath = `M ${b.x1} ${b.y1} L ${exitX} ${b.y1} C ${cp1x} ${b.y1 + Math.abs(dy) * 0.4}, ${cp2x} ${cp2y}, ${b.x2} ${b.y2}`; // Outer wall (dark) const outerStroke = ``; // Inner channel (surface) const innerStroke = ``; // Water flow (animated, only when active) const waterFlow = b.isActive ? ` ` : ""; // Midpoint for label const midX = (b.x1 + exitX + b.x2) / 3; const midY = (b.y1 + b.y2) / 2 - 12; return `${outerStroke}${innerStroke}${waterFlow} ${b.percentage}%`; } function renderSource(s: SourceLayout, maxFlowRate: number): string { const cx = s.x + s.width / 2; const valveY = s.y + 35; const nozzleTop = valveY + VALVE_RADIUS; const nozzleBottom = s.y + TAP_HEIGHT; const typeColor = COLORS.sourceTypeColors[s.sourceType] || COLORS.sourceTypeColors.unconfigured; const flowRatio = Math.min(1, s.flowRate / maxFlowRate); const streamWidth = 4 + flowRatio * 12; const streamOpacity = 0.3 + flowRatio * 0.5; const metalGradId = `metal-grad-${s.id}`; const valveGradId = `valve-grad-${s.id}`; return ` ${flowRatio > 0.01 ? ` ${[0, 1, 2].map((i) => ``).join("")}` : ""} $ ${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 isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant"; const cx = f.x + f.vesselWidth / 2; const vw = f.vesselWidth; const vbw = f.vesselBottomWidth; const vh = f.vesselHeight; // Trapezoid corners const tl = f.x; const tr = f.x + vw; const bl = cx - vbw / 2; const br = cx + vbw / 2; const vesselGradId = `vessel-grad-${f.id}`; const waterGradId = `vessel-water-${f.id}`; const clipId = `vessel-clip-${f.id}`; // Threshold Y positions (from top: 0=top, vh=bottom) const overflowFrac = 1 - f.overflowLevel; const overflowY = f.y + overflowFrac * vh; const minFrac = 1 - (f.data.minThreshold / (f.data.maxCapacity || 1)); const minY = f.y + minFrac * vh; const suffFrac = 1 - ((f.data.sufficientThreshold ?? f.data.maxThreshold) / (f.data.maxCapacity || 1)); const suffY = f.y + suffFrac * vh; // Water fill const fillTop = f.y + (1 - f.fillLevel) * vh; const inflow = f.data.inflowRate || 0; const outflow = f.data.desiredOutflow || f.data.inflowRate || 1; const fundingPct = Math.round(f.fillLevel * (f.data.maxCapacity / (f.data.maxThreshold || 1)) * 100); const underfunded = f.data.currentValue < f.data.minThreshold; // Overflow lip positions const overflowEdges = vesselEdgesAtY(f.x, vw, vbw, vh, overflowFrac); // Vessel outline path const vesselPath = `M ${tl} ${f.y} L ${bl} ${f.y + vh} L ${br} ${f.y + vh} L ${tr} ${f.y} Z`; // Water fill clipped to vessel const fillEdgesTop = vesselEdgesAtY(f.x, vw, vbw, vh, (fillTop - f.y) / vh); const fillPathStr = `M ${fillEdgesTop.left} ${fillTop} L ${bl} ${f.y + vh} L ${br} ${f.y + vh} L ${fillEdgesTop.right} ${fillTop} Z`; // Overflow lips (U-shaped notch cutouts) const lipH = 12; const hasLeftLips = f.leftOverflowPipes.length > 0; const hasRightLips = f.rightOverflowPipes.length > 0; // Pour animation — only when overflowing const isOverflowing = f.data.currentValue > f.data.maxThreshold; const excessRatio = isOverflowing ? Math.min(1, (f.data.currentValue - f.data.maxThreshold) / (f.data.maxCapacity - f.data.maxThreshold || 1)) : 0; return ` ${isSufficient ? `` : ""} ${f.fillLevel > 0.01 ? ` ${[0, 1, 2].map((i) => ``).join("")} ` : ""} ${[ { y: overflowY, label: "max", frac: overflowFrac }, { y: suffY, label: "suff", frac: suffFrac }, { y: minY, label: "min", frac: minFrac }, ].map((t) => { if (t.frac < 0 || t.frac > 1) return ""; const edges = vesselEdgesAtY(f.x, vw, vbw, vh, t.frac); return ``; }).join("")} ${hasLeftLips ? `` : ""} ${hasRightLips ? `` : ""} ${isOverflowing && hasLeftLips ? ` ${[0, 1].map((i) => ``).join("")}` : ""} ${isOverflowing && hasRightLips ? ` ${[0, 1].map((i) => ``).join("")}` : ""} ${esc(f.label)} $${Math.floor(inflow).toLocaleString()} → $${Math.floor(outflow).toLocaleString()}/mo ${underfunded ? `(${Math.round((f.data.currentValue / (f.data.minThreshold || 1)) * 100)}%)` : isSufficient ? "✨" : ""} `; } 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 FolkFlowRiver extends HTMLElement { private shadow: ShadowRoot; private nodes: FlowNode[] = []; private simulating = false; private simTimer: ReturnType | null = null; private dragging = false; private dragStartX = 0; private dragStartY = 0; private scrollStartX = 0; private scrollStartY = 0; // Valve drag state private valveDragging = false; private valveDragSourceId: string | null = null; private currentLayout: RiverLayout | null = null; // Popover state private activePopover: HTMLElement | null = null; private renderScheduled = false; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } static get observedAttributes() { return ["simulate"]; } connectedCallback() { this.simulating = this.getAttribute("simulate") === "true"; if (this.nodes.length === 0) { this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))]; } 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 scheduleRender() { if (this.renderScheduled) return; this.renderScheduled = true; requestAnimationFrame(() => { this.renderScheduled = false; 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 showAmountPopover(sourceId: string, anchorX: number, anchorY: number) { this.closePopover(); const sourceNode = this.nodes.find((n) => n.id === sourceId); if (!sourceNode) return; const data = sourceNode.data as SourceNodeData; const popover = document.createElement("div"); popover.className = "amount-popover"; popover.style.left = `${anchorX}px`; popover.style.top = `${anchorY}px`; popover.innerHTML = `
`; const container = this.shadow.querySelector(".container") as HTMLElement; container.appendChild(popover); this.activePopover = popover; const rateInput = popover.querySelector(".pop-rate") as HTMLInputElement; const dateInput = popover.querySelector(".pop-date") as HTMLInputElement; rateInput.focus(); rateInput.select(); const apply = () => { const newRate = parseFloat(rateInput.value) || 0; const newDate = dateInput.value || undefined; this.updateSourceFlowRate(sourceId, Math.max(0, newRate), newDate); this.closePopover(); }; popover.querySelector(".pop-apply")!.addEventListener("click", apply); popover.querySelector(".pop-cancel")!.addEventListener("click", () => this.closePopover()); rateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); }); dateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); }); } private closePopover() { if (this.activePopover) { this.activePopover.remove(); this.activePopover = null; } } private updateSourceFlowRate(sourceId: string, flowRate: number, effectiveDate?: string) { this.nodes = this.nodes.map((n) => { if (n.id === sourceId && n.type === "source") { return { ...n, data: { ...n.data, flowRate, effectiveDate } as SourceNodeData }; } return n; }); this.dispatchEvent(new CustomEvent("flow-rate-change", { detail: { sourceId, flowRate, effectiveDate }, bubbles: true })); this.scheduleRender(); } private render() { const layout = computeLayout(this.nodes); this.currentLayout = layout; 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((s) => renderSource(s, layout.maxSourceFlowRate)).join("")} ${layout.funnels.map(renderFunnel).join("")} ${layout.outcomes.map(renderOutcome).join("")} ${renderSufficiencyBadge(score, layout.width - 70, 10)}
Inflow
Healthy
Overflow
Critical
Spending
Sufficient
`; // Event: toggle simulation this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => { this.simulating = !this.simulating; if (this.simulating) this.startSimulation(); else this.stopSimulation(); this.render(); }); // Event delegation for interactive elements + drag-to-pan const container = this.shadow.querySelector(".container") as HTMLElement; const svg = this.shadow.querySelector("svg") as SVGSVGElement; if (!container || !svg) return; container.addEventListener("pointerdown", (e: PointerEvent) => { const target = e.target as Element; if (target.closest("button")) return; if (target.closest(".amount-popover")) return; // Check for interactive SVG elements const interactive = target.closest("[data-interactive]") as Element | null; if (interactive) { const action = interactive.getAttribute("data-interactive"); const sourceId = interactive.getAttribute("data-source-id"); if (action === "valve" && sourceId) { // Start valve drag this.valveDragging = true; this.valveDragSourceId = sourceId; container.setPointerCapture(e.pointerId); e.preventDefault(); return; } if (action === "edit-rate" && sourceId) { // Show amount popover const rect = container.getBoundingClientRect(); const svgRect = svg.getBoundingClientRect(); // Position popover near click const popX = e.clientX - rect.left + container.scrollLeft + 10; const popY = e.clientY - rect.top + container.scrollTop + 10; this.showAmountPopover(sourceId, popX, popY); e.preventDefault(); return; } } // Close popover on click outside this.closePopover(); // Start pan drag this.dragging = true; this.dragStartX = e.clientX; this.dragStartY = e.clientY; this.scrollStartX = container.scrollLeft; this.scrollStartY = container.scrollTop; container.classList.add("dragging"); container.setPointerCapture(e.pointerId); }); container.addEventListener("pointermove", (e: PointerEvent) => { if (this.valveDragging && this.valveDragSourceId) { // Map pointer position to valve angle const sourceLayout = this.currentLayout?.sources.find((s) => s.id === this.valveDragSourceId); if (!sourceLayout) return; const svgRect = svg.getBoundingClientRect(); const svgX = (e.clientX - svgRect.left) * (layout.width / svgRect.width); const svgY = (e.clientY - svgRect.top) * (layout.height / svgRect.height); const cx = sourceLayout.x + sourceLayout.width / 2; const valveY = sourceLayout.y + 35; const angle = Math.atan2(svgX - cx, valveY - svgY) * (180 / Math.PI); const clampedAngle = Math.max(0, Math.min(90, angle)); const newRate = (clampedAngle / 90) * layout.maxSourceFlowRate; // Round to nearest 100 const roundedRate = Math.round(newRate / 100) * 100; this.nodes = this.nodes.map((n) => { if (n.id === this.valveDragSourceId && n.type === "source") { return { ...n, data: { ...n.data, flowRate: roundedRate } as SourceNodeData }; } return n; }); this.scheduleRender(); return; } if (!this.dragging) return; container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX); container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY); }); container.addEventListener("pointerup", (e: PointerEvent) => { if (this.valveDragging) { const sourceId = this.valveDragSourceId; this.valveDragging = false; this.valveDragSourceId = null; container.releasePointerCapture(e.pointerId); // Dispatch event if (sourceId) { const sourceNode = this.nodes.find((n) => n.id === sourceId); if (sourceNode) { const data = sourceNode.data as SourceNodeData; this.dispatchEvent(new CustomEvent("flow-rate-change", { detail: { sourceId, flowRate: data.flowRate, effectiveDate: data.effectiveDate }, bubbles: true })); } } return; } this.dragging = false; container.classList.remove("dragging"); container.releasePointerCapture(e.pointerId); }); // Auto-center on initial render container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2; container.scrollTop = 0; } } customElements.define("folk-flow-river", FolkFlowRiver);