From c85c6368f32dd7ced54b915078a89f0dfb61add3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:10:05 -0500 Subject: [PATCH] feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre Co-Authored-By: Claude Opus 4.6 --- .../components/tree-editor/useTreeLayout.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 frontend/src/components/tree-editor/useTreeLayout.ts diff --git a/frontend/src/components/tree-editor/useTreeLayout.ts b/frontend/src/components/tree-editor/useTreeLayout.ts new file mode 100644 index 00000000..3c8aef2c --- /dev/null +++ b/frontend/src/components/tree-editor/useTreeLayout.ts @@ -0,0 +1,199 @@ +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 })) +}