'use client' import { memo, useState, useCallback, useRef, useEffect } from 'react' import { Handle, Position } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' import type { FunnelNodeData } from '@/lib/types' // Pie chart colors const PIE_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4'] function FunnelNode({ data, selected }: NodeProps) { const nodeData = data as FunnelNodeData const { label, currentValue, maxCapacity, outflowAllocations } = nodeData const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const [dragging, setDragging] = useState<'min' | 'max' | null>(null) const sliderRef = useRef(null) // Calculate status const isOverflowing = currentValue > maxThreshold const isCritical = currentValue < minThreshold // Funnel dimensions const width = 180 const height = 160 const topWidth = 160 const bottomWidth = 40 const padding = 8 // Calculate Y positions const scaleY = (value: number) => padding + ((maxCapacity - value) / maxCapacity) * (height * 0.65) const maxY = scaleY(maxThreshold) const minY = scaleY(minThreshold) const funnelStartY = minY + 10 const balanceY = Math.max(padding, scaleY(Math.min(currentValue, maxCapacity * 1.1))) // Funnel shape const leftTop = (width - topWidth) / 2 const rightTop = (width + topWidth) / 2 const leftBottom = (width - bottomWidth) / 2 const rightBottom = (width + bottomWidth) / 2 const clipPath = ` M ${leftTop} ${padding} L ${rightTop} ${padding} L ${rightTop} ${funnelStartY} L ${rightBottom} ${height - padding - 10} L ${rightBottom} ${height - padding} L ${leftBottom} ${height - padding} L ${leftBottom} ${height - padding - 10} L ${leftTop} ${funnelStartY} Z ` // Dual range slider logic const handleSliderMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => { e.stopPropagation() setDragging(type) }, []) const handleSliderMouseMove = useCallback((e: MouseEvent) => { if (!dragging || !sliderRef.current) return const rect = sliderRef.current.getBoundingClientRect() const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) const value = Math.round((x / rect.width) * maxCapacity) if (dragging === 'min') { setMinThreshold(Math.min(value, maxThreshold - 1000)) } else { setMaxThreshold(Math.max(value, minThreshold + 1000)) } }, [dragging, maxCapacity, minThreshold, maxThreshold]) const handleSliderMouseUp = useCallback(() => { setDragging(null) }, []) useEffect(() => { if (dragging) { window.addEventListener('mousemove', handleSliderMouseMove) window.addEventListener('mouseup', handleSliderMouseUp) return () => { window.removeEventListener('mousemove', handleSliderMouseMove) window.removeEventListener('mouseup', handleSliderMouseUp) } } }, [dragging, handleSliderMouseMove, handleSliderMouseUp]) // Pie chart calculations const pieRadius = 24 const pieCenter = { x: pieRadius + 4, y: pieRadius + 4 } const getPieSlices = () => { if (outflowAllocations.length === 0) return [] let currentAngle = -90 // Start at top return outflowAllocations.map((alloc, idx) => { const angle = (alloc.percentage / 100) * 360 const startAngle = currentAngle const endAngle = currentAngle + angle currentAngle = endAngle const startRad = (startAngle * Math.PI) / 180 const endRad = (endAngle * Math.PI) / 180 const x1 = pieCenter.x + pieRadius * Math.cos(startRad) const y1 = pieCenter.y + pieRadius * Math.sin(startRad) const x2 = pieCenter.x + pieRadius * Math.cos(endRad) const y2 = pieCenter.y + pieRadius * Math.sin(endRad) const largeArc = angle > 180 ? 1 : 0 return { path: `M ${pieCenter.x} ${pieCenter.y} L ${x1} ${y1} A ${pieRadius} ${pieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`, color: alloc.color || PIE_COLORS[idx % PIE_COLORS.length], percentage: alloc.percentage, targetId: alloc.targetId, } }) } const pieSlices = getPieSlices() return (
{/* Top Handle - Inflow */} {/* Header */}
{label} {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
{/* Main content - Funnel and Pie side by side */}
{/* Funnel SVG */} {/* Zone backgrounds */} {/* Liquid fill */} {/* Funnel outline */} {/* Threshold zone indicator (single bar on right side) */} {/* Overflow particles */} {isOverflowing && ( <> )} {/* Pie chart for outflow allocation */} {outflowAllocations.length > 0 && (
Outflow {pieSlices.map((slice, idx) => ( ))} {/* Mini legend */}
{outflowAllocations.slice(0, 3).map((alloc, idx) => (
{alloc.percentage}%
))}
)}
{/* Value display */}
${Math.floor(currentValue).toLocaleString()}
{/* Dual range slider */}
MIN: ${(minThreshold/1000).toFixed(0)}k MAX: ${(maxThreshold/1000).toFixed(0)}k
e.stopPropagation()} > {/* Track background */}
{/* Red zone (0 to min) */}
{/* Green zone (min to max) */}
{/* Amber zone (max to capacity) */}
{/* Min handle */}
handleSliderMouseDown(e, 'min')} /> {/* Max handle */}
handleSliderMouseDown(e, 'max')} />
{/* Bottom Handle - Outflow */} {/* Side Handles - Overflow */}
) } export default memo(FunnelNode)