flow-funding/components/FundingFunnel.tsx

577 lines
20 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
interface FundingFunnelProps {
name: string
currentBalance: number
minThreshold: number
maxThreshold: number
inflowRate: number // per hour
outflowRate: number // per hour
maxCapacity?: number
onMinThresholdChange?: (value: number) => void
onMaxThresholdChange?: (value: number) => void
}
export default function FundingFunnel({
name,
currentBalance,
minThreshold: initialMin,
maxThreshold: initialMax,
inflowRate,
outflowRate,
maxCapacity = 100000,
onMinThresholdChange,
onMaxThresholdChange,
}: FundingFunnelProps) {
const [minThreshold, setMinThreshold] = useState(initialMin)
const [maxThreshold, setMaxThreshold] = useState(initialMax)
const [balance, setBalance] = useState(currentBalance)
const [isDraggingMin, setIsDraggingMin] = useState(false)
const [isDraggingMax, setIsDraggingMax] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Funnel dimensions
const width = 300
const height = 500
const funnelTopWidth = 280
const funnelNarrowWidth = 80
const padding = 10
// Calculate Y positions for thresholds (inverted - 0 is at bottom)
const minY = height - (minThreshold / maxCapacity) * height
const maxY = height - (maxThreshold / maxCapacity) * height
const balanceY = height - (balance / maxCapacity) * height
// Funnel shape points
const funnelPath = `
M ${padding} ${maxY}
L ${padding} ${minY}
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
L ${width - padding} ${minY}
L ${width - padding} ${maxY}
L ${padding} ${maxY}
`
// Overflow zone (above max)
const overflowPath = `
M ${padding} ${padding}
L ${padding} ${maxY}
L ${width - padding} ${maxY}
L ${width - padding} ${padding}
Z
`
// Calculate fill path based on current balance
const getFillPath = () => {
if (balance <= 0) return ''
const fillY = Math.max(balanceY, padding)
if (balance >= maxThreshold) {
// Overflow zone - straight walls above max
const overflowY = Math.max(fillY, padding)
return `
M ${padding} ${minY}
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
L ${width - padding} ${minY}
L ${width - padding} ${overflowY}
L ${padding} ${overflowY}
Z
`
} else if (balance >= minThreshold) {
// Between min and max - straight walls
return `
M ${padding} ${minY}
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
L ${width - padding} ${minY}
L ${width - padding} ${fillY}
L ${padding} ${fillY}
Z
`
} else {
// Below min - in the funnel narrowing section
const ratio = balance / minThreshold
const bottomWidth = funnelNarrowWidth
const topWidth = funnelTopWidth - 2 * padding
const currentWidth = bottomWidth + (topWidth - bottomWidth) * ratio
const leftX = (width - currentWidth) / 2
const rightX = (width + currentWidth) / 2
return `
M ${(width - funnelNarrowWidth) / 2} ${height - padding}
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
L ${rightX} ${fillY}
L ${leftX} ${fillY}
Z
`
}
}
// Simulate balance changes
useEffect(() => {
const interval = setInterval(() => {
setBalance((prev) => {
const netFlow = (inflowRate - outflowRate) / 3600 // per second
const newBalance = prev + netFlow
return Math.max(0, Math.min(maxCapacity * 1.2, newBalance))
})
}, 100)
return () => clearInterval(interval)
}, [inflowRate, outflowRate, maxCapacity])
// Handle threshold dragging
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const y = e.clientY - rect.top
const value = Math.max(0, Math.min(maxCapacity, ((height - y) / height) * maxCapacity))
if (isDraggingMin) {
const newMin = Math.min(value, maxThreshold - 1000)
setMinThreshold(Math.max(0, newMin))
onMinThresholdChange?.(Math.max(0, newMin))
} else if (isDraggingMax) {
const newMax = Math.max(value, minThreshold + 1000)
setMaxThreshold(Math.min(maxCapacity, newMax))
onMaxThresholdChange?.(Math.min(maxCapacity, newMax))
}
},
[isDraggingMin, isDraggingMax, maxThreshold, minThreshold, maxCapacity, onMinThresholdChange, onMaxThresholdChange]
)
const handleMouseUp = useCallback(() => {
setIsDraggingMin(false)
setIsDraggingMax(false)
}, [])
useEffect(() => {
if (isDraggingMin || isDraggingMax) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDraggingMin, isDraggingMax, handleMouseMove, handleMouseUp])
// Determine zone status
const getZoneStatus = () => {
if (balance < minThreshold) return { zone: 'critical', color: '#F43F5E', label: 'Below Minimum' }
if (balance > maxThreshold) return { zone: 'overflow', color: '#F59E0B', label: 'Overflow' }
return { zone: 'healthy', color: '#10B981', label: 'Healthy Range' }
}
const status = getZoneStatus()
return (
<div className="flex flex-col items-center">
<h3 className="text-xl font-bold text-white mb-2">{name}</h3>
<div className="flex gap-8">
{/* Main Funnel Visualization */}
<div
ref={containerRef}
className="relative bg-slate-900 rounded-2xl p-4 border border-slate-700"
style={{ width: width + 80, height: height + 40 }}
>
<svg width={width} height={height} className="overflow-visible">
<defs>
{/* Gradient for the fill */}
<linearGradient id={`fill-gradient-${name}`} x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stopColor="#3B82F6" stopOpacity="0.9" />
<stop offset="50%" stopColor="#8B5CF6" stopOpacity="0.8" />
<stop offset="100%" stopColor="#EC4899" stopOpacity="0.7" />
</linearGradient>
{/* Glow filter */}
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Wave pattern for liquid effect */}
<pattern id="wave" x="0" y="0" width="20" height="10" patternUnits="userSpaceOnUse">
<path
d="M0 5 Q5 0 10 5 T20 5"
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth="1"
/>
</pattern>
</defs>
{/* Background zones */}
{/* Overflow zone (above max) */}
<rect
x={padding}
y={padding}
width={width - 2 * padding}
height={maxY - padding}
fill="#F59E0B"
fillOpacity="0.1"
stroke="#F59E0B"
strokeWidth="1"
strokeDasharray="4 4"
/>
{/* Healthy zone (between min and max) - straight walls */}
<rect
x={padding}
y={maxY}
width={width - 2 * padding}
height={minY - maxY}
fill="#10B981"
fillOpacity="0.1"
/>
{/* Funnel zone (below min) */}
<path
d={`
M ${padding} ${minY}
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
L ${width - padding} ${minY}
Z
`}
fill="#F43F5E"
fillOpacity="0.1"
/>
{/* Funnel outline */}
<path
d={`
M ${padding} ${padding}
L ${padding} ${minY}
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
L ${width - padding} ${minY}
L ${width - padding} ${padding}
`}
fill="none"
stroke="#475569"
strokeWidth="2"
/>
{/* Fill (current balance) */}
<path
d={getFillPath()}
fill={`url(#fill-gradient-${name})`}
filter="url(#glow)"
>
<animate
attributeName="opacity"
values="0.8;1;0.8"
dur="2s"
repeatCount="indefinite"
/>
</path>
{/* Animated inflow particles */}
{inflowRate > 0 && (
<>
{[...Array(5)].map((_, i) => (
<circle
key={`inflow-${i}`}
r="4"
fill="#3B82F6"
opacity="0.8"
>
<animate
attributeName="cy"
values={`-10;${balanceY}`}
dur={`${1 + i * 0.2}s`}
repeatCount="indefinite"
begin={`${i * 0.2}s`}
/>
<animate
attributeName="cx"
values={`${width / 2 - 20 + i * 10};${width / 2 - 10 + i * 5}`}
dur={`${1 + i * 0.2}s`}
repeatCount="indefinite"
begin={`${i * 0.2}s`}
/>
<animate
attributeName="opacity"
values="0.8;0.8;0"
dur={`${1 + i * 0.2}s`}
repeatCount="indefinite"
begin={`${i * 0.2}s`}
/>
</circle>
))}
</>
)}
{/* Animated outflow particles */}
{outflowRate > 0 && balance > 0 && (
<>
{[...Array(3)].map((_, i) => (
<circle
key={`outflow-${i}`}
r="3"
fill="#EC4899"
opacity="0.8"
>
<animate
attributeName="cy"
values={`${height - padding};${height + 30}`}
dur={`${0.8 + i * 0.15}s`}
repeatCount="indefinite"
begin={`${i * 0.25}s`}
/>
<animate
attributeName="cx"
values={`${width / 2 - 10 + i * 10};${width / 2 - 15 + i * 15}`}
dur={`${0.8 + i * 0.15}s`}
repeatCount="indefinite"
begin={`${i * 0.25}s`}
/>
<animate
attributeName="opacity"
values="0.8;0.6;0"
dur={`${0.8 + i * 0.15}s`}
repeatCount="indefinite"
begin={`${i * 0.25}s`}
/>
</circle>
))}
</>
)}
{/* Max threshold line (draggable) */}
<g
className="cursor-ns-resize"
onMouseDown={() => setIsDraggingMax(true)}
>
<line
x1={0}
y1={maxY}
x2={width}
y2={maxY}
stroke="#F59E0B"
strokeWidth={isDraggingMax ? 4 : 2}
strokeDasharray="8 4"
/>
<rect
x={width - 8}
y={maxY - 12}
width={16}
height={24}
rx={4}
fill="#F59E0B"
className="cursor-ns-resize"
/>
<text
x={width + 12}
y={maxY + 5}
fill="#F59E0B"
fontSize="12"
fontFamily="monospace"
>
MAX ${maxThreshold.toLocaleString()}
</text>
</g>
{/* Min threshold line (draggable) */}
<g
className="cursor-ns-resize"
onMouseDown={() => setIsDraggingMin(true)}
>
<line
x1={padding}
y1={minY}
x2={width - padding}
y2={minY}
stroke="#F43F5E"
strokeWidth={isDraggingMin ? 4 : 2}
strokeDasharray="8 4"
/>
<rect
x={width - 8}
y={minY - 12}
width={16}
height={24}
rx={4}
fill="#F43F5E"
className="cursor-ns-resize"
/>
<text
x={width + 12}
y={minY + 5}
fill="#F43F5E"
fontSize="12"
fontFamily="monospace"
>
MIN ${minThreshold.toLocaleString()}
</text>
</g>
{/* Current balance indicator */}
<g>
<line
x1={0}
y1={balanceY}
x2={padding - 2}
y2={balanceY}
stroke={status.color}
strokeWidth={3}
/>
<polygon
points={`${padding - 2},${balanceY - 6} ${padding - 2},${balanceY + 6} ${padding + 6},${balanceY}`}
fill={status.color}
/>
</g>
</svg>
{/* Zone labels */}
<div
className="absolute text-xs text-amber-400/60 font-medium"
style={{ right: 8, top: maxY / 2 + 20 }}
>
OVERFLOW
</div>
<div
className="absolute text-xs text-emerald-400/60 font-medium"
style={{ right: 8, top: (maxY + minY) / 2 + 20 }}
>
HEALTHY
</div>
<div
className="absolute text-xs text-rose-400/60 font-medium"
style={{ right: 8, top: (minY + height) / 2 + 10 }}
>
CRITICAL
</div>
</div>
{/* Stats Panel */}
<div className="flex flex-col gap-4 min-w-[200px]">
{/* Current Balance */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
<div className="text-sm text-slate-400 mb-1">Current Balance</div>
<div className="text-3xl font-bold font-mono" style={{ color: status.color }}>
${balance.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</div>
<div
className="text-sm mt-2 px-2 py-1 rounded-full inline-block"
style={{ backgroundColor: `${status.color}20`, color: status.color }}
>
{status.label}
</div>
</div>
{/* Flow Rates */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
<div className="text-sm text-slate-400 mb-3">Flow Rates</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-slate-300">Inflow</span>
</div>
<span className="font-mono text-blue-400">+${inflowRate}/hr</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-pink-500" />
<span className="text-slate-300">Outflow</span>
</div>
<span className="font-mono text-pink-400">-${outflowRate}/hr</span>
</div>
<div className="border-t border-slate-600 pt-3">
<div className="flex items-center justify-between">
<span className="text-slate-300">Net Flow</span>
<span
className={`font-mono font-bold ${
inflowRate - outflowRate >= 0 ? 'text-emerald-400' : 'text-rose-400'
}`}
>
{inflowRate - outflowRate >= 0 ? '+' : ''}${inflowRate - outflowRate}/hr
</span>
</div>
</div>
</div>
</div>
{/* Thresholds */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
<div className="text-sm text-slate-400 mb-3">Thresholds</div>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-rose-400">Minimum</span>
<span className="font-mono text-rose-400">${minThreshold.toLocaleString()}</span>
</div>
<div className="text-xs text-slate-500">
Drag the red line to adjust
</div>
</div>
<div>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-amber-400">Maximum</span>
<span className="font-mono text-amber-400">${maxThreshold.toLocaleString()}</span>
</div>
<div className="text-xs text-slate-500">
Drag the yellow line to adjust
</div>
</div>
</div>
</div>
{/* Progress to thresholds */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
<div className="text-sm text-slate-400 mb-3">Progress</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-400">To Minimum</span>
<span className="text-slate-400">
{Math.min(100, (balance / minThreshold) * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-rose-500 transition-all duration-300"
style={{ width: `${Math.min(100, (balance / minThreshold) * 100)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-400">To Maximum</span>
<span className="text-slate-400">
{Math.min(100, (balance / maxThreshold) * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-amber-500 transition-all duration-300"
style={{ width: `${Math.min(100, (balance / maxThreshold) * 100)}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}