Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/sim/lib/workflows/autolayout/core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge } from '@/lib/workflows/autolayout/types'
import type { BlockState } from '@/stores/workflows/workflow/types'

vi.mock('@/blocks', () => ({
getBlock: () => null,
}))

function createBlock(id: string): BlockState {
return {
id,
type: 'agent',
name: id,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
height: 120,
layout: { measuredWidth: 250, measuredHeight: 120 },
} as BlockState
}

describe('layoutBlocksCore', () => {
it('keeps each branch in a stable row regardless of block insertion order', () => {
// Two parallel chains from one source. Insertion order is interleaved so the
// per-layer order flips: layer1 [a1,b1], layer2 [b2,a2], layer3 [a3,b3].
// Row assignment must come from the resolved predecessor position, not from
// per-layer insertion-order tie-breaks.
const blocks: Record<string, BlockState> = {}
for (const id of ['s', 'a1', 'b1', 'b2', 'a2', 'a3', 'b3']) {
blocks[id] = createBlock(id)
}
const edges: Edge[] = [
{ id: 'e1', source: 's', target: 'a1' },
{ id: 'e2', source: 's', target: 'b1' },
{ id: 'e3', source: 'a1', target: 'a2' },
{ id: 'e4', source: 'b1', target: 'b2' },
{ id: 'e5', source: 'a2', target: 'a3' },
{ id: 'e6', source: 'b2', target: 'b3' },
]

const { nodes } = layoutBlocksCore(blocks, edges, { isContainer: false })
const y = (id: string) => nodes.get(id)!.position.y

const aAboveInLayer1 = y('a1') < y('b1')
expect(y('a2') < y('b2')).toBe(aAboveInLayer1)
expect(y('a3') < y('b3')).toBe(aAboveInLayer1)
})

it('leaves no vertical overlaps within a layer', () => {
const blocks: Record<string, BlockState> = {}
for (const id of ['s', 'a1', 'b1', 'c1']) {
blocks[id] = createBlock(id)
}
const edges: Edge[] = [
{ id: 'e1', source: 's', target: 'a1' },
{ id: 'e2', source: 's', target: 'b1' },
{ id: 'e3', source: 's', target: 'c1' },
]

const { nodes } = layoutBlocksCore(blocks, edges, { isContainer: false })
const layer1 = ['a1', 'b1', 'c1']
.map((id) => nodes.get(id)!)
.sort((a, b) => a.position.y - b.position.y)

for (let i = 0; i < layer1.length - 1; i++) {
expect(layer1[i + 1].position.y).toBeGreaterThanOrEqual(
layer1[i].position.y + layer1[i].metrics.height
)
}
})
})
61 changes: 13 additions & 48 deletions apps/sim/lib/workflows/autolayout/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@sim/workflow-renderer'
import {
CONTAINER_LAYOUT_OPTIONS,
DEFAULT_LAYOUT_OPTIONS,
MAX_OVERLAP_ITERATIONS,
} from '@/lib/workflows/autolayout/constants'
import type { Edge, GraphNode, LayoutOptions } from '@/lib/workflows/autolayout/types'
import {
Expand Down Expand Up @@ -200,57 +199,23 @@ export function groupByLayer(nodes: Map<string, GraphNode>): Map<number, GraphNo
}

/**
* Resolves vertical overlaps between nodes in the same layer.
* Resolves vertical overlaps between nodes within a single layer by pushing
* lower nodes down. Must run before the next layer is positioned so successors
* derive their Y from resolved predecessor positions — this keeps each branch
* in a stable row instead of re-breaking Y ties per layer.
* X overlaps are prevented by construction via cumulative width-based positioning.
*/
function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
let iteration = 0
let hasOverlap = true

while (hasOverlap && iteration < MAX_OVERLAP_ITERATIONS) {
hasOverlap = false
iteration++

const nodesByLayer = new Map<number, GraphNode[]>()
for (const node of nodes) {
if (!nodesByLayer.has(node.layer)) {
nodesByLayer.set(node.layer, [])
}
nodesByLayer.get(node.layer)!.push(node)
}

for (const [layer, layerNodes] of nodesByLayer) {
if (layerNodes.length < 2) continue

layerNodes.sort((a, b) => a.position.y - b.position.y)
function resolveLayerOverlaps(layerNodes: GraphNode[], verticalSpacing: number): void {
if (layerNodes.length < 2) return

for (let i = 0; i < layerNodes.length - 1; i++) {
const node1 = layerNodes[i]
const node2 = layerNodes[i + 1]
const sorted = [...layerNodes].sort((a, b) => a.position.y - b.position.y)

const node1Bottom = node1.position.y + node1.metrics.height
const requiredY = node1Bottom + verticalSpacing

if (node2.position.y < requiredY) {
hasOverlap = true
node2.position.y = requiredY

logger.debug('Resolved vertical overlap in layer', {
layer,
block1: node1.id,
block2: node2.id,
iteration,
})
}
}
for (let i = 0; i < sorted.length - 1; i++) {
const requiredY = sorted[i].position.y + sorted[i].metrics.height + verticalSpacing
if (sorted[i + 1].position.y < requiredY) {
sorted[i + 1].position.y = requiredY
}
}

if (hasOverlap) {
logger.warn('Could not fully resolve all vertical overlaps after max iterations', {
iterations: MAX_OVERLAP_ITERATIONS,
})
}
}

/**
Expand Down Expand Up @@ -372,9 +337,9 @@ export function calculatePositions(

node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset }
}
}

resolveVerticalOverlaps(Array.from(layers.values()).flat(), verticalSpacing)
resolveLayerOverlaps(nodesInLayer, verticalSpacing)
}
}

/**
Expand Down
Loading