'use client' import { useState, useEffect, useMemo, useCallback } from 'react' import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from '@/lib/types' // ─── 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' } 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 yStart: number yEnd: number width: number 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 = 100 const GAP = 40 const MIN_RIVER_WIDTH = 24 const MAX_RIVER_WIDTH = 100 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'], overflowBranch: '#f59e0b', spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'], outcomePool: '#3b82f6', bg: '#0f172a', text: '#e2e8f0', textMuted: '#94a3b8', } // ─── 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') // Build adjacency: which funnels are children (overflow targets) of which const overflowTargets = new Set() const spendingTargets = new Set() const sourceTargets = 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)) }) sourceNodes.forEach(n => { const data = n.data as SourceNodeData data.targetAllocations?.forEach(a => sourceTargets.add(a.targetId)) }) // Root funnels = funnels that are NOT overflow targets of other funnels // (but may be source targets) const rootFunnels = funnelNodes.filter(n => !overflowTargets.has(n.id)) const childFunnels = funnelNodes.filter(n => overflowTargets.has(n.id)) // Assign layers const funnelLayers = new Map() rootFunnels.forEach(n => funnelLayers.set(n.id, 0)) // BFS to assign layers to overflow children 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) } }) } // Find max flow rate for normalization const allFlowRates = funnelNodes.map(n => (n.data as FunnelNodeData).inflowRate || 1) const maxFlowRate = Math.max(...allFlowRates, 1) const maxValue = Math.max(...funnelNodes.map(n => (n.data as FunnelNodeData).currentValue || 1), 1) // Compute funnel layouts // Group by layer, center each layer 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[] = [] const layerXRanges = new Map() 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, }) const range = layerXRanges.get(layer) || { minX: Infinity, maxX: -Infinity } range.minX = Math.min(range.minX, x) range.maxX = Math.max(range.maxX, x + SEGMENT_LENGTH) layerXRanges.set(layer, range) }) } // 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 (source → root funnel) 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, i) => { const targetLayout = funnelLayouts.find(f => f.id === alloc.targetId) if (!targetLayout) return const flowAmount = (alloc.percentage / 100) * data.flowRate const width = Math.max(6, (flowAmount / Math.max(data.flowRate, 1)) * 30) sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: sourceLayout.x + sourceLayout.width / 2 + (i - (data.targetAllocations.length - 1) / 2) * 30, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width, color: COLORS.sourceWaterfall, flowAmount, }) }) }) // If no source nodes, create implicit waterfalls for root funnels with inflowRate 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, yStart: GAP, yEnd: layout.y, width: Math.max(8, (data.inflowRate / maxFlowRate) * 30), color: COLORS.sourceWaterfall, flowAmount: data.inflowRate, }) }) } // Overflow branches (funnel → child funnel) 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, i) => { const childLayout = funnelLayouts.find(f => f.id === alloc.targetId) if (!childLayout) return const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) const width = Math.max(4, (alloc.percentage / 100) * parentLayout.riverWidth * 0.6) 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 (funnel → outcome) const spendingWaterfalls: WaterfallLayout[] = [] funnelNodes.forEach(n => { const data = n.data as FunnelNodeData const parentLayout = funnelLayouts.find(f => f.id === n.id) if (!parentLayout) return data.spendingAllocations?.forEach((alloc, i) => { const outcomeLayout = outcomeLayouts.find(o => o.id === alloc.targetId) if (!outcomeLayout) return const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) const width = Math.max(4, (alloc.percentage / 100) * 24) spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: parentLayout.x + parentLayout.segmentLength / 2 + (i - ((data.spendingAllocations?.length || 1) - 1) / 2) * 24, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, width, color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], flowAmount, }) }) }) // Compute total bounds 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 // Shift everything so minX starts at padding const offsetX = -minX + padding const offsetY = padding // Apply offsets funnelLayouts.forEach(f => { f.x += offsetX; f.y += offsetY }) outcomeLayouts.forEach(o => { o.x += offsetX; o.y += offsetY }) sourceLayouts.forEach(s => { s.x += offsetX; s.y += offsetY }) sourceWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) overflowBranches.forEach(b => { b.x1 += offsetX; b.y1 += offsetY; b.x2 += offsetX; b.y2 += offsetY }) spendingWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetY + padding, } } // ─── SVG Sub-components ────────────────────────────────── function WaterfallStream({ wf, index }: { wf: WaterfallLayout; index: number }) { const height = wf.yEnd - wf.yStart const numDroplets = Math.max(3, Math.min(8, Math.floor(wf.width / 3))) return ( {/* Main water stream */} {/* Glow behind */} {/* Animated water strips */} {[0, 1, 2].map(i => ( ))} {/* Side mist lines */} {/* Droplets */} {Array.from({ length: numDroplets }, (_, i) => { const dx = (Math.random() - 0.5) * (wf.width + 10) const delay = Math.random() * 2 const dur = 1 + Math.random() * 1.5 return ( ) })} {/* Splash ripples at bottom */} {[0, 1, 2].map(i => ( ))} {/* Label */} {wf.label} ) } function RiverSegment({ funnel }: { funnel: FunnelLayout }) { const { id, label, data, x, y, riverWidth, segmentLength, status } = funnel const gradColors = status === 'overflow' ? COLORS.riverOverflow : status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy const fillPercent = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100) const thresholdMinY = y + riverWidth * 0.85 const thresholdMaxY = y + riverWidth * 0.15 return ( {/* River glow */} {/* River body */} {/* Surface current lines */} {[0.3, 0.5, 0.7].map((pos, i) => ( ))} {/* Shimmer highlight */} {/* Threshold markers */} MAX MIN {/* Label */} {label} {/* Value */} ${Math.floor(data.currentValue).toLocaleString()} {/* Status pill */} {status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'} ) } function OverflowBranch({ branch }: { branch: BranchLayout }) { const { x1, y1, x2, y2, width, color, percentage } = branch const midX = (x1 + x2) / 2 const midY = (y1 + y2) / 2 // Curved path connecting two river segments const path = `M ${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` return ( {/* Glow */} {/* Main branch */} {/* Flow direction arrows */} {percentage}% ) } function OutcomePool({ outcome }: { outcome: OutcomeLayout }) { const { id, label, data, x, y, poolWidth, fillPercent } = outcome const fillHeight = (fillPercent / 100) * POOL_HEIGHT const statusColor = data.status === 'completed' ? '#10b981' : data.status === 'in-progress' ? '#3b82f6' : data.status === 'blocked' ? '#ef4444' : '#64748b' return ( {/* Pool container */} {/* Water fill */} {/* Water surface wave */} {fillPercent > 5 && ( )} {/* Label */} {label} {/* Value */} ${Math.floor(data.fundingReceived).toLocaleString()} {/* Progress */} {Math.round(fillPercent)}% of ${Math.floor(data.fundingTarget).toLocaleString()} ) } function SourceBox({ source }: { source: SourceLayout }) { return ( {source.label} ${source.flowRate.toLocaleString()}/mo ) } // ─── Main Component ────────────────────────────────────── interface BudgetRiverProps { nodes: FlowNode[] isSimulating?: boolean } export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiverProps) { const [animatedNodes, setAnimatedNodes] = useState(nodes) // Update when parent nodes change useEffect(() => { setAnimatedNodes(nodes) }, [nodes]) // Simulation useEffect(() => { if (!isSimulating) return const interval = setInterval(() => { setAnimatedNodes(prev => prev.map(node => { if (node.type === 'funnel') { const data = node.data as FunnelNodeData const change = (Math.random() - 0.45) * 300 return { ...node, data: { ...data, currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)), }, } } else if (node.type === 'outcome') { const data = node.data as OutcomeNodeData const change = Math.random() * 80 const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change) return { ...node, data: { ...data, fundingReceived: newReceived, status: newReceived >= data.fundingTarget ? 'completed' : data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status, }, } } return node }) ) }, 500) return () => clearInterval(interval) }, [isSimulating]) const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes]) return (
{/* Background */} {/* Background stars/dots for atmosphere */} {Array.from({ length: 30 }, (_, i) => ( ))} {/* Layer 1: Source boxes */} {layout.sources.map(s => ( ))} {/* Layer 2: Source waterfalls (flowing into river) */} {layout.sourceWaterfalls.map((wf, i) => ( ))} {/* Layer 3: River segments */} {layout.funnels.map(f => ( ))} {/* Layer 4: Overflow branches */} {layout.overflowBranches.map((b, i) => ( ))} {/* Layer 5: Spending waterfalls (flowing out) */} {layout.spendingWaterfalls.map((wf, i) => ( ))} {/* Layer 6: Outcome pools */} {layout.outcomes.map(o => ( ))} {/* Title watermark */} rFUNDS BUDGET RIVER
) }