672 lines
24 KiB
TypeScript
672 lines
24 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import Link from "next/link"
|
|
import type { FlowNetwork, FlowAllocation, FlowParticle } from "@/lib/tbff-flow/types"
|
|
import { renderFlowNetwork } from "@/lib/tbff-flow/rendering"
|
|
import {
|
|
flowSampleNetworks,
|
|
flowNetworkOptions,
|
|
getFlowSampleNetwork,
|
|
} from "@/lib/tbff-flow/sample-networks"
|
|
import {
|
|
formatFlow,
|
|
getFlowStatusColorClass,
|
|
normalizeFlowAllocations,
|
|
calculateFlowNetworkTotals,
|
|
updateFlowNodeProperties,
|
|
} from "@/lib/tbff-flow/utils"
|
|
import { propagateFlow, updateFlowParticles } from "@/lib/tbff-flow/algorithms"
|
|
|
|
type Tool = 'select' | 'create-allocation'
|
|
|
|
export default function TBFFFlowPage() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const animationFrameRef = useRef<number | null>(null)
|
|
|
|
const [network, setNetwork] = useState<FlowNetwork>(flowSampleNetworks.linear)
|
|
const [particles, setParticles] = useState<FlowParticle[]>([])
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
|
const [selectedAllocationId, setSelectedAllocationId] = useState<string | null>(null)
|
|
const [selectedNetworkKey, setSelectedNetworkKey] = useState<string>('linear')
|
|
const [tool, setTool] = useState<Tool>('select')
|
|
const [allocationSourceId, setAllocationSourceId] = useState<string | null>(null)
|
|
const [isAnimating, setIsAnimating] = useState(true)
|
|
|
|
// Animation loop
|
|
useEffect(() => {
|
|
if (!isAnimating) return
|
|
|
|
const animate = () => {
|
|
// Update particles
|
|
setParticles(prev => updateFlowParticles(prev))
|
|
|
|
animationFrameRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
animationFrameRef.current = requestAnimationFrame(animate)
|
|
|
|
return () => {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current)
|
|
}
|
|
}
|
|
}, [isAnimating])
|
|
|
|
// Render canvas
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const ctx = canvas.getContext("2d")
|
|
if (!ctx) return
|
|
|
|
// Set canvas size
|
|
canvas.width = canvas.offsetWidth
|
|
canvas.height = canvas.offsetHeight
|
|
|
|
// Render the network
|
|
renderFlowNetwork(
|
|
ctx,
|
|
network,
|
|
canvas.width,
|
|
canvas.height,
|
|
particles,
|
|
selectedNodeId,
|
|
selectedAllocationId
|
|
)
|
|
}, [network, particles, selectedNodeId, selectedAllocationId])
|
|
|
|
// Propagate flow when network changes
|
|
const handlePropagateFlow = () => {
|
|
const result = propagateFlow(network)
|
|
setNetwork(result.network)
|
|
setParticles(result.particles)
|
|
}
|
|
|
|
// Set external flow for selected node
|
|
const handleSetNodeFlow = (nodeId: string, flow: number) => {
|
|
const updatedNodes = network.nodes.map(node =>
|
|
node.id === nodeId
|
|
? { ...node, externalFlow: Math.max(0, flow) }
|
|
: node
|
|
)
|
|
|
|
const updatedNetwork = {
|
|
...network,
|
|
nodes: updatedNodes,
|
|
}
|
|
|
|
setNetwork(updatedNetwork)
|
|
|
|
// Auto-propagate
|
|
setTimeout(() => {
|
|
const result = propagateFlow(updatedNetwork)
|
|
setNetwork(result.network)
|
|
setParticles(result.particles)
|
|
}, 100)
|
|
}
|
|
|
|
// Handle canvas click
|
|
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const y = e.clientY - rect.top
|
|
|
|
// Find clicked node
|
|
const clickedNode = network.nodes.find(
|
|
node =>
|
|
x >= node.x &&
|
|
x <= node.x + node.width &&
|
|
y >= node.y &&
|
|
y <= node.y + node.height
|
|
)
|
|
|
|
if (tool === 'select') {
|
|
if (clickedNode) {
|
|
setSelectedNodeId(clickedNode.id)
|
|
setSelectedAllocationId(null)
|
|
} else {
|
|
// Check if clicked on allocation
|
|
const clickedAllocation = findAllocationAtPoint(x, y)
|
|
if (clickedAllocation) {
|
|
setSelectedAllocationId(clickedAllocation.id)
|
|
setSelectedNodeId(null)
|
|
} else {
|
|
// Deselect all
|
|
setSelectedNodeId(null)
|
|
setSelectedAllocationId(null)
|
|
}
|
|
}
|
|
} else if (tool === 'create-allocation') {
|
|
if (clickedNode) {
|
|
if (!allocationSourceId) {
|
|
// First click: set source
|
|
setAllocationSourceId(clickedNode.id)
|
|
} else {
|
|
// Second click: create allocation
|
|
if (clickedNode.id !== allocationSourceId) {
|
|
createAllocation(allocationSourceId, clickedNode.id)
|
|
}
|
|
setAllocationSourceId(null)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find allocation at point
|
|
const findAllocationAtPoint = (x: number, y: number): FlowAllocation | null => {
|
|
const tolerance = 15
|
|
|
|
for (const allocation of network.allocations) {
|
|
const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId)
|
|
const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId)
|
|
if (!sourceNode || !targetNode) continue
|
|
|
|
const sourceCenter = {
|
|
x: sourceNode.x + sourceNode.width / 2,
|
|
y: sourceNode.y + sourceNode.height / 2,
|
|
}
|
|
const targetCenter = {
|
|
x: targetNode.x + targetNode.width / 2,
|
|
y: targetNode.y + targetNode.height / 2,
|
|
}
|
|
|
|
const distance = pointToLineDistance(
|
|
x, y,
|
|
sourceCenter.x, sourceCenter.y,
|
|
targetCenter.x, targetCenter.y
|
|
)
|
|
|
|
if (distance < tolerance) {
|
|
return allocation
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Point to line distance
|
|
const pointToLineDistance = (
|
|
px: number, py: number,
|
|
x1: number, y1: number,
|
|
x2: number, y2: number
|
|
): number => {
|
|
const dot = (px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)
|
|
const lenSq = (x2 - x1) ** 2 + (y2 - y1) ** 2
|
|
const param = lenSq !== 0 ? dot / lenSq : -1
|
|
|
|
let xx, yy
|
|
if (param < 0) {
|
|
[xx, yy] = [x1, y1]
|
|
} else if (param > 1) {
|
|
[xx, yy] = [x2, y2]
|
|
} else {
|
|
xx = x1 + param * (x2 - x1)
|
|
yy = y1 + param * (y2 - y1)
|
|
}
|
|
|
|
return Math.sqrt((px - xx) ** 2 + (py - yy) ** 2)
|
|
}
|
|
|
|
// Create allocation
|
|
const createAllocation = (sourceId: string, targetId: string) => {
|
|
const newAllocation: FlowAllocation = {
|
|
id: `alloc_${Date.now()}`,
|
|
sourceNodeId: sourceId,
|
|
targetNodeId: targetId,
|
|
percentage: 0.5,
|
|
}
|
|
|
|
const updatedAllocations = [...network.allocations, newAllocation]
|
|
const sourceAllocations = updatedAllocations.filter(a => a.sourceNodeId === sourceId)
|
|
const normalized = normalizeFlowAllocations(sourceAllocations)
|
|
|
|
const finalAllocations = updatedAllocations.map(a => {
|
|
const normalizedVersion = normalized.find(n => n.id === a.id)
|
|
return normalizedVersion || a
|
|
})
|
|
|
|
const updatedNetwork = {
|
|
...network,
|
|
allocations: finalAllocations,
|
|
}
|
|
|
|
setNetwork(updatedNetwork)
|
|
setSelectedAllocationId(newAllocation.id)
|
|
setTool('select')
|
|
|
|
// Re-propagate
|
|
setTimeout(() => handlePropagateFlow(), 100)
|
|
}
|
|
|
|
// Update allocation percentage
|
|
const updateAllocationPercentage = (allocationId: string, newPercentage: number) => {
|
|
const allocation = network.allocations.find(a => a.id === allocationId)
|
|
if (!allocation) return
|
|
|
|
const updatedAllocations = network.allocations.map(a =>
|
|
a.id === allocationId
|
|
? { ...a, percentage: Math.max(0, Math.min(1, newPercentage)) }
|
|
: a
|
|
)
|
|
|
|
const sourceAllocations = updatedAllocations.filter(
|
|
a => a.sourceNodeId === allocation.sourceNodeId
|
|
)
|
|
const normalized = normalizeFlowAllocations(sourceAllocations)
|
|
|
|
const finalAllocations = updatedAllocations.map(a => {
|
|
const normalizedVersion = normalized.find(n => n.id === a.id)
|
|
return normalizedVersion || a
|
|
})
|
|
|
|
const updatedNetwork = {
|
|
...network,
|
|
allocations: finalAllocations,
|
|
}
|
|
|
|
setNetwork(updatedNetwork)
|
|
|
|
// Re-propagate
|
|
setTimeout(() => {
|
|
const result = propagateFlow(updatedNetwork)
|
|
setNetwork(result.network)
|
|
setParticles(result.particles)
|
|
}, 100)
|
|
}
|
|
|
|
// Delete allocation
|
|
const deleteAllocation = (allocationId: string) => {
|
|
const allocation = network.allocations.find(a => a.id === allocationId)
|
|
if (!allocation) return
|
|
|
|
const updatedAllocations = network.allocations.filter(a => a.id !== allocationId)
|
|
|
|
const sourceAllocations = updatedAllocations.filter(
|
|
a => a.sourceNodeId === allocation.sourceNodeId
|
|
)
|
|
const normalized = normalizeFlowAllocations(sourceAllocations)
|
|
|
|
const finalAllocations = updatedAllocations.map(a => {
|
|
const normalizedVersion = normalized.find(n => n.id === a.id)
|
|
return normalizedVersion || a
|
|
})
|
|
|
|
const updatedNetwork = {
|
|
...network,
|
|
allocations: finalAllocations,
|
|
}
|
|
|
|
setNetwork(updatedNetwork)
|
|
setSelectedAllocationId(null)
|
|
|
|
// Re-propagate
|
|
setTimeout(() => handlePropagateFlow(), 100)
|
|
}
|
|
|
|
// Load network
|
|
const handleLoadNetwork = (key: string) => {
|
|
setSelectedNetworkKey(key)
|
|
const newNetwork = getFlowSampleNetwork(key as keyof typeof flowSampleNetworks)
|
|
setNetwork(newNetwork)
|
|
setSelectedNodeId(null)
|
|
setSelectedAllocationId(null)
|
|
setAllocationSourceId(null)
|
|
setTool('select')
|
|
|
|
// Propagate initial flows
|
|
setTimeout(() => handlePropagateFlow(), 100)
|
|
}
|
|
|
|
// Get selected details
|
|
const selectedNode = selectedNodeId
|
|
? network.nodes.find(n => n.id === selectedNodeId)
|
|
: null
|
|
|
|
const selectedAllocation = selectedAllocationId
|
|
? network.allocations.find(a => a.id === selectedAllocationId)
|
|
: null
|
|
|
|
const outgoingAllocations = selectedNode
|
|
? network.allocations.filter(a => a.sourceNodeId === selectedNode.id)
|
|
: []
|
|
|
|
const selectedAllocationSiblings = selectedAllocation
|
|
? network.allocations.filter(a => a.sourceNodeId === selectedAllocation.sourceNodeId)
|
|
: []
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setTool('select')
|
|
setAllocationSourceId(null)
|
|
setSelectedNodeId(null)
|
|
setSelectedAllocationId(null)
|
|
} else if (e.key === 'Delete' && selectedAllocationId) {
|
|
deleteAllocation(selectedAllocationId)
|
|
} else if (e.key === ' ') {
|
|
e.preventDefault()
|
|
setIsAnimating(prev => !prev)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [selectedAllocationId])
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-900 text-white">
|
|
{/* Header */}
|
|
<header className="flex items-center justify-between p-4 border-b border-slate-700">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-cyan-400">
|
|
Flow-Based Flow Funding
|
|
</h1>
|
|
<p className="text-sm text-slate-400 mt-1">
|
|
Resource circulation visualization
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<Link href="/tbff" className="text-cyan-400 hover:text-cyan-300 transition-colors">
|
|
Stock Model →
|
|
</Link>
|
|
<Link href="/" className="text-cyan-400 hover:text-cyan-300 transition-colors">
|
|
← Home
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex h-[calc(100vh-73px)]">
|
|
{/* Canvas */}
|
|
<div className="flex-1 relative">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={`w-full h-full ${
|
|
tool === 'create-allocation' ? 'cursor-crosshair' : 'cursor-pointer'
|
|
}`}
|
|
onClick={handleCanvasClick}
|
|
/>
|
|
|
|
{/* Tool indicator */}
|
|
{allocationSourceId && (
|
|
<div className="absolute top-4 left-4 bg-cyan-600 px-4 py-2 rounded-lg text-sm font-medium">
|
|
Click target node to create allocation
|
|
</div>
|
|
)}
|
|
|
|
{/* Animation status */}
|
|
<div className="absolute top-4 right-4 bg-slate-800 px-4 py-2 rounded-lg text-sm">
|
|
{isAnimating ? '▶️ Animating' : '⏸️ Paused'} (Space to toggle)
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="w-80 bg-slate-800 p-6 space-y-6 overflow-y-auto">
|
|
{/* Tools */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-semibold text-slate-400">Tools</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setTool('select')
|
|
setAllocationSourceId(null)
|
|
}}
|
|
className={`px-3 py-2 rounded text-sm transition-colors ${
|
|
tool === 'select'
|
|
? 'bg-cyan-600 text-white'
|
|
: 'bg-slate-700 hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
Select
|
|
</button>
|
|
<button
|
|
onClick={() => setTool('create-allocation')}
|
|
className={`px-3 py-2 rounded text-sm transition-colors ${
|
|
tool === 'create-allocation'
|
|
? 'bg-cyan-600 text-white'
|
|
: 'bg-slate-700 hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
Create Arrow
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Network Selector */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-semibold text-slate-400">Select Network</h3>
|
|
<select
|
|
value={selectedNetworkKey}
|
|
onChange={(e) => handleLoadNetwork(e.target.value)}
|
|
className="w-full px-3 py-2 bg-slate-700 rounded text-sm"
|
|
>
|
|
{flowNetworkOptions.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Network Info */}
|
|
<div className="bg-slate-700 p-4 rounded">
|
|
<h3 className="font-semibold text-cyan-400 mb-3">{network.name}</h3>
|
|
<div className="text-xs space-y-2">
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Nodes:</span>
|
|
<span className="text-white">{network.nodes.filter(n => !n.isOverflowSink).length}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Allocations:</span>
|
|
<span className="text-white">{network.allocations.length}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-blue-400">Total Inflow:</span>
|
|
<span className="text-blue-400">{formatFlow(network.totalInflow)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-green-400">Total Absorbed:</span>
|
|
<span className="text-green-400">{formatFlow(network.totalAbsorbed)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-yellow-400">Total Outflow:</span>
|
|
<span className="text-yellow-400">{formatFlow(network.totalOutflow)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Set Flow Input */}
|
|
{selectedNode && !selectedNode.isOverflowSink && (
|
|
<div className="bg-green-900/30 border border-green-500/30 p-4 rounded">
|
|
<h3 className="font-semibold text-green-400 mb-3">💧 Set Flow Input</h3>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1">
|
|
External Flow for {selectedNode.name}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={selectedNode.externalFlow}
|
|
onChange={(e) => handleSetNodeFlow(selectedNode.id, parseFloat(e.target.value) || 0)}
|
|
className="w-full px-3 py-2 bg-slate-700 rounded text-sm"
|
|
min="0"
|
|
step="10"
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-slate-400">
|
|
Current inflow: {formatFlow(selectedNode.inflow)}
|
|
<br />
|
|
Absorbed: {formatFlow(selectedNode.absorbed)}
|
|
<br />
|
|
Outflow: {formatFlow(selectedNode.outflow)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Selected Allocation Editor */}
|
|
{selectedAllocation && (
|
|
<div className="bg-slate-700 p-4 rounded">
|
|
<h3 className="font-semibold text-cyan-400 mb-3">Edit Allocation</h3>
|
|
<div className="text-xs space-y-3">
|
|
<div>
|
|
<span className="text-slate-400">From: </span>
|
|
<span className="text-white font-medium">
|
|
{network.nodes.find(n => n.id === selectedAllocation.sourceNodeId)?.name}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400">To: </span>
|
|
<span className="text-white font-medium">
|
|
{network.nodes.find(n => n.id === selectedAllocation.targetNodeId)?.name}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<label className="text-slate-400 block mb-1">
|
|
Percentage: {Math.round(selectedAllocation.percentage * 100)}%
|
|
</label>
|
|
{selectedAllocationSiblings.length === 1 ? (
|
|
<div className="text-[10px] text-yellow-400 bg-slate-800 p-2 rounded">
|
|
Single allocation must be 100%.
|
|
</div>
|
|
) : (
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={selectedAllocation.percentage * 100}
|
|
onChange={(e) =>
|
|
updateAllocationPercentage(
|
|
selectedAllocation.id,
|
|
parseFloat(e.target.value) / 100
|
|
)
|
|
}
|
|
className="w-full"
|
|
/>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => deleteAllocation(selectedAllocation.id)}
|
|
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors"
|
|
>
|
|
Delete Allocation
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Selected Node Details */}
|
|
{selectedNode && (
|
|
<div className="bg-slate-700 p-4 rounded">
|
|
<h3 className="font-semibold text-cyan-400 mb-3">Node Details</h3>
|
|
<div className="text-xs space-y-2">
|
|
<div>
|
|
<span className="text-slate-400">Name: </span>
|
|
<span className="text-white font-medium">{selectedNode.name}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400">Status: </span>
|
|
<span className={`font-medium ${getFlowStatusColorClass(selectedNode.status)}`}>
|
|
{selectedNode.status.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="pt-2 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Inflow:</span>
|
|
<span className="text-blue-400 font-mono">{formatFlow(selectedNode.inflow)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Absorbed:</span>
|
|
<span className="text-green-400 font-mono">{formatFlow(selectedNode.absorbed)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Outflow:</span>
|
|
<span className="text-yellow-400 font-mono">{formatFlow(selectedNode.outflow)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Min Absorption:</span>
|
|
<span className="text-white font-mono">{formatFlow(selectedNode.minAbsorption)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">Max Absorption:</span>
|
|
<span className="text-white font-mono">{formatFlow(selectedNode.maxAbsorption)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Outgoing Allocations */}
|
|
{outgoingAllocations.length > 0 && (
|
|
<div className="pt-3 border-t border-slate-600">
|
|
<div className="text-slate-400 mb-2">Outgoing Allocations:</div>
|
|
{outgoingAllocations.map((alloc) => {
|
|
const target = network.nodes.find(n => n.id === alloc.targetNodeId)
|
|
return (
|
|
<div
|
|
key={alloc.id}
|
|
className="flex justify-between items-center mb-1 cursor-pointer hover:bg-slate-600 p-1 rounded"
|
|
onClick={() => setSelectedAllocationId(alloc.id)}
|
|
>
|
|
<span className="text-white">→ {target?.name}</span>
|
|
<span className="text-cyan-400 font-mono">
|
|
{Math.round(alloc.percentage * 100)}%
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="bg-slate-700 p-4 rounded">
|
|
<h3 className="text-sm font-semibold text-slate-400 mb-3">Legend</h3>
|
|
<div className="space-y-2 text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-red-500 rounded"></div>
|
|
<span>Starved - Below minimum absorption</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
|
<span>Minimum - At minimum absorption</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
|
<span>Healthy - Between min and max</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
|
<span>Saturated - At maximum capacity</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-green-400 rounded-full"></div>
|
|
<span>Particle - Flow animation</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Instructions */}
|
|
<div className="bg-slate-700 p-4 rounded text-xs text-slate-300">
|
|
<p className="mb-2">
|
|
<strong className="text-white">Flow-Based Model</strong>
|
|
</p>
|
|
<ul className="space-y-1 list-disc list-inside">
|
|
<li>Click node to select and <strong className="text-green-400">set flow</strong></li>
|
|
<li>Use <strong className="text-cyan-400">Create Arrow</strong> to draw allocations</li>
|
|
<li>Watch flows <strong className="text-green-400">propagate</strong> in real-time</li>
|
|
<li>Press <kbd className="px-1 bg-slate-800 rounded">Space</kbd> to pause/play animation</li>
|
|
<li>Overflow sink appears automatically if needed</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|