Unified funnel system with pie charts and proportional edges
- All nodes now use single FunnelNode type - Dual-handle range slider for min/max on one line - Min cannot exceed max (constrained) - Pie chart showing outflow allocations - Edge thickness proportional to allocation percentage - Removed old SourceNode, RecipientNode, ThresholdNode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6bf9b0ed19
commit
531eb7d373
|
|
@ -15,181 +15,184 @@ import {
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
import SourceNode from './nodes/SourceNode'
|
import FunnelNode from './nodes/FunnelNode'
|
||||||
import ThresholdNode from './nodes/ThresholdNode'
|
import type { FlowNode, FlowEdge, FunnelNodeData } from '@/lib/types'
|
||||||
import RecipientNode from './nodes/RecipientNode'
|
|
||||||
import type { FlowNode, FlowEdge, SourceNodeData, ThresholdNodeData, RecipientNodeData } from '@/lib/types'
|
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
source: SourceNode,
|
funnel: FunnelNode,
|
||||||
threshold: ThresholdNode,
|
|
||||||
recipient: RecipientNode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical layout - sources at top, recipients at bottom
|
// Color palette for allocations
|
||||||
|
const COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4']
|
||||||
|
|
||||||
const initialNodes: FlowNode[] = [
|
const initialNodes: FlowNode[] = [
|
||||||
// Top row - Source
|
|
||||||
{
|
{
|
||||||
id: 'source-1',
|
id: 'treasury',
|
||||||
type: 'source',
|
type: 'funnel',
|
||||||
position: { x: 350, y: 0 },
|
position: { x: 400, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Treasury',
|
label: 'Treasury',
|
||||||
balance: 100000,
|
currentValue: 85000,
|
||||||
flowRate: 1000,
|
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',
|
id: 'public-goods',
|
||||||
type: 'threshold',
|
type: 'funnel',
|
||||||
position: { x: 100, y: 200 },
|
position: { x: 100, y: 350 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Public Goods',
|
label: 'Public Goods',
|
||||||
|
currentValue: 45000,
|
||||||
minThreshold: 15000,
|
minThreshold: 15000,
|
||||||
maxThreshold: 60000,
|
maxThreshold: 50000,
|
||||||
currentValue: 72000, // Overflowing
|
maxCapacity: 70000,
|
||||||
|
inflowRate: 400,
|
||||||
|
outflowAllocations: [
|
||||||
|
{ targetId: 'project-alpha', percentage: 60, color: COLORS[0] },
|
||||||
|
{ targetId: 'project-beta', percentage: 40, color: COLORS[1] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'threshold-2',
|
id: 'research',
|
||||||
type: 'threshold',
|
type: 'funnel',
|
||||||
position: { x: 400, y: 200 },
|
position: { x: 400, y: 350 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Research',
|
label: 'Research',
|
||||||
|
currentValue: 28000,
|
||||||
minThreshold: 20000,
|
minThreshold: 20000,
|
||||||
maxThreshold: 50000,
|
maxThreshold: 45000,
|
||||||
currentValue: 35000, // Healthy
|
maxCapacity: 60000,
|
||||||
|
inflowRate: 350,
|
||||||
|
outflowAllocations: [
|
||||||
|
{ targetId: 'project-gamma', percentage: 70, color: COLORS[0] },
|
||||||
|
{ targetId: 'project-beta', percentage: 30, color: COLORS[1] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'threshold-3',
|
id: 'emergency',
|
||||||
type: 'threshold',
|
type: 'funnel',
|
||||||
position: { x: 700, y: 200 },
|
position: { x: 700, y: 350 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Emergency',
|
label: 'Emergency',
|
||||||
minThreshold: 30000,
|
currentValue: 12000,
|
||||||
maxThreshold: 80000,
|
minThreshold: 25000,
|
||||||
currentValue: 18000, // Critical
|
maxThreshold: 60000,
|
||||||
|
maxCapacity: 80000,
|
||||||
|
inflowRate: 250,
|
||||||
|
outflowAllocations: [
|
||||||
|
{ targetId: 'reserve', percentage: 100, color: COLORS[0] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Bottom row - Recipients
|
|
||||||
{
|
{
|
||||||
id: 'recipient-1',
|
id: 'project-alpha',
|
||||||
type: 'recipient',
|
type: 'funnel',
|
||||||
position: { x: 50, y: 620 },
|
position: { x: 0, y: 700 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Project Alpha',
|
label: 'Project Alpha',
|
||||||
received: 24500,
|
currentValue: 18000,
|
||||||
target: 30000,
|
minThreshold: 10000,
|
||||||
|
maxThreshold: 30000,
|
||||||
|
maxCapacity: 40000,
|
||||||
|
inflowRate: 240,
|
||||||
|
outflowAllocations: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'recipient-2',
|
id: 'project-beta',
|
||||||
type: 'recipient',
|
type: 'funnel',
|
||||||
position: { x: 300, y: 620 },
|
position: { x: 300, y: 700 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Project Beta',
|
label: 'Project Beta',
|
||||||
received: 18000,
|
currentValue: 22000,
|
||||||
target: 25000,
|
minThreshold: 15000,
|
||||||
|
maxThreshold: 35000,
|
||||||
|
maxCapacity: 45000,
|
||||||
|
inflowRate: 265,
|
||||||
|
outflowAllocations: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'recipient-3',
|
id: 'project-gamma',
|
||||||
type: 'recipient',
|
type: 'funnel',
|
||||||
position: { x: 550, y: 620 },
|
position: { x: 600, y: 700 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Research Lab',
|
label: 'Project Gamma',
|
||||||
received: 12000,
|
currentValue: 8000,
|
||||||
target: 40000,
|
minThreshold: 12000,
|
||||||
|
maxThreshold: 28000,
|
||||||
|
maxCapacity: 35000,
|
||||||
|
inflowRate: 245,
|
||||||
|
outflowAllocations: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'recipient-4',
|
id: 'reserve',
|
||||||
type: 'recipient',
|
type: 'funnel',
|
||||||
position: { x: 800, y: 620 },
|
position: { x: 900, y: 700 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Reserve Fund',
|
label: 'Reserve',
|
||||||
received: 5000,
|
currentValue: 5000,
|
||||||
target: 50000,
|
minThreshold: 20000,
|
||||||
|
maxThreshold: 50000,
|
||||||
|
maxCapacity: 60000,
|
||||||
|
inflowRate: 250,
|
||||||
|
outflowAllocations: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const initialEdges: FlowEdge[] = [
|
// Generate edges from node allocations with proportional thickness
|
||||||
// Source to thresholds (top to middle)
|
function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
{
|
const edges: FlowEdge[] = []
|
||||||
id: 'e-source-t1',
|
const maxAllocation = 100 // Max percentage for scaling
|
||||||
source: 'source-1',
|
|
||||||
target: 'threshold-1',
|
nodes.forEach((node) => {
|
||||||
animated: true,
|
const data = node.data as FunnelNodeData
|
||||||
style: { stroke: '#3b82f6', strokeWidth: 3 },
|
data.outflowAllocations.forEach((alloc) => {
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' },
|
// Calculate stroke width: min 2px, max 12px based on percentage
|
||||||
},
|
const strokeWidth = 2 + (alloc.percentage / maxAllocation) * 10
|
||||||
{
|
|
||||||
id: 'e-source-t2',
|
edges.push({
|
||||||
source: 'source-1',
|
id: `e-${node.id}-${alloc.targetId}`,
|
||||||
target: 'threshold-2',
|
source: node.id,
|
||||||
animated: true,
|
target: alloc.targetId,
|
||||||
style: { stroke: '#3b82f6', strokeWidth: 3 },
|
animated: true,
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' },
|
style: {
|
||||||
},
|
stroke: alloc.color,
|
||||||
{
|
strokeWidth,
|
||||||
id: 'e-source-t3',
|
opacity: 0.8,
|
||||||
source: 'source-1',
|
},
|
||||||
target: 'threshold-3',
|
markerEnd: {
|
||||||
animated: true,
|
type: MarkerType.ArrowClosed,
|
||||||
style: { stroke: '#3b82f6', strokeWidth: 3 },
|
color: alloc.color,
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' },
|
width: 15 + alloc.percentage / 10,
|
||||||
},
|
height: 15 + alloc.percentage / 10,
|
||||||
// Threshold to recipients (middle to bottom)
|
},
|
||||||
{
|
data: {
|
||||||
id: 'e-t1-r1',
|
allocation: alloc.percentage,
|
||||||
source: 'threshold-1',
|
color: alloc.color,
|
||||||
target: 'recipient-1',
|
},
|
||||||
animated: true,
|
})
|
||||||
style: { stroke: '#ec4899', strokeWidth: 2 },
|
})
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' },
|
})
|
||||||
},
|
|
||||||
{
|
return edges
|
||||||
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' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function FlowCanvas() {
|
export default function FlowCanvas() {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
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 [isSimulating, setIsSimulating] = useState(true)
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
|
|
@ -199,7 +202,7 @@ export default function FlowCanvas() {
|
||||||
{
|
{
|
||||||
...params,
|
...params,
|
||||||
animated: true,
|
animated: true,
|
||||||
style: { stroke: '#64748b', strokeWidth: 2 },
|
style: { stroke: '#64748b', strokeWidth: 4 },
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b' },
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b' },
|
||||||
},
|
},
|
||||||
eds
|
eds
|
||||||
|
|
@ -215,41 +218,16 @@ export default function FlowCanvas() {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setNodes((nds) =>
|
setNodes((nds) =>
|
||||||
nds.map((node) => {
|
nds.map((node) => {
|
||||||
if (node.type === 'source') {
|
const data = node.data as FunnelNodeData
|
||||||
const data = node.data as SourceNodeData
|
// Random walk for demo
|
||||||
return {
|
const change = (Math.random() - 0.45) * 300
|
||||||
...node,
|
return {
|
||||||
data: {
|
...node,
|
||||||
...data,
|
data: {
|
||||||
balance: Math.max(0, data.balance - data.flowRate / 3600),
|
...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)
|
}, 500)
|
||||||
|
|
@ -257,6 +235,11 @@ export default function FlowCanvas() {
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [isSimulating, setNodes])
|
}, [isSimulating, setNodes])
|
||||||
|
|
||||||
|
// Regenerate edges when nodes change (to update proportions if needed)
|
||||||
|
useEffect(() => {
|
||||||
|
setEdges(generateEdges(nodes))
|
||||||
|
}, [nodes, setEdges])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
|
@ -269,10 +252,6 @@ export default function FlowCanvas() {
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.1 }}
|
fitViewOptions={{ padding: 0.1 }}
|
||||||
className="bg-slate-50"
|
className="bg-slate-50"
|
||||||
defaultEdgeOptions={{
|
|
||||||
animated: true,
|
|
||||||
style: { strokeWidth: 2 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
||||||
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
|
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
|
||||||
|
|
@ -280,7 +259,7 @@ export default function FlowCanvas() {
|
||||||
{/* Title Panel */}
|
{/* Title Panel */}
|
||||||
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
||||||
<h1 className="text-xl font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
<h1 className="text-xl font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
||||||
<p className="text-sm text-slate-500 mt-1">Funds flow top→bottom through funnel thresholds</p>
|
<p className="text-sm text-slate-500 mt-1">Drag min/max handles • Line thickness = allocation %</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Simulation Toggle */}
|
{/* Simulation Toggle */}
|
||||||
|
|
@ -299,35 +278,31 @@ export default function FlowCanvas() {
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
||||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">Flow Types</div>
|
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">Funnel Zones</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-0.5 bg-blue-500" />
|
<div className="w-4 h-4 rounded bg-amber-200 border border-amber-400" />
|
||||||
<span className="text-slate-600">Inflow (from source)</span>
|
<span className="text-slate-600">Overflow (above MAX)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-0.5 bg-pink-500" />
|
<div className="w-4 h-4 rounded bg-emerald-200 border border-emerald-400" />
|
||||||
<span className="text-slate-600">Outflow (to recipients)</span>
|
<span className="text-slate-600">Healthy (MIN to MAX)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-0.5 bg-amber-500" style={{ backgroundImage: 'repeating-linear-gradient(90deg, #f59e0b 0, #f59e0b 5px, transparent 5px, transparent 10px)' }} />
|
<div className="w-4 h-4 rounded bg-red-200 border border-red-400" />
|
||||||
<span className="text-slate-600">Overflow (excess)</span>
|
<span className="text-slate-600">Critical (below MIN)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-200 mt-3 pt-3">
|
<div className="border-t border-slate-200 mt-3 pt-3">
|
||||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Funnel Zones</div>
|
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Lines</div>
|
||||||
<div className="space-y-1 text-xs">
|
<div className="space-y-1 text-xs text-slate-600">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded bg-amber-100 border border-amber-300" />
|
<div className="w-8 h-1 bg-blue-500 rounded" />
|
||||||
<span className="text-slate-600">Overflow (above MAX)</span>
|
<span>Thin = small allocation</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded bg-emerald-100 border border-emerald-300" />
|
<div className="w-8 h-3 bg-blue-500 rounded" />
|
||||||
<span className="text-slate-600">Healthy (MIN to MAX)</span>
|
<span>Thick = large allocation</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded bg-red-100 border border-red-300" />
|
|
||||||
<span className="text-slate-600">Critical (below MIN)</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
bg-white rounded-xl shadow-lg border-2 transition-all duration-200
|
||||||
|
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
||||||
|
`}
|
||||||
|
style={{ width: width + 100 }}
|
||||||
|
>
|
||||||
|
{/* Top Handle - Inflow */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-top-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-3 py-2 border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-slate-800 text-sm">{label}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
isOverflowing ? 'bg-amber-100 text-amber-700' :
|
||||||
|
isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content - Funnel and Pie side by side */}
|
||||||
|
<div className="flex items-start p-2 gap-2">
|
||||||
|
{/* Funnel SVG */}
|
||||||
|
<svg width={width} height={height} className="flex-shrink-0">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`fill-${label}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
||||||
|
<stop offset="100%" stopColor={isOverflowing ? '#f59e0b' : isCritical ? '#ef4444' : '#10b981'} />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id={`clip-${label}`}>
|
||||||
|
<path d={clipPath} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Zone backgrounds */}
|
||||||
|
<rect x={leftTop} y={padding} width={topWidth} height={maxY - padding} fill="#fef3c7" />
|
||||||
|
<rect x={leftTop} y={maxY} width={topWidth} height={funnelStartY - maxY} fill="#d1fae5" />
|
||||||
|
<path
|
||||||
|
d={`M ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} Z`}
|
||||||
|
fill="#fee2e2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Liquid fill */}
|
||||||
|
<g clipPath={`url(#clip-${label})`}>
|
||||||
|
<rect x={0} y={balanceY} width={width} height={height} fill={`url(#fill-${label})`}>
|
||||||
|
<animate attributeName="y" values={`${balanceY};${balanceY - 1};${balanceY}`} dur="2s" repeatCount="indefinite" />
|
||||||
|
</rect>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Funnel outline */}
|
||||||
|
<path
|
||||||
|
d={`M ${leftTop} ${padding} L ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${leftBottom} ${height - padding}
|
||||||
|
M ${rightBottom} ${height - padding} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} L ${rightTop} ${padding}`}
|
||||||
|
fill="none" stroke="#64748b" strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<line x1={leftTop} y1={padding} x2={rightTop} y2={padding} stroke="#64748b" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* Threshold zone indicator (single bar on right side) */}
|
||||||
|
<rect x={rightTop + 4} y={maxY} width={6} height={minY - maxY} fill="#10b981" rx="2" />
|
||||||
|
<line x1={rightTop + 2} y1={maxY} x2={rightTop + 12} y2={maxY} stroke="#f59e0b" strokeWidth="2" />
|
||||||
|
<line x1={rightTop + 2} y1={minY} x2={rightTop + 12} y2={minY} stroke="#ef4444" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* Overflow particles */}
|
||||||
|
{isOverflowing && (
|
||||||
|
<>
|
||||||
|
<circle r="3" fill="#f59e0b">
|
||||||
|
<animate attributeName="cx" values={`${leftTop};${leftTop - 20}`} dur="0.7s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="cy" values={`${padding + 5};${padding + 40}`} dur="0.7s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="opacity" values="1;0" dur="0.7s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle r="3" fill="#f59e0b">
|
||||||
|
<animate attributeName="cx" values={`${rightTop};${rightTop + 20}`} dur="0.8s" repeatCount="indefinite" begin="0.2s" />
|
||||||
|
<animate attributeName="cy" values={`${padding + 5};${padding + 40}`} dur="0.8s" repeatCount="indefinite" begin="0.2s" />
|
||||||
|
<animate attributeName="opacity" values="1;0" dur="0.8s" repeatCount="indefinite" begin="0.2s" />
|
||||||
|
</circle>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Pie chart for outflow allocation */}
|
||||||
|
{outflowAllocations.length > 0 && (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide mb-1">Outflow</span>
|
||||||
|
<svg width={pieRadius * 2 + 8} height={pieRadius * 2 + 8}>
|
||||||
|
{pieSlices.map((slice, idx) => (
|
||||||
|
<path key={idx} d={slice.path} fill={slice.color} stroke="white" strokeWidth="1" />
|
||||||
|
))}
|
||||||
|
<circle cx={pieCenter.x} cy={pieCenter.y} r={pieRadius * 0.4} fill="white" />
|
||||||
|
</svg>
|
||||||
|
{/* Mini legend */}
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{outflowAllocations.slice(0, 3).map((alloc, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1 text-[9px]">
|
||||||
|
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: alloc.color || PIE_COLORS[idx] }} />
|
||||||
|
<span className="text-slate-600 truncate max-w-[50px]">{alloc.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value display */}
|
||||||
|
<div className="px-3 py-1 text-center border-t border-slate-100">
|
||||||
|
<span className={`text-lg font-bold font-mono ${
|
||||||
|
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
||||||
|
}`}>
|
||||||
|
${Math.floor(currentValue).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dual range slider */}
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500 mb-1">
|
||||||
|
<span>MIN: <span className="text-red-600 font-mono">${(minThreshold/1000).toFixed(0)}k</span></span>
|
||||||
|
<span>MAX: <span className="text-amber-600 font-mono">${(maxThreshold/1000).toFixed(0)}k</span></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={sliderRef}
|
||||||
|
className="relative h-4 bg-slate-100 rounded-full cursor-pointer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Track background */}
|
||||||
|
<div className="absolute inset-y-0 left-0 right-0 rounded-full overflow-hidden">
|
||||||
|
{/* Red zone (0 to min) */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-red-200"
|
||||||
|
style={{ left: 0, width: `${(minThreshold / maxCapacity) * 100}%` }}
|
||||||
|
/>
|
||||||
|
{/* Green zone (min to max) */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-emerald-300"
|
||||||
|
style={{
|
||||||
|
left: `${(minThreshold / maxCapacity) * 100}%`,
|
||||||
|
width: `${((maxThreshold - minThreshold) / maxCapacity) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Amber zone (max to capacity) */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-amber-200"
|
||||||
|
style={{ left: `${(maxThreshold / maxCapacity) * 100}%`, right: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Min handle */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-red-500 border-2 border-white rounded-full shadow cursor-grab ${dragging === 'min' ? 'cursor-grabbing scale-110' : ''}`}
|
||||||
|
style={{ left: `calc(${(minThreshold / maxCapacity) * 100}% - 8px)` }}
|
||||||
|
onMouseDown={(e) => handleSliderMouseDown(e, 'min')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Max handle */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-amber-500 border-2 border-white rounded-full shadow cursor-grab ${dragging === 'max' ? 'cursor-grabbing scale-110' : ''}`}
|
||||||
|
style={{ left: `calc(${(maxThreshold / maxCapacity) * 100}% - 8px)` }}
|
||||||
|
onMouseDown={(e) => handleSliderMouseDown(e, 'max')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Handle - Outflow */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-bottom-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Side Handles - Overflow */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Left}
|
||||||
|
id="overflow-left"
|
||||||
|
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||||
|
style={{ top: '25%' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="overflow-right"
|
||||||
|
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||||
|
style={{ top: '25%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(FunnelNode)
|
||||||
|
|
@ -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 (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
bg-white rounded-lg shadow-lg border-2 min-w-[180px]
|
|
||||||
transition-all duration-200
|
|
||||||
${selected ? 'border-emerald-500 shadow-emerald-100' : 'border-slate-200'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle - Top for vertical flow */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-top-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className={`px-4 py-2 rounded-t-md bg-gradient-to-r ${
|
|
||||||
isFunded ? 'from-emerald-500 to-emerald-600' : 'from-slate-500 to-slate-600'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-white/20 rounded flex items-center justify-center">
|
|
||||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-white font-medium text-sm">{label}</span>
|
|
||||||
{isFunded && (
|
|
||||||
<svg className="w-4 h-4 text-white ml-auto" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Received</span>
|
|
||||||
<span className="font-mono font-semibold text-slate-800">
|
|
||||||
${Math.floor(received).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-1">
|
|
||||||
<span className="text-slate-500 text-xs">Progress</span>
|
|
||||||
<span className="text-xs font-medium text-slate-600">{progress.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all duration-500 ${
|
|
||||||
isFunded ? 'bg-emerald-500' : 'bg-blue-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-2 border-t border-slate-100">
|
|
||||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Target</span>
|
|
||||||
<span className="font-mono text-slate-600">
|
|
||||||
${target.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(RecipientNode)
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
bg-white rounded-lg shadow-lg border-2 min-w-[200px]
|
|
||||||
transition-all duration-200
|
|
||||||
${selected ? 'border-blue-500 shadow-blue-100' : 'border-slate-200'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-2 rounded-t-md">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-white/20 rounded flex items-center justify-center">
|
|
||||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-white font-medium text-sm">{label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Balance</span>
|
|
||||||
<span className="font-mono font-semibold text-slate-800">
|
|
||||||
${Math.floor(balance).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Flow Rate</span>
|
|
||||||
<span className="font-mono text-blue-600">
|
|
||||||
${flowRate}/hr
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Flow indicator */}
|
|
||||||
<div className="flex items-center justify-center pt-2">
|
|
||||||
<svg className="w-6 h-6 text-blue-500 animate-bounce" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output Handle - Bottom for vertical flow */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(SourceNode)
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
bg-white rounded-xl shadow-lg border-2 transition-all duration-200
|
|
||||||
${selected ? 'border-purple-500 shadow-purple-200' : 'border-slate-200'}
|
|
||||||
`}
|
|
||||||
style={{ width: width + 80, padding: '12px' }}
|
|
||||||
>
|
|
||||||
{/* Top Handle - Inflow */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-top-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center mb-2">
|
|
||||||
<div className="font-semibold text-slate-800 text-sm">{nodeData.label}</div>
|
|
||||||
<div className={`text-xs px-2 py-0.5 rounded-full inline-block mt-1 ${
|
|
||||||
isOverflowing ? 'bg-amber-100 text-amber-700' :
|
|
||||||
isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
|
|
||||||
}`}>
|
|
||||||
{isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Funnel SVG */}
|
|
||||||
<svg width={width} height={height} className="mx-auto overflow-visible">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={`fill-${nodeData.label}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
|
||||||
<stop offset="100%" stopColor={isOverflowing ? '#f59e0b' : isCritical ? '#ef4444' : '#10b981'} />
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id={`clip-${nodeData.label}`}>
|
|
||||||
<path d={clipPath} />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Zone backgrounds */}
|
|
||||||
{/* Overflow zone */}
|
|
||||||
<rect
|
|
||||||
x={leftTop}
|
|
||||||
y={padding}
|
|
||||||
width={topWidth}
|
|
||||||
height={maxY - padding}
|
|
||||||
fill="#fef3c7"
|
|
||||||
rx="2"
|
|
||||||
/>
|
|
||||||
{/* Healthy zone */}
|
|
||||||
<rect
|
|
||||||
x={leftTop}
|
|
||||||
y={maxY}
|
|
||||||
width={topWidth}
|
|
||||||
height={funnelStartY - maxY}
|
|
||||||
fill="#d1fae5"
|
|
||||||
/>
|
|
||||||
{/* Critical zone (funnel part) */}
|
|
||||||
<path
|
|
||||||
d={`
|
|
||||||
M ${leftTop} ${funnelStartY}
|
|
||||||
L ${leftBottom} ${height - padding - 15}
|
|
||||||
L ${rightBottom} ${height - padding - 15}
|
|
||||||
L ${rightTop} ${funnelStartY}
|
|
||||||
Z
|
|
||||||
`}
|
|
||||||
fill="#fee2e2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Liquid fill */}
|
|
||||||
<g clipPath={`url(#clip-${nodeData.label})`}>
|
|
||||||
<rect
|
|
||||||
x={0}
|
|
||||||
y={balanceY}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
fill={`url(#fill-${nodeData.label})`}
|
|
||||||
>
|
|
||||||
<animate
|
|
||||||
attributeName="y"
|
|
||||||
values={`${balanceY};${balanceY - 1};${balanceY}`}
|
|
||||||
dur="1.5s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</rect>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Funnel outline */}
|
|
||||||
<path
|
|
||||||
d={`
|
|
||||||
M ${leftTop} ${padding}
|
|
||||||
L ${leftTop} ${funnelStartY}
|
|
||||||
L ${leftBottom} ${height - padding - 15}
|
|
||||||
L ${leftBottom} ${height - padding}
|
|
||||||
M ${rightBottom} ${height - padding}
|
|
||||||
L ${rightBottom} ${height - padding - 15}
|
|
||||||
L ${rightTop} ${funnelStartY}
|
|
||||||
L ${rightTop} ${padding}
|
|
||||||
`}
|
|
||||||
fill="none"
|
|
||||||
stroke="#64748b"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
{/* Top line */}
|
|
||||||
<line x1={leftTop} y1={padding} x2={rightTop} y2={padding} stroke="#64748b" strokeWidth="2" />
|
|
||||||
|
|
||||||
{/* MAX line */}
|
|
||||||
<line
|
|
||||||
x1={leftTop - 5}
|
|
||||||
y1={maxY}
|
|
||||||
x2={rightTop + 5}
|
|
||||||
y2={maxY}
|
|
||||||
stroke="#f59e0b"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray="4 2"
|
|
||||||
/>
|
|
||||||
<text x={rightTop + 8} y={maxY + 3} fill="#f59e0b" fontSize="9" fontWeight="500">MAX</text>
|
|
||||||
|
|
||||||
{/* MIN line */}
|
|
||||||
<line
|
|
||||||
x1={leftTop - 5}
|
|
||||||
y1={minY}
|
|
||||||
x2={rightTop + 5}
|
|
||||||
y2={minY}
|
|
||||||
stroke="#ef4444"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray="4 2"
|
|
||||||
/>
|
|
||||||
<text x={rightTop + 8} y={minY + 3} fill="#ef4444" fontSize="9" fontWeight="500">MIN</text>
|
|
||||||
|
|
||||||
{/* Overflow particles */}
|
|
||||||
{isOverflowing && (
|
|
||||||
<>
|
|
||||||
<circle r="3" fill="#f59e0b">
|
|
||||||
<animate attributeName="cx" values={`${leftTop};${leftTop - 25}`} dur="0.8s" repeatCount="indefinite" />
|
|
||||||
<animate attributeName="cy" values={`${padding + 5};${padding + 50}`} dur="0.8s" repeatCount="indefinite" />
|
|
||||||
<animate attributeName="opacity" values="1;0" dur="0.8s" repeatCount="indefinite" />
|
|
||||||
</circle>
|
|
||||||
<circle r="3" fill="#f59e0b">
|
|
||||||
<animate attributeName="cx" values={`${rightTop};${rightTop + 25}`} dur="0.9s" repeatCount="indefinite" begin="0.3s" />
|
|
||||||
<animate attributeName="cy" values={`${padding + 5};${padding + 50}`} dur="0.9s" repeatCount="indefinite" begin="0.3s" />
|
|
||||||
<animate attributeName="opacity" values="1;0" dur="0.9s" repeatCount="indefinite" begin="0.3s" />
|
|
||||||
</circle>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Value display */}
|
|
||||||
<div className="text-center mt-2">
|
|
||||||
<div className={`text-xl font-bold font-mono ${
|
|
||||||
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
|
||||||
}`}>
|
|
||||||
${Math.floor(currentValue).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Threshold sliders */}
|
|
||||||
<div className="mt-3 space-y-2 px-2">
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
|
||||||
<span>Min</span>
|
|
||||||
<span className="font-mono text-red-600">${minThreshold.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={maxThreshold - 1000}
|
|
||||||
value={minThreshold}
|
|
||||||
onChange={(e) => setMinThreshold(Number(e.target.value))}
|
|
||||||
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-red-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
|
||||||
<span>Max</span>
|
|
||||||
<span className="font-mono text-amber-600">${maxThreshold.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={minThreshold + 1000}
|
|
||||||
max="100000"
|
|
||||||
value={maxThreshold}
|
|
||||||
onChange={(e) => setMaxThreshold(Number(e.target.value))}
|
|
||||||
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Handle - Outflow */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-bottom-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Side Handles - Overflow */}
|
|
||||||
{isOverflowing && (
|
|
||||||
<>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
id="overflow-left"
|
|
||||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
|
||||||
style={{ top: '30%' }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="overflow-right"
|
|
||||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
|
||||||
style={{ top: '30%' }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(ThresholdNode)
|
|
||||||
28
lib/types.ts
28
lib/types.ts
|
|
@ -1,26 +1,28 @@
|
||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
|
||||||
export interface SourceNodeData {
|
export interface OutflowAllocation {
|
||||||
label: string
|
targetId: string
|
||||||
balance: number
|
percentage: number // 0-100
|
||||||
flowRate: number
|
color: string
|
||||||
[key: string]: unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThresholdNodeData {
|
export interface FunnelNodeData {
|
||||||
label: string
|
label: string
|
||||||
|
currentValue: number
|
||||||
minThreshold: number
|
minThreshold: number
|
||||||
maxThreshold: number
|
maxThreshold: number
|
||||||
currentValue: number
|
maxCapacity: number
|
||||||
|
inflowRate: number
|
||||||
|
outflowAllocations: OutflowAllocation[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecipientNodeData {
|
export type FlowNode = Node<FunnelNodeData>
|
||||||
label: string
|
|
||||||
received: number
|
export interface FlowEdgeData {
|
||||||
target: number
|
allocation: number // percentage 0-100
|
||||||
|
color: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlowNode = Node<SourceNodeData | ThresholdNodeData | RecipientNodeData>
|
export type FlowEdge = Edge<FlowEdgeData>
|
||||||
export type FlowEdge = Edge<{ animated?: boolean }>
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue