diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index bd219ca..7d646cc 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -15,181 +15,184 @@ import { } from '@xyflow/react' import '@xyflow/react/dist/style.css' -import SourceNode from './nodes/SourceNode' -import ThresholdNode from './nodes/ThresholdNode' -import RecipientNode from './nodes/RecipientNode' -import type { FlowNode, FlowEdge, SourceNodeData, ThresholdNodeData, RecipientNodeData } from '@/lib/types' +import FunnelNode from './nodes/FunnelNode' +import type { FlowNode, FlowEdge, FunnelNodeData } from '@/lib/types' const nodeTypes = { - source: SourceNode, - threshold: ThresholdNode, - recipient: RecipientNode, + funnel: FunnelNode, } -// Vertical layout - sources at top, recipients at bottom +// Color palette for allocations +const COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4'] + const initialNodes: FlowNode[] = [ - // Top row - Source { - id: 'source-1', - type: 'source', - position: { x: 350, y: 0 }, + id: 'treasury', + type: 'funnel', + position: { x: 400, y: 0 }, data: { label: 'Treasury', - balance: 100000, - flowRate: 1000, + currentValue: 85000, + minThreshold: 20000, + maxThreshold: 70000, + maxCapacity: 100000, + inflowRate: 1000, + outflowAllocations: [ + { targetId: 'public-goods', percentage: 40, color: COLORS[0] }, + { targetId: 'research', percentage: 35, color: COLORS[1] }, + { targetId: 'emergency', percentage: 25, color: COLORS[2] }, + ], }, }, - // Middle row - Threshold funnels { - id: 'threshold-1', - type: 'threshold', - position: { x: 100, y: 200 }, + id: 'public-goods', + type: 'funnel', + position: { x: 100, y: 350 }, data: { label: 'Public Goods', + currentValue: 45000, minThreshold: 15000, - maxThreshold: 60000, - currentValue: 72000, // Overflowing + maxThreshold: 50000, + maxCapacity: 70000, + inflowRate: 400, + outflowAllocations: [ + { targetId: 'project-alpha', percentage: 60, color: COLORS[0] }, + { targetId: 'project-beta', percentage: 40, color: COLORS[1] }, + ], }, }, { - id: 'threshold-2', - type: 'threshold', - position: { x: 400, y: 200 }, + id: 'research', + type: 'funnel', + position: { x: 400, y: 350 }, data: { label: 'Research', + currentValue: 28000, minThreshold: 20000, - maxThreshold: 50000, - currentValue: 35000, // Healthy + maxThreshold: 45000, + maxCapacity: 60000, + inflowRate: 350, + outflowAllocations: [ + { targetId: 'project-gamma', percentage: 70, color: COLORS[0] }, + { targetId: 'project-beta', percentage: 30, color: COLORS[1] }, + ], }, }, { - id: 'threshold-3', - type: 'threshold', - position: { x: 700, y: 200 }, + id: 'emergency', + type: 'funnel', + position: { x: 700, y: 350 }, data: { label: 'Emergency', - minThreshold: 30000, - maxThreshold: 80000, - currentValue: 18000, // Critical + currentValue: 12000, + minThreshold: 25000, + maxThreshold: 60000, + maxCapacity: 80000, + inflowRate: 250, + outflowAllocations: [ + { targetId: 'reserve', percentage: 100, color: COLORS[0] }, + ], }, }, - // Bottom row - Recipients { - id: 'recipient-1', - type: 'recipient', - position: { x: 50, y: 620 }, + id: 'project-alpha', + type: 'funnel', + position: { x: 0, y: 700 }, data: { label: 'Project Alpha', - received: 24500, - target: 30000, + currentValue: 18000, + minThreshold: 10000, + maxThreshold: 30000, + maxCapacity: 40000, + inflowRate: 240, + outflowAllocations: [], }, }, { - id: 'recipient-2', - type: 'recipient', - position: { x: 300, y: 620 }, + id: 'project-beta', + type: 'funnel', + position: { x: 300, y: 700 }, data: { label: 'Project Beta', - received: 18000, - target: 25000, + currentValue: 22000, + minThreshold: 15000, + maxThreshold: 35000, + maxCapacity: 45000, + inflowRate: 265, + outflowAllocations: [], }, }, { - id: 'recipient-3', - type: 'recipient', - position: { x: 550, y: 620 }, + id: 'project-gamma', + type: 'funnel', + position: { x: 600, y: 700 }, data: { - label: 'Research Lab', - received: 12000, - target: 40000, + label: 'Project Gamma', + currentValue: 8000, + minThreshold: 12000, + maxThreshold: 28000, + maxCapacity: 35000, + inflowRate: 245, + outflowAllocations: [], }, }, { - id: 'recipient-4', - type: 'recipient', - position: { x: 800, y: 620 }, + id: 'reserve', + type: 'funnel', + position: { x: 900, y: 700 }, data: { - label: 'Reserve Fund', - received: 5000, - target: 50000, + label: 'Reserve', + currentValue: 5000, + minThreshold: 20000, + maxThreshold: 50000, + maxCapacity: 60000, + inflowRate: 250, + outflowAllocations: [], }, }, ] -const initialEdges: FlowEdge[] = [ - // Source to thresholds (top to middle) - { - id: 'e-source-t1', - source: 'source-1', - target: 'threshold-1', - animated: true, - style: { stroke: '#3b82f6', strokeWidth: 3 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, - }, - { - id: 'e-source-t2', - source: 'source-1', - target: 'threshold-2', - animated: true, - style: { stroke: '#3b82f6', strokeWidth: 3 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, - }, - { - id: 'e-source-t3', - source: 'source-1', - target: 'threshold-3', - animated: true, - style: { stroke: '#3b82f6', strokeWidth: 3 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, - }, - // Threshold to recipients (middle to bottom) - { - id: 'e-t1-r1', - source: 'threshold-1', - target: 'recipient-1', - animated: true, - style: { stroke: '#ec4899', strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, - }, - { - id: 'e-t1-r2', - source: 'threshold-1', - target: 'recipient-2', - animated: true, - style: { stroke: '#ec4899', strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, - }, - { - id: 'e-t2-r3', - source: 'threshold-2', - target: 'recipient-3', - animated: true, - style: { stroke: '#ec4899', strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, - }, - { - id: 'e-t3-r4', - source: 'threshold-3', - target: 'recipient-4', - animated: true, - style: { stroke: '#ec4899', strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, - }, - // Overflow connections (side handles) - from overflowing funnel to neighbors - { - id: 'e-overflow-1', - source: 'threshold-1', - sourceHandle: 'overflow-right', - target: 'threshold-2', - animated: true, - style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5 5' }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#f59e0b' }, - }, -] +// Generate edges from node allocations with proportional thickness +function generateEdges(nodes: FlowNode[]): FlowEdge[] { + const edges: FlowEdge[] = [] + const maxAllocation = 100 // Max percentage for scaling + + nodes.forEach((node) => { + const data = node.data as FunnelNodeData + data.outflowAllocations.forEach((alloc) => { + // Calculate stroke width: min 2px, max 12px based on percentage + const strokeWidth = 2 + (alloc.percentage / maxAllocation) * 10 + + edges.push({ + id: `e-${node.id}-${alloc.targetId}`, + source: node.id, + target: alloc.targetId, + animated: true, + style: { + stroke: alloc.color, + strokeWidth, + opacity: 0.8, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: alloc.color, + width: 15 + alloc.percentage / 10, + height: 15 + alloc.percentage / 10, + }, + data: { + allocation: alloc.percentage, + color: alloc.color, + }, + }) + }) + }) + + return edges +} export default function FlowCanvas() { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) + const [edges, setEdges, onEdgesChange] = useEdgesState(generateEdges(initialNodes)) const [isSimulating, setIsSimulating] = useState(true) const onConnect = useCallback( @@ -199,7 +202,7 @@ export default function FlowCanvas() { { ...params, animated: true, - style: { stroke: '#64748b', strokeWidth: 2 }, + style: { stroke: '#64748b', strokeWidth: 4 }, markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b' }, }, eds @@ -215,41 +218,16 @@ export default function FlowCanvas() { const interval = setInterval(() => { setNodes((nds) => nds.map((node) => { - if (node.type === 'source') { - const data = node.data as SourceNodeData - return { - ...node, - data: { - ...data, - balance: Math.max(0, data.balance - data.flowRate / 3600), - }, - } + const data = node.data as FunnelNodeData + // Random walk for demo + const change = (Math.random() - 0.45) * 300 + return { + ...node, + data: { + ...data, + currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)), + }, } - if (node.type === 'threshold') { - const data = node.data as ThresholdNodeData - // Random walk for demo - const change = (Math.random() - 0.4) * 200 - return { - ...node, - data: { - ...data, - currentValue: Math.max(0, Math.min(100000, data.currentValue + change)), - }, - } - } - if (node.type === 'recipient') { - const data = node.data as RecipientNodeData - if (data.received < data.target) { - return { - ...node, - data: { - ...data, - received: Math.min(data.target, data.received + Math.random() * 20), - }, - } - } - } - return node }) ) }, 500) @@ -257,6 +235,11 @@ export default function FlowCanvas() { return () => clearInterval(interval) }, [isSimulating, setNodes]) + // Regenerate edges when nodes change (to update proportions if needed) + useEffect(() => { + setEdges(generateEdges(nodes)) + }, [nodes, setEdges]) + return (
@@ -280,7 +259,7 @@ export default function FlowCanvas() { {/* Title Panel */}

Threshold-Based Flow Funding

-

Funds flow top→bottom through funnel thresholds

+

Drag min/max handles • Line thickness = allocation %

{/* Simulation Toggle */} @@ -299,35 +278,31 @@ export default function FlowCanvas() { {/* Legend */} -
Flow Types
+
Funnel Zones
-
- Inflow (from source) +
+ Overflow (above MAX)
-
- Outflow (to recipients) +
+ Healthy (MIN to MAX)
-
- Overflow (excess) +
+ Critical (below MIN)
-
Funnel Zones
-
+
Flow Lines
+
-
- Overflow (above MAX) +
+ Thin = small allocation
-
- Healthy (MIN to MAX) -
-
-
- Critical (below MIN) +
+ Thick = large allocation
diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx new file mode 100644 index 0000000..54a4959 --- /dev/null +++ b/components/nodes/FunnelNode.tsx @@ -0,0 +1,320 @@ +'use client' + +import { memo, useState, useCallback, useRef, useEffect } from 'react' +import { Handle, Position } from '@xyflow/react' +import type { NodeProps } from '@xyflow/react' +import type { FunnelNodeData } from '@/lib/types' + +// Pie chart colors +const PIE_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4'] + +function FunnelNode({ data, selected }: NodeProps) { + const nodeData = data as FunnelNodeData + const { label, currentValue, maxCapacity, outflowAllocations } = nodeData + + const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) + const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) + const [dragging, setDragging] = useState<'min' | 'max' | null>(null) + const sliderRef = useRef(null) + + // Calculate status + const isOverflowing = currentValue > maxThreshold + const isCritical = currentValue < minThreshold + + // Funnel dimensions + const width = 180 + const height = 160 + const topWidth = 160 + const bottomWidth = 40 + const padding = 8 + + // Calculate Y positions + const scaleY = (value: number) => padding + ((maxCapacity - value) / maxCapacity) * (height * 0.65) + const maxY = scaleY(maxThreshold) + const minY = scaleY(minThreshold) + const funnelStartY = minY + 10 + const balanceY = Math.max(padding, scaleY(Math.min(currentValue, maxCapacity * 1.1))) + + // Funnel shape + const leftTop = (width - topWidth) / 2 + const rightTop = (width + topWidth) / 2 + const leftBottom = (width - bottomWidth) / 2 + const rightBottom = (width + bottomWidth) / 2 + + const clipPath = ` + M ${leftTop} ${padding} + L ${rightTop} ${padding} + L ${rightTop} ${funnelStartY} + L ${rightBottom} ${height - padding - 10} + L ${rightBottom} ${height - padding} + L ${leftBottom} ${height - padding} + L ${leftBottom} ${height - padding - 10} + L ${leftTop} ${funnelStartY} + Z + ` + + // Dual range slider logic + const handleSliderMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => { + e.stopPropagation() + setDragging(type) + }, []) + + const handleSliderMouseMove = useCallback((e: MouseEvent) => { + if (!dragging || !sliderRef.current) return + + const rect = sliderRef.current.getBoundingClientRect() + const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) + const value = Math.round((x / rect.width) * maxCapacity) + + if (dragging === 'min') { + setMinThreshold(Math.min(value, maxThreshold - 1000)) + } else { + setMaxThreshold(Math.max(value, minThreshold + 1000)) + } + }, [dragging, maxCapacity, minThreshold, maxThreshold]) + + const handleSliderMouseUp = useCallback(() => { + setDragging(null) + }, []) + + useEffect(() => { + if (dragging) { + window.addEventListener('mousemove', handleSliderMouseMove) + window.addEventListener('mouseup', handleSliderMouseUp) + return () => { + window.removeEventListener('mousemove', handleSliderMouseMove) + window.removeEventListener('mouseup', handleSliderMouseUp) + } + } + }, [dragging, handleSliderMouseMove, handleSliderMouseUp]) + + // Pie chart calculations + const pieRadius = 24 + const pieCenter = { x: pieRadius + 4, y: pieRadius + 4 } + + const getPieSlices = () => { + if (outflowAllocations.length === 0) return [] + + let currentAngle = -90 // Start at top + return outflowAllocations.map((alloc, idx) => { + const angle = (alloc.percentage / 100) * 360 + const startAngle = currentAngle + const endAngle = currentAngle + angle + currentAngle = endAngle + + const startRad = (startAngle * Math.PI) / 180 + const endRad = (endAngle * Math.PI) / 180 + + const x1 = pieCenter.x + pieRadius * Math.cos(startRad) + const y1 = pieCenter.y + pieRadius * Math.sin(startRad) + const x2 = pieCenter.x + pieRadius * Math.cos(endRad) + const y2 = pieCenter.y + pieRadius * Math.sin(endRad) + + const largeArc = angle > 180 ? 1 : 0 + + return { + path: `M ${pieCenter.x} ${pieCenter.y} L ${x1} ${y1} A ${pieRadius} ${pieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`, + color: alloc.color || PIE_COLORS[idx % PIE_COLORS.length], + percentage: alloc.percentage, + targetId: alloc.targetId, + } + }) + } + + const pieSlices = getPieSlices() + + return ( +
+ {/* Top Handle - Inflow */} + + + {/* Header */} +
+
+ {label} + + {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'} + +
+
+ + {/* Main content - Funnel and Pie side by side */} +
+ {/* Funnel SVG */} + + + + + + + + + + + + {/* Zone backgrounds */} + + + + + {/* Liquid fill */} + + + + + + + {/* Funnel outline */} + + + + {/* Threshold zone indicator (single bar on right side) */} + + + + + {/* Overflow particles */} + {isOverflowing && ( + <> + + + + + + + + + + + + )} + + + {/* Pie chart for outflow allocation */} + {outflowAllocations.length > 0 && ( +
+ Outflow + + {pieSlices.map((slice, idx) => ( + + ))} + + + {/* Mini legend */} +
+ {outflowAllocations.slice(0, 3).map((alloc, idx) => ( +
+
+ {alloc.percentage}% +
+ ))} +
+
+ )} +
+ + {/* Value display */} +
+ + ${Math.floor(currentValue).toLocaleString()} + +
+ + {/* Dual range slider */} +
+
+ MIN: ${(minThreshold/1000).toFixed(0)}k + MAX: ${(maxThreshold/1000).toFixed(0)}k +
+
e.stopPropagation()} + > + {/* Track background */} +
+ {/* Red zone (0 to min) */} +
+ {/* Green zone (min to max) */} +
+ {/* Amber zone (max to capacity) */} +
+
+ + {/* Min handle */} +
handleSliderMouseDown(e, 'min')} + /> + + {/* Max handle */} +
handleSliderMouseDown(e, 'max')} + /> +
+
+ + {/* Bottom Handle - Outflow */} + + + {/* Side Handles - Overflow */} + + +
+ ) +} + +export default memo(FunnelNode) diff --git a/components/nodes/RecipientNode.tsx b/components/nodes/RecipientNode.tsx deleted file mode 100644 index ca0d404..0000000 --- a/components/nodes/RecipientNode.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client' - -import { memo } from 'react' -import { Handle, Position } from '@xyflow/react' -import type { NodeProps } from '@xyflow/react' -import type { RecipientNodeData } from '@/lib/types' - -function RecipientNode({ data, selected }: NodeProps) { - const { label, received, target } = data as RecipientNodeData - const progress = Math.min(100, (received / target) * 100) - const isFunded = received >= target - - return ( -
- {/* Input Handle - Top for vertical flow */} - - - {/* Header */} -
-
-
- - - -
- {label} - {isFunded && ( - - - - )} -
-
- - {/* Body */} -
-
- Received - - ${Math.floor(received).toLocaleString()} - -
- - {/* Progress bar */} -
-
- Progress - {progress.toFixed(0)}% -
-
-
-
-
- -
- Target - - ${target.toLocaleString()} - -
-
-
- ) -} - -export default memo(RecipientNode) diff --git a/components/nodes/SourceNode.tsx b/components/nodes/SourceNode.tsx deleted file mode 100644 index 6cc0c87..0000000 --- a/components/nodes/SourceNode.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client' - -import { memo } from 'react' -import { Handle, Position } from '@xyflow/react' -import type { NodeProps } from '@xyflow/react' -import type { SourceNodeData } from '@/lib/types' - -function SourceNode({ data, selected }: NodeProps) { - const { label, balance, flowRate } = data as SourceNodeData - - return ( -
- {/* Header */} -
-
-
- - - -
- {label} -
-
- - {/* Body */} -
-
- Balance - - ${Math.floor(balance).toLocaleString()} - -
-
- Flow Rate - - ${flowRate}/hr - -
- {/* Flow indicator */} -
- - - -
-
- - {/* Output Handle - Bottom for vertical flow */} - -
- ) -} - -export default memo(SourceNode) diff --git a/components/nodes/ThresholdNode.tsx b/components/nodes/ThresholdNode.tsx deleted file mode 100644 index b2a6375..0000000 --- a/components/nodes/ThresholdNode.tsx +++ /dev/null @@ -1,270 +0,0 @@ -'use client' - -import { memo, useState } from 'react' -import { Handle, Position } from '@xyflow/react' -import type { NodeProps } from '@xyflow/react' -import type { ThresholdNodeData } from '@/lib/types' - -function ThresholdNode({ data, selected }: NodeProps) { - const nodeData = data as ThresholdNodeData - const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) - const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) - const currentValue = nodeData.currentValue - const maxCapacity = 100000 - - // Calculate status - const isOverflowing = currentValue > maxThreshold - const isCritical = currentValue < minThreshold - const isHealthy = !isOverflowing && !isCritical - - // Funnel dimensions - const width = 160 - const height = 200 - const topWidth = 140 - const bottomWidth = 30 - const padding = 10 - - // Calculate Y positions for thresholds and fill - const maxY = padding + ((maxCapacity - maxThreshold) / maxCapacity) * (height * 0.6) - const minY = padding + ((maxCapacity - minThreshold) / maxCapacity) * (height * 0.6) - const funnelStartY = minY + 15 - const balanceY = Math.max(padding, padding + ((maxCapacity - Math.min(currentValue, maxCapacity * 1.1)) / maxCapacity) * (height * 0.6)) - - // Funnel shape calculations - const leftTop = (width - topWidth) / 2 - const rightTop = (width + topWidth) / 2 - const leftBottom = (width - bottomWidth) / 2 - const rightBottom = (width + bottomWidth) / 2 - - // Clip path for liquid fill - const clipPath = ` - M ${leftTop} ${padding} - L ${rightTop} ${padding} - L ${rightTop} ${funnelStartY} - L ${rightBottom} ${height - padding - 15} - L ${rightBottom} ${height - padding} - L ${leftBottom} ${height - padding} - L ${leftBottom} ${height - padding - 15} - L ${leftTop} ${funnelStartY} - Z - ` - - return ( -
- {/* Top Handle - Inflow */} - - - {/* Header */} -
-
{nodeData.label}
-
- {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'} -
-
- - {/* Funnel SVG */} - - - - - - - - - - - - {/* Zone backgrounds */} - {/* Overflow zone */} - - {/* Healthy zone */} - - {/* Critical zone (funnel part) */} - - - {/* Liquid fill */} - - - - - - - {/* Funnel outline */} - - {/* Top line */} - - - {/* MAX line */} - - MAX - - {/* MIN line */} - - MIN - - {/* Overflow particles */} - {isOverflowing && ( - <> - - - - - - - - - - - - )} - - - {/* Value display */} -
-
- ${Math.floor(currentValue).toLocaleString()} -
-
- - {/* Threshold sliders */} -
-
-
- Min - ${minThreshold.toLocaleString()} -
- setMinThreshold(Number(e.target.value))} - className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-red-500" - /> -
-
-
- Max - ${maxThreshold.toLocaleString()} -
- setMaxThreshold(Number(e.target.value))} - className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500" - /> -
-
- - {/* Bottom Handle - Outflow */} - - - {/* Side Handles - Overflow */} - {isOverflowing && ( - <> - - - - )} -
- ) -} - -export default memo(ThresholdNode) diff --git a/lib/types.ts b/lib/types.ts index df87227..55c9ba7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,26 +1,28 @@ import type { Node, Edge } from '@xyflow/react' -export interface SourceNodeData { - label: string - balance: number - flowRate: number - [key: string]: unknown +export interface OutflowAllocation { + targetId: string + percentage: number // 0-100 + color: string } -export interface ThresholdNodeData { +export interface FunnelNodeData { label: string + currentValue: number minThreshold: number maxThreshold: number - currentValue: number + maxCapacity: number + inflowRate: number + outflowAllocations: OutflowAllocation[] [key: string]: unknown } -export interface RecipientNodeData { - label: string - received: number - target: number +export type FlowNode = Node + +export interface FlowEdgeData { + allocation: number // percentage 0-100 + color: string [key: string]: unknown } -export type FlowNode = Node -export type FlowEdge = Edge<{ animated?: boolean }> +export type FlowEdge = Edge