better-chatbot / src /lib /ai /workflow /arrange-nodes.ts
Bot
Initial commit for HF Spaces
05c5ed5
Raw
History Blame Contribute Delete
11.1 kB
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<string, string[]>();
const parentsMap = new Map<string, string[]>();
// 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<string, number>();
const queue = [{ nodeId: inputNode.id, level: 0 }];
const visited = new Set<string>();
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<string, { x: number; y: number }>();
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<string, NodeToPlace[]>();
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;
}