Files
simstudioai--sim/apps/sim/lib/workflows/autolayout/utils.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

749 lines
23 KiB
TypeScript

import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
AUTO_LAYOUT_EXCLUDED_TYPES,
CONTAINER_BLOCK_TYPES,
CONTAINER_PADDING,
CONTAINER_PADDING_X,
CONTAINER_PADDING_Y,
NOTE_BLOCK_TYPE,
ROOT_PADDING_X,
ROOT_PADDING_Y,
} from '@/lib/workflows/autolayout/constants'
import type { BlockMetrics, BoundingBox, Edge, GraphNode } from '@/lib/workflows/autolayout/types'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockHidden,
isSubBlockVisibleForMode,
isTriggerModeSubBlock,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Resolves a potentially undefined numeric value to a fallback
*/
function resolveNumeric(value: number | undefined, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
/**
* Snaps a single coordinate value to the nearest grid position
*/
function snapToGrid(value: number, gridSize: number): number {
return Math.round(value / gridSize) * gridSize
}
/**
* Snaps a position to the nearest grid point.
* Returns the original position if gridSize is 0 or not provided.
*/
export function snapPositionToGrid(
position: { x: number; y: number },
gridSize: number | undefined
): { x: number; y: number } {
if (!gridSize || gridSize <= 0) {
return position
}
return {
x: snapToGrid(position.x, gridSize),
y: snapToGrid(position.y, gridSize),
}
}
/**
* Snaps all node positions in a graph to grid positions and returns updated dimensions.
* Returns null if gridSize is not set or no snapping was needed.
*/
export function snapNodesToGrid(
nodes: Map<string, GraphNode>,
gridSize: number | undefined
): { width: number; height: number } | null {
if (!gridSize || gridSize <= 0 || nodes.size === 0) {
return null
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const node of nodes.values()) {
node.position = snapPositionToGrid(node.position, gridSize)
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + node.metrics.width)
maxY = Math.max(maxY, node.position.y + node.metrics.height)
}
return {
width: maxX - minX + CONTAINER_PADDING * 2,
height: maxY - minY + CONTAINER_PADDING * 2,
}
}
/**
* Checks if a block type is a container (loop or parallel)
*/
export function isContainerType(blockType: string): boolean {
return CONTAINER_BLOCK_TYPES.has(blockType)
}
/**
* Checks if a block should be excluded from autolayout
*/
export function shouldSkipAutoLayout(block?: BlockState): boolean {
if (!block) return true
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
}
/**
* Filters block IDs to only include those eligible for layout
*/
export function filterLayoutEligibleBlockIds(
blockIds: string[],
blocks: Record<string, BlockState>
): string[] {
return blockIds.filter((id) => {
const block = blocks[id]
if (!block) return false
return !shouldSkipAutoLayout(block)
})
}
/**
* Gets metrics for a container block
*/
function getContainerMetrics(block: BlockState): BlockMetrics {
const measuredWidth = block.layout?.measuredWidth
const measuredHeight = block.layout?.measuredHeight
const containerWidth = Math.max(
measuredWidth ?? 0,
resolveNumeric(block.data?.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH)
)
const containerHeight = Math.max(
measuredHeight ?? 0,
resolveNumeric(block.data?.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT)
)
return {
width: containerWidth,
height: containerHeight,
minWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
minHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
paddingTop: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingBottom: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingLeft: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingRight: BLOCK_DIMENSIONS.HEADER_HEIGHT,
}
}
/**
* Counts visible preview subblocks using the same visibility rules as the
* workflow block preview when no DOM measurements are available.
*/
function getVisiblePreviewSubBlockCount(block: BlockState): number {
const blockConfig = getBlock(block.type)
if (!blockConfig?.subBlocks?.length) {
return Object.values(block.subBlocks || {}).filter((subBlock) => subBlock != null).length
}
const rawValues = buildSubBlockValues(block.subBlocks || {})
const canonicalModeOverrides =
typeof block.data?.canonicalModes === 'object' && block.data.canonicalModes !== null
? (block.data.canonicalModes as CanonicalModeOverrides)
: undefined
if (canonicalModeOverrides) {
rawValues.__canonicalModes = canonicalModeOverrides
}
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const effectiveAdvanced = Boolean(block.advancedMode)
const effectiveTrigger = Boolean(block.triggerMode)
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (isSubBlockHidden(subBlock)) return false
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? isTriggerModeSubBlock(subBlock) || !subBlock.mode
: isTriggerModeSubBlock(subBlock)
if (!isValidTriggerSubblock) {
return false
}
} else if (isTriggerModeSubBlock(subBlock)) {
return false
}
if (
!isSubBlockVisibleForMode(
subBlock,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
}).length
}
/**
* Estimates workflow block dimensions using the same deterministic row-based
* formula used by the canvas block renderer.
*/
function estimateWorkflowBlockDimensions(block: BlockState): { width: number; height: number } {
const blockConfig = getBlock(block.type)
const visibleCount = getVisiblePreviewSubBlockCount(block)
if (!blockConfig) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
visibleCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
}
return calculateWorkflowBlockDimensions({
blockType: block.type,
category: blockConfig.category,
displayTriggerMode: Boolean(block.triggerMode),
visibleSubBlockCount: visibleCount,
conditionRowCount:
block.type === 'condition'
? getConditionRows(block.id, block.subBlocks?.conditions?.value).length
: 0,
routerRowCount:
block.type === 'router_v2'
? getRouterRows(block.id, block.subBlocks?.routes?.value).length
: 0,
})
}
/**
* Gets metrics for a regular (non-container) block.
* Falls back to subblock-based height estimation when no measurement exists,
* which prevents overlaps for newly added blocks that haven't been rendered.
*/
function getRegularBlockMetrics(block: BlockState): BlockMetrics {
const minWidth = BLOCK_DIMENSIONS.FIXED_WIDTH
const minHeight = BLOCK_DIMENSIONS.MIN_HEIGHT
const measuredH = block.layout?.measuredHeight ?? block.height
const measuredW = block.layout?.measuredWidth
const estimatedDimensions = estimateWorkflowBlockDimensions(block)
const estimatedHeight = estimatedDimensions.height
const hasMeasurement = typeof measuredH === 'number' && measuredH > 0
const height = hasMeasurement ? Math.max(measuredH, estimatedHeight, minHeight) : estimatedHeight
const width = Math.max(measuredW ?? estimatedDimensions.width, minWidth)
return {
width,
height,
minWidth,
minHeight,
paddingTop: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingBottom: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingLeft: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingRight: BLOCK_DIMENSIONS.HEADER_HEIGHT,
}
}
/**
* Gets the dimensions and metrics for a block
*/
export function getBlockMetrics(block: BlockState): BlockMetrics {
if (isContainerType(block.type)) {
return getContainerMetrics(block)
}
return getRegularBlockMetrics(block)
}
/**
* Prepares metrics for all nodes in a graph
*/
export function prepareBlockMetrics(nodes: Map<string, GraphNode>): void {
for (const node of nodes.values()) {
node.metrics = getBlockMetrics(node.block)
}
}
/**
* Creates a bounding box from position and dimensions
*/
export function createBoundingBox(
position: { x: number; y: number },
dimensions: Pick<BlockMetrics, 'width' | 'height'>
): BoundingBox {
return {
x: position.x,
y: position.y,
width: dimensions.width,
height: dimensions.height,
}
}
/**
* Checks if two bounding boxes overlap (with optional margin)
*/
export function boxesOverlap(box1: BoundingBox, box2: BoundingBox, margin = 0): boolean {
return !(
box1.x + box1.width + margin <= box2.x ||
box2.x + box2.width + margin <= box1.x ||
box1.y + box1.height + margin <= box2.y ||
box2.y + box2.height + margin <= box1.y
)
}
/**
* Resolves the on-canvas dimensions of a note block.
* Notes are fixed-width and use a deterministic height, but fall back to any
* stored measurement or data override when present.
*/
function getNoteDimensions(block: BlockState): { width: number; height: number } {
const width = Math.max(
resolveNumeric(block.data?.width, 0),
block.layout?.measuredWidth ?? 0,
BLOCK_DIMENSIONS.FIXED_WIDTH
)
const defaultHeight =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING +
BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT
const height = Math.max(
resolveNumeric(block.data?.height, 0),
block.layout?.measuredHeight ?? 0,
block.height ?? 0,
defaultHeight
)
return { width, height }
}
/**
* Checks if a block has a valid, finite position.
* Returns false for missing, undefined, NaN, or Infinity coordinates.
*/
export function hasFinitePosition(block: BlockState): boolean {
return Number.isFinite(block.position?.x) && Number.isFinite(block.position?.y)
}
/**
* Determines whether a note and a block overlapped at their pre-layout
* positions. Used by targeted layout to distinguish overlaps introduced by the
* current pass from arrangements that already existed.
*/
function noteOverlappedBlockBefore(
previousBlocks: Record<string, BlockState>,
noteId: string,
blockId: string
): boolean {
const previousNote = previousBlocks[noteId]
const previousBlock = previousBlocks[blockId]
if (!previousNote || !previousBlock) return false
// A block without a finite prior position was not yet placed on the canvas,
// so it could not have overlapped anything before this pass.
if (!hasFinitePosition(previousNote) || !hasFinitePosition(previousBlock)) return false
// Derive dimensions from the prior blocks so a resize between passes does not
// pair new dimensions with the old position.
const noteBox = createBoundingBox(previousNote.position, getNoteDimensions(previousNote))
const blockBox = createBoundingBox(previousBlock.position, getBlockMetrics(previousBlock))
return boxesOverlap(blockBox, noteBox)
}
export interface ResolveNoteOverlapsOptions {
/**
* Pre-layout block snapshot. When provided, only notes whose overlap with a
* block was *introduced* by the current pass are relocated (i.e. they overlap
* now but did not at these positions). Targeted layout passes this so that
* pre-existing note arrangements are preserved. When omitted (full
* auto-layout), any note overlapping a block is relocated.
*/
previousBlocks?: Record<string, BlockState>
}
/**
* Relocates note blocks that overlap the laid-out workflow blocks.
*
* Notes are excluded from the topological layout because they are free-form
* annotations, but repositioned workflow blocks can land on top of them. This
* pass keeps notes that already sit in clear space and stacks any relocated
* notes in a column beneath their parent group's blocks, preserving the notes'
* relative reading order. Notes are grouped by parent so notes inside a
* container are resolved against that container's children only.
*
* Placement is always computed against the full set of blocks and notes in the
* group, so a relocated note never collides with anything regardless of which
* overlaps triggered the relocation.
*/
export function resolveNoteOverlaps(
blocks: Record<string, BlockState>,
verticalSpacing: number,
options: ResolveNoteOverlapsOptions = {}
): void {
const { previousBlocks } = options
const { root, children } = getBlocksByParent(blocks)
const groups: string[][] = [root, ...Array.from(children.values())]
for (const groupIds of groups) {
const obstacles: Array<{ id: string; box: BoundingBox }> = []
const noteIds: string[] = []
let maxBottom = Number.NEGATIVE_INFINITY
let minX = Number.POSITIVE_INFINITY
for (const id of groupIds) {
const block = blocks[id]
// Skip non-finite positions so corrupted coordinates never propagate into
// minX / maxBottom (and therefore into relocated note positions).
if (!block || !hasFinitePosition(block)) continue
if (block.type === NOTE_BLOCK_TYPE) {
noteIds.push(id)
const { height } = getNoteDimensions(block)
maxBottom = Math.max(maxBottom, block.position.y + height)
continue
}
if (shouldSkipAutoLayout(block)) continue
const box = createBoundingBox(block.position, getBlockMetrics(block))
obstacles.push({ id, box })
maxBottom = Math.max(maxBottom, box.y + box.height)
minX = Math.min(minX, box.x)
}
if (noteIds.length === 0 || obstacles.length === 0) continue
noteIds.sort((a, b) => {
const posA = blocks[a].position
const posB = blocks[b].position
return posA.y - posB.y || posA.x - posB.x
})
let stackY = maxBottom + verticalSpacing
for (const id of noteIds) {
const note = blocks[id]
const dimensions = getNoteDimensions(note)
const noteBox = createBoundingBox(note.position, dimensions)
const needsRelocation = obstacles.some(({ id: blockId, box }) => {
if (!boxesOverlap(box, noteBox)) return false
if (previousBlocks) {
return !noteOverlappedBlockBefore(previousBlocks, id, blockId)
}
return true
})
if (!needsRelocation) continue
note.position = { x: minX, y: stackY }
stackY += dimensions.height + verticalSpacing
}
}
}
/**
* Groups blocks by their parent container
*/
export function getBlocksByParent(blocks: Record<string, BlockState>): {
root: string[]
children: Map<string, string[]>
} {
const root: string[] = []
const children = new Map<string, string[]>()
for (const [id, block] of Object.entries(blocks)) {
const parentId = block.data?.parentId
if (!parentId) {
root.push(id)
} else {
if (!children.has(parentId)) {
children.set(parentId, [])
}
children.get(parentId)!.push(id)
}
}
return { root, children }
}
/**
* Normalizes node positions to start from a given padding offset.
* Returns the bounding box dimensions of the normalized layout.
*/
export function normalizePositions(
nodes: Map<string, GraphNode>,
options: { isContainer: boolean }
): { width: number; height: number } {
if (nodes.size === 0) {
return { width: 0, height: 0 }
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const node of nodes.values()) {
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + node.metrics.width)
maxY = Math.max(maxY, node.position.y + node.metrics.height)
}
const paddingX = options.isContainer ? CONTAINER_PADDING_X : ROOT_PADDING_X
const paddingY = options.isContainer ? CONTAINER_PADDING_Y : ROOT_PADDING_Y
const xOffset = paddingX - minX
const yOffset = paddingY - minY
for (const node of nodes.values()) {
node.position = {
x: node.position.x + xOffset,
y: node.position.y + yOffset,
}
}
const width = maxX - minX + CONTAINER_PADDING * 2
const height = maxY - minY + CONTAINER_PADDING * 2
return { width, height }
}
/**
* Transfers block height measurements from source blocks to target blocks.
* Matches blocks by type:name key.
*/
export function transferBlockHeights(
sourceBlocks: Record<string, BlockState>,
targetBlocks: Record<string, BlockState>
): void {
// Build a map of block type+name to heights from source
const heightMap = new Map<string, { height: number; width: number }>()
for (const block of Object.values(sourceBlocks)) {
const key = `${block.type}:${block.name}`
heightMap.set(key, {
height: block.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
width: block.layout?.measuredWidth || BLOCK_DIMENSIONS.FIXED_WIDTH,
})
}
// Transfer heights to target blocks
for (const block of Object.values(targetBlocks)) {
const key = `${block.type}:${block.name}`
const measurements = heightMap.get(key)
if (measurements) {
block.height = measurements.height
if (!block.layout) {
block.layout = {}
}
block.layout.measuredHeight = measurements.height
block.layout.measuredWidth = measurements.width
}
}
}
/**
* Calculates the internal depth (max layer count) for each subflow container.
* Used to properly position blocks that connect after a subflow ends.
*
* @param blocks - All blocks in the workflow
* @param edges - All edges in the workflow
* @param assignLayersFn - Function to assign layers to blocks
* @returns Map of container block IDs to their internal layer depth
*/
export function calculateSubflowDepths(
blocks: Record<string, BlockState>,
edges: Edge[],
assignLayersFn: (blocks: Record<string, BlockState>, edges: Edge[]) => Map<string, GraphNode>
): Map<string, number> {
const depths = new Map<string, number>()
const { children } = getBlocksByParent(blocks)
for (const [containerId, childIds] of children.entries()) {
if (childIds.length === 0) {
depths.set(containerId, 1)
continue
}
const childBlocks: Record<string, BlockState> = {}
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
for (const childId of layoutChildIds) {
childBlocks[childId] = blocks[childId]
}
const childEdges = edges.filter(
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
)
if (Object.keys(childBlocks).length === 0) {
depths.set(containerId, 1)
continue
}
const childNodes = assignLayersFn(childBlocks, childEdges)
let maxLayer = 0
for (const node of childNodes.values()) {
maxLayer = Math.max(maxLayer, node.layer)
}
depths.set(containerId, Math.max(maxLayer + 1, 1))
}
return depths
}
/**
* Layout function type for preparing container dimensions.
* Returns laid out nodes and bounding dimensions.
*/
export type LayoutFunction = (
blocks: Record<string, BlockState>,
edges: Edge[],
options: {
isContainer: boolean
layoutOptions?: {
horizontalSpacing?: number
verticalSpacing?: number
padding?: { x: number; y: number }
gridSize?: number
}
subflowDepths?: Map<string, number>
}
) => { nodes: Map<string, GraphNode>; dimensions: { width: number; height: number } }
/**
* Pre-calculates container dimensions by laying out their children.
* Processes containers bottom-up to handle nested subflows correctly.
* This ensures accurate width/height values before root-level layout.
*
* @param blocks - All blocks in the workflow (will be mutated with updated dimensions)
* @param edges - All edges in the workflow
* @param layoutFn - The layout function to use for calculating dimensions
* @param horizontalSpacing - Horizontal spacing between blocks
* @param verticalSpacing - Vertical spacing between blocks
* @param gridSize - Optional grid size for snap-to-grid
*/
export function prepareContainerDimensions(
blocks: Record<string, BlockState>,
edges: Edge[],
layoutFn: LayoutFunction,
horizontalSpacing: number,
verticalSpacing: number,
gridSize?: number
): void {
const { children } = getBlocksByParent(blocks)
// Build dependency graph to process nested containers bottom-up
const containerIds = Array.from(children.keys())
const containerDepth = new Map<string, number>()
// Calculate nesting depth for each container
for (const containerId of containerIds) {
let depth = 0
let currentId: string | undefined = containerId
while (currentId) {
const block: BlockState | undefined = blocks[currentId]
const parentId: string | undefined = block?.data?.parentId
currentId = parentId
if (currentId) depth++
}
containerDepth.set(containerId, depth)
}
// Sort containers by depth (deepest first) for bottom-up processing
const sortedContainerIds = containerIds.sort((a, b) => {
const depthA = containerDepth.get(a) ?? 0
const depthB = containerDepth.get(b) ?? 0
return depthB - depthA
})
// Process each container, laying out its children to determine dimensions
for (const containerId of sortedContainerIds) {
const container = blocks[containerId]
if (!container) continue
const childIds = children.get(containerId) ?? []
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
if (layoutChildIds.length === 0) {
// Empty container - use default dimensions
container.data = {
...container.data,
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
container.layout = {
...container.layout,
measuredWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
measuredHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
continue
}
// Build subset of blocks and edges for this container's children
const childBlocks: Record<string, BlockState> = {}
for (const childId of layoutChildIds) {
childBlocks[childId] = blocks[childId]
}
const childEdges = edges.filter(
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
)
// Layout children to get dimensions
const { dimensions } = layoutFn(childBlocks, childEdges, {
isContainer: true,
layoutOptions: {
horizontalSpacing: horizontalSpacing * 0.85,
verticalSpacing,
gridSize,
},
})
// Update container with calculated dimensions
const calculatedWidth = Math.max(dimensions.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH)
const calculatedHeight = Math.max(dimensions.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT)
container.data = {
...container.data,
width: calculatedWidth,
height: calculatedHeight,
}
container.layout = {
...container.layout,
measuredWidth: calculatedWidth,
measuredHeight: calculatedHeight,
}
}
}