Skip to content

Commit a50edf8

Browse files
fix(autolayout): subflow calculation (#2223)
* fix(autolayout): subflow calculation * cleanup code * fix missing import * add back missing import
1 parent 656dfaf commit a50edf8

File tree

4 files changed

+144
-16
lines changed

4 files changed

+144
-16
lines changed

apps/sim/lib/workflows/autolayout/core.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,29 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
1717

1818
const logger = createLogger('AutoLayout:Core')
1919

20+
/** Handle names that indicate edges from subflow end */
21+
const SUBFLOW_END_HANDLES = new Set(['loop-end-source', 'parallel-end-source'])
22+
23+
/**
24+
* Checks if an edge comes from a subflow end handle
25+
*/
26+
function isSubflowEndEdge(edge: Edge): boolean {
27+
return edge.sourceHandle != null && SUBFLOW_END_HANDLES.has(edge.sourceHandle)
28+
}
29+
2030
/**
2131
* Assigns layers (columns) to blocks using topological sort.
2232
* Blocks with no incoming edges are placed in layer 0.
33+
* When edges come from subflow end handles, the subflow's internal depth is added.
34+
*
35+
* @param blocks - The blocks to assign layers to
36+
* @param edges - The edges connecting blocks
37+
* @param subflowDepths - Optional map of container block IDs to their internal depth (max layers inside)
2338
*/
2439
export function assignLayers(
2540
blocks: Record<string, BlockState>,
26-
edges: Edge[]
41+
edges: Edge[],
42+
subflowDepths?: Map<string, number>
2743
): Map<string, GraphNode> {
2844
const nodes = new Map<string, GraphNode>()
2945

@@ -40,6 +56,15 @@ export function assignLayers(
4056
})
4157
}
4258

59+
// Build a map of target node -> edges coming into it (to check sourceHandle later)
60+
const incomingEdgesMap = new Map<string, Edge[]>()
61+
for (const edge of edges) {
62+
if (!incomingEdgesMap.has(edge.target)) {
63+
incomingEdgesMap.set(edge.target, [])
64+
}
65+
incomingEdgesMap.get(edge.target)!.push(edge)
66+
}
67+
4368
// Build adjacency from edges
4469
for (const edge of edges) {
4570
const sourceNode = nodes.get(edge.source)
@@ -79,15 +104,33 @@ export function assignLayers(
79104
processed.add(nodeId)
80105

81106
// Calculate layer based on max incoming layer + 1
107+
// For edges from subflow ends, add the subflow's internal depth (minus 1 to avoid double-counting)
82108
if (node.incoming.size > 0) {
83-
let maxIncomingLayer = -1
109+
let maxEffectiveLayer = -1
110+
const incomingEdges = incomingEdgesMap.get(nodeId) || []
111+
84112
for (const incomingId of node.incoming) {
85113
const incomingNode = nodes.get(incomingId)
86114
if (incomingNode) {
87-
maxIncomingLayer = Math.max(maxIncomingLayer, incomingNode.layer)
115+
// Find edges from this incoming node to check if it's a subflow end edge
116+
const edgesFromSource = incomingEdges.filter((e) => e.source === incomingId)
117+
let additionalDepth = 0
118+
119+
// Check if any edge from this source is a subflow end edge
120+
const hasSubflowEndEdge = edgesFromSource.some(isSubflowEndEdge)
121+
if (hasSubflowEndEdge && subflowDepths) {
122+
// Get the internal depth of the subflow
123+
// Subtract 1 because the +1 at the end of layer calculation already accounts for one layer
124+
// E.g., if subflow has 2 internal layers (depth=2), we add 1 extra so total offset is 2
125+
const depth = subflowDepths.get(incomingId) ?? 1
126+
additionalDepth = Math.max(0, depth - 1)
127+
}
128+
129+
const effectiveLayer = incomingNode.layer + additionalDepth
130+
maxEffectiveLayer = Math.max(maxEffectiveLayer, effectiveLayer)
88131
}
89132
}
90-
node.layer = maxIncomingLayer + 1
133+
node.layer = maxEffectiveLayer + 1
91134
}
92135

93136
// Add outgoing nodes when all dependencies processed
@@ -254,12 +297,19 @@ export function calculatePositions(
254297
* 4. Calculate positions
255298
* 5. Normalize positions to start from padding
256299
*
300+
* @param blocks - The blocks to lay out
301+
* @param edges - The edges connecting blocks
302+
* @param options - Layout options including container flag and subflow depths
257303
* @returns The laid-out nodes with updated positions, and bounding dimensions
258304
*/
259305
export function layoutBlocksCore(
260306
blocks: Record<string, BlockState>,
261307
edges: Edge[],
262-
options: { isContainer: boolean; layoutOptions?: LayoutOptions }
308+
options: {
309+
isContainer: boolean
310+
layoutOptions?: LayoutOptions
311+
subflowDepths?: Map<string, number>
312+
}
263313
): { nodes: Map<string, GraphNode>; dimensions: { width: number; height: number } } {
264314
if (Object.keys(blocks).length === 0) {
265315
return { nodes: new Map(), dimensions: { width: 0, height: 0 } }
@@ -269,8 +319,8 @@ export function layoutBlocksCore(
269319
options.layoutOptions ??
270320
(options.isContainer ? CONTAINER_LAYOUT_OPTIONS : DEFAULT_LAYOUT_OPTIONS)
271321

272-
// 1. Assign layers
273-
const nodes = assignLayers(blocks, edges)
322+
// 1. Assign layers (with subflow depth adjustment for subflow end edges)
323+
const nodes = assignLayers(blocks, edges, options.subflowDepths)
274324

275325
// 2. Prepare metrics
276326
prepareBlockMetrics(nodes)

apps/sim/lib/workflows/autolayout/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { createLogger } from '@/lib/logs/console/logger'
22
import { layoutContainers } from '@/lib/workflows/autolayout/containers'
3-
import { layoutBlocksCore } from '@/lib/workflows/autolayout/core'
3+
import { assignLayers, layoutBlocksCore } from '@/lib/workflows/autolayout/core'
44
import type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types'
5-
import { filterLayoutEligibleBlockIds, getBlocksByParent } from '@/lib/workflows/autolayout/utils'
5+
import {
6+
calculateSubflowDepths,
7+
filterLayoutEligibleBlockIds,
8+
getBlocksByParent,
9+
} from '@/lib/workflows/autolayout/utils'
610
import type { BlockState } from '@/stores/workflows/workflow/types'
711

812
const logger = createLogger('AutoLayout')
@@ -36,10 +40,15 @@ export function applyAutoLayout(
3640
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
3741
)
3842

43+
// Calculate subflow depths before laying out root blocks
44+
// This ensures blocks connected to subflow ends are positioned correctly
45+
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
46+
3947
if (Object.keys(rootBlocks).length > 0) {
4048
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
4149
isContainer: false,
4250
layoutOptions: options,
51+
subflowDepths,
4352
})
4453

4554
for (const node of nodes.values()) {

apps/sim/lib/workflows/autolayout/targeted.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import {
44
DEFAULT_HORIZONTAL_SPACING,
55
DEFAULT_VERTICAL_SPACING,
66
} from '@/lib/workflows/autolayout/constants'
7-
import { layoutBlocksCore } from '@/lib/workflows/autolayout/core'
7+
import { assignLayers, layoutBlocksCore } from '@/lib/workflows/autolayout/core'
88
import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types'
99
import {
10+
calculateSubflowDepths,
1011
filterLayoutEligibleBlockIds,
1112
getBlockMetrics,
1213
getBlocksByParent,
@@ -48,7 +49,19 @@ export function applyTargetedLayout(
4849

4950
const groups = getBlocksByParent(blocksCopy)
5051

51-
layoutGroup(null, groups.root, blocksCopy, edges, changedSet, verticalSpacing, horizontalSpacing)
52+
// Calculate subflow depths before layout to properly position blocks after subflow ends
53+
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
54+
55+
layoutGroup(
56+
null,
57+
groups.root,
58+
blocksCopy,
59+
edges,
60+
changedSet,
61+
verticalSpacing,
62+
horizontalSpacing,
63+
subflowDepths
64+
)
5265

5366
for (const [parentId, childIds] of groups.children.entries()) {
5467
layoutGroup(
@@ -58,7 +71,8 @@ export function applyTargetedLayout(
5871
edges,
5972
changedSet,
6073
verticalSpacing,
61-
horizontalSpacing
74+
horizontalSpacing,
75+
subflowDepths
6276
)
6377
}
6478

@@ -75,7 +89,8 @@ function layoutGroup(
7589
edges: Edge[],
7690
changedSet: Set<string>,
7791
verticalSpacing: number,
78-
horizontalSpacing: number
92+
horizontalSpacing: number,
93+
subflowDepths: Map<string, number>
7994
): void {
8095
if (childIds.length === 0) return
8196

@@ -123,13 +138,15 @@ function layoutGroup(
123138
}
124139

125140
// Compute layout positions using core function
141+
// Only pass subflowDepths for root-level layout (not inside containers)
126142
const layoutPositions = computeLayoutPositions(
127143
layoutEligibleChildIds,
128144
blocks,
129145
edges,
130146
parentBlock,
131147
horizontalSpacing,
132-
verticalSpacing
148+
verticalSpacing,
149+
parentId === null ? subflowDepths : undefined
133150
)
134151

135152
if (layoutPositions.size === 0) {
@@ -177,7 +194,8 @@ function computeLayoutPositions(
177194
edges: Edge[],
178195
parentBlock: BlockState | undefined,
179196
horizontalSpacing: number,
180-
verticalSpacing: number
197+
verticalSpacing: number,
198+
subflowDepths?: Map<string, number>
181199
): Map<string, { x: number; y: number }> {
182200
const subsetBlocks: Record<string, BlockState> = {}
183201
for (const id of childIds) {
@@ -200,6 +218,7 @@ function computeLayoutPositions(
200218
verticalSpacing,
201219
alignment: 'center',
202220
},
221+
subflowDepths,
203222
})
204223

205224
// Update parent container dimensions if applicable

apps/sim/lib/workflows/autolayout/utils.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ROOT_PADDING_X,
88
ROOT_PADDING_Y,
99
} from '@/lib/workflows/autolayout/constants'
10-
import type { BlockMetrics, BoundingBox, GraphNode } from '@/lib/workflows/autolayout/types'
10+
import type { BlockMetrics, BoundingBox, Edge, GraphNode } from '@/lib/workflows/autolayout/types'
1111
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
1212
import type { BlockState } from '@/stores/workflows/workflow/types'
1313

@@ -265,3 +265,53 @@ export function transferBlockHeights(
265265
}
266266
}
267267
}
268+
269+
/**
270+
* Calculates the internal depth (max layer count) for each subflow container.
271+
* Used to properly position blocks that connect after a subflow ends.
272+
*
273+
* @param blocks - All blocks in the workflow
274+
* @param edges - All edges in the workflow
275+
* @param assignLayersFn - Function to assign layers to blocks
276+
* @returns Map of container block IDs to their internal layer depth
277+
*/
278+
export function calculateSubflowDepths(
279+
blocks: Record<string, BlockState>,
280+
edges: Edge[],
281+
assignLayersFn: (blocks: Record<string, BlockState>, edges: Edge[]) => Map<string, GraphNode>
282+
): Map<string, number> {
283+
const depths = new Map<string, number>()
284+
const { children } = getBlocksByParent(blocks)
285+
286+
for (const [containerId, childIds] of children.entries()) {
287+
if (childIds.length === 0) {
288+
depths.set(containerId, 1)
289+
continue
290+
}
291+
292+
const childBlocks: Record<string, BlockState> = {}
293+
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
294+
for (const childId of layoutChildIds) {
295+
childBlocks[childId] = blocks[childId]
296+
}
297+
298+
const childEdges = edges.filter(
299+
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
300+
)
301+
302+
if (Object.keys(childBlocks).length === 0) {
303+
depths.set(containerId, 1)
304+
continue
305+
}
306+
307+
const childNodes = assignLayersFn(childBlocks, childEdges)
308+
let maxLayer = 0
309+
for (const node of childNodes.values()) {
310+
maxLayer = Math.max(maxLayer, node.layer)
311+
}
312+
313+
depths.set(containerId, Math.max(maxLayer + 1, 1))
314+
}
315+
316+
return depths
317+
}

0 commit comments

Comments
 (0)