feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-18 21:10:05 -05:00
parent e94171fb18
commit c85c6368f3

View File

@@ -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<string>
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<Set<string>>(new Set())
const [measuredHeights, setMeasuredHeights] = useState<Map<string, number>>(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<string>()
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 }))
}