@@ -10,12 +10,55 @@ import {
1010 normalizePositions ,
1111 prepareBlockMetrics ,
1212} from '@/lib/workflows/autolayout/utils'
13+ import { BLOCK_DIMENSIONS , HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
1314import type { BlockState } from '@/stores/workflows/workflow/types'
1415
1516const logger = createLogger ( 'AutoLayout:Core' )
1617
17- /** Handle names that indicate edges from subflow end */
1818const SUBFLOW_END_HANDLES = new Set ( [ 'loop-end-source' , 'parallel-end-source' ] )
19+ const SUBFLOW_START_HANDLES = new Set ( [ 'loop-start-source' , 'parallel-start-source' ] )
20+
21+ /**
22+ * Calculates the Y offset for a source handle based on block type and handle ID.
23+ */
24+ function getSourceHandleYOffset ( block : BlockState , sourceHandle ?: string | null ) : number {
25+ if ( sourceHandle === 'error' ) {
26+ const blockHeight = block . height || BLOCK_DIMENSIONS . MIN_HEIGHT
27+ return blockHeight - HANDLE_POSITIONS . ERROR_BOTTOM_OFFSET
28+ }
29+
30+ if ( sourceHandle && SUBFLOW_START_HANDLES . has ( sourceHandle ) ) {
31+ return HANDLE_POSITIONS . SUBFLOW_START_Y_OFFSET
32+ }
33+
34+ if ( block . type === 'condition' && sourceHandle ?. startsWith ( 'condition-' ) ) {
35+ const conditionId = sourceHandle . replace ( 'condition-' , '' )
36+ try {
37+ const conditionsValue = block . subBlocks ?. conditions ?. value
38+ if ( typeof conditionsValue === 'string' && conditionsValue ) {
39+ const conditions = JSON . parse ( conditionsValue ) as Array < { id ?: string } >
40+ const conditionIndex = conditions . findIndex ( ( c ) => c . id === conditionId )
41+ if ( conditionIndex >= 0 ) {
42+ return (
43+ HANDLE_POSITIONS . CONDITION_START_Y +
44+ conditionIndex * HANDLE_POSITIONS . CONDITION_ROW_HEIGHT
45+ )
46+ }
47+ }
48+ } catch {
49+ // Fall back to default offset
50+ }
51+ }
52+
53+ return HANDLE_POSITIONS . DEFAULT_Y_OFFSET
54+ }
55+
56+ /**
57+ * Calculates the Y offset for a target handle based on block type and handle ID.
58+ */
59+ function getTargetHandleYOffset ( _block : BlockState , _targetHandle ?: string | null ) : number {
60+ return HANDLE_POSITIONS . DEFAULT_Y_OFFSET
61+ }
1962
2063/**
2164 * Checks if an edge comes from a subflow end handle
@@ -225,18 +268,36 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v
225268 }
226269}
227270
271+ /**
272+ * Checks if a block is a container type (loop or parallel)
273+ */
274+ function isContainerBlock ( node : GraphNode ) : boolean {
275+ return node . block . type === 'loop' || node . block . type === 'parallel'
276+ }
277+
278+ /**
279+ * Extra vertical spacing after containers to prevent edge crossings with sibling blocks.
280+ * This creates clearance for edges from container ends to route cleanly.
281+ */
282+ const CONTAINER_VERTICAL_CLEARANCE = 120
283+
228284/**
229285 * Calculates positions for nodes organized by layer.
230286 * Uses cumulative width-based X positioning to properly handle containers of varying widths.
287+ * Aligns blocks based on their connected predecessors to achieve handle-to-handle alignment.
288+ *
289+ * Handle alignment: Calculates actual source handle Y positions based on block type
290+ * (condition blocks have handles at different heights for each branch).
291+ * Target handles are also calculated per-block to ensure precise alignment.
231292 */
232293export function calculatePositions (
233294 layers : Map < number , GraphNode [ ] > ,
295+ edges : Edge [ ] ,
234296 options : LayoutOptions = { }
235297) : void {
236298 const horizontalSpacing = options . horizontalSpacing ?? DEFAULT_LAYOUT_OPTIONS . horizontalSpacing
237299 const verticalSpacing = options . verticalSpacing ?? DEFAULT_LAYOUT_OPTIONS . verticalSpacing
238300 const padding = options . padding ?? DEFAULT_LAYOUT_OPTIONS . padding
239- const alignment = options . alignment ?? DEFAULT_LAYOUT_OPTIONS . alignment
240301
241302 const layerNumbers = Array . from ( layers . keys ( ) ) . sort ( ( a , b ) => a - b )
242303
@@ -257,41 +318,89 @@ export function calculatePositions(
257318 cumulativeX += layerWidths . get ( layerNum ) ! + horizontalSpacing
258319 }
259320
260- // Position nodes using cumulative X
321+ // Build a flat map of all nodes for quick lookups
322+ const allNodes = new Map < string , GraphNode > ( )
323+ for ( const nodesInLayer of layers . values ( ) ) {
324+ for ( const node of nodesInLayer ) {
325+ allNodes . set ( node . id , node )
326+ }
327+ }
328+
329+ // Build incoming edges map for handle lookups
330+ const incomingEdgesMap = new Map < string , Edge [ ] > ( )
331+ for ( const edge of edges ) {
332+ if ( ! incomingEdgesMap . has ( edge . target ) ) {
333+ incomingEdgesMap . set ( edge . target , [ ] )
334+ }
335+ incomingEdgesMap . get ( edge . target ) ! . push ( edge )
336+ }
337+
338+ // Position nodes layer by layer, aligning with connected predecessors
261339 for ( const layerNum of layerNumbers ) {
262340 const nodesInLayer = layers . get ( layerNum ) !
263341 const xPosition = layerXPositions . get ( layerNum ) !
264342
265- // Calculate total height for this layer
266- const totalHeight = nodesInLayer . reduce (
267- ( sum , node , idx ) => sum + node . metrics . height + ( idx > 0 ? verticalSpacing : 0 ) ,
268- 0
269- )
270-
271- // Start Y based on alignment
272- let yOffset : number
273- switch ( alignment ) {
274- case 'start' :
275- yOffset = padding . y
276- break
277- case 'center' :
278- yOffset = Math . max ( padding . y , 300 - totalHeight / 2 )
279- break
280- case 'end' :
281- yOffset = 600 - totalHeight - padding . y
282- break
283- default :
284- yOffset = padding . y
285- break
343+ // Separate containers and non-containers
344+ const containersInLayer = nodesInLayer . filter ( isContainerBlock )
345+ const nonContainersInLayer = nodesInLayer . filter ( ( n ) => ! isContainerBlock ( n ) )
346+
347+ // For the first layer (layer 0), position sequentially from padding.y
348+ if ( layerNum === 0 ) {
349+ let yOffset = padding . y
350+
351+ // Sort containers by height for visual balance
352+ containersInLayer . sort ( ( a , b ) => b . metrics . height - a . metrics . height )
353+
354+ for ( const node of containersInLayer ) {
355+ node . position = { x : xPosition , y : yOffset }
356+ yOffset += node . metrics . height + verticalSpacing
357+ }
358+
359+ if ( containersInLayer . length > 0 && nonContainersInLayer . length > 0 ) {
360+ yOffset += CONTAINER_VERTICAL_CLEARANCE
361+ }
362+
363+ // Sort non-containers by outgoing connections
364+ nonContainersInLayer . sort ( ( a , b ) => b . outgoing . size - a . outgoing . size )
365+
366+ for ( const node of nonContainersInLayer ) {
367+ node . position = { x : xPosition , y : yOffset }
368+ yOffset += node . metrics . height + verticalSpacing
369+ }
370+ continue
286371 }
287372
288- // Position each node
289- for ( const node of nodesInLayer ) {
290- node . position = {
291- x : xPosition ,
292- y : yOffset ,
373+ // For subsequent layers, align with connected predecessors (handle-to-handle)
374+ for ( const node of [ ...containersInLayer , ...nonContainersInLayer ] ) {
375+ // Find the bottommost predecessor handle Y (highest value) and align to it
376+ let bestSourceHandleY = - 1
377+ let bestEdge : Edge | null = null
378+ const incomingEdges = incomingEdgesMap . get ( node . id ) || [ ]
379+
380+ for ( const edge of incomingEdges ) {
381+ const predecessor = allNodes . get ( edge . source )
382+ if ( predecessor ) {
383+ // Calculate actual source handle Y position based on block type and handle
384+ const sourceHandleOffset = getSourceHandleYOffset ( predecessor . block , edge . sourceHandle )
385+ const sourceHandleY = predecessor . position . y + sourceHandleOffset
386+
387+ if ( sourceHandleY > bestSourceHandleY ) {
388+ bestSourceHandleY = sourceHandleY
389+ bestEdge = edge
390+ }
391+ }
392+ }
393+
394+ // If no predecessors found (shouldn't happen for layer > 0), use padding
395+ if ( bestSourceHandleY < 0 ) {
396+ bestSourceHandleY = padding . y + HANDLE_POSITIONS . DEFAULT_Y_OFFSET
293397 }
294- yOffset += node . metrics . height + verticalSpacing
398+
399+ // Calculate the target handle Y offset for this node
400+ const targetHandleOffset = getTargetHandleYOffset ( node . block , bestEdge ?. targetHandle )
401+
402+ // Position node so its target handle aligns with the source handle Y
403+ node . position = { x : xPosition , y : bestSourceHandleY - targetHandleOffset }
295404 }
296405 }
297406
@@ -338,8 +447,8 @@ export function layoutBlocksCore(
338447 // 3. Group by layer
339448 const layers = groupByLayer ( nodes )
340449
341- // 4. Calculate positions
342- calculatePositions ( layers , layoutOptions )
450+ // 4. Calculate positions (pass edges for handle offset calculations)
451+ calculatePositions ( layers , edges , layoutOptions )
343452
344453 // 5. Normalize positions
345454 const dimensions = normalizePositions ( nodes , { isContainer : options . isContainer } )
0 commit comments