533 lines
17 KiB
TypeScript
533 lines
17 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 type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
|
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
|
|
|
const nodeTypes = {
|
|
funnel: FunnelNode,
|
|
outcome: OutcomeNode,
|
|
}
|
|
|
|
// Generate edges with proportional Sankey-style widths
|
|
function generateEdges(nodes: FlowNode[]): 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
|
|
|
|
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,
|
|
},
|
|
label: `${alloc.percentage}%`,
|
|
labelStyle: {
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
fill: alloc.color,
|
|
},
|
|
labelBgStyle: {
|
|
fill: 'white',
|
|
fillOpacity: 0.9,
|
|
},
|
|
labelBgPadding: [4, 2] as [number, number],
|
|
labelBgBorderRadius: 4,
|
|
data: {
|
|
allocation: alloc.percentage,
|
|
color: alloc.color,
|
|
edgeType: 'overflow' as const,
|
|
},
|
|
type: 'smoothstep',
|
|
})
|
|
})
|
|
|
|
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,
|
|
},
|
|
label: `${alloc.percentage}%`,
|
|
labelStyle: {
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
fill: alloc.color,
|
|
},
|
|
labelBgStyle: {
|
|
fill: 'white',
|
|
fillOpacity: 0.9,
|
|
},
|
|
labelBgPadding: [4, 2] as [number, number],
|
|
labelBgBorderRadius: 4,
|
|
data: {
|
|
allocation: alloc.percentage,
|
|
color: alloc.color,
|
|
edgeType: 'spending' as const,
|
|
},
|
|
type: 'smoothstep',
|
|
})
|
|
})
|
|
})
|
|
|
|
return edges
|
|
}
|
|
|
|
interface FlowCanvasInnerProps {
|
|
initialNodes: FlowNode[]
|
|
mode: 'demo' | 'space'
|
|
onNodesChange?: (nodes: FlowNode[]) => void
|
|
}
|
|
|
|
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) {
|
|
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(generateEdges(initNodes))
|
|
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])
|
|
|
|
// 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,
|
|
rate: d.inflowRate,
|
|
}
|
|
})
|
|
)
|
|
}, [nodes])
|
|
|
|
useEffect(() => {
|
|
setEdges(generateEdges(nodes as FlowNode[]))
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [allocationsKey])
|
|
|
|
const onConnect = useCallback(
|
|
(params: Connection) => {
|
|
if (!params.source || !params.target) return
|
|
|
|
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
|
const isSpending = params.sourceHandle === 'spending-out'
|
|
|
|
if (!isOverflow && !isSpending) 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 {
|
|
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],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|
|
}))
|
|
},
|
|
[setNodes]
|
|
)
|
|
|
|
const onReconnect = useCallback(
|
|
(oldEdge: FlowEdge, newConnection: Connection) => {
|
|
const edgeData = oldEdge.data
|
|
if (!edgeData || !newConnection.target) return
|
|
|
|
const oldTargetId = oldEdge.target
|
|
const newTargetId = newConnection.target
|
|
if (oldTargetId === newTargetId) 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) {
|
|
setNodes((nds) => nds.map((node) => {
|
|
if (node.id !== edge.source || node.type !== 'funnel') return node
|
|
const data = node.data as FunnelNodeData
|
|
|
|
if (edge.data!.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]
|
|
)
|
|
|
|
// 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
|
|
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])
|
|
|
|
return (
|
|
<div className="w-full h-full">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChangeHandler}
|
|
onEdgesChange={handleEdgesChange}
|
|
onConnect={onConnect}
|
|
onReconnect={onReconnect}
|
|
nodeTypes={nodeTypes}
|
|
edgesReconnectable={true}
|
|
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="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
|
<h1 className="text-lg font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
<span className="text-emerald-600">Inflows</span> (top) •
|
|
<span className="text-amber-600 ml-1">Overflow</span> (sides) •
|
|
<span className="text-blue-600 ml-1">Spending</span> (bottom)
|
|
</p>
|
|
<p className="text-[10px] text-slate-400 mt-1">
|
|
Drag handles to connect • Double-click funnels to edit • Select + Delete to remove edges
|
|
</p>
|
|
</Panel>
|
|
|
|
{/* Top-right Controls */}
|
|
<Panel position="top-right" className="m-4 flex gap-2">
|
|
{mode === 'space' && (
|
|
<>
|
|
<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="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
|
|
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
|
|
<div className="space-y-1.5 text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
|
<span className="text-slate-600">Inflows (top)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
|
<span className="text-slate-600">Overflow (sides)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
|
<span className="text-slate-600">Spending (bottom)</span>
|
|
</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 amount</span>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</ReactFlow>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Props for the exported component
|
|
interface FlowCanvasProps {
|
|
initialNodes: FlowNode[]
|
|
mode?: 'demo' | 'space'
|
|
onNodesChange?: (nodes: FlowNode[]) => void
|
|
}
|
|
|
|
export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange }: FlowCanvasProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<FlowCanvasInner initialNodes={initialNodes} mode={mode} onNodesChange={onNodesChange} />
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|