'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 SourceNode from './nodes/SourceNode' import AllocationEdge from './edges/AllocationEdge' import StreamEdge from './edges/StreamEdge' import IntegrationPanel from './IntegrationPanel' import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, SourceNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types' import { SPENDING_COLORS, OVERFLOW_COLORS, SOURCE_COLORS } from '@/lib/presets' const nodeTypes = { funnel: FunnelNode, outcome: OutcomeNode, source: SourceNode, } 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 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', }) }) }) // Source node edges nodes.forEach((node) => { if (node.type !== 'source') return const data = node.data as SourceNodeData const rate = data.flowRate || 1 const allocCount = data.targetAllocations?.length ?? 0 data.targetAllocations?.forEach((alloc) => { const flowValue = (alloc.percentage / 100) * rate const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) edges.push({ id: `source-${node.id}-${alloc.targetId}`, source: node.id, target: alloc.targetId, sourceHandle: 'source-out', animated: true, style: { stroke: alloc.color || '#10b981', strokeWidth, opacity: 0.8, }, markerEnd: { type: MarkerType.ArrowClosed, color: alloc.color || '#10b981', width: 16, height: 16, }, data: { allocation: alloc.percentage, color: alloc.color || '#10b981', edgeType: 'spending' as const, sourceId: node.id, targetId: alloc.targetId, siblingCount: allocCount, onAdjust, }, type: 'allocation', }) }) }) 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 [panelsCollapsed, setPanelsCollapsed] = useState(false) 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(() => { const funnelKeys = 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, } }) const sourceKeys = nodes .filter(n => n.type === 'source') .map(n => { const d = n.data as SourceNodeData return { id: n.id, targets: d.targetAllocations, rate: d.flowRate, } }) return JSON.stringify({ funnelKeys, sourceKeys }) }, [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' const isSourceOut = params.sourceHandle === 'source-out' if (!isOverflow && !isSpending && !isStream && !isSourceOut) return // Handle source node connections if (isSourceOut) { setNodes((nds) => nds.map((node) => { if (node.id !== params.source || node.type !== 'source') return node const data = node.data as SourceNodeData const existing = data.targetAllocations || [] 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, targetAllocations: [ ...redistributed, { targetId: params.target!, percentage: newPct, color: SOURCE_COLORS[existing.length % SOURCE_COLORS.length], }, ], }, } })) 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 // Source edges: reconnect source allocation if (oldEdge.id?.startsWith('source-')) { setNodes((nds) => nds.map((node) => { if (node.id !== oldEdge.source || node.type !== 'source') return node const data = node.data as SourceNodeData return { ...node, data: { ...data, targetAllocations: (data.targetAllocations || []).map(a => a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a ), }, } })) 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) { // Source edge removal if (edge.id?.startsWith('source-')) { setNodes((nds) => nds.map((node) => { if (node.id !== edge.source || node.type !== 'source') return node const data = node.data as SourceNodeData const filtered = (data.targetAllocations || []).filter(a => a.targetId !== edge.target) const total = filtered.reduce((s, a) => s + a.percentage, 0) return { ...node, data: { ...data, targetAllocations: total > 0 ? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) : filtered, }, } })) onEdgesChange([change]) return } 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]) // Add source node at viewport center const addSource = useCallback(() => { const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 - 100 }) const newId = `source-${Date.now()}` setNodes((nds) => [ ...nds, { id: newId, type: 'source', position: pos, data: { label: 'New Source', flowRate: 500, sourceType: 'recurring', targetAllocations: [], } as SourceNodeData, }, ]) }, [setNodes, screenToFlowPosition]) // Handle node deletion — clean up references in other nodes const onNodesDelete = useCallback((deletedNodes: FlowNode[]) => { const deletedIds = new Set(deletedNodes.map(n => n.id)) setNodes((nds) => nds.map((node) => { if (node.type === 'source') { const data = node.data as SourceNodeData const filtered = (data.targetAllocations || []).filter(a => !deletedIds.has(a.targetId)) if (filtered.length === data.targetAllocations?.length) return node const total = filtered.reduce((s, a) => s + a.percentage, 0) return { ...node, data: { ...data, targetAllocations: total > 0 ? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) : filtered, }, } } if (node.type === 'funnel') { const data = node.data as FunnelNodeData let changed = false let overflow = data.overflowAllocations || [] const filteredOverflow = overflow.filter(a => !deletedIds.has(a.targetId)) if (filteredOverflow.length !== overflow.length) { changed = true const total = filteredOverflow.reduce((s, a) => s + a.percentage, 0) overflow = total > 0 ? filteredOverflow.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) : filteredOverflow } let spending = data.spendingAllocations || [] const filteredSpending = spending.filter(a => !deletedIds.has(a.targetId)) if (filteredSpending.length !== spending.length) { changed = true const total = filteredSpending.reduce((s, a) => s + a.percentage, 0) spending = total > 0 ? filteredSpending.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) : filteredSpending } if (!changed) return node return { ...node, data: { ...data, overflowAllocations: overflow, spendingAllocations: spending, }, } } return node })) }, [setNodes]) // Simulation useEffect(() => { if (!isSimulating) return const interval = setInterval(() => { setNodes((nds) => nds.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, setNodes]) // Auto-collapse title & legend panels after 5 seconds useEffect(() => { const timer = setTimeout(() => setPanelsCollapsed(true), 5000) return () => clearTimeout(timer) }, []) return (
connection.source !== connection.target} defaultEdgeOptions={{ type: 'smoothstep' }} > {/* Title Panel */}
setPanelsCollapsed((c) => !c)} >

TBFF

{!panelsCollapsed && (

SourcesFunnelsOutcomes

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

)}
{/* Top-right Controls */} {mode === 'space' && ( )} {/* Legend */}
setPanelsCollapsed((c) => !c)} >
Legend
{panelsCollapsed && (
)}
{!panelsCollapsed && (
Source (funding origin)
Funnel (threshold pool)
Outcome (deliverable)
Source flow
Overflow (sides)
Spending (down)
Edge width = relative flow • Select + Delete to remove
)}
{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 ( ) }