Files
simstudioai--sim/apps/sim/lib/workflows/autolayout/targeted.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

545 lines
15 KiB
TypeScript

import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
CONTAINER_PADDING,
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_VERTICAL_SPACING,
MAX_OVERLAP_ITERATIONS,
} from '@/lib/workflows/autolayout/constants'
import { assignLayers, layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types'
import {
calculateSubflowDepths,
filterLayoutEligibleBlockIds,
getBlockMetrics,
getBlocksByParent,
hasFinitePosition,
prepareContainerDimensions,
resolveNoteOverlaps,
shouldSkipAutoLayout,
snapPositionToGrid,
} from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
type TargetedBlockInfo = {
id: string
block: BlockState
metrics: ReturnType<typeof getBlockMetrics>
}
export interface TargetedLayoutOptions extends LayoutOptions {
changedBlockIds: string[]
/** Existing blocks whose size changed but whose position should remain anchored. */
resizedBlockIds?: string[]
shiftSourceBlockIds?: string[]
verticalSpacing?: number
horizontalSpacing?: number
}
/**
* Applies targeted layout to only reposition changed blocks.
* Unchanged blocks act as anchors to preserve existing layout.
*/
export function applyTargetedLayout(
blocks: Record<string, BlockState>,
edges: Edge[],
options: TargetedLayoutOptions
): Record<string, BlockState> {
const {
changedBlockIds,
resizedBlockIds = [],
shiftSourceBlockIds = [],
verticalSpacing = DEFAULT_VERTICAL_SPACING,
horizontalSpacing = DEFAULT_HORIZONTAL_SPACING,
gridSize,
} = options
if (
(!changedBlockIds || changedBlockIds.length === 0) &&
resizedBlockIds.length === 0 &&
shiftSourceBlockIds.length === 0
) {
return blocks
}
const changedSet = new Set(changedBlockIds)
const resizedSet = new Set(resizedBlockIds)
const shiftSourceSet = new Set(shiftSourceBlockIds)
const blocksCopy: Record<string, BlockState> = structuredClone(blocks)
prepareContainerDimensions(
blocksCopy,
edges,
layoutBlocksCore,
horizontalSpacing,
verticalSpacing,
gridSize
)
const groups = getBlocksByParent(blocksCopy)
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
layoutGroup(
null,
groups.root,
blocksCopy,
edges,
changedSet,
resizedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
gridSize
)
for (const [parentId, childIds] of groups.children.entries()) {
layoutGroup(
parentId,
childIds,
blocksCopy,
edges,
changedSet,
resizedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
gridSize
)
}
// Relocate notes only where this pass introduced an overlap, comparing against
// the original positions so pre-existing note arrangements are preserved.
resolveNoteOverlaps(blocksCopy, verticalSpacing, { previousBlocks: blocks })
return blocksCopy
}
/**
* Selects the best anchor block for offset computation.
* Prefers an upstream (predecessor) anchor over a downstream one because
* upstream blocks keep their layer assignment when new blocks are inserted
* after them, giving a stable offset. Downstream blocks shift to later
* layers in the ideal layout, producing a large incorrect offset.
*/
function selectBestAnchor(
eligibleIds: string[],
needsLayoutSet: Set<string>,
edges: Edge[],
layoutPositions: Map<string, { x: number; y: number }>
): string | undefined {
const candidates = eligibleIds.filter((id) => !needsLayoutSet.has(id) && layoutPositions.has(id))
if (candidates.length === 0) return undefined
if (candidates.length === 1) return candidates[0]
const candidateSet = new Set(candidates)
for (const edge of edges) {
if (needsLayoutSet.has(edge.target) && candidateSet.has(edge.source)) {
return edge.source
}
}
for (const edge of edges) {
if (needsLayoutSet.has(edge.source) && candidateSet.has(edge.target)) {
return edge.target
}
}
return candidates[0]
}
/**
* Layouts a group of blocks (either root level or within a container).
* Only repositions blocks in `changedSet` or those with invalid positions.
* Resized existing blocks remain anchored and instead drive shifts in nearby
* frozen blocks when their new dimensions create overlap.
*/
function layoutGroup(
parentId: string | null,
childIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
changedSet: Set<string>,
resizedSet: Set<string>,
shiftSourceSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number,
subflowDepths: Map<string, number>,
gridSize?: number
): void {
if (childIds.length === 0) return
const parentBlock = parentId ? blocks[parentId] : undefined
const layoutEligibleChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
if (layoutEligibleChildIds.length === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
const requestedLayout = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
return changedSet.has(id)
})
const invalidPositions = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
return !hasFinitePosition(block)
})
const needsLayoutSet = new Set([...requestedLayout, ...invalidPositions])
const needsLayout = Array.from(needsLayoutSet)
const resizedAnchorIds = layoutEligibleChildIds.filter((id) => resizedSet.has(id))
const groupShiftSourceIds = layoutEligibleChildIds.filter((id) => shiftSourceSet.has(id))
const activeShiftSourceSet = new Set([
...needsLayoutSet,
...resizedAnchorIds,
...groupShiftSourceIds,
])
if (needsLayout.length === 0 && activeShiftSourceSet.size === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
if (needsLayout.length > 0) {
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
if (!block) continue
oldPositions.set(id, { ...block.position })
}
const layoutPositions = computeLayoutPositions(
layoutEligibleChildIds,
blocks,
edges,
parentBlock,
horizontalSpacing,
verticalSpacing,
parentId === null ? subflowDepths : undefined,
gridSize
)
if (layoutPositions.size === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
let offsetX = 0
let offsetY = 0
const anchorId = selectBestAnchor(
layoutEligibleChildIds,
needsLayoutSet,
edges,
layoutPositions
)
if (anchorId) {
const oldPos = oldPositions.get(anchorId)
const newPos = layoutPositions.get(anchorId)
if (oldPos && newPos) {
offsetX = oldPos.x - newPos.x
offsetY = oldPos.y - newPos.y
}
}
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
block.position = snapPositionToGrid(
{ x: newPos.x + offsetX, y: newPos.y + offsetY },
gridSize
)
}
}
const shiftedFrozenIds = shiftDownstreamFrozenBlocks(
activeShiftSourceSet,
needsLayoutSet,
layoutEligibleChildIds,
blocks,
edges,
horizontalSpacing,
gridSize
)
const affectedBlockIds = new Set([...needsLayoutSet, ...resizedAnchorIds, ...shiftedFrozenIds])
if (affectedBlockIds.size > 0) {
resolveVerticalOverlapsWithFrozen(
affectedBlockIds,
layoutEligibleChildIds,
blocks,
verticalSpacing,
gridSize
)
}
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
}
/**
* Shifts frozen (unchanged) blocks rightward when a newly placed block
* overlaps with them in the X-axis. Traverses the DAG forward from changed
* blocks via BFS, cascading shifts through downstream frozen blocks so that
* insertions between existing layers push everything after them to the right.
*
* Only considers edges within the current layout group (scoped to subflow).
*/
function shiftDownstreamFrozenBlocks(
shiftSourceSet: Set<string>,
needsLayoutSet: Set<string>,
eligibleIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
horizontalSpacing: number,
gridSize?: number
): Set<string> {
const eligibleSet = new Set(eligibleIds)
const downstreamMap = new Map<string, string[]>()
for (const edge of edges) {
if (!eligibleSet.has(edge.source) || !eligibleSet.has(edge.target)) continue
if (!downstreamMap.has(edge.source)) downstreamMap.set(edge.source, [])
downstreamMap.get(edge.source)!.push(edge.target)
}
const shifted = new Set<string>()
const queue: string[] = Array.from(shiftSourceSet)
while (queue.length > 0) {
const sourceId = queue.shift()!
const sourceBlock = blocks[sourceId]
if (!sourceBlock) continue
const sourceMetrics = getBlockMetrics(sourceBlock)
const sourceRight = sourceBlock.position.x + sourceMetrics.width
const successors = downstreamMap.get(sourceId) || []
for (const targetId of successors) {
if (needsLayoutSet.has(targetId)) continue
if (shifted.has(targetId)) continue
const targetBlock = blocks[targetId]
if (!targetBlock) continue
if (targetBlock.position.x < sourceRight + horizontalSpacing) {
const shiftX = sourceRight + horizontalSpacing - targetBlock.position.x
targetBlock.position = snapPositionToGrid(
{ x: targetBlock.position.x + shiftX, y: targetBlock.position.y },
gridSize
)
shifted.add(targetId)
queue.push(targetId)
}
}
}
return shifted
}
/**
* Resolves Y-axis overlaps between changed/shifted blocks and frozen blocks
* that share the same column (overlapping X ranges). When a new block is
* inserted into the same layer as existing blocks (e.g. adding a parallel
* branch), this pushes frozen blocks downward to make room, cascading
* through any further blocks below.
*/
function resolveVerticalOverlapsWithFrozen(
affectedBlockIds: Set<string>,
eligibleIds: string[],
blocks: Record<string, BlockState>,
verticalSpacing: number,
gridSize?: number
): void {
const blockInfos = eligibleIds
.map((id) => {
const block = blocks[id]
if (!block) return null
return { id, block, metrics: getBlockMetrics(block) }
})
.filter((info): info is TargetedBlockInfo => info !== null)
if (blockInfos.length < 2 || affectedBlockIds.size === 0) return
const movedSet = new Set(affectedBlockIds)
let hasOverlap = true
let iteration = 0
while (hasOverlap && iteration < MAX_OVERLAP_ITERATIONS) {
hasOverlap = false
iteration++
blockInfos.sort((a, b) => a.block.position.y - b.block.position.y)
for (let i = 0; i < blockInfos.length - 1; i++) {
const upper = blockInfos[i]
for (let lowerIndex = i + 1; lowerIndex < blockInfos.length; lowerIndex++) {
const lower = blockInfos[lowerIndex]
if (!movedSet.has(upper.id) && !movedSet.has(lower.id)) continue
if (!blocksOverlapOnX(upper, lower)) continue
const requiredY = upper.block.position.y + upper.metrics.height + verticalSpacing
if (lower.block.position.y >= requiredY) continue
lower.block.position = snapPositionToGrid(
{ x: lower.block.position.x, y: requiredY },
gridSize
)
movedSet.add(lower.id)
reorderBlockInfoByY(blockInfos, lowerIndex)
hasOverlap = true
}
}
}
}
function blocksOverlapOnX(left: TargetedBlockInfo, right: TargetedBlockInfo): boolean {
const leftRight = left.block.position.x + left.metrics.width
const rightRight = right.block.position.x + right.metrics.width
return left.block.position.x < rightRight && right.block.position.x < leftRight
}
function reorderBlockInfoByY(blockInfos: TargetedBlockInfo[], fromIndex: number): void {
const [movedInfo] = blockInfos.splice(fromIndex, 1)
let insertIndex = fromIndex
while (
insertIndex < blockInfos.length &&
blockInfos[insertIndex].block.position.y < movedInfo.block.position.y
) {
insertIndex++
}
blockInfos.splice(insertIndex, 0, movedInfo)
}
/**
* Computes layout positions for a subset of blocks using the core layout function
*/
function computeLayoutPositions(
childIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
parentBlock: BlockState | undefined,
horizontalSpacing: number,
verticalSpacing: number,
subflowDepths?: Map<string, number>,
gridSize?: number
): Map<string, { x: number; y: number }> {
const subsetBlocks: Record<string, BlockState> = {}
for (const id of childIds) {
subsetBlocks[id] = blocks[id]
}
const subsetEdges = edges.filter(
(edge) => childIds.includes(edge.source) && childIds.includes(edge.target)
)
if (Object.keys(subsetBlocks).length === 0) {
return new Map()
}
const isContainer = !!parentBlock
const { nodes, dimensions } = layoutBlocksCore(subsetBlocks, subsetEdges, {
isContainer,
layoutOptions: {
horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing,
verticalSpacing,
gridSize,
},
subflowDepths,
})
if (parentBlock) {
parentBlock.data = {
...parentBlock.data,
width: Math.max(dimensions.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
height: Math.max(dimensions.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
}
}
const positions = new Map<string, { x: number; y: number }>()
for (const node of nodes.values()) {
positions.set(node.id, { x: node.position.x, y: node.position.y })
}
return positions
}
/**
* Updates container dimensions based on children
*/
function updateContainerDimensions(
parentBlock: BlockState,
childIds: string[],
blocks: Record<string, BlockState>
): void {
if (childIds.length === 0) {
parentBlock.data = {
...parentBlock.data,
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
parentBlock.layout = {
...parentBlock.layout,
measuredWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
measuredHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
return
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const id of childIds) {
const child = blocks[id]
if (!child) continue
if (shouldSkipAutoLayout(child)) {
continue
}
const metrics = getBlockMetrics(child)
minX = Math.min(minX, child.position.x)
minY = Math.min(minY, child.position.y)
maxX = Math.max(maxX, child.position.x + metrics.width)
maxY = Math.max(maxY, child.position.y + metrics.height)
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
return
}
const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2
const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2
parentBlock.data = {
...parentBlock.data,
width: Math.max(calculatedWidth, CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
height: Math.max(calculatedHeight, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
}
parentBlock.layout = {
...parentBlock.layout,
measuredWidth: parentBlock.data.width,
measuredHeight: parentBlock.data.height,
}
}