feat: render cross-reference edges as dashed purple arrows on canvas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,46 @@ function estimateNodeHeight(node: TreeStructure): number {
|
|||||||
return height
|
return height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Collect all node IDs in the tree. */
|
||||||
|
function collectAllIds(root: TreeStructure): Set<string> {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
function walk(node: TreeStructure) {
|
||||||
|
ids.add(node.id)
|
||||||
|
node.children?.forEach(walk)
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find all cross-reference edges (next_node_id pointing outside children). */
|
||||||
|
function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; target: string; label?: string }> {
|
||||||
|
const refs: Array<{ source: string; target: string; label?: string }> = []
|
||||||
|
const allIds = collectAllIds(root)
|
||||||
|
|
||||||
|
function walk(node: TreeStructure) {
|
||||||
|
const childIds = new Set(node.children?.map(c => c.id) ?? [])
|
||||||
|
|
||||||
|
// Decision options pointing outside children
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
for (const opt of node.options) {
|
||||||
|
if (opt.next_node_id && !childIds.has(opt.next_node_id) && allIds.has(opt.next_node_id)) {
|
||||||
|
refs.push({ source: node.id, target: opt.next_node_id, label: opt.label })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action next_node_id pointing to non-child (always a cross-ref since actions use next_node_id not children)
|
||||||
|
if (node.type === 'action' && node.next_node_id && allIds.has(node.next_node_id) && !childIds.has(node.next_node_id)) {
|
||||||
|
refs.push({ source: node.id, target: node.next_node_id, label: 'loops back' })
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children?.forEach(walk)
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(root)
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
interface UseTreeLayoutResult {
|
interface UseTreeLayoutResult {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
@@ -128,6 +168,40 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
|||||||
|
|
||||||
walk(treeStructure, null)
|
walk(treeStructure, null)
|
||||||
|
|
||||||
|
// Add cross-reference edges (dashed, purple)
|
||||||
|
if (treeStructure) {
|
||||||
|
const crossRefs = collectCrossRefEdges(treeStructure)
|
||||||
|
for (const ref of crossRefs) {
|
||||||
|
// Only add if both source and target nodes are visible (not collapsed away)
|
||||||
|
const sourceVisible = nodes.some(n => n.id === ref.source)
|
||||||
|
const targetVisible = nodes.some(n => n.id === ref.target)
|
||||||
|
if (sourceVisible && targetVisible) {
|
||||||
|
edges.push({
|
||||||
|
id: `xref-${ref.source}->${ref.target}`,
|
||||||
|
source: ref.source,
|
||||||
|
target: ref.target,
|
||||||
|
type: 'smoothstep',
|
||||||
|
animated: true,
|
||||||
|
label: ref.label ? truncateLabel(ref.label) : undefined,
|
||||||
|
labelStyle: { fill: 'hsl(var(--primary))', fontSize: 10, fontWeight: 500 },
|
||||||
|
labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.95 },
|
||||||
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
|
style: {
|
||||||
|
stroke: 'hsl(var(--primary))',
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeDasharray: '6 3',
|
||||||
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: 'arrowclosed' as const,
|
||||||
|
color: 'hsl(var(--primary))',
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { rawNodes: nodes, rawEdges: edges }
|
return { rawNodes: nodes, rawEdges: edges }
|
||||||
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])
|
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user