956 lines
33 KiB
TypeScript
956 lines
33 KiB
TypeScript
'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<typeof onEdgesChange>[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 (
|
|
<div className="w-full h-full">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChangeHandler}
|
|
onEdgesChange={handleEdgesChange}
|
|
onNodesDelete={onNodesDelete}
|
|
onConnect={onConnect}
|
|
onReconnect={onReconnect}
|
|
nodeTypes={nodeTypes}
|
|
edgeTypes={edgeTypes}
|
|
edgesReconnectable={true}
|
|
deleteKeyCode={['Backspace', 'Delete']}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.15 }}
|
|
className="bg-slate-50"
|
|
connectionLineStyle={{ stroke: '#94a3b8', strokeWidth: 3 }}
|
|
isValidConnection={(connection) => connection.source !== connection.target}
|
|
defaultEdgeOptions={{ type: 'smoothstep' }}
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
|
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
|
|
|
|
{/* Title Panel */}
|
|
<Panel position="top-left" className="m-4">
|
|
<div
|
|
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden"
|
|
onClick={() => setPanelsCollapsed((c) => !c)}
|
|
>
|
|
<div className="px-4 py-2 flex items-center gap-2">
|
|
<h1 className="text-sm font-bold text-slate-800">TBFF</h1>
|
|
<svg className={`w-3 h-3 text-slate-400 transition-transform duration-300 ${panelsCollapsed ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
{!panelsCollapsed && (
|
|
<div className="px-4 pb-3 border-t border-slate-100 pt-2">
|
|
<p className="text-xs text-slate-500">
|
|
<span className="text-emerald-600">Sources</span> →
|
|
<span className="text-amber-600 ml-1">Funnels</span> →
|
|
<span className="text-pink-600 ml-1">Outcomes</span>
|
|
</p>
|
|
<p className="text-[10px] text-slate-400 mt-1">
|
|
Drag handles to connect • Double-click to edit • Select + Delete to remove
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
|
|
{/* Top-right Controls */}
|
|
<Panel position="top-right" className="m-4 flex gap-2">
|
|
{mode === 'space' && (
|
|
<button
|
|
onClick={() => setShowIntegrations(true)}
|
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-indigo-500 text-white hover:bg-indigo-600 transition-all"
|
|
>
|
|
Link Data
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={addSource}
|
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-emerald-500 text-white hover:bg-emerald-600 transition-all"
|
|
>
|
|
+ Source
|
|
</button>
|
|
<button
|
|
onClick={addFunnel}
|
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
|
>
|
|
+ Funnel
|
|
</button>
|
|
<button
|
|
onClick={addOutcome}
|
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-pink-500 text-white hover:bg-pink-600 transition-all"
|
|
>
|
|
+ Outcome
|
|
</button>
|
|
<button
|
|
onClick={() => setIsSimulating(!isSimulating)}
|
|
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
|
isSimulating
|
|
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
|
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
|
}`}
|
|
>
|
|
{isSimulating ? 'Pause' : 'Start'}
|
|
</button>
|
|
</Panel>
|
|
|
|
{/* Legend */}
|
|
<Panel position="bottom-left" className="m-4">
|
|
<div
|
|
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden"
|
|
onClick={() => setPanelsCollapsed((c) => !c)}
|
|
>
|
|
<div className="px-3 py-2 flex items-center gap-2">
|
|
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide">Legend</div>
|
|
{panelsCollapsed && (
|
|
<div className="flex items-center gap-1 ml-1">
|
|
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
|
|
<div className="w-2 h-2 rounded-sm bg-amber-500" />
|
|
<div className="w-2 h-2 rounded-sm bg-pink-500" />
|
|
</div>
|
|
)}
|
|
<svg className={`w-3 h-3 text-slate-400 transition-transform duration-300 ${panelsCollapsed ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
</div>
|
|
{!panelsCollapsed && (
|
|
<div className="px-3 pb-3 border-t border-slate-100 pt-2">
|
|
<div className="space-y-1.5 text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded bg-emerald-500" />
|
|
<span className="text-slate-600">Source (funding origin)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded bg-amber-500" />
|
|
<span className="text-slate-600">Funnel (threshold pool)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded bg-pink-500" />
|
|
<span className="text-slate-600">Outcome (deliverable)</span>
|
|
</div>
|
|
<div className="border-t border-slate-200 pt-1.5 mt-1.5 space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-0.5 bg-emerald-500" />
|
|
<span className="text-slate-600">Source flow</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-0.5 bg-amber-500" />
|
|
<span className="text-slate-600">Overflow (sides)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-0.5 bg-blue-500" />
|
|
<span className="text-slate-600">Spending (down)</span>
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
|
|
<span className="text-[10px] text-slate-400">Edge width = relative flow • Select + Delete to remove</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
</ReactFlow>
|
|
{mode === 'space' && (
|
|
<IntegrationPanel
|
|
isOpen={showIntegrations}
|
|
onClose={() => setShowIntegrations(false)}
|
|
onImportNodes={handleImportNodes}
|
|
integrations={integrations}
|
|
onIntegrationsChange={onIntegrationsChange || (() => {})}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<ReactFlowProvider>
|
|
<FlowCanvasInner
|
|
initialNodes={initialNodes}
|
|
mode={mode}
|
|
onNodesChange={onNodesChange}
|
|
integrations={integrations}
|
|
onIntegrationsChange={onIntegrationsChange}
|
|
/>
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|