'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 for spending (cool tones - going DOWN to outcomes) const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] // Overflow colors (warm tones - going SIDEWAYS to other funnels) const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] function FunnelNode({ data, selected, id }: NodeProps) { const nodeData = data as FunnelNodeData const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const [dragging, setDragging] = useState<'min' | 'max' | null>(null) const [isEditing, setIsEditing] = useState(false) const [editValues, setEditValues] = useState({ minThreshold: nodeData.minThreshold, maxThreshold: nodeData.maxThreshold, label: label, }) const sliderRef = useRef(null) // Calculate status const isOverflowing = currentValue > maxThreshold const isCritical = currentValue < minThreshold // Funnel dimensions const width = 200 const height = 160 const topWidth = 180 const bottomWidth = 50 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]) // Double-click to edit const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() setEditValues({ minThreshold, maxThreshold, label, }) setIsEditing(true) }, [minThreshold, maxThreshold, label]) const handleSaveEdit = useCallback(() => { setMinThreshold(editValues.minThreshold) setMaxThreshold(editValues.maxThreshold) setIsEditing(false) }, [editValues]) const handleCancelEdit = useCallback(() => { setIsEditing(false) }, []) // Pie chart calculations for SPENDING (downward to outcomes) const spendingPieRadius = 20 const spendingPieCenter = { x: spendingPieRadius + 4, y: spendingPieRadius + 4 } const getSpendingPieSlices = () => { if (spendingAllocations.length === 0) return [] let currentAngle = -90 return spendingAllocations.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 = spendingPieCenter.x + spendingPieRadius * Math.cos(startRad) const y1 = spendingPieCenter.y + spendingPieRadius * Math.sin(startRad) const x2 = spendingPieCenter.x + spendingPieRadius * Math.cos(endRad) const y2 = spendingPieCenter.y + spendingPieRadius * Math.sin(endRad) const largeArc = angle > 180 ? 1 : 0 return { path: `M ${spendingPieCenter.x} ${spendingPieCenter.y} L ${x1} ${y1} A ${spendingPieRadius} ${spendingPieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`, color: alloc.color || SPENDING_COLORS[idx % SPENDING_COLORS.length], percentage: alloc.percentage, targetId: alloc.targetId, } }) } // Mini bar chart for OVERFLOW (sideways to other funnels) const getOverflowBars = () => { return overflowAllocations.map((alloc, idx) => ({ color: alloc.color || OVERFLOW_COLORS[idx % OVERFLOW_COLORS.length], percentage: alloc.percentage, targetId: alloc.targetId, })) } const spendingSlices = getSpendingPieSlices() const overflowBars = getOverflowBars() const hasOverflow = overflowAllocations.length > 0 const hasSpending = spendingAllocations.length > 0 return ( <>
{/* Top Handle - Inflow from parent funnel overflow */} {/* Header */}
{label} {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
{/* Main content */}
{/* Funnel SVG */} {/* Zone backgrounds */} {/* Liquid fill */} {/* Funnel outline */} {/* Threshold zone indicator */} {/* Overflow particles - flying to the sides */} {isOverflowing && ( <> )} {/* Spending flow particles - going down through the funnel */} {hasSpending && currentValue > minThreshold && ( <> )} {/* Right side info */}
{/* Spending pie chart (downward) */} {hasSpending && (
Spend {spendingSlices.map((slice, idx) => ( ))}
)} {/* Overflow bars (sideways) */} {hasOverflow && (
Overflow
{overflowBars.map((bar, idx) => (
))}
→ ←
)}
{/* 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 */}
{/* Min handle */}
handleSliderMouseDown(e, 'min')} /> {/* Max handle */}
handleSliderMouseDown(e, 'max')} />
Double-click to edit
{/* Bottom Handle - Spending outflow to outcomes */} {/* Side Handles - Overflow to other funnels */} {/* Side Handles - Inflow from other funnel overflow */}
{/* Edit Modal */} {isEditing && (
e.stopPropagation()} >

Edit {label}

{/* Min Threshold */}
setEditValues(v => ({ ...v, minThreshold: Number(e.target.value) }))} className="flex-1" /> ${(editValues.minThreshold / 1000).toFixed(0)}k
{/* Max Threshold */}
setEditValues(v => ({ ...v, maxThreshold: Number(e.target.value) }))} className="flex-1" /> ${(editValues.maxThreshold / 1000).toFixed(0)}k
{/* Visual preview */}
Threshold Range
0 ${(maxCapacity / 1000).toFixed(0)}k
{/* Overflow allocations info */} {hasOverflow && (
Overflow Allocations (to other funnels)
{overflowAllocations.map((alloc, idx) => (
{alloc.targetId} {alloc.percentage}%
))}
)} {/* Spending allocations info */} {hasSpending && (
Spending Allocations (to outcomes)
{spendingAllocations.map((alloc, idx) => (
{alloc.targetId} {alloc.percentage}%
))}
)}
{/* Buttons */}
)} ) } export default memo(FunnelNode)