import { useMemo, useCallback, useState, useRef, useEffect } from 'react' import type { Node, Edge } from '@xyflow/react' import type { TreeStructure } from '@/types' import { getLayoutedElements, NODE_WIDTH } from '@/lib/dagreLayout' import type { FlowCanvasNodeData } from './FlowCanvasNode' import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode' import { useTreeEditorStore } from '@/store/treeEditorStore' const MAX_EDGE_LABEL_LENGTH = 35 function truncateLabel(label: string): string { if (label.length <= MAX_EDGE_LABEL_LENGTH) return label return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…' } function estimateNodeHeight(node: TreeStructure): number { let height = 52 // header baseline if (node.type === 'decision' && node.options) { height += 24 // options header line height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown) if (node.options.length > 3) height += 18 // "+N more" row } if ((node.type === 'action' || node.type === 'solution') && node.description) { height += 36 // description preview (2 lines) } if (node.type === 'answer') { return 70 // fixed height for answer stubs } return height } interface UseTreeLayoutResult { nodes: Node[] edges: Edge[] collapsedNodeIds: Set toggleCollapse: (nodeId: string) => void onNodesMeasured: (measuredNodes: Node[]) => void } export function useTreeLayout(): UseTreeLayoutResult { const treeStructure = useTreeEditorStore(s => s.treeStructure) const validationErrors = useTreeEditorStore(s => s.validationErrors) const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) const [measuredHeights, setMeasuredHeights] = useState>(new Map()) const correctionDone = useRef(false) const toggleCollapse = useCallback((nodeId: string) => { setCollapsedNodeIds(prev => { const next = new Set(prev) if (next.has(nodeId)) next.delete(nodeId) else next.add(nodeId) return next }) }, []) // Convert tree structure to flat nodes and edges const { rawNodes, rawEdges } = useMemo(() => { const nodes: Node[] = [] const edges: Edge[] = [] if (!treeStructure) return { rawNodes: nodes, rawEdges: edges } function walk(node: TreeStructure, _parentId: string | null) { const isCollapsed = collapsedNodeIds.has(node.id) const hasChildren = (node.children?.length ?? 0) > 0 const hasErrors = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error') const estimatedHeight = measuredHeights.get(node.id) ?? estimateNodeHeight(node) if (node.type === 'answer') { nodes.push({ id: node.id, type: 'answerStub', position: { x: 0, y: 0 }, // dagre will set this data: { node, onSelectType: () => {}, // placeholder — set by FlowCanvas } satisfies FlowCanvasAnswerNodeData, style: { width: NODE_WIDTH }, measured: { width: NODE_WIDTH, height: estimatedHeight }, }) } else { nodes.push({ id: node.id, type: 'flowNode', position: { x: 0, y: 0 }, data: { node, hasChildren, isCollapsed, hasValidationErrors: hasErrors, isNew: false, onToggleCollapse: () => {}, // placeholder — set by FlowCanvas } satisfies FlowCanvasNodeData, style: { width: NODE_WIDTH }, measured: { width: NODE_WIDTH, height: estimatedHeight }, }) } // Skip children if collapsed if (isCollapsed) return // Create edges and recurse into children if (node.children) { // For decision nodes: order children by option link, then unlinked const orderedChildren = orderChildren(node) for (const { child, optionLabel } of orderedChildren) { const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined edges.push({ id: `${node.id}->${child.id}`, source: node.id, target: child.id, type: 'smoothstep', label: edgeLabel, labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 11 }, labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.9 }, labelBgPadding: [4, 2] as [number, number], style: { stroke: 'hsl(var(--border))' }, }) walk(child, node.id) } } } walk(treeStructure, null) return { rawNodes: nodes, rawEdges: edges } }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights]) // Run dagre layout const { nodes, edges } = useMemo(() => { if (rawNodes.length === 0) return { nodes: rawNodes, edges: rawEdges } const layouted = getLayoutedElements(rawNodes, rawEdges) return { nodes: layouted, edges: rawEdges } }, [rawNodes, rawEdges]) // Height measurement correction callback const onNodesMeasured = useCallback((measuredNodes: Node[]) => { if (correctionDone.current) return let needsCorrection = false const newHeights = new Map(measuredHeights) for (const mNode of measuredNodes) { const actual = mNode.measured?.height if (!actual) continue const estimated = measuredHeights.get(mNode.id) ?? estimateNodeHeight( (mNode.data as unknown as FlowCanvasNodeData)?.node ?? (mNode.data as unknown as FlowCanvasAnswerNodeData)?.node ) if (Math.abs(actual - estimated) > 10) { newHeights.set(mNode.id, actual) needsCorrection = true } } if (needsCorrection) { correctionDone.current = true setMeasuredHeights(newHeights) } }, [measuredHeights]) // Reset correction flag when tree structure changes useEffect(() => { correctionDone.current = false }, [treeStructure, collapsedNodeIds]) return { nodes, edges, collapsedNodeIds, toggleCollapse, onNodesMeasured } } // Helper: order children by decision option links function orderChildren(node: TreeStructure): Array<{ child: TreeStructure; optionLabel?: string }> { if (!node.children || node.children.length === 0) return [] if (node.type === 'decision' && node.options) { const linked: Array<{ child: TreeStructure; optionLabel: string }> = [] const linkedIds = new Set() for (const opt of node.options) { if (opt.next_node_id) { const child = node.children.find(c => c.id === opt.next_node_id) if (child) { linked.push({ child, optionLabel: opt.label }) linkedIds.add(child.id) } } } const unlinked = node.children .filter(c => !linkedIds.has(c.id)) .map(child => ({ child, optionLabel: undefined })) return [...linked, ...unlinked] } return node.children.map(child => ({ child, optionLabel: undefined })) }