'use client' /** * Flow Funding V2 - Continuous Flow Dynamics Demo * * Interactive visualization of progressive outflow zones and * steady-state flow equilibrium */ import { useState, useEffect, useCallback, useMemo } from 'react' import type { FlowNode, FlowNetwork, ScenarioV2 } from '../../lib/flow-v2/types' import { calculateSteadyState, getFlowZone, cloneNodes, updateBalances, perSecondToPerMonth, } from '../../lib/flow-v2/engine-v2' import { ALL_SCENARIOS_V2, linearChainV2, } from '../../lib/flow-v2/scenarios-v2' /** * Flow particle for animation */ interface FlowParticle { id: string sourceId: string targetId: string progress: number // 0 to 1 startTime: number } /** * Main component */ export default function FlowFundingV2() { // Scenario selection const [currentScenario, setCurrentScenario] = useState(linearChainV2) // Node state (with adjustable external inflows) const [nodes, setNodes] = useState(() => cloneNodes(currentScenario.nodes) ) // Network state (calculated) const [network, setNetwork] = useState(null) // Animation state const [particles, setParticles] = useState([]) const [isPlaying, setIsPlaying] = useState(true) const [simulationTime, setSimulationTime] = useState(0) // UI state const [selectedNodeId, setSelectedNodeId] = useState(null) const [showMetrics, setShowMetrics] = useState(true) /** * Recalculate network whenever nodes change */ useEffect(() => { try { const result = calculateSteadyState(cloneNodes(nodes), { verbose: false, }) setNetwork(result) } catch (error) { console.error('Failed to calculate steady state:', error) } }, [nodes]) /** * Handle scenario change */ const handleScenarioChange = useCallback((scenario: ScenarioV2) => { setCurrentScenario(scenario) setNodes(cloneNodes(scenario.nodes)) setSelectedNodeId(null) setSimulationTime(0) }, []) /** * Handle external inflow adjustment */ const handleInflowChange = useCallback( (nodeId: string, newInflow: number) => { setNodes(prev => prev.map(n => n.id === nodeId ? { ...n, externalInflow: newInflow } : n ) ) }, [] ) /** * Animation loop - update balances and particles */ useEffect(() => { if (!isPlaying || !network) return let lastTime = performance.now() let animationFrameId: number const animate = (currentTime: number) => { const deltaMs = currentTime - lastTime lastTime = currentTime const deltaSeconds = deltaMs / 1000 // Update simulation time setSimulationTime(prev => prev + deltaSeconds) // Update node balances (for visualization) const updatedNodes = cloneNodes(nodes) // Set total inflows/outflows from network calculation if (network?.nodes) { updatedNodes.forEach(node => { const networkNode = network.nodes.get(node.id) if (networkNode) { node.totalInflow = networkNode.totalInflow node.totalOutflow = networkNode.totalOutflow } }) } updateBalances(updatedNodes, deltaSeconds) setNodes(updatedNodes) // Update particles setParticles(prev => { const updated = prev .map(p => ({ ...p, progress: p.progress + deltaSeconds / 2, // 2 second transit time })) .filter(p => p.progress < 1) // Spawn new particles const now = currentTime / 1000 if (network?.edges) { network.edges.forEach(edge => { // Spawn rate based on flow amount const spawnRate = Math.min(2, Math.max(0.2, edge.flowRate / 500)) const shouldSpawn = Math.random() < spawnRate * deltaSeconds if (shouldSpawn) { updated.push({ id: `${edge.source}-${edge.target}-${now}-${Math.random()}`, sourceId: edge.source, targetId: edge.target, progress: 0, startTime: now, }) } }) } return updated }) animationFrameId = requestAnimationFrame(animate) } animationFrameId = requestAnimationFrame(animate) return () => { cancelAnimationFrame(animationFrameId) } }, [isPlaying, network, nodes]) /** * Get node position */ const getNodePos = useCallback( (nodeId: string): { x: number; y: number } => { return currentScenario.layout.get(nodeId) || { x: 0, y: 0 } }, [currentScenario] ) /** * Get color for flow zone */ const getZoneColor = useCallback((node: FlowNode): string => { const zone = getFlowZone(node) switch (zone) { case 'deficit': return '#ef4444' // red case 'building': return '#f59e0b' // amber case 'capacity': return '#10b981' // green } }, []) /** * Render network SVG */ const renderNetwork = useMemo(() => { if (!network) return null const svgWidth = 800 const svgHeight = 650 return ( {/* Edges */} {network.edges.map(edge => { const source = getNodePos(edge.source) const target = getNodePos(edge.target) // Edge width based on flow rate (logarithmic scale) const baseWidth = 2 const maxWidth = 12 const flowWidth = baseWidth + (maxWidth - baseWidth) * Math.min(1, Math.log(edge.flowRate + 1) / Math.log(1000)) return ( {/* Edge line */} {/* Flow label */} ${edge.flowRate.toFixed(0)}/mo ) })} {/* Flow particles */} {particles.map(particle => { const source = getNodePos(particle.sourceId) const target = getNodePos(particle.targetId) const x = source.x + (target.x - source.x) * particle.progress const y = source.y + (target.y - source.y) * particle.progress return ( ) })} {/* Nodes */} {Array.from(network.nodes.values()).map(node => { const pos = getNodePos(node.id) const zone = getFlowZone(node) const color = getZoneColor(node) const isSelected = selectedNodeId === node.id const totalInflow = node.totalInflow || 0 const totalOutflow = node.totalOutflow || 0 const retention = totalInflow - totalOutflow return ( setSelectedNodeId(node.id)} className="cursor-pointer" > {/* Selection ring */} {isSelected && ( )} {/* Node circle */} {/* Node label */} {node.name} {/* Zone indicator */} {zone} {/* Retention rate */} +${retention.toFixed(0)}/mo ) })} {/* Overflow node */} {network.overflowNode && ( Overflow ${network.overflowNode.totalInflow.toFixed(0)}/mo )} {/* Arrow marker definition */} ) }, [network, particles, selectedNodeId, getNodePos, getZoneColor, currentScenario]) return (
{/* Header */}

Flow Funding V2

Continuous flow dynamics with progressive outflow zones

{/* Controls */}
{/* Scenario selector */}
{/* Play/pause */}
{/* Metrics toggle */}
{/* Simulation time */}
{simulationTime.toFixed(1)}s
{/* Scenario description */}

{currentScenario.description}

{/* Main layout */}
{/* Network visualization */}
{renderNetwork}
{/* Control panel */}

External Inflows

{/* Node inflow sliders */} {nodes.map(node => { const networkNode = network?.nodes.get(node.id) const zone = networkNode ? getFlowZone(networkNode) : 'deficit' const color = networkNode ? getZoneColor(networkNode) : '#ef4444' return (
{/* Node name and zone */}
{node.name} {zone}
{/* External inflow slider */}
handleInflowChange(node.id, parseFloat(e.target.value)) } className="w-full" />
{/* Thresholds */}
Min: ${node.minThreshold}/mo
Max: ${node.maxThreshold}/mo
{/* Flow metrics */} {showMetrics && networkNode && (
Total In: ${(networkNode.totalInflow || 0).toFixed(0)}/mo
Total Out: ${(networkNode.totalOutflow || 0).toFixed(0)}/mo
Retained: $ {( (networkNode.totalInflow || 0) - (networkNode.totalOutflow || 0) ).toFixed(0)} /mo
Balance: ${(networkNode.balance || 0).toFixed(0)}
)}
) })} {/* Network totals */} {showMetrics && network && (

Network Totals

External Inflow: ${network.totalExternalInflow.toFixed(0)}/mo
Network Needs: ${network.totalNetworkNeeds.toFixed(0)}/mo
Network Capacity: ${network.totalNetworkCapacity.toFixed(0)}/mo
{network.overflowNode && (
Overflow: ${network.overflowNode.totalInflow.toFixed(0)}/mo
)}
Converged: {network.converged ? '✓ Yes' : '✗ No'}
{network.iterations} iterations
)}
{/* Legend */}

Flow Zones

Deficit Zone
Inflow below min threshold. Keep everything (0% outflow).
Building Zone
Between min and max. Progressive sharing based on capacity.
Capacity Zone
Above max threshold. Redirect 100% of excess.
) }