670 lines
22 KiB
TypeScript
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) •
|
|
<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={() => 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>
|
|
)
|
|
}
|