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:
199
frontend/src/components/tree-editor/useTreeLayout.ts
Normal file
199
frontend/src/components/tree-editor/useTreeLayout.ts
Normal 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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user