diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 5a387fa..0aa6850 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -18,6 +18,7 @@ import '@xyflow/react/dist/style.css' import FunnelNode from './nodes/FunnelNode' import OutcomeNode from './nodes/OutcomeNode' +import AllocationEdge from './edges/AllocationEdge' import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types' import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets' @@ -26,8 +27,15 @@ const nodeTypes = { outcome: OutcomeNode, } +const edgeTypes = { + allocation: AllocationEdge, +} + // Generate edges with proportional Sankey-style widths -function generateEdges(nodes: FlowNode[]): FlowEdge[] { +function generateEdges( + nodes: FlowNode[], + onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void +): FlowEdge[] { const edges: FlowEdge[] = [] const flowValues: number[] = [] @@ -54,6 +62,7 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] { const sourceX = node.position.x const rate = data.inflowRate || 1 + const overflowCount = data.overflowAllocations?.length ?? 0 data.overflowAllocations?.forEach((alloc) => { const flowValue = (alloc.percentage / 100) * rate const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) @@ -83,27 +92,20 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] { width: 16, height: 16, }, - label: `${alloc.percentage}%`, - labelStyle: { - fontSize: 11, - fontWeight: 600, - fill: alloc.color, - }, - labelBgStyle: { - fill: 'white', - fillOpacity: 0.9, - }, - labelBgPadding: [4, 2] as [number, number], - labelBgBorderRadius: 4, data: { allocation: alloc.percentage, color: alloc.color, edgeType: 'overflow' as const, + sourceId: node.id, + targetId: alloc.targetId, + siblingCount: overflowCount, + onAdjust, }, - type: 'smoothstep', + type: 'allocation', }) }) + const spendingCount = data.spendingAllocations?.length ?? 0 data.spendingAllocations?.forEach((alloc) => { const flowValue = (alloc.percentage / 100) * rate const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) @@ -125,24 +127,16 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] { width: 16, height: 16, }, - label: `${alloc.percentage}%`, - labelStyle: { - fontSize: 11, - fontWeight: 600, - fill: alloc.color, - }, - labelBgStyle: { - fill: 'white', - fillOpacity: 0.9, - }, - labelBgPadding: [4, 2] as [number, number], - labelBgBorderRadius: 4, data: { allocation: alloc.percentage, color: alloc.color, edgeType: 'spending' as const, + sourceId: node.id, + targetId: alloc.targetId, + siblingCount: spendingCount, + onAdjust, }, - type: 'smoothstep', + type: 'allocation', }) }) }) @@ -158,7 +152,7 @@ interface FlowCanvasInnerProps { function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) { const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes) - const [edges, setEdges, onEdgesChange] = useEdgesState(generateEdges(initNodes)) + const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[]) const [isSimulating, setIsSimulating] = useState(mode === 'demo') const edgesRef = useRef(edges) edgesRef.current = edges @@ -173,6 +167,54 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC } }, [nodes, onNodesChange]) + // Adjust allocation percentage inline from edge +/- buttons + const onAdjustAllocation = useCallback( + (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => { + setNodes((nds) => nds.map((node) => { + if (node.id !== sourceId || node.type !== 'funnel') return node + const data = node.data as FunnelNodeData + const allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations' + const allocs = [...data[allocKey]] + if (allocs.length <= 1) return node + + const idx = allocs.findIndex(a => a.targetId === targetId) + if (idx === -1) return node + + const current = allocs[idx].percentage + const newPct = Math.max(5, Math.min(95, current + delta)) + const actualDelta = newPct - current + if (actualDelta === 0) return node + + // Apply delta to target, distribute inverse across siblings + const siblings = allocs.filter((_, i) => i !== idx) + const siblingTotal = siblings.reduce((s, a) => s + a.percentage, 0) + + const updated = allocs.map((a, i) => { + if (i === idx) return { ...a, percentage: newPct } + // Proportionally adjust siblings + const share = siblingTotal > 0 ? a.percentage / siblingTotal : 1 / siblings.length + return { ...a, percentage: Math.max(1, Math.round(a.percentage - actualDelta * share)) } + }) + + // Normalize to exactly 100 + const sum = updated.reduce((s, a) => s + a.percentage, 0) + if (sum !== 100 && updated.length > 1) { + const diff = 100 - sum + // Apply rounding correction to largest sibling + const largestSibIdx = updated.reduce((best, a, i) => + i !== idx && a.percentage > updated[best].percentage ? i : best, idx === 0 ? 1 : 0) + updated[largestSibIdx] = { ...updated[largestSibIdx], percentage: updated[largestSibIdx].percentage + diff } + } + + return { + ...node, + data: { ...data, [allocKey]: updated }, + } + })) + }, + [setNodes] + ) + // Smart edge regeneration const allocationsKey = useMemo(() => { return JSON.stringify( @@ -191,9 +233,9 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC }, [nodes]) useEffect(() => { - setEdges(generateEdges(nodes as FlowNode[])) + setEdges(generateEdges(nodes as FlowNode[], onAdjustAllocation)) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allocationsKey]) + }, [allocationsKey, onAdjustAllocation]) const onConnect = useCallback( (params: Connection) => { @@ -436,6 +478,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC onConnect={onConnect} onReconnect={onReconnect} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} edgesReconnectable={true} fitView fitViewOptions={{ padding: 0.15 }} diff --git a/components/edges/AllocationEdge.tsx b/components/edges/AllocationEdge.tsx new file mode 100644 index 0000000..3645bf0 --- /dev/null +++ b/components/edges/AllocationEdge.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useCallback } from 'react' +import { + getSmoothStepPath, + EdgeLabelRenderer, + BaseEdge, + type EdgeProps, +} from '@xyflow/react' +import type { FlowEdgeData } from '@/lib/types' + +export default function AllocationEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + style, + markerEnd, +}: EdgeProps) { + const edgeData = data as FlowEdgeData | undefined + + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }) + + const allocation = edgeData?.allocation ?? 0 + const color = edgeData?.color ?? '#94a3b8' + const hasSiblings = (edgeData?.siblingCount ?? 1) > 1 + + const handleAdjust = useCallback( + (delta: number) => { + if (!edgeData?.onAdjust || !edgeData.sourceId || !edgeData.targetId || !edgeData.edgeType) return + edgeData.onAdjust(edgeData.sourceId, edgeData.targetId, edgeData.edgeType, delta) + }, + [edgeData] + ) + + return ( + <> + + +
+ {hasSiblings && ( + + )} + + {Math.round(allocation)}% + + {hasSiblings && ( + + )} +
+
+ + ) +} diff --git a/lib/types.ts b/lib/types.ts index c802919..d0e61ef 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -43,6 +43,10 @@ export interface FlowEdgeData { allocation: number // percentage 0-100 color: string edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward + sourceId: string + targetId: string + siblingCount: number // how many allocations in this group + onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void [key: string]: unknown }