'use client' import { useCallback, useState, useEffect, useMemo, useRef } from 'react' import { ReactFlow, Controls, Background, BackgroundVariant, useNodesState, useEdgesState, Connection, MarkerType, Panel, useReactFlow, ReactFlowProvider, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import FunnelNode from './nodes/FunnelNode' import OutcomeNode from './nodes/OutcomeNode' import AllocationEdge from './edges/AllocationEdge' import StreamEdge from './edges/StreamEdge' import IntegrationPanel from './IntegrationPanel' import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types' import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets' import { simulateTick } from '@/lib/simulation' const nodeTypes = { funnel: FunnelNode, outcome: OutcomeNode, } const edgeTypes = { allocation: AllocationEdge, stream: StreamEdge, } // Generate edges with proportional Sankey-style widths function generateEdges( nodes: FlowNode[], onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void ): FlowEdge[] { const edges: FlowEdge[] = [] const flowValues: number[] = [] nodes.forEach((node) => { if (node.type !== 'funnel') return const data = node.data as FunnelNodeData const rate = data.inflowRate || 1 data.overflowAllocations?.forEach((alloc) => { flowValues.push((alloc.percentage / 100) * rate) }) data.spendingAllocations?.forEach((alloc) => { flowValues.push((alloc.percentage / 100) * rate) }) }) const maxFlow = Math.max(...flowValues, 1) const MIN_WIDTH = 3 const MAX_WIDTH = 24 nodes.forEach((node) => { if (node.type !== 'funnel') return const data = node.data as FunnelNodeData const sourceX = node.position.x const rate = data.inflowRate || 1 const overflowCount = data.overflowAllocations?.length ?? 0 data.overflowAllocations?.forEach((alloc) => { const flowValue = (alloc.percentage / 100) * rate const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) const targetNode = nodes.find(n => n.id === alloc.targetId) if (!targetNode) return const targetX = targetNode.position.x const goingRight = targetX > sourceX const sourceHandle = goingRight ? 'outflow-right' : 'outflow-left' edges.push({ id: `outflow-${node.id}-${alloc.targetId}`, source: node.id, target: alloc.targetId, sourceHandle, targetHandle: undefined, animated: true, style: { stroke: alloc.color, strokeWidth, opacity: 0.7, }, markerEnd: { type: MarkerType.ArrowClosed, color: alloc.color, width: 16, height: 16, }, data: { allocation: alloc.percentage, color: alloc.color, edgeType: 'overflow' as const, sourceId: node.id, targetId: alloc.targetId, siblingCount: overflowCount, onAdjust, }, type: 'allocation', }) }) const spendingCount = data.spendingAllocations?.length ?? 0 data.spendingAllocations?.forEach((alloc) => { const flowValue = (alloc.percentage / 100) * rate const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) edges.push({ id: `spending-${node.id}-${alloc.targetId}`, source: node.id, target: alloc.targetId, sourceHandle: 'spending-out', animated: true, style: { stroke: alloc.color, strokeWidth, opacity: 0.8, }, markerEnd: { type: MarkerType.ArrowClosed, color: alloc.color, width: 16, height: 16, }, data: { allocation: alloc.percentage, color: alloc.color, edgeType: 'spending' as const, sourceId: node.id, targetId: alloc.targetId, siblingCount: spendingCount, onAdjust, }, type: 'allocation', }) }) // Stream edges (Superfluid visual planning) data.streamAllocations?.forEach((stream) => { const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e' edges.push({ id: `stream-${node.id}-${stream.targetId}`, source: node.id, target: stream.targetId, sourceHandle: 'stream-out', animated: false, style: { stroke: statusColor, strokeWidth: 3, }, markerEnd: { type: MarkerType.ArrowClosed, color: statusColor, width: 14, height: 14, }, data: { flowRate: stream.flowRate, tokenSymbol: stream.tokenSymbol, status: stream.status, sourceId: node.id, targetId: stream.targetId, }, type: 'stream', }) }) }) return edges } interface FlowCanvasInnerProps { initialNodes: FlowNode[] mode: 'demo' | 'space' onNodesChange?: (nodes: FlowNode[]) => void integrations?: IntegrationConfig onIntegrationsChange?: (config: IntegrationConfig) => void } function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integrations, onIntegrationsChange }: FlowCanvasInnerProps) { const [showIntegrations, setShowIntegrations] = useState(false) const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[]) const [isSimulating, setIsSimulating] = useState(mode === 'demo') const edgesRef = useRef(edges) edgesRef.current = edges const { screenToFlowPosition } = useReactFlow() // Notify parent of node changes for save/share const nodesRef = useRef(nodes) nodesRef.current = nodes useEffect(() => { if (onNodesChange) { onNodesChange(nodes as FlowNode[]) } }, [nodes, onNodesChange]) // Adjust allocation percentage inline from edge +/- buttons const onAdjustAllocation = useCallback( (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => { setNodes((nds) => nds.map((node) => { if (node.id !== sourceId || node.type !== 'funnel') return node const data = node.data as FunnelNodeData const allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations' const allocs = [...data[allocKey]] if (allocs.length <= 1) return node const idx = allocs.findIndex(a => a.targetId === targetId) if (idx === -1) return node const current = allocs[idx].percentage const newPct = Math.max(5, Math.min(95, current + delta)) const actualDelta = newPct - current if (actualDelta === 0) return node // Apply delta to target, distribute inverse across siblings const siblings = allocs.filter((_, i) => i !== idx) const siblingTotal = siblings.reduce((s, a) => s + a.percentage, 0) const updated = allocs.map((a, i) => { if (i === idx) return { ...a, percentage: newPct } // Proportionally adjust siblings const share = siblingTotal > 0 ? a.percentage / siblingTotal : 1 / siblings.length return { ...a, percentage: Math.max(1, Math.round(a.percentage - actualDelta * share)) } }) // Normalize to exactly 100 const sum = updated.reduce((s, a) => s + a.percentage, 0) if (sum !== 100 && updated.length > 1) { const diff = 100 - sum // Apply rounding correction to largest sibling const largestSibIdx = updated.reduce((best, a, i) => i !== idx && a.percentage > updated[best].percentage ? i : best, idx === 0 ? 1 : 0) updated[largestSibIdx] = { ...updated[largestSibIdx], percentage: updated[largestSibIdx].percentage + diff } } return { ...node, data: { ...data, [allocKey]: updated }, } })) }, [setNodes] ) // Smart edge regeneration const allocationsKey = useMemo(() => { return JSON.stringify( nodes .filter(n => n.type === 'funnel') .map(n => { const d = n.data as FunnelNodeData return { id: n.id, overflow: d.overflowAllocations, spending: d.spendingAllocations, streams: d.streamAllocations, rate: d.inflowRate, } }) ) }, [nodes]) useEffect(() => { setEdges(generateEdges(nodes as FlowNode[], onAdjustAllocation)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [allocationsKey, onAdjustAllocation]) const onConnect = useCallback( (params: Connection) => { if (!params.source || !params.target) return const isOverflow = params.sourceHandle?.startsWith('outflow') const isSpending = params.sourceHandle === 'spending-out' const isStream = params.sourceHandle === 'stream-out' if (!isOverflow && !isSpending && !isStream) return setNodes((nds) => nds.map((node) => { if (node.id !== params.source || node.type !== 'funnel') return node const data = node.data as FunnelNodeData if (isOverflow) { const existing = data.overflowAllocations || [] if (existing.some(a => a.targetId === params.target)) return node const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1)) const redistributed = existing.map(a => ({ ...a, percentage: Math.floor(a.percentage * existing.length / (existing.length + 1)) })) return { ...node, data: { ...data, overflowAllocations: [ ...redistributed, { targetId: params.target!, percentage: newPct, color: OVERFLOW_COLORS[existing.length % OVERFLOW_COLORS.length], }, ], }, } } else if (isSpending) { const existing = data.spendingAllocations || [] if (existing.some(a => a.targetId === params.target)) return node const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1)) const redistributed = existing.map(a => ({ ...a, percentage: Math.floor(a.percentage * existing.length / (existing.length + 1)) })) return { ...node, data: { ...data, spendingAllocations: [ ...redistributed, { targetId: params.target!, percentage: newPct, color: SPENDING_COLORS[existing.length % SPENDING_COLORS.length], }, ], }, } } else if (isStream) { const existing = data.streamAllocations || [] if (existing.some(s => s.targetId === params.target)) return node return { ...node, data: { ...data, streamAllocations: [ ...existing, { targetId: params.target!, flowRate: 100, tokenSymbol: 'DAIx', status: 'planned' as const, color: '#22c55e', }, ], }, } } else { return node } })) }, [setNodes] ) const onReconnect = useCallback( (oldEdge: FlowEdge, newConnection: Connection) => { const edgeData = oldEdge.data as AllocationEdgeData | undefined if (!edgeData || !newConnection.target) return const oldTargetId = oldEdge.target const newTargetId = newConnection.target if (oldTargetId === newTargetId) return // Stream edges: reconnect stream allocation if (oldEdge.type === 'stream') { setNodes((nds) => nds.map((node) => { if (node.id !== oldEdge.source || node.type !== 'funnel') return node const data = node.data as FunnelNodeData return { ...node, data: { ...data, streamAllocations: (data.streamAllocations || []).map(s => s.targetId === oldTargetId ? { ...s, targetId: newTargetId } : s ), }, } })) return } setNodes((nds) => nds.map((node) => { if (node.id !== oldEdge.source || node.type !== 'funnel') return node const data = node.data as FunnelNodeData if (edgeData.edgeType === 'overflow') { return { ...node, data: { ...data, overflowAllocations: data.overflowAllocations.map(a => a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a ), }, } } else { return { ...node, data: { ...data, spendingAllocations: data.spendingAllocations.map(a => a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a ), }, } } })) }, [setNodes] ) const handleEdgesChange = useCallback( (changes: Parameters[0]) => { changes.forEach((change) => { if (change.type === 'remove') { const edge = edgesRef.current.find(e => e.id === change.id) if (edge?.data) { setNodes((nds) => nds.map((node) => { if (node.id !== edge.source || node.type !== 'funnel') return node const data = node.data as FunnelNodeData // Stream edge removal if (edge.type === 'stream') { return { ...node, data: { ...data, streamAllocations: (data.streamAllocations || []).filter(s => s.targetId !== edge.target), }, } } const allocData = edge.data as AllocationEdgeData if (allocData.edgeType === 'overflow') { const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target) const total = filtered.reduce((s, a) => s + a.percentage, 0) return { ...node, data: { ...data, overflowAllocations: total > 0 ? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) : filtered, }, } } else { const filtered = data.spendingAllocations.filter(a => a.targetId !== edge.target) const total = filtered.reduce((s, a) => s + a.percentage, 0) return { ...node, data: { ...data, spendingAllocations: total > 0 ? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) : filtered, }, } } })) } } }) onEdgesChange(changes) }, [onEdgesChange, setNodes] ) // Import nodes from integrations panel const handleImportNodes = useCallback((newNodes: FlowNode[]) => { setNodes((nds) => [...nds, ...newNodes]) }, [setNodes]) // Add funnel node at viewport center const addFunnel = useCallback(() => { const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }) const newId = `funnel-${Date.now()}` setNodes((nds) => [ ...nds, { id: newId, type: 'funnel', position: pos, data: { label: 'New Funnel', currentValue: 0, minThreshold: 10000, maxThreshold: 40000, maxCapacity: 50000, inflowRate: 0, overflowAllocations: [], spendingAllocations: [], } as FunnelNodeData, }, ]) }, [setNodes, screenToFlowPosition]) // Add outcome node at viewport center const addOutcome = useCallback(() => { const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 + 100 }) const newId = `outcome-${Date.now()}` setNodes((nds) => [ ...nds, { id: newId, type: 'outcome', position: pos, data: { label: 'New Outcome', description: '', fundingReceived: 0, fundingTarget: 20000, status: 'not-started', } as OutcomeNodeData, }, ]) }, [setNodes, screenToFlowPosition]) // Simulation — real flow logic (inflow → overflow → spending → outcomes) useEffect(() => { if (!isSimulating) return const interval = setInterval(() => { setNodes((nds) => simulateTick(nds as FlowNode[])) }, 1000) return () => clearInterval(interval) }, [isSimulating, setNodes]) return (
connection.source !== connection.target} defaultEdgeOptions={{ type: 'smoothstep' }} > {/* Title Panel */}

Threshold-Based Flow Funding

Inflows (top) • Overflow (sides) • Spending (bottom)

Drag handles to connect • Double-click funnels to edit • Select + Delete to remove edges

{/* Top-right Controls */} {mode === 'space' && ( <> )} {/* Legend */}
Flow Types
Inflows (top)
Overflow (sides)
Spending (bottom)
Stream (Superfluid)
Edge width = relative flow amount
{mode === 'space' && ( setShowIntegrations(false)} onImportNodes={handleImportNodes} integrations={integrations} onIntegrationsChange={onIntegrationsChange || (() => {})} /> )}
) } // Props for the exported component interface FlowCanvasProps { initialNodes: FlowNode[] mode?: 'demo' | 'space' onNodesChange?: (nodes: FlowNode[]) => void integrations?: IntegrationConfig onIntegrationsChange?: (config: IntegrationConfig) => void } export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange, integrations, onIntegrationsChange }: FlowCanvasProps) { return ( ) }