Replace rectangular nodes with funnel visualizations

n8n-style interface now uses funnel-shaped threshold nodes:
- Funnel shape with narrowing bottom (critical zone)
- Straight walls between MIN and MAX (healthy zone)
- Overflow zone at top with animated particles spilling over sides
- Vertical flow layout: Source (top) → Funnels (middle) → Recipients (bottom)
- Inflow enters via top handle (blue)
- Outflow exits via bottom handle (pink)
- Overflow exits via side handles (amber) when above MAX
- Interactive MIN/MAX sliders on each funnel
- Color-coded zones: amber (overflow), green (healthy), red (critical)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-29 18:45:03 +00:00
parent aa8c714146
commit 6bf9b0ed19
4 changed files with 360 additions and 162 deletions

View File

@ -26,43 +26,58 @@ const nodeTypes = {
recipient: RecipientNode, recipient: RecipientNode,
} }
// Vertical layout - sources at top, recipients at bottom
const initialNodes: FlowNode[] = [ const initialNodes: FlowNode[] = [
// Top row - Source
{ {
id: 'source-1', id: 'source-1',
type: 'source', type: 'source',
position: { x: 50, y: 150 }, position: { x: 350, y: 0 },
data: { data: {
label: 'Treasury', label: 'Treasury',
balance: 75000, balance: 100000,
flowRate: 500, flowRate: 1000,
}, },
}, },
// Middle row - Threshold funnels
{ {
id: 'threshold-1', id: 'threshold-1',
type: 'threshold', type: 'threshold',
position: { x: 350, y: 50 }, position: { x: 100, y: 200 },
data: { data: {
label: 'Public Goods Gate', label: 'Public Goods',
minThreshold: 10000, minThreshold: 15000,
maxThreshold: 50000, maxThreshold: 60000,
currentValue: 32000, currentValue: 72000, // Overflowing
}, },
}, },
{ {
id: 'threshold-2', id: 'threshold-2',
type: 'threshold', type: 'threshold',
position: { x: 350, y: 350 }, position: { x: 400, y: 200 },
data: { data: {
label: 'Research Gate', label: 'Research',
minThreshold: 5000, minThreshold: 20000,
maxThreshold: 30000, maxThreshold: 50000,
currentValue: 8500, currentValue: 35000, // Healthy
}, },
}, },
{
id: 'threshold-3',
type: 'threshold',
position: { x: 700, y: 200 },
data: {
label: 'Emergency',
minThreshold: 30000,
maxThreshold: 80000,
currentValue: 18000, // Critical
},
},
// Bottom row - Recipients
{ {
id: 'recipient-1', id: 'recipient-1',
type: 'recipient', type: 'recipient',
position: { x: 700, y: 50 }, position: { x: 50, y: 620 },
data: { data: {
label: 'Project Alpha', label: 'Project Alpha',
received: 24500, received: 24500,
@ -72,65 +87,103 @@ const initialNodes: FlowNode[] = [
{ {
id: 'recipient-2', id: 'recipient-2',
type: 'recipient', type: 'recipient',
position: { x: 700, y: 250 }, position: { x: 300, y: 620 },
data: { data: {
label: 'Project Beta', label: 'Project Beta',
received: 8000, received: 18000,
target: 25000, target: 25000,
}, },
}, },
{ {
id: 'recipient-3', id: 'recipient-3',
type: 'recipient', type: 'recipient',
position: { x: 700, y: 450 }, position: { x: 550, y: 620 },
data: { data: {
label: 'Research Fund', label: 'Research Lab',
received: 15000, received: 12000,
target: 15000, target: 40000,
},
},
{
id: 'recipient-4',
type: 'recipient',
position: { x: 800, y: 620 },
data: {
label: 'Reserve Fund',
received: 5000,
target: 50000,
}, },
}, },
] ]
const initialEdges: FlowEdge[] = [ const initialEdges: FlowEdge[] = [
// Source to thresholds (top to middle)
{ {
id: 'e1', id: 'e-source-t1',
source: 'source-1', source: 'source-1',
target: 'threshold-1', target: 'threshold-1',
animated: true, animated: true,
style: { stroke: '#3b82f6', strokeWidth: 2 }, style: { stroke: '#3b82f6', strokeWidth: 3 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' },
}, },
{ {
id: 'e2', id: 'e-source-t2',
source: 'source-1', source: 'source-1',
target: 'threshold-2', target: 'threshold-2',
animated: true, animated: true,
style: { stroke: '#3b82f6', strokeWidth: 2 }, style: { stroke: '#3b82f6', strokeWidth: 3 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' },
}, },
{ {
id: 'e3', 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', source: 'threshold-1',
target: 'recipient-1', target: 'recipient-1',
animated: true, animated: true,
style: { stroke: '#a855f7', strokeWidth: 2 }, style: { stroke: '#ec4899', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' },
}, },
{ {
id: 'e4', id: 'e-t1-r2',
source: 'threshold-1', source: 'threshold-1',
target: 'recipient-2', target: 'recipient-2',
animated: true, animated: true,
style: { stroke: '#a855f7', strokeWidth: 2 }, style: { stroke: '#ec4899', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' },
}, },
{ {
id: 'e5', id: 'e-t2-r3',
source: 'threshold-2', source: 'threshold-2',
target: 'recipient-3', target: 'recipient-3',
animated: true, animated: true,
style: { stroke: '#a855f7', strokeWidth: 2 }, style: { stroke: '#ec4899', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' }, 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' },
}, },
] ]
@ -174,7 +227,8 @@ export default function FlowCanvas() {
} }
if (node.type === 'threshold') { if (node.type === 'threshold') {
const data = node.data as ThresholdNodeData const data = node.data as ThresholdNodeData
const change = (Math.random() - 0.3) * 100 // Random walk for demo
const change = (Math.random() - 0.4) * 200
return { return {
...node, ...node,
data: { data: {
@ -190,7 +244,7 @@ export default function FlowCanvas() {
...node, ...node,
data: { data: {
...data, ...data,
received: Math.min(data.target, data.received + Math.random() * 50), received: Math.min(data.target, data.received + Math.random() * 20),
}, },
} }
} }
@ -198,7 +252,7 @@ export default function FlowCanvas() {
return node return node
}) })
) )
}, 1000) }, 500)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [isSimulating, setNodes]) }, [isSimulating, setNodes])
@ -213,7 +267,7 @@ export default function FlowCanvas() {
onConnect={onConnect} onConnect={onConnect}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
fitViewOptions={{ padding: 0.2 }} fitViewOptions={{ padding: 0.1 }}
className="bg-slate-50" className="bg-slate-50"
defaultEdgeOptions={{ defaultEdgeOptions={{
animated: true, animated: true,
@ -223,10 +277,10 @@ export default function FlowCanvas() {
<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" />
{/* Top Panel - Title and Controls */} {/* 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">Drag nodes to rearrange Connect nodes to create flows</p> <p className="text-sm text-slate-500 mt-1">Funds flow topbottom through funnel thresholds</p>
</Panel> </Panel>
{/* Simulation Toggle */} {/* Simulation Toggle */}
@ -239,29 +293,42 @@ export default function FlowCanvas() {
: 'bg-slate-200 text-slate-700 hover:bg-slate-300' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`} }`}
> >
{isSimulating ? '⏸ Pause' : '▶ Start'} Simulation {isSimulating ? '⏸ Pause' : '▶ Start'}
</button> </button>
</Panel> </Panel>
{/* 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">Node Types</div> <div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">Flow Types</div>
<div className="space-y-2"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-r from-blue-500 to-blue-600" /> <div className="w-8 h-0.5 bg-blue-500" />
<span className="text-sm text-slate-600">Source (Funding Origin)</span> <span className="text-slate-600">Inflow (from source)</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-r from-purple-500 to-purple-600" /> <div className="w-8 h-0.5 bg-pink-500" />
<span className="text-sm text-slate-600">Threshold Gate (Min/Max)</span> <span className="text-slate-600">Outflow (to recipients)</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-r from-emerald-500 to-emerald-600" /> <div className="w-8 h-0.5 bg-amber-500" style={{ backgroundImage: 'repeating-linear-gradient(90deg, #f59e0b 0, #f59e0b 5px, transparent 5px, transparent 10px)' }} />
<span className="text-sm text-slate-600">Recipient (Funded)</span> <span className="text-slate-600">Overflow (excess)</span>
</div> </div>
<div className="flex items-center gap-2"> </div>
<div className="w-4 h-4 rounded bg-gradient-to-r from-slate-500 to-slate-600" /> <div className="border-t border-slate-200 mt-3 pt-3">
<span className="text-sm text-slate-600">Recipient (Pending)</span> <div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Funnel Zones</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-amber-100 border border-amber-300" />
<span className="text-slate-600">Overflow (above MAX)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-emerald-100 border border-emerald-300" />
<span className="text-slate-600">Healthy (MIN to MAX)</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>
</Panel> </Panel>

View File

@ -13,16 +13,16 @@ function RecipientNode({ data, selected }: NodeProps) {
return ( return (
<div <div
className={` className={`
bg-white rounded-lg shadow-lg border-2 min-w-[200px] bg-white rounded-lg shadow-lg border-2 min-w-[180px]
transition-all duration-200 transition-all duration-200
${selected ? 'border-emerald-500 shadow-emerald-100' : 'border-slate-200'} ${selected ? 'border-emerald-500 shadow-emerald-100' : 'border-slate-200'}
`} `}
> >
{/* Input Handle */} {/* Input Handle - Top for vertical flow */}
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Top}
className="!w-3 !h-3 !bg-slate-400 !border-2 !border-white" className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-top-2"
/> />
{/* Header */} {/* Header */}
@ -49,7 +49,7 @@ function RecipientNode({ data, selected }: NodeProps) {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-slate-500 text-xs uppercase tracking-wide">Received</span> <span className="text-slate-500 text-xs uppercase tracking-wide">Received</span>
<span className="font-mono font-semibold text-slate-800"> <span className="font-mono font-semibold text-slate-800">
${received.toLocaleString()} ${Math.floor(received).toLocaleString()}
</span> </span>
</div> </div>

View File

@ -33,7 +33,7 @@ function SourceNode({ data, selected }: NodeProps) {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-slate-500 text-xs uppercase tracking-wide">Balance</span> <span className="text-slate-500 text-xs uppercase tracking-wide">Balance</span>
<span className="font-mono font-semibold text-slate-800"> <span className="font-mono font-semibold text-slate-800">
${balance.toLocaleString()} ${Math.floor(balance).toLocaleString()}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@ -42,13 +42,19 @@ function SourceNode({ data, selected }: NodeProps) {
${flowRate}/hr ${flowRate}/hr
</span> </span>
</div> </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> </div>
{/* Output Handle */} {/* Output Handle - Bottom for vertical flow */}
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Bottom}
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-white" className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
/> />
</div> </div>
) )

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useState, useCallback } from 'react' import { memo, useState } from 'react'
import { Handle, Position } from '@xyflow/react' import { Handle, Position } from '@xyflow/react'
import type { NodeProps } from '@xyflow/react' import type { NodeProps } from '@xyflow/react'
import type { ThresholdNodeData } from '@/lib/types' import type { ThresholdNodeData } from '@/lib/types'
@ -10,134 +10,259 @@ function ThresholdNode({ data, selected }: NodeProps) {
const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold)
const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold)
const currentValue = nodeData.currentValue const currentValue = nodeData.currentValue
const maxCapacity = 100000
// Calculate status // Calculate status
const getStatus = () => { const isOverflowing = currentValue > maxThreshold
if (currentValue < minThreshold) return { label: 'Below Min', color: 'red', bg: 'bg-red-500' } const isCritical = currentValue < minThreshold
if (currentValue > maxThreshold) return { label: 'Overflow', color: 'amber', bg: 'bg-amber-500' } const isHealthy = !isOverflowing && !isCritical
return { label: 'Active', color: 'green', bg: 'bg-emerald-500' }
}
const status = getStatus() // Funnel dimensions
const fillPercent = Math.min(100, Math.max(0, ((currentValue - minThreshold) / (maxThreshold - minThreshold)) * 100)) 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 ( return (
<div <div
className={` className={`
bg-white rounded-lg shadow-lg border-2 min-w-[240px] bg-white rounded-xl shadow-lg border-2 transition-all duration-200
transition-all duration-200 ${selected ? 'border-purple-500 shadow-purple-200' : 'border-slate-200'}
${selected ? 'border-purple-500 shadow-purple-100' : 'border-slate-200'}
`} `}
style={{ width: width + 80, padding: '12px' }}
> >
{/* Input Handle */} {/* Top Handle - Inflow */}
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Top}
className="!w-3 !h-3 !bg-slate-400 !border-2 !border-white" className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-top-2"
/> />
{/* Header */} {/* Header */}
<div className="bg-gradient-to-r from-purple-500 to-purple-600 px-4 py-2 rounded-t-md"> <div className="text-center mb-2">
<div className="flex items-center justify-between"> <div className="font-semibold text-slate-800 text-sm">{nodeData.label}</div>
<div className="flex items-center gap-2"> <div className={`text-xs px-2 py-0.5 rounded-full inline-block mt-1 ${
<div className="w-6 h-6 bg-white/20 rounded flex items-center justify-center"> isOverflowing ? 'bg-amber-100 text-amber-700' :
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"> isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /> }`}>
</svg> {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
</div>
<span className="text-white font-medium text-sm">{nodeData.label}</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium text-white ${status.bg}`}>
{status.label}
</span>
</div> </div>
</div> </div>
{/* Body */} {/* Funnel SVG */}
<div className="p-4 space-y-4"> <svg width={width} height={height} className="mx-auto overflow-visible">
{/* Current Value Display */} <defs>
<div className="text-center"> <linearGradient id={`fill-${nodeData.label}`} x1="0%" y1="0%" x2="0%" y2="100%">
<span className="text-2xl font-bold font-mono text-slate-800"> <stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
${currentValue.toLocaleString()} <stop offset="100%" stopColor={isOverflowing ? '#f59e0b' : isCritical ? '#ef4444' : '#10b981'} />
</span> </linearGradient>
<p className="text-xs text-slate-500 mt-1">Current Value</p> <clipPath id={`clip-${nodeData.label}`}>
</div> <path d={clipPath} />
</clipPath>
</defs>
{/* Visual Bar */} {/* Zone backgrounds */}
<div className="relative"> {/* Overflow zone */}
<div className="h-8 bg-slate-100 rounded-lg overflow-hidden relative"> <rect
{/* Fill */} x={leftTop}
<div y={padding}
className={`absolute left-0 top-0 h-full transition-all duration-500 ${ width={topWidth}
currentValue < minThreshold ? 'bg-red-400' : height={maxY - padding}
currentValue > maxThreshold ? 'bg-amber-400' : 'bg-emerald-400' fill="#fef3c7"
}`} rx="2"
style={{ />
width: currentValue < minThreshold {/* Healthy zone */}
? `${(currentValue / minThreshold) * 33}%` <rect
: currentValue > maxThreshold x={leftTop}
? '100%' y={maxY}
: `${33 + fillPercent * 0.67}%` width={topWidth}
}} height={funnelStartY - maxY}
/> fill="#d1fae5"
{/* Min marker */} />
<div {/* Critical zone (funnel part) */}
className="absolute top-0 bottom-0 w-0.5 bg-red-500" <path
style={{ left: '33%' }} d={`
/> M ${leftTop} ${funnelStartY}
{/* Max marker */} L ${leftBottom} ${height - padding - 15}
<div L ${rightBottom} ${height - padding - 15}
className="absolute top-0 bottom-0 w-0.5 bg-amber-500" L ${rightTop} ${funnelStartY}
style={{ left: '100%', transform: 'translateX(-2px)' }} Z
/> `}
</div> fill="#fee2e2"
<div className="flex justify-between mt-1 text-xs text-slate-500"> />
<span>$0</span>
<span className="text-red-500">Min</span>
<span className="text-amber-500">Max</span>
</div>
</div>
{/* Threshold Controls */} {/* Liquid fill */}
<div className="space-y-3"> <g clipPath={`url(#clip-${nodeData.label})`}>
<div> <rect
<div className="flex justify-between items-center mb-1"> x={0}
<label className="text-xs text-slate-500 uppercase tracking-wide">Min Threshold</label> y={balanceY}
<span className="font-mono text-sm text-red-600">${minThreshold.toLocaleString()}</span> width={width}
</div> height={height}
<input fill={`url(#fill-${nodeData.label})`}
type="range" >
min="0" <animate
max={maxThreshold - 1000} attributeName="y"
value={minThreshold} values={`${balanceY};${balanceY - 1};${balanceY}`}
onChange={(e) => setMinThreshold(Number(e.target.value))} dur="1.5s"
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-red-500" repeatCount="indefinite"
/> />
</div> </rect>
</g>
<div> {/* Funnel outline */}
<div className="flex justify-between items-center mb-1"> <path
<label className="text-xs text-slate-500 uppercase tracking-wide">Max Threshold</label> d={`
<span className="font-mono text-sm text-amber-600">${maxThreshold.toLocaleString()}</span> M ${leftTop} ${padding}
</div> L ${leftTop} ${funnelStartY}
<input L ${leftBottom} ${height - padding - 15}
type="range" L ${leftBottom} ${height - padding}
min={minThreshold + 1000} M ${rightBottom} ${height - padding}
max="100000" L ${rightBottom} ${height - padding - 15}
value={maxThreshold} L ${rightTop} ${funnelStartY}
onChange={(e) => setMaxThreshold(Number(e.target.value))} L ${rightTop} ${padding}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500" `}
/> fill="none"
</div> 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>
</div> </div>
{/* Output Handle */} {/* 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 <Handle
type="source" type="source"
position={Position.Right} position={Position.Bottom}
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-white" 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> </div>
) )
} }