'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' // Colors const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] 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 [isEditing, setIsEditing] = useState(false) const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null) const [localOverflow, setLocalOverflow] = useState(overflowAllocations) const [localSpending, setLocalSpending] = useState(spendingAllocations) const sliderRef = useRef(null) const overflowPieRef = useRef(null) const spendingPieRef = useRef(null) // Calculate status const isOverflowing = currentValue > maxThreshold const isCritical = currentValue < minThreshold const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100) // Simplified funnel dimensions const width = 140 const height = 100 const topWidth = 120 const bottomWidth = 40 // Double-click to edit const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() setLocalOverflow([...overflowAllocations]) setLocalSpending([...spendingAllocations]) setIsEditing(true) }, [overflowAllocations, spendingAllocations]) const handleCloseEdit = useCallback(() => { setIsEditing(false) }, []) // Threshold slider drag const [draggingThreshold, setDraggingThreshold] = useState<'min' | 'max' | null>(null) const handleThresholdMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => { e.stopPropagation() setDraggingThreshold(type) }, []) useEffect(() => { if (!draggingThreshold || !sliderRef.current) return const handleMove = (e: MouseEvent) => { 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 (draggingThreshold === 'min') { setMinThreshold(Math.min(value, maxThreshold - 1000)) } else { setMaxThreshold(Math.max(value, minThreshold + 1000)) } } const handleUp = () => setDraggingThreshold(null) window.addEventListener('mousemove', handleMove) window.addEventListener('mouseup', handleUp) return () => { window.removeEventListener('mousemove', handleMove) window.removeEventListener('mouseup', handleUp) } }, [draggingThreshold, maxCapacity, minThreshold, maxThreshold]) // Pie chart drag editing useEffect(() => { if (!draggingPie) return const handleMove = (e: MouseEvent) => { const pieRef = draggingPie.type === 'overflow' ? overflowPieRef.current : spendingPieRef.current if (!pieRef) return const rect = pieRef.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2 const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI) + 90 const normalizedAngle = ((angle % 360) + 360) % 360 const percentage = Math.round((normalizedAngle / 360) * 100) if (draggingPie.type === 'overflow') { setLocalOverflow(prev => { const newAllocs = [...prev] const total = newAllocs.reduce((sum, a) => sum + a.percentage, 0) const diff = percentage - newAllocs[draggingPie.index].percentage // Redistribute to maintain 100% total if (newAllocs.length > 1) { const otherIdx = (draggingPie.index + 1) % newAllocs.length const newOther = Math.max(5, newAllocs[otherIdx].percentage - diff) const newCurrent = Math.max(5, Math.min(95, percentage)) newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent } newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) } } return newAllocs }) } else { setLocalSpending(prev => { const newAllocs = [...prev] if (newAllocs.length > 1) { const otherIdx = (draggingPie.index + 1) % newAllocs.length const newCurrent = Math.max(5, Math.min(95, percentage)) newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent } newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) } } return newAllocs }) } } const handleUp = () => setDraggingPie(null) window.addEventListener('mousemove', handleMove) window.addEventListener('mouseup', handleUp) return () => { window.removeEventListener('mousemove', handleMove) window.removeEventListener('mouseup', handleUp) } }, [draggingPie]) // Pie chart rendering helper const renderPieChart = (allocations: typeof overflowAllocations, colors: string[], type: 'overflow' | 'spending', size: number) => { if (allocations.length === 0) return null const center = size / 2 const radius = size / 2 - 4 let currentAngle = -90 return allocations.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 = center + radius * Math.cos(startRad) const y1 = center + radius * Math.sin(startRad) const x2 = center + radius * Math.cos(endRad) const y2 = center + radius * Math.sin(endRad) const largeArc = angle > 180 ? 1 : 0 return ( { e.stopPropagation() setDraggingPie({ type, index: idx }) } : undefined} /> ) }) } // Simple bar representation for allocations const renderSimpleBars = (allocations: typeof overflowAllocations, colors: string[], direction: 'horizontal' | 'vertical') => { if (allocations.length === 0) return null return (
{allocations.map((alloc, idx) => (
))}
) } const hasOverflow = overflowAllocations.length > 0 const hasSpending = spendingAllocations.length > 0 return ( <>
{/* TOP Handle - INFLOWS */} {/* Header */}
{label} {isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'}
{/* Simplified Funnel View */}
{/* Inflow indicator */}
In
{/* Simple funnel shape with fill */} {/* Funnel background */} {/* Fill level */} {/* Funnel outline */} {/* Value */}
${Math.floor(currentValue / 1000)}k
{/* Simplified allocation bars */}
{/* Outflow (left side) */}
Out {hasOverflow ? ( renderSimpleBars(overflowAllocations, OVERFLOW_COLORS, 'horizontal') ) : (
)}
{/* Outcomes (bottom indicator) */}
Spend {hasSpending ? ( renderSimpleBars(spendingAllocations, SPENDING_COLORS, 'horizontal') ) : (
)}
Double-click to edit
{/* SIDE Handles - OUTFLOWS to other funnels */} {/* BOTTOM Handle - OUTCOMES/DELIVERABLES */}
{/* Edit Modal */} {isEditing && (
e.stopPropagation()} >

{label}

{/* Current Value Display */}
${Math.floor(currentValue).toLocaleString()} / ${maxCapacity.toLocaleString()}
{/* MIN/MAX Threshold Slider */}
MIN: ${(minThreshold/1000).toFixed(0)}k MAX: ${(maxThreshold/1000).toFixed(0)}k
{/* Zone colors */}
{/* Current value indicator */}
{/* Min handle */}
handleThresholdMouseDown(e, 'min')} /> {/* Max handle */}
handleThresholdMouseDown(e, 'max')} />
$0 Drag handles to adjust ${(maxCapacity/1000).toFixed(0)}k
{/* Pie Charts Row */}
{/* Outflows Pie */} {localOverflow.length > 0 && (
Outflows (to Funnels) {renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 120)}
{localOverflow.map((alloc, idx) => (
{alloc.targetId} {alloc.percentage}%
))}
)} {/* Spending Pie */} {localSpending.length > 0 && (
Spending (to Outcomes) {renderPieChart(localSpending, SPENDING_COLORS, 'spending', 120)}
{localSpending.map((alloc, idx) => (
{alloc.targetId} {alloc.percentage}%
))}
)}
{(localOverflow.length > 0 || localSpending.length > 0) && (

Drag pie slices to adjust allocations

)} {/* Close button */}
)} ) } export default memo(FunnelNode)