|
|
@ -18,6 +18,16 @@ type LayoutNode = WorkflowNode & { |
|
|
type: NonNullable<WorkflowNode["type"]>; |
|
|
type: NonNullable<WorkflowNode["type"]>; |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
interface LayoutSegment { |
|
|
|
|
|
nodes: LayoutNode[]; |
|
|
|
|
|
edges: WorkflowEdge[]; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
interface SegmentLayout { |
|
|
|
|
|
positions: CanvasAutoLayoutPosition[]; |
|
|
|
|
|
height: number; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
function isLayoutNode(node: WorkflowNode): node is LayoutNode { |
|
|
function isLayoutNode(node: WorkflowNode): node is LayoutNode { |
|
|
return Boolean(node.type) && node.type !== "group" && !node.parentId; |
|
|
return Boolean(node.type) && node.type !== "group" && !node.parentId; |
|
|
} |
|
|
} |
|
|
@ -118,29 +128,75 @@ function orderLayerNodes( |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
export function calculateCanvasAutoLayout( |
|
|
function splitWorkflowSegments(nodes: LayoutNode[], edges: WorkflowEdge[]): LayoutSegment[] { |
|
|
nodes: WorkflowNode[], |
|
|
const nodeById = new Map(nodes.map((node) => [node.id, node])); |
|
|
edges: WorkflowEdge[], |
|
|
const adjacency = new Map(nodes.map((node) => [node.id, [] as string[]])); |
|
|
options: CanvasAutoLayoutOptions = {} |
|
|
|
|
|
): CanvasAutoLayoutPosition[] { |
|
|
|
|
|
const layoutNodes = nodes.filter(isLayoutNode); |
|
|
|
|
|
if (layoutNodes.length === 0) return []; |
|
|
|
|
|
|
|
|
|
|
|
const columnGap = options.columnGap ?? DEFAULT_COLUMN_GAP; |
|
|
edges.forEach((edge) => { |
|
|
const rowGap = options.rowGap ?? DEFAULT_ROW_GAP; |
|
|
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) return; |
|
|
const layoutNodeIds = new Set(layoutNodes.map((node) => node.id)); |
|
|
adjacency.get(edge.source)?.push(edge.target); |
|
|
const layoutEdges = edges.filter((edge) => { |
|
|
adjacency.get(edge.target)?.push(edge.source); |
|
|
const edgeData = edge.data as { isLoop?: boolean } | undefined; |
|
|
}); |
|
|
return layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target) && !edgeData?.isLoop; |
|
|
|
|
|
|
|
|
const visited = new Set<string>(); |
|
|
|
|
|
const segments: LayoutSegment[] = []; |
|
|
|
|
|
|
|
|
|
|
|
nodes |
|
|
|
|
|
.slice() |
|
|
|
|
|
.sort(compareByCurrentPosition) |
|
|
|
|
|
.forEach((startNode) => { |
|
|
|
|
|
if (visited.has(startNode.id)) return; |
|
|
|
|
|
|
|
|
|
|
|
const queue = [startNode.id]; |
|
|
|
|
|
const segmentNodeIds = new Set<string>(); |
|
|
|
|
|
|
|
|
|
|
|
while (queue.length > 0) { |
|
|
|
|
|
const nodeId = queue.shift(); |
|
|
|
|
|
if (!nodeId || visited.has(nodeId)) continue; |
|
|
|
|
|
|
|
|
|
|
|
visited.add(nodeId); |
|
|
|
|
|
segmentNodeIds.add(nodeId); |
|
|
|
|
|
|
|
|
|
|
|
(adjacency.get(nodeId) ?? []).forEach((neighborId) => { |
|
|
|
|
|
if (!visited.has(neighborId)) queue.push(neighborId); |
|
|
|
|
|
}); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const segmentNodes = nodes |
|
|
|
|
|
.filter((node) => segmentNodeIds.has(node.id)) |
|
|
|
|
|
.sort(compareByCurrentPosition); |
|
|
|
|
|
const segmentEdges = edges.filter((edge) => segmentNodeIds.has(edge.source) && segmentNodeIds.has(edge.target)); |
|
|
|
|
|
|
|
|
|
|
|
segments.push({ nodes: segmentNodes, edges: segmentEdges }); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
return segments.sort((a, b) => { |
|
|
|
|
|
const aTop = Math.min(...a.nodes.map((node) => node.position.y)); |
|
|
|
|
|
const bTop = Math.min(...b.nodes.map((node) => node.position.y)); |
|
|
|
|
|
if (aTop !== bTop) return aTop - bTop; |
|
|
|
|
|
|
|
|
|
|
|
const aLeft = Math.min(...a.nodes.map((node) => node.position.x)); |
|
|
|
|
|
const bLeft = Math.min(...b.nodes.map((node) => node.position.x)); |
|
|
|
|
|
if (aLeft !== bLeft) return aLeft - bLeft; |
|
|
|
|
|
|
|
|
|
|
|
return a.nodes[0]?.id.localeCompare(b.nodes[0]?.id ?? "") ?? 0; |
|
|
}); |
|
|
}); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function layoutSegment( |
|
|
|
|
|
nodes: LayoutNode[], |
|
|
|
|
|
edges: WorkflowEdge[], |
|
|
|
|
|
startX: number, |
|
|
|
|
|
startY: number, |
|
|
|
|
|
columnGap: number, |
|
|
|
|
|
rowGap: number |
|
|
|
|
|
): SegmentLayout { |
|
|
const positions: CanvasAutoLayoutPosition[] = []; |
|
|
const positions: CanvasAutoLayoutPosition[] = []; |
|
|
const layerById = computeLayers(layoutNodes, layoutEdges); |
|
|
const layerById = computeLayers(nodes, edges); |
|
|
const minX = Math.min(...layoutNodes.map((node) => node.position.x)); |
|
|
|
|
|
const minY = Math.min(...layoutNodes.map((node) => node.position.y)); |
|
|
|
|
|
const layers = new Map<number, LayoutNode[]>(); |
|
|
const layers = new Map<number, LayoutNode[]>(); |
|
|
const yById = new Map<string, number>(); |
|
|
const yById = new Map<string, number>(); |
|
|
|
|
|
|
|
|
layoutNodes.forEach((node) => { |
|
|
nodes.forEach((node) => { |
|
|
const layer = layerById.get(node.id) ?? 0; |
|
|
const layer = layerById.get(node.id) ?? 0; |
|
|
layers.set(layer, [...(layers.get(layer) ?? []), node]); |
|
|
layers.set(layer, [...(layers.get(layer) ?? []), node]); |
|
|
}); |
|
|
}); |
|
|
@ -148,10 +204,10 @@ export function calculateCanvasAutoLayout( |
|
|
const sortedLayers = Array.from(layers.keys()).sort((a, b) => a - b); |
|
|
const sortedLayers = Array.from(layers.keys()).sort((a, b) => a - b); |
|
|
const orderedNodesByLayer = new Map<number, LayoutNode[]>(); |
|
|
const orderedNodesByLayer = new Map<number, LayoutNode[]>(); |
|
|
const layerXByLayer = new Map<number, number>(); |
|
|
const layerXByLayer = new Map<number, number>(); |
|
|
let currentX = minX; |
|
|
let currentX = startX; |
|
|
|
|
|
|
|
|
sortedLayers.forEach((layer) => { |
|
|
sortedLayers.forEach((layer) => { |
|
|
const layerNodes = orderLayerNodes(layers.get(layer) ?? [], layoutEdges, yById); |
|
|
const layerNodes = orderLayerNodes(layers.get(layer) ?? [], edges, yById); |
|
|
orderedNodesByLayer.set(layer, layerNodes); |
|
|
orderedNodesByLayer.set(layer, layerNodes); |
|
|
const maxLayerWidth = Math.max( |
|
|
const maxLayerWidth = Math.max( |
|
|
DEFAULT_NODE_DIMENSIONS.width, |
|
|
DEFAULT_NODE_DIMENSIONS.width, |
|
|
@ -163,7 +219,8 @@ export function calculateCanvasAutoLayout( |
|
|
|
|
|
|
|
|
const maxRowCount = Math.max(0, ...Array.from(orderedNodesByLayer.values()).map((layerNodes) => layerNodes.length)); |
|
|
const maxRowCount = Math.max(0, ...Array.from(orderedNodesByLayer.values()).map((layerNodes) => layerNodes.length)); |
|
|
const rowYByIndex = new Map<number, number>(); |
|
|
const rowYByIndex = new Map<number, number>(); |
|
|
let currentY = minY; |
|
|
const rowHeightByIndex = new Map<number, number>(); |
|
|
|
|
|
let currentY = startY; |
|
|
|
|
|
|
|
|
for (let rowIndex = 0; rowIndex < maxRowCount; rowIndex += 1) { |
|
|
for (let rowIndex = 0; rowIndex < maxRowCount; rowIndex += 1) { |
|
|
rowYByIndex.set(rowIndex, currentY); |
|
|
rowYByIndex.set(rowIndex, currentY); |
|
|
@ -173,6 +230,7 @@ export function calculateCanvasAutoLayout( |
|
|
return node ? getNodeDimensions(node).height : 0; |
|
|
return node ? getNodeDimensions(node).height : 0; |
|
|
}) |
|
|
}) |
|
|
); |
|
|
); |
|
|
|
|
|
rowHeightByIndex.set(rowIndex, maxRowHeight); |
|
|
currentY += maxRowHeight + rowGap; |
|
|
currentY += maxRowHeight + rowGap; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -180,11 +238,11 @@ export function calculateCanvasAutoLayout( |
|
|
const layerNodes = orderedNodesByLayer.get(layer) ?? []; |
|
|
const layerNodes = orderedNodesByLayer.get(layer) ?? []; |
|
|
|
|
|
|
|
|
layerNodes.forEach((node, rowIndex) => { |
|
|
layerNodes.forEach((node, rowIndex) => { |
|
|
const rowY = rowYByIndex.get(rowIndex) ?? minY; |
|
|
const rowY = rowYByIndex.get(rowIndex) ?? startY; |
|
|
positions.push({ |
|
|
positions.push({ |
|
|
id: node.id, |
|
|
id: node.id, |
|
|
position: { |
|
|
position: { |
|
|
x: layerXByLayer.get(layer) ?? minX, |
|
|
x: layerXByLayer.get(layer) ?? startX, |
|
|
y: rowY, |
|
|
y: rowY, |
|
|
}, |
|
|
}, |
|
|
}); |
|
|
}); |
|
|
@ -194,5 +252,39 @@ export function calculateCanvasAutoLayout( |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
const height = Array.from(rowHeightByIndex.values()).reduce( |
|
|
|
|
|
(sum, rowHeight, index) => sum + rowHeight + (index === rowHeightByIndex.size - 1 ? 0 : rowGap), |
|
|
|
|
|
0 |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
return { positions, height }; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export function calculateCanvasAutoLayout( |
|
|
|
|
|
nodes: WorkflowNode[], |
|
|
|
|
|
edges: WorkflowEdge[], |
|
|
|
|
|
options: CanvasAutoLayoutOptions = {} |
|
|
|
|
|
): CanvasAutoLayoutPosition[] { |
|
|
|
|
|
const layoutNodes = nodes.filter(isLayoutNode); |
|
|
|
|
|
if (layoutNodes.length === 0) return []; |
|
|
|
|
|
|
|
|
|
|
|
const columnGap = options.columnGap ?? DEFAULT_COLUMN_GAP; |
|
|
|
|
|
const rowGap = options.rowGap ?? DEFAULT_ROW_GAP; |
|
|
|
|
|
const layoutNodeIds = new Set(layoutNodes.map((node) => node.id)); |
|
|
|
|
|
const layoutEdges = edges.filter((edge) => { |
|
|
|
|
|
const edgeData = edge.data as { isLoop?: boolean } | undefined; |
|
|
|
|
|
return layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target) && !edgeData?.isLoop; |
|
|
|
|
|
}); |
|
|
|
|
|
const positions: CanvasAutoLayoutPosition[] = []; |
|
|
|
|
|
const minX = Math.min(...layoutNodes.map((node) => node.position.x)); |
|
|
|
|
|
const minY = Math.min(...layoutNodes.map((node) => node.position.y)); |
|
|
|
|
|
let currentY = minY; |
|
|
|
|
|
|
|
|
|
|
|
splitWorkflowSegments(layoutNodes, layoutEdges).forEach((segment) => { |
|
|
|
|
|
const segmentLayout = layoutSegment(segment.nodes, segment.edges, minX, currentY, columnGap, rowGap); |
|
|
|
|
|
positions.push(...segmentLayout.positions); |
|
|
|
|
|
currentY += segmentLayout.height + rowGap; |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
return positions; |
|
|
return positions; |
|
|
} |
|
|
} |
|
|
|