rfunds-online/components/FlowCanvas.tsx

670 lines
22 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 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<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
// 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 (
<div className="w-full h-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeHandler}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
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) &bull;
<span className="text-amber-600 ml-1">Overflow</span> (sides) &bull;
<span className="text-blue-600 ml-1">Spending</span> (bottom)
</p>
<p className="text-[10px] text-slate-400 mt-1">
Drag handles to connect &bull; Double-click funnels to edit &bull; Select + Delete to remove edges
</p>
</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={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="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 border border-dashed border-green-700" />
<span className="text-slate-600">Stream (Superfluid)</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>
{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>
)
}