import { Edge } from "@xyflow/react"; import { UINode, NodeKind } from "./workflow.interface"; const LEVEL_GAP = 360; // Gap between levels on X axis const NODE_PADDING = 120; // Padding between nodes to prevent overlap const DEFAULT_NODE_HEIGHT = 120; // Default node height if measured is not available export interface ArrangeNodesResult { nodes: UINode[]; } interface PlacedNode { id: string; y: number; height: number; topBound: number; bottomBound: number; } interface NodeToPlace { nodeId: string; parentIds: string[]; originalY: number; // Keep track of original Y position height: number; } /** * Arrange workflow nodes in a hierarchical layout * Starting from Input node as root, arranges nodes in a DAG structure * Considers existing positions and actual node heights to prevent overlaps */ export function arrangeNodes( nodes: UINode[], edges: Edge[], ): ArrangeNodesResult { // Create a copy of nodes const arrangedNodes = nodes.map((node) => ({ ...node })); // Filter out nodes without edges const connectedNodeIds = new Set([ ...edges.map((edge) => edge.source), ...edges.map((edge) => edge.target), ]); const nodesWithEdges = arrangedNodes.filter((node) => connectedNodeIds.has(node.id), ); // Find Input node const inputNode = nodesWithEdges.find( (node) => node.data.kind === NodeKind.Input, ); if (!inputNode) { return { nodes: arrangedNodes }; } // Build adjacency maps const childrenMap = new Map(); const parentsMap = new Map(); // Sort edges for condition nodes by sourceHandle priority const sortedEdges = [...edges].sort((a, b) => { if (a.source === b.source) { return ( getSourceHandlePriority(a.sourceHandle) - getSourceHandlePriority(b.sourceHandle) ); } return 0; }); sortedEdges.forEach((edge) => { if (!childrenMap.has(edge.source)) { childrenMap.set(edge.source, []); } if (!parentsMap.has(edge.target)) { parentsMap.set(edge.target, []); } childrenMap.get(edge.source)!.push(edge.target); parentsMap.get(edge.target)!.push(edge.source); }); // Calculate levels using BFS const levels = new Map(); const queue = [{ nodeId: inputNode.id, level: 0 }]; const visited = new Set(); while (queue.length > 0) { const { nodeId, level } = queue.shift()!; if (visited.has(nodeId)) continue; visited.add(nodeId); levels.set(nodeId, level); const children = childrenMap.get(nodeId) || []; children.forEach((childId) => { if (!visited.has(childId)) { queue.push({ nodeId: childId, level: level + 1 }); } }); } // Calculate node positions const nodePositions = new Map(); const maxLevel = Math.max(...levels.values()); // Place input node at origin nodePositions.set(inputNode.id, { x: 0, y: 0 }); // Process each level for (let level = 0; level <= maxLevel; level++) { if (level === 0) continue; // Input node already placed const levelNodes = Array.from(levels.entries()) .filter(([_, nodeLevel]) => nodeLevel === level) .map(([nodeId, _]) => nodeId); // Group nodes by their parents const parentGroups = new Map(); levelNodes.forEach((nodeId) => { const parents = parentsMap.get(nodeId) || []; const parentKey = parents.sort().join(","); const nodeHeight = getNodeHeight( arrangedNodes.find((n) => n.id === nodeId), ); const originalY = arrangedNodes.find((n) => n.id === nodeId)?.position.y || 0; if (!parentGroups.has(parentKey)) { parentGroups.set(parentKey, []); } parentGroups.get(parentKey)!.push({ nodeId, parentIds: parents, originalY, height: nodeHeight, }); }); // Sort each parent group by original Y position to maintain relative order within siblings parentGroups.forEach((groupNodes) => { groupNodes.sort((a, b) => a.originalY - b.originalY); }); // Sort parent groups by their parents' Y positions const sortedParentGroups = Array.from(parentGroups.entries()).sort( ([keyA], [keyB]) => { const parentsA = keyA ? keyA.split(",") : []; const parentsB = keyB ? keyB.split(",") : []; // Get average parent Y position for each group const getGroupParentAvgY = (parents: string[]) => { if (parents.length === 0) return 0; const parentYs = parents .map((parentId) => nodePositions.get(parentId)?.y) .filter((y) => y !== undefined) as number[]; return parentYs.length > 0 ? parentYs.reduce((sum, y) => sum + y, 0) / parentYs.length : 0; }; const avgYA = getGroupParentAvgY(parentsA); const avgYB = getGroupParentAvgY(parentsB); return avgYA - avgYB; }, ); // Track placed nodes in this level to avoid overlaps const placedNodesInLevel: PlacedNode[] = []; const x = level * LEVEL_GAP; // Place nodes from each parent group in order for (const [parentKey, groupNodes] of sortedParentGroups) { const parents = parentKey ? parentKey.split(",") : []; if (parents.length === 1) { // Single parent case const parentPos = nodePositions.get(parents[0]); if (!parentPos) continue; if (groupNodes.length === 1) { // Single child - try to keep same Y as parent, but avoid overlaps const nodeToPlace = groupNodes[0]; let targetY = parentPos.y; // Check for overlaps and adjust if necessary targetY = findNonOverlappingY( targetY, nodeToPlace.height, placedNodesInLevel, ); nodePositions.set(nodeToPlace.nodeId, { x, y: targetY }); placedNodesInLevel.push({ id: nodeToPlace.nodeId, y: targetY, height: nodeToPlace.height, topBound: targetY - nodeToPlace.height / 2 - NODE_PADDING / 2, bottomBound: targetY + nodeToPlace.height / 2 + NODE_PADDING / 2, }); } else { // Multiple children - distribute around parent, maintaining original relative order const totalRequiredHeight = groupNodes.reduce((sum, nodeToPlace) => { return sum + nodeToPlace.height + NODE_PADDING; }, -NODE_PADDING); // Remove last padding let startY = parentPos.y - totalRequiredHeight / 2; // Adjust start position to avoid overlaps with existing nodes const firstNodeHeight = groupNodes[0].height; const adjustedStartY = findNonOverlappingY( startY + firstNodeHeight / 2, firstNodeHeight, placedNodesInLevel, ); const adjustment = adjustedStartY - (startY + firstNodeHeight / 2); startY += adjustment; let currentY = startY; groupNodes.forEach((nodeToPlace) => { const nodeY = currentY + nodeToPlace.height / 2; nodePositions.set(nodeToPlace.nodeId, { x, y: nodeY }); placedNodesInLevel.push({ id: nodeToPlace.nodeId, y: nodeY, height: nodeToPlace.height, topBound: nodeY - nodeToPlace.height / 2 - NODE_PADDING / 2, bottomBound: nodeY + nodeToPlace.height / 2 + NODE_PADDING / 2, }); currentY += nodeToPlace.height + NODE_PADDING; }); } } else if (parents.length > 1) { // Merge node case - use the topmost parent's Y position, but avoid overlaps const parentPositions = parents .map((parentId) => nodePositions.get(parentId)) .filter((pos) => pos !== undefined) .sort((a, b) => a!.y - b!.y); if (parentPositions.length > 0) { const baseY = parentPositions[0]!.y; groupNodes.forEach((nodeToPlace) => { if (!nodePositions.has(nodeToPlace.nodeId)) { const targetY = findNonOverlappingY( baseY, nodeToPlace.height, placedNodesInLevel, ); nodePositions.set(nodeToPlace.nodeId, { x, y: targetY }); placedNodesInLevel.push({ id: nodeToPlace.nodeId, y: targetY, height: nodeToPlace.height, topBound: targetY - nodeToPlace.height / 2 - NODE_PADDING / 2, bottomBound: targetY + nodeToPlace.height / 2 + NODE_PADDING / 2, }); } }); } } } } // Apply positions to nodes arrangedNodes.forEach((node) => { const newPosition = nodePositions.get(node.id); if (newPosition) { node.position = newPosition; } }); return { nodes: arrangedNodes }; } /** * Get priority for sourceHandle sorting * Lower numbers have higher priority */ function getSourceHandlePriority( sourceHandle: string | undefined | null, ): number { if (!sourceHandle) return 0; switch (sourceHandle) { case "if": return 1; case "elseif": return 2; case "else": return 3; default: return 0; } } /** * Get the height of a node, considering measured height if available */ function getNodeHeight(node: UINode | undefined): number { if (!node) return DEFAULT_NODE_HEIGHT; // Check if node has measured height const measured = (node as any).measured; if (measured && measured.height) { return measured.height; } return DEFAULT_NODE_HEIGHT; } /** * Find a Y position that doesn't overlap with existing placed nodes */ function findNonOverlappingY( preferredY: number, nodeHeight: number, placedNodes: PlacedNode[], ): number { const halfHeight = nodeHeight / 2; const paddingHalf = NODE_PADDING / 2; let candidateY = preferredY; let attempts = 0; const maxAttempts = 100; // Prevent infinite loops while (attempts < maxAttempts) { const topBound = candidateY - halfHeight - paddingHalf; const bottomBound = candidateY + halfHeight + paddingHalf; // Check if this position overlaps with any placed node const hasOverlap = placedNodes.some( (placed) => !(bottomBound <= placed.topBound || topBound >= placed.bottomBound), ); if (!hasOverlap) { return candidateY; } // Find the closest conflicting node and position after it const conflictingNodes = placedNodes.filter( (placed) => !(bottomBound <= placed.topBound || topBound >= placed.bottomBound), ); if (conflictingNodes.length > 0) { const lowestConflictBottom = Math.max( ...conflictingNodes.map((n) => n.bottomBound), ); candidateY = lowestConflictBottom + halfHeight + paddingHalf; } else { candidateY += nodeHeight + NODE_PADDING; } attempts++; } return candidateY; }