diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 4596d15e10..57ca10e708 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -4,6 +4,11 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' +import { + DEFAULT_HORIZONTAL_SPACING, + DEFAULT_LAYOUT_PADDING, + DEFAULT_VERTICAL_SPACING, +} from '@/lib/workflows/autolayout/constants' import { loadWorkflowFromNormalizedTables, type NormalizedWorkflowData, @@ -15,24 +20,18 @@ export const dynamic = 'force-dynamic' const logger = createLogger('AutoLayoutAPI') const AutoLayoutRequestSchema = z.object({ - strategy: z - .enum(['smart', 'hierarchical', 'layered', 'force-directed']) - .optional() - .default('smart'), - direction: z.enum(['horizontal', 'vertical', 'auto']).optional().default('auto'), spacing: z .object({ - horizontal: z.number().min(100).max(1000).optional().default(400), - vertical: z.number().min(50).max(500).optional().default(200), - layer: z.number().min(200).max(1200).optional().default(600), + horizontal: z.number().min(100).max(1000).optional(), + vertical: z.number().min(50).max(500).optional(), }) .optional() .default({}), alignment: z.enum(['start', 'center', 'end']).optional().default('center'), padding: z .object({ - x: z.number().min(50).max(500).optional().default(200), - y: z.number().min(50).max(500).optional().default(200), + x: z.number().min(50).max(500).optional(), + y: z.number().min(50).max(500).optional(), }) .optional() .default({}), @@ -68,8 +67,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const layoutOptions = AutoLayoutRequestSchema.parse(body) logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { - strategy: layoutOptions.strategy, - direction: layoutOptions.direction, userId, }) @@ -121,11 +118,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } const autoLayoutOptions = { - horizontalSpacing: layoutOptions.spacing?.horizontal || 550, - verticalSpacing: layoutOptions.spacing?.vertical || 200, + horizontalSpacing: layoutOptions.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING, + verticalSpacing: layoutOptions.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING, padding: { - x: layoutOptions.padding?.x || 150, - y: layoutOptions.padding?.y || 150, + x: layoutOptions.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, + y: layoutOptions.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, }, alignment: layoutOptions.alignment, } @@ -133,8 +130,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const layoutResult = applyAutoLayout( currentWorkflowData.blocks, currentWorkflowData.edges, - currentWorkflowData.loops || {}, - currentWorkflowData.parallels || {}, autoLayoutOptions ) @@ -156,7 +151,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, { blockCount, - strategy: layoutOptions.strategy, workflowId, }) @@ -164,8 +158,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ success: true, message: `Autolayout applied successfully to ${blockCount} blocks`, data: { - strategy: layoutOptions.strategy, - direction: layoutOptions.direction, blockCount, elapsed: `${elapsed}ms`, layoutedBlocks: layoutResult.blocks, diff --git a/apps/sim/app/api/yaml/autolayout/route.ts b/apps/sim/app/api/yaml/autolayout/route.ts index 1a28e13de0..02855a4b6b 100644 --- a/apps/sim/app/api/yaml/autolayout/route.ts +++ b/apps/sim/app/api/yaml/autolayout/route.ts @@ -3,6 +3,11 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' +import { + DEFAULT_HORIZONTAL_SPACING, + DEFAULT_LAYOUT_PADDING, + DEFAULT_VERTICAL_SPACING, +} from '@/lib/workflows/autolayout/constants' const logger = createLogger('YamlAutoLayoutAPI') @@ -15,13 +20,10 @@ const AutoLayoutRequestSchema = z.object({ }), options: z .object({ - strategy: z.enum(['smart', 'hierarchical', 'layered', 'force-directed']).optional(), - direction: z.enum(['horizontal', 'vertical', 'auto']).optional(), spacing: z .object({ horizontal: z.number().optional(), vertical: z.number().optional(), - layer: z.number().optional(), }) .optional(), alignment: z.enum(['start', 'center', 'end']).optional(), @@ -45,24 +47,21 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Applying auto layout`, { blockCount: Object.keys(workflowState.blocks).length, edgeCount: workflowState.edges.length, - strategy: options?.strategy || 'smart', }) const autoLayoutOptions = { - horizontalSpacing: options?.spacing?.horizontal || 550, - verticalSpacing: options?.spacing?.vertical || 200, + horizontalSpacing: options?.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING, + verticalSpacing: options?.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING, padding: { - x: options?.padding?.x || 150, - y: options?.padding?.y || 150, + x: options?.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, + y: options?.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, }, - alignment: options?.alignment || 'center', + alignment: options?.alignment ?? 'center', } const layoutResult = applyAutoLayout( workflowState.blocks, workflowState.edges, - workflowState.loops || {}, - workflowState.parallels || {}, autoLayoutOptions ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts index 170a31f606..d8857c37d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts @@ -2,6 +2,9 @@ import { useEffect, useRef } from 'react' import { useUpdateNodeInternals } from 'reactflow' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +// Re-export for backwards compatibility +export { BLOCK_DIMENSIONS } from '@/lib/blocks/block-dimensions' + interface BlockDimensions { width: number height: number @@ -13,24 +16,6 @@ interface UseBlockDimensionsOptions { dependencies: React.DependencyList } -/** - * Shared block dimension constants - */ -export const BLOCK_DIMENSIONS = { - FIXED_WIDTH: 250, - HEADER_HEIGHT: 40, - MIN_HEIGHT: 100, - - // Workflow blocks - WORKFLOW_CONTENT_PADDING: 16, - WORKFLOW_ROW_HEIGHT: 29, - - // Note blocks - NOTE_CONTENT_PADDING: 14, - NOTE_MIN_CONTENT_HEIGHT: 20, - NOTE_BASE_CONTENT_HEIGHT: 60, -} as const - /** * Hook to manage deterministic block dimensions without ResizeObserver. * Calculates dimensions based on content structure and updates the store. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index ac6bf6170e..07ce2736c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -1,12 +1,10 @@ import { useCallback } from 'react' import { useReactFlow } from 'reactflow' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/blocks/block-dimensions' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('NodeUtilities') -const DEFAULT_CONTAINER_WIDTH = 500 -const DEFAULT_CONTAINER_HEIGHT = 300 - /** * Hook providing utilities for node position, hierarchy, and dimension calculations */ @@ -27,40 +25,43 @@ export function useNodeUtilities(blocks: Record) { const getBlockDimensions = useCallback( (blockId: string): { width: number; height: number } => { const block = blocks[blockId] - if (!block) return { width: 250, height: 100 } + if (!block) { + return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } + } if (isContainerType(block.type)) { return { - width: block.data?.width ? Math.max(block.data.width, 400) : DEFAULT_CONTAINER_WIDTH, - height: block.data?.height ? Math.max(block.data.height, 200) : DEFAULT_CONTAINER_HEIGHT, + width: block.data?.width + ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH) + : CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: block.data?.height + ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT) + : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, } } // Workflow block nodes have fixed visual width - const width = 250 + const width = BLOCK_DIMENSIONS.FIXED_WIDTH // Prefer deterministic height published by the block component; fallback to estimate let height = block.height if (!height) { // Estimate height for workflow blocks before ResizeObserver measures them - // Block structure: header (40px) + content area with subblocks + // Block structure: header + content area with subblocks // Each subblock row is approximately 29px (14px text + 8px gap + padding) - const headerHeight = 40 - const subblockRowHeight = 29 - const contentPadding = 16 // p-[8px] top and bottom = 16px total - - // Estimate number of visible subblock rows - // This is a rough estimate - actual rendering may vary const estimatedRows = 3 // Conservative estimate for typical blocks const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0 - height = headerHeight + contentPadding + (estimatedRows + hasErrorRow) * subblockRowHeight + height = + BLOCK_DIMENSIONS.HEADER_HEIGHT + + BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + + (estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT } return { width, - height: Math.max(height, 100), + height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT), } }, [blocks, isContainerType] @@ -205,9 +206,9 @@ export function useNodeUtilities(blocks: Record) { const absolutePos = getNodeAbsolutePosition(n.id) const rect = { left: absolutePos.x, - right: absolutePos.x + (n.data?.width || DEFAULT_CONTAINER_WIDTH), + right: absolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH), top: absolutePos.y, - bottom: absolutePos.y + (n.data?.height || DEFAULT_CONTAINER_HEIGHT), + bottom: absolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), } return ( @@ -222,8 +223,8 @@ export function useNodeUtilities(blocks: Record) { // Return absolute position so callers can compute relative placement correctly loopPosition: getNodeAbsolutePosition(n.id), dimensions: { - width: n.data?.width || DEFAULT_CONTAINER_WIDTH, - height: n.data?.height || DEFAULT_CONTAINER_HEIGHT, + width: n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, }, })) @@ -247,8 +248,8 @@ export function useNodeUtilities(blocks: Record) { */ const calculateLoopDimensions = useCallback( (nodeId: string): { width: number; height: number } => { - const minWidth = DEFAULT_CONTAINER_WIDTH - const minHeight = DEFAULT_CONTAINER_HEIGHT + const minWidth = CONTAINER_DIMENSIONS.DEFAULT_WIDTH + const minHeight = CONTAINER_DIMENSIONS.DEFAULT_HEIGHT // Match styling in subflow-node.tsx: // - Header section: 50px total height diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts index b5566f1bf7..1355e7c5a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts @@ -1,4 +1,9 @@ import { createLogger } from '@/lib/logs/console/logger' +import { + DEFAULT_HORIZONTAL_SPACING, + DEFAULT_LAYOUT_PADDING, + DEFAULT_VERTICAL_SPACING, +} from '@/lib/workflows/autolayout/constants' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('AutoLayoutUtils') @@ -7,12 +12,9 @@ const logger = createLogger('AutoLayoutUtils') * Auto layout options interface */ export interface AutoLayoutOptions { - strategy?: 'smart' | 'hierarchical' | 'layered' | 'force-directed' - direction?: 'horizontal' | 'vertical' | 'auto' spacing?: { horizontal?: number vertical?: number - layer?: number } alignment?: 'start' | 'center' | 'end' padding?: { @@ -21,24 +23,6 @@ export interface AutoLayoutOptions { } } -/** - * Default auto layout options - */ -const DEFAULT_AUTO_LAYOUT_OPTIONS = { - strategy: 'smart' as const, - direction: 'auto' as const, - spacing: { - horizontal: 550, - vertical: 200, - layer: 550, - }, - alignment: 'center' as const, - padding: { - x: 150, - y: 150, - }, -} - /** * Apply auto layout and update store * Standalone utility for use outside React context (event handlers, tools, etc.) @@ -69,17 +53,14 @@ export async function applyAutoLayoutAndUpdateStore( // Merge with default options const layoutOptions = { - strategy: options.strategy || DEFAULT_AUTO_LAYOUT_OPTIONS.strategy, - direction: options.direction || DEFAULT_AUTO_LAYOUT_OPTIONS.direction, spacing: { - horizontal: options.spacing?.horizontal || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing.horizontal, - vertical: options.spacing?.vertical || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing.vertical, - layer: options.spacing?.layer || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing.layer, + horizontal: options.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING, + vertical: options.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING, }, - alignment: options.alignment || DEFAULT_AUTO_LAYOUT_OPTIONS.alignment, + alignment: options.alignment ?? 'center', padding: { - x: options.padding?.x || DEFAULT_AUTO_LAYOUT_OPTIONS.padding.x, - y: options.padding?.y || DEFAULT_AUTO_LAYOUT_OPTIONS.padding.y, + x: options.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, + y: options.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, }, } diff --git a/apps/sim/lib/blocks/block-dimensions.ts b/apps/sim/lib/blocks/block-dimensions.ts new file mode 100644 index 0000000000..cff175dc30 --- /dev/null +++ b/apps/sim/lib/blocks/block-dimensions.ts @@ -0,0 +1,57 @@ +/** + * Shared Block Dimension Constants + * + * Single source of truth for block dimensions used by: + * - UI components (workflow-block, note-block) + * - Autolayout system + * - Node utilities + * + * IMPORTANT: These values must match the actual CSS dimensions in the UI. + * Changing these values will affect both rendering and layout calculations. + */ + +/** + * Block dimension constants for workflow blocks + */ +export const BLOCK_DIMENSIONS = { + /** Fixed width for all workflow blocks (matches w-[250px] in workflow-block.tsx) */ + FIXED_WIDTH: 250, + + /** Header height for blocks */ + HEADER_HEIGHT: 40, + + /** Minimum height for blocks */ + MIN_HEIGHT: 100, + + /** Padding around workflow block content (p-[8px] top + bottom = 16px) */ + WORKFLOW_CONTENT_PADDING: 16, + + /** Height of each subblock row (14px text + 8px gap + padding) */ + WORKFLOW_ROW_HEIGHT: 29, + + /** Padding around note block content */ + NOTE_CONTENT_PADDING: 14, + + /** Minimum content height for note blocks */ + NOTE_MIN_CONTENT_HEIGHT: 20, + + /** Base content height for note blocks */ + NOTE_BASE_CONTENT_HEIGHT: 60, +} as const + +/** + * Container block dimension constants (loop, parallel, subflow) + */ +export const CONTAINER_DIMENSIONS = { + /** Default width for container blocks */ + DEFAULT_WIDTH: 500, + + /** Default height for container blocks */ + DEFAULT_HEIGHT: 300, + + /** Minimum width for container blocks */ + MIN_WIDTH: 400, + + /** Minimum height for container blocks */ + MIN_HEIGHT: 200, +} as const diff --git a/apps/sim/lib/workflows/autolayout/constants.ts b/apps/sim/lib/workflows/autolayout/constants.ts new file mode 100644 index 0000000000..a97051287b --- /dev/null +++ b/apps/sim/lib/workflows/autolayout/constants.ts @@ -0,0 +1,94 @@ +/** + * Autolayout Constants + * + * Layout algorithm specific constants for spacing, padding, and overlap detection. + * Block dimensions are imported from the shared source: @/lib/blocks/block-dimensions + */ + +// Re-export block dimensions for autolayout consumers +export { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/blocks/block-dimensions' + +/** + * Horizontal spacing between layers (columns) + */ +export const DEFAULT_HORIZONTAL_SPACING = 550 + +/** + * Vertical spacing between blocks in the same layer + */ +export const DEFAULT_VERTICAL_SPACING = 200 + +/** + * General container padding for layout calculations + */ +export const CONTAINER_PADDING = 150 + +/** + * Container horizontal padding (X offset for children in layout coordinates) + */ +export const CONTAINER_PADDING_X = 180 + +/** + * Container vertical padding (Y offset for children in layout coordinates) + */ +export const CONTAINER_PADDING_Y = 100 + +/** + * Root level horizontal padding + */ +export const ROOT_PADDING_X = 150 + +/** + * Root level vertical padding + */ +export const ROOT_PADDING_Y = 150 + +/** + * Default padding for layout positioning + */ +export const DEFAULT_LAYOUT_PADDING = { x: 150, y: 150 } + +/** + * Margin for overlap detection + */ +export const OVERLAP_MARGIN = 30 + +/** + * Maximum iterations for overlap resolution + */ +export const MAX_OVERLAP_ITERATIONS = 20 + +/** + * Block types excluded from autolayout + */ +export const AUTO_LAYOUT_EXCLUDED_TYPES = new Set(['note']) + +/** + * Container block types that can have children + */ +export const CONTAINER_BLOCK_TYPES = new Set(['loop', 'parallel']) + +/** + * Default layout options + */ +export const DEFAULT_LAYOUT_OPTIONS = { + horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, + verticalSpacing: DEFAULT_VERTICAL_SPACING, + padding: DEFAULT_LAYOUT_PADDING, + alignment: 'center' as const, +} + +/** + * Default horizontal spacing for containers (tighter than root level) + */ +export const DEFAULT_CONTAINER_HORIZONTAL_SPACING = 400 + +/** + * Container-specific layout options (tighter spacing for nested layouts) + */ +export const CONTAINER_LAYOUT_OPTIONS = { + horizontalSpacing: DEFAULT_CONTAINER_HORIZONTAL_SPACING, + verticalSpacing: DEFAULT_VERTICAL_SPACING, + padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, + alignment: 'center' as const, +} diff --git a/apps/sim/lib/workflows/autolayout/containers.ts b/apps/sim/lib/workflows/autolayout/containers.ts index e0bdf2a589..311b436211 100644 --- a/apps/sim/lib/workflows/autolayout/containers.ts +++ b/apps/sim/lib/workflows/autolayout/containers.ts @@ -1,31 +1,41 @@ +import { CONTAINER_DIMENSIONS } from '@/lib/blocks/block-dimensions' import { createLogger } from '@/lib/logs/console/logger' -import { assignLayers, groupByLayer } from '@/lib/workflows/autolayout/layering' -import { calculatePositions } from '@/lib/workflows/autolayout/positioning' -import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types' import { - CONTAINER_PADDING, CONTAINER_PADDING_X, CONTAINER_PADDING_Y, - DEFAULT_CONTAINER_HEIGHT, - DEFAULT_CONTAINER_WIDTH, - filterLayoutEligibleBlockIds, - getBlocksByParent, - prepareBlockMetrics, -} from '@/lib/workflows/autolayout/utils' + DEFAULT_VERTICAL_SPACING, +} from '@/lib/workflows/autolayout/constants' +import { layoutBlocksCore } from '@/lib/workflows/autolayout/core' +import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types' +import { filterLayoutEligibleBlockIds, getBlocksByParent } from '@/lib/workflows/autolayout/utils' import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('AutoLayout:Containers') +/** + * Default horizontal spacing for containers (tighter than root level) + */ +const DEFAULT_CONTAINER_HORIZONTAL_SPACING = 400 + +/** + * Lays out children within container blocks (loops and parallels). + * Updates both child positions and container dimensions. + */ export function layoutContainers( blocks: Record, edges: Edge[], options: LayoutOptions = {} ): void { - const { root, children } = getBlocksByParent(blocks) + const { children } = getBlocksByParent(blocks) + // Build container-specific layout options + // If horizontalSpacing provided, reduce by 15% for tighter container layout + // Otherwise use the default container spacing (400) const containerOptions: LayoutOptions = { - horizontalSpacing: options.horizontalSpacing ? options.horizontalSpacing * 0.85 : 400, - verticalSpacing: options.verticalSpacing ? options.verticalSpacing : 200, + horizontalSpacing: options.horizontalSpacing + ? options.horizontalSpacing * 0.85 + : DEFAULT_CONTAINER_HORIZONTAL_SPACING, + verticalSpacing: options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING, padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, alignment: options.alignment, } @@ -50,40 +60,23 @@ export function layoutContainers( continue } - const childNodes = assignLayers(childBlocks, childEdges) - prepareBlockMetrics(childNodes) - const childLayers = groupByLayer(childNodes) - calculatePositions(childLayers, containerOptions) - - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - // Normalize positions to start from padding offset - for (const node of childNodes.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) - } - - // Adjust all child positions to start at proper padding from container edges - const xOffset = CONTAINER_PADDING_X - minX - const yOffset = CONTAINER_PADDING_Y - minY + // Use the shared core layout function with container options + const { nodes, dimensions } = layoutBlocksCore(childBlocks, childEdges, { + isContainer: true, + layoutOptions: containerOptions, + }) - for (const node of childNodes.values()) { - childBlocks[node.id].position = { - x: node.position.x + xOffset, - y: node.position.y + yOffset, - } + // Apply positions back to blocks + for (const node of nodes.values()) { + blocks[node.id].position = node.position } - const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2 - const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2 + // Update container dimensions + const calculatedWidth = dimensions.width + const calculatedHeight = dimensions.height - const containerWidth = Math.max(calculatedWidth, DEFAULT_CONTAINER_WIDTH) - const containerHeight = Math.max(calculatedHeight, DEFAULT_CONTAINER_HEIGHT) + const containerWidth = Math.max(calculatedWidth, CONTAINER_DIMENSIONS.DEFAULT_WIDTH) + const containerHeight = Math.max(calculatedHeight, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT) if (!parentBlock.data) { parentBlock.data = {} diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts new file mode 100644 index 0000000000..2ea0cb947c --- /dev/null +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -0,0 +1,288 @@ +import { createLogger } from '@/lib/logs/console/logger' +import { + CONTAINER_LAYOUT_OPTIONS, + DEFAULT_LAYOUT_OPTIONS, + MAX_OVERLAP_ITERATIONS, + OVERLAP_MARGIN, +} from '@/lib/workflows/autolayout/constants' +import type { Edge, GraphNode, LayoutOptions } from '@/lib/workflows/autolayout/types' +import { + boxesOverlap, + createBoundingBox, + getBlockMetrics, + normalizePositions, + prepareBlockMetrics, +} from '@/lib/workflows/autolayout/utils' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('AutoLayout:Core') + +/** + * Assigns layers (columns) to blocks using topological sort. + * Blocks with no incoming edges are placed in layer 0. + */ +export function assignLayers( + blocks: Record, + edges: Edge[] +): Map { + const nodes = new Map() + + // Initialize nodes + for (const [id, block] of Object.entries(blocks)) { + nodes.set(id, { + id, + block, + metrics: getBlockMetrics(block), + incoming: new Set(), + outgoing: new Set(), + layer: 0, + position: { ...block.position }, + }) + } + + // Build adjacency from edges + for (const edge of edges) { + const sourceNode = nodes.get(edge.source) + const targetNode = nodes.get(edge.target) + + if (sourceNode && targetNode) { + sourceNode.outgoing.add(edge.target) + targetNode.incoming.add(edge.source) + } + } + + // Find starter nodes (no incoming edges) + const starterNodes = Array.from(nodes.values()).filter((node) => node.incoming.size === 0) + + if (starterNodes.length === 0 && nodes.size > 0) { + const firstNode = Array.from(nodes.values())[0] + starterNodes.push(firstNode) + logger.warn('No starter blocks found, using first block as starter', { blockId: firstNode.id }) + } + + // Topological sort using Kahn's algorithm + const inDegreeCount = new Map() + + for (const node of nodes.values()) { + inDegreeCount.set(node.id, node.incoming.size) + if (starterNodes.includes(node)) { + node.layer = 0 + } + } + + const queue: string[] = starterNodes.map((n) => n.id) + const processed = new Set() + + while (queue.length > 0) { + const nodeId = queue.shift()! + const node = nodes.get(nodeId)! + processed.add(nodeId) + + // Calculate layer based on max incoming layer + 1 + if (node.incoming.size > 0) { + let maxIncomingLayer = -1 + for (const incomingId of node.incoming) { + const incomingNode = nodes.get(incomingId) + if (incomingNode) { + maxIncomingLayer = Math.max(maxIncomingLayer, incomingNode.layer) + } + } + node.layer = maxIncomingLayer + 1 + } + + // Add outgoing nodes when all dependencies processed + for (const targetId of node.outgoing) { + const currentCount = inDegreeCount.get(targetId) || 0 + inDegreeCount.set(targetId, currentCount - 1) + + if (inDegreeCount.get(targetId) === 0 && !processed.has(targetId)) { + queue.push(targetId) + } + } + } + + // Handle isolated nodes + for (const node of nodes.values()) { + if (!processed.has(node.id)) { + logger.debug('Isolated node detected, assigning to layer 0', { blockId: node.id }) + node.layer = 0 + } + } + + return nodes +} + +/** + * Groups nodes by their layer number + */ +export function groupByLayer(nodes: Map): Map { + const layers = new Map() + + for (const node of nodes.values()) { + if (!layers.has(node.layer)) { + layers.set(node.layer, []) + } + layers.get(node.layer)!.push(node) + } + + return layers +} + +/** + * Resolves overlaps between all nodes, including across layers. + * Nodes in the same layer are shifted vertically to avoid overlap. + * Nodes in different layers that overlap are shifted down. + */ +function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void { + let iteration = 0 + let hasOverlap = true + + while (hasOverlap && iteration < MAX_OVERLAP_ITERATIONS) { + hasOverlap = false + iteration++ + + // Sort nodes by layer then by Y position for consistent processing + const sortedNodes = [...nodes].sort((a, b) => { + if (a.layer !== b.layer) return a.layer - b.layer + return a.position.y - b.position.y + }) + + for (let i = 0; i < sortedNodes.length; i++) { + for (let j = i + 1; j < sortedNodes.length; j++) { + const node1 = sortedNodes[i] + const node2 = sortedNodes[j] + + const box1 = createBoundingBox(node1.position, node1.metrics) + const box2 = createBoundingBox(node2.position, node2.metrics) + + // Check for overlap with margin + if (boxesOverlap(box1, box2, OVERLAP_MARGIN)) { + hasOverlap = true + + // If in same layer, shift vertically around midpoint + if (node1.layer === node2.layer) { + const midpoint = (node1.position.y + node2.position.y) / 2 + + node1.position.y = midpoint - node1.metrics.height / 2 - verticalSpacing / 2 + node2.position.y = midpoint + node2.metrics.height / 2 + verticalSpacing / 2 + } else { + // Different layers - shift the later one down + const requiredSpace = box1.y + box1.height + verticalSpacing + if (node2.position.y < requiredSpace) { + node2.position.y = requiredSpace + } + } + + logger.debug('Resolved overlap between blocks', { + block1: node1.id, + block2: node2.id, + sameLayer: node1.layer === node2.layer, + iteration, + }) + } + } + } + } + + if (hasOverlap) { + logger.warn('Could not fully resolve all overlaps after max iterations', { + iterations: MAX_OVERLAP_ITERATIONS, + }) + } +} + +/** + * Calculates positions for nodes organized by layer + */ +export function calculatePositions( + layers: Map, + options: LayoutOptions = {} +): void { + const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_LAYOUT_OPTIONS.horizontalSpacing + const verticalSpacing = options.verticalSpacing ?? DEFAULT_LAYOUT_OPTIONS.verticalSpacing + const padding = options.padding ?? DEFAULT_LAYOUT_OPTIONS.padding + const alignment = options.alignment ?? DEFAULT_LAYOUT_OPTIONS.alignment + + const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b) + + for (const layerNum of layerNumbers) { + const nodesInLayer = layers.get(layerNum)! + const xPosition = padding.x + layerNum * horizontalSpacing + + // Calculate total height for this layer + const totalHeight = nodesInLayer.reduce( + (sum, node, idx) => sum + node.metrics.height + (idx > 0 ? verticalSpacing : 0), + 0 + ) + + // Start Y based on alignment + let yOffset: number + switch (alignment) { + case 'start': + yOffset = padding.y + break + case 'center': + yOffset = Math.max(padding.y, 300 - totalHeight / 2) + break + case 'end': + yOffset = 600 - totalHeight - padding.y + break + default: + yOffset = padding.y + break + } + + // Position each node + for (const node of nodesInLayer) { + node.position = { + x: xPosition, + y: yOffset, + } + yOffset += node.metrics.height + verticalSpacing + } + } + + // Resolve overlaps across all nodes + resolveOverlaps(Array.from(layers.values()).flat(), verticalSpacing) +} + +/** + * Core layout function that performs the complete layout pipeline: + * 1. Assign layers using topological sort + * 2. Prepare block metrics + * 3. Group nodes by layer + * 4. Calculate positions + * 5. Normalize positions to start from padding + * + * @returns The laid-out nodes with updated positions, and bounding dimensions + */ +export function layoutBlocksCore( + blocks: Record, + edges: Edge[], + options: { isContainer: boolean; layoutOptions?: LayoutOptions } +): { nodes: Map; dimensions: { width: number; height: number } } { + if (Object.keys(blocks).length === 0) { + return { nodes: new Map(), dimensions: { width: 0, height: 0 } } + } + + const layoutOptions = + options.layoutOptions ?? + (options.isContainer ? CONTAINER_LAYOUT_OPTIONS : DEFAULT_LAYOUT_OPTIONS) + + // 1. Assign layers + const nodes = assignLayers(blocks, edges) + + // 2. Prepare metrics + prepareBlockMetrics(nodes) + + // 3. Group by layer + const layers = groupByLayer(nodes) + + // 4. Calculate positions + calculatePositions(layers, layoutOptions) + + // 5. Normalize positions + const dimensions = normalizePositions(nodes, { isContainer: options.isContainer }) + + return { nodes, dimensions } +} diff --git a/apps/sim/lib/workflows/autolayout/incremental.ts b/apps/sim/lib/workflows/autolayout/incremental.ts deleted file mode 100644 index 3cbb0598eb..0000000000 --- a/apps/sim/lib/workflows/autolayout/incremental.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { createLogger } from '@/lib/logs/console/logger' -import type { AdjustmentOptions, Edge } from '@/lib/workflows/autolayout/types' -import { - boxesOverlap, - createBoundingBox, - getBlockMetrics, - shouldSkipAutoLayout, -} from '@/lib/workflows/autolayout/utils' -import type { BlockState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('AutoLayout:Incremental') - -const DEFAULT_SHIFT_SPACING = 550 - -export function adjustForNewBlock( - blocks: Record, - edges: Edge[], - newBlockId: string, - options: AdjustmentOptions = {} -): void { - const newBlock = blocks[newBlockId] - if (!newBlock) { - logger.warn('New block not found in blocks', { newBlockId }) - return - } - - if (shouldSkipAutoLayout(newBlock)) { - logger.debug('Skipping incremental layout for block excluded from auto layout', { - newBlockId, - type: newBlock.type, - }) - return - } - - const shiftSpacing = options.horizontalSpacing ?? DEFAULT_SHIFT_SPACING - - const incomingEdges = edges.filter((e) => e.target === newBlockId) - const outgoingEdges = edges.filter((e) => e.source === newBlockId) - - if (incomingEdges.length === 0 && outgoingEdges.length === 0) { - logger.debug('New block has no connections, no adjustment needed', { newBlockId }) - return - } - - const sourceBlocks = incomingEdges - .map((e) => blocks[e.source]) - .filter((b) => b !== undefined && b.id !== newBlockId) - - if (sourceBlocks.length > 0) { - const avgSourceX = sourceBlocks.reduce((sum, b) => sum + b.position.x, 0) / sourceBlocks.length - const avgSourceY = sourceBlocks.reduce((sum, b) => sum + b.position.y, 0) / sourceBlocks.length - const maxSourceX = Math.max(...sourceBlocks.map((b) => b.position.x)) - - newBlock.position = { - x: maxSourceX + shiftSpacing, - y: avgSourceY, - } - - logger.debug('Positioned new block based on source blocks', { - newBlockId, - position: newBlock.position, - sourceCount: sourceBlocks.length, - }) - } - - const targetBlocks = outgoingEdges - .map((e) => blocks[e.target]) - .filter((b) => b !== undefined && b.id !== newBlockId) - - if (targetBlocks.length > 0 && sourceBlocks.length === 0) { - const minTargetX = Math.min(...targetBlocks.map((b) => b.position.x)) - const avgTargetY = targetBlocks.reduce((sum, b) => sum + b.position.y, 0) / targetBlocks.length - - newBlock.position = { - x: Math.max(150, minTargetX - shiftSpacing), - y: avgTargetY, - } - - logger.debug('Positioned new block based on target blocks', { - newBlockId, - position: newBlock.position, - targetCount: targetBlocks.length, - }) - } - - const newBlockMetrics = getBlockMetrics(newBlock) - const newBlockBox = createBoundingBox(newBlock.position, newBlockMetrics) - - const blocksToShift: Array<{ block: BlockState; shiftAmount: number }> = [] - - for (const [id, block] of Object.entries(blocks)) { - if (id === newBlockId) continue - if (block.data?.parentId) continue - if (shouldSkipAutoLayout(block)) continue - - if (block.position.x >= newBlock.position.x) { - const blockMetrics = getBlockMetrics(block) - const blockBox = createBoundingBox(block.position, blockMetrics) - - if (boxesOverlap(newBlockBox, blockBox, 50)) { - const requiredShift = newBlock.position.x + newBlockMetrics.width + 50 - block.position.x - if (requiredShift > 0) { - blocksToShift.push({ block, shiftAmount: requiredShift }) - } - } - } - } - - if (blocksToShift.length > 0) { - logger.debug('Shifting blocks to accommodate new block', { - newBlockId, - shiftCount: blocksToShift.length, - }) - - for (const { block, shiftAmount } of blocksToShift) { - block.position.x += shiftAmount - } - } -} - -export function compactHorizontally(blocks: Record, edges: Edge[]): void { - const blockArray = Object.values(blocks).filter( - (b) => !b.data?.parentId && !shouldSkipAutoLayout(b) - ) - - blockArray.sort((a, b) => a.position.x - b.position.x) - - const MIN_SPACING = 500 - - for (let i = 1; i < blockArray.length; i++) { - const prevBlock = blockArray[i - 1] - const currentBlock = blockArray[i] - - const prevMetrics = getBlockMetrics(prevBlock) - const expectedX = prevBlock.position.x + prevMetrics.width + MIN_SPACING - - if (currentBlock.position.x > expectedX + 150) { - const shift = currentBlock.position.x - expectedX - currentBlock.position.x = expectedX - - logger.debug('Compacted block horizontally', { - blockId: currentBlock.id, - shift, - }) - } - } -} diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts index 54d883d336..4500adb895 100644 --- a/apps/sim/lib/workflows/autolayout/index.ts +++ b/apps/sim/lib/workflows/autolayout/index.ts @@ -1,33 +1,30 @@ import { createLogger } from '@/lib/logs/console/logger' +import { layoutContainers } from '@/lib/workflows/autolayout/containers' +import { layoutBlocksCore } from '@/lib/workflows/autolayout/core' +import type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types' +import { filterLayoutEligibleBlockIds, getBlocksByParent } from '@/lib/workflows/autolayout/utils' import type { BlockState } from '@/stores/workflows/workflow/types' -import { layoutContainers } from './containers' -import { adjustForNewBlock as adjustForNewBlockInternal, compactHorizontally } from './incremental' -import { assignLayers, groupByLayer } from './layering' -import { calculatePositions } from './positioning' -import type { AdjustmentOptions, Edge, LayoutOptions, LayoutResult, Loop, Parallel } from './types' -import { filterLayoutEligibleBlockIds, getBlocksByParent, prepareBlockMetrics } from './utils' const logger = createLogger('AutoLayout') +/** + * Applies automatic layout to all blocks in a workflow. + * Positions blocks in layers based on their connections (edges). + */ export function applyAutoLayout( blocks: Record, edges: Edge[], - loops: Record = {}, - parallels: Record = {}, options: LayoutOptions = {} ): LayoutResult { try { logger.info('Starting auto layout', { blockCount: Object.keys(blocks).length, edgeCount: edges.length, - loopCount: Object.keys(loops).length, - parallelCount: Object.keys(parallels).length, }) const blocksCopy: Record = JSON.parse(JSON.stringify(blocks)) const { root: rootBlockIds } = getBlocksByParent(blocksCopy) - const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy) const rootBlocks: Record = {} @@ -40,10 +37,10 @@ export function applyAutoLayout( ) if (Object.keys(rootBlocks).length > 0) { - const nodes = assignLayers(rootBlocks, rootEdges) - prepareBlockMetrics(nodes) - const layers = groupByLayer(nodes) - calculatePositions(layers, options) + const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, { + isContainer: false, + layoutOptions: options, + }) for (const node of nodes.values()) { blocksCopy[node.id].position = node.position @@ -70,38 +67,14 @@ export function applyAutoLayout( } } -export function adjustForNewBlock( - blocks: Record, - edges: Edge[], - newBlockId: string, - options: AdjustmentOptions = {} -): LayoutResult { - try { - logger.info('Adjusting layout for new block', { newBlockId }) - - const blocksCopy: Record = JSON.parse(JSON.stringify(blocks)) - - adjustForNewBlockInternal(blocksCopy, edges, newBlockId, options) - - if (!options.preservePositions) { - compactHorizontally(blocksCopy, edges) - } - - return { - blocks: blocksCopy, - success: true, - } - } catch (error) { - logger.error('Failed to adjust layout for new block', { newBlockId, error }) - return { - blocks, - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - } - } -} - -export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel } -export type { TargetedLayoutOptions } from './targeted' -export { applyTargetedLayout, transferBlockHeights } from './targeted' -export { getBlockMetrics, isContainerType, shouldSkipAutoLayout } from './utils' +export type { TargetedLayoutOptions } from '@/lib/workflows/autolayout/targeted' +// Function exports +export { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted' +// Type exports +export type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types' +export { + getBlockMetrics, + isContainerType, + shouldSkipAutoLayout, + transferBlockHeights, +} from '@/lib/workflows/autolayout/utils' diff --git a/apps/sim/lib/workflows/autolayout/layering.ts b/apps/sim/lib/workflows/autolayout/layering.ts deleted file mode 100644 index f5993a8a1c..0000000000 --- a/apps/sim/lib/workflows/autolayout/layering.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createLogger } from '@/lib/logs/console/logger' -import type { Edge, GraphNode } from '@/lib/workflows/autolayout/types' -import { getBlockMetrics } from '@/lib/workflows/autolayout/utils' -import type { BlockState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('AutoLayout:Layering') - -export function assignLayers( - blocks: Record, - edges: Edge[] -): Map { - const nodes = new Map() - - for (const [id, block] of Object.entries(blocks)) { - nodes.set(id, { - id, - block, - metrics: getBlockMetrics(block), - incoming: new Set(), - outgoing: new Set(), - layer: 0, - position: { ...block.position }, - }) - } - - for (const edge of edges) { - const sourceNode = nodes.get(edge.source) - const targetNode = nodes.get(edge.target) - - if (sourceNode && targetNode) { - sourceNode.outgoing.add(edge.target) - targetNode.incoming.add(edge.source) - } - } - - // Only treat blocks as starters if they have no incoming edges - // This prevents triggers that are mid-flow from being forced to layer 0 - const starterNodes = Array.from(nodes.values()).filter((node) => node.incoming.size === 0) - - if (starterNodes.length === 0 && nodes.size > 0) { - const firstNode = Array.from(nodes.values())[0] - starterNodes.push(firstNode) - logger.warn('No starter blocks found, using first block as starter', { blockId: firstNode.id }) - } - - // Use topological sort to ensure proper layering based on dependencies - // Each node's layer = max(all incoming nodes' layers) + 1 - const inDegreeCount = new Map() - - for (const node of nodes.values()) { - inDegreeCount.set(node.id, node.incoming.size) - if (starterNodes.includes(node)) { - node.layer = 0 - } - } - - const queue: string[] = starterNodes.map((n) => n.id) - const processed = new Set() - - while (queue.length > 0) { - const nodeId = queue.shift()! - const node = nodes.get(nodeId)! - processed.add(nodeId) - - // Calculate this node's layer based on all incoming edges - if (node.incoming.size > 0) { - let maxIncomingLayer = -1 - for (const incomingId of node.incoming) { - const incomingNode = nodes.get(incomingId) - if (incomingNode) { - maxIncomingLayer = Math.max(maxIncomingLayer, incomingNode.layer) - } - } - node.layer = maxIncomingLayer + 1 - } - - // Add outgoing nodes to queue when all their dependencies are processed - for (const targetId of node.outgoing) { - const currentCount = inDegreeCount.get(targetId) || 0 - inDegreeCount.set(targetId, currentCount - 1) - - if (inDegreeCount.get(targetId) === 0 && !processed.has(targetId)) { - queue.push(targetId) - } - } - } - - for (const node of nodes.values()) { - if (!processed.has(node.id)) { - logger.debug('Isolated node detected, assigning to layer 0', { blockId: node.id }) - node.layer = 0 - } - } - - return nodes -} - -export function groupByLayer(nodes: Map): Map { - const layers = new Map() - - for (const node of nodes.values()) { - if (!layers.has(node.layer)) { - layers.set(node.layer, []) - } - layers.get(node.layer)!.push(node) - } - - return layers -} diff --git a/apps/sim/lib/workflows/autolayout/positioning.ts b/apps/sim/lib/workflows/autolayout/positioning.ts deleted file mode 100644 index 44f003f017..0000000000 --- a/apps/sim/lib/workflows/autolayout/positioning.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { createLogger } from '@/lib/logs/console/logger' -import type { GraphNode, LayoutOptions } from '@/lib/workflows/autolayout/types' -import { boxesOverlap, createBoundingBox } from '@/lib/workflows/autolayout/utils' - -const logger = createLogger('AutoLayout:Positioning') - -const DEFAULT_HORIZONTAL_SPACING = 550 -const DEFAULT_VERTICAL_SPACING = 200 -const DEFAULT_PADDING = { x: 150, y: 150 } - -export function calculatePositions( - layers: Map, - options: LayoutOptions = {} -): void { - const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING - const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING - const padding = options.padding ?? DEFAULT_PADDING - const alignment = options.alignment ?? 'center' - - const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b) - - // Calculate positions for each layer - for (const layerNum of layerNumbers) { - const nodesInLayer = layers.get(layerNum)! - const xPosition = padding.x + layerNum * horizontalSpacing - - // Calculate total height needed for this layer - const totalHeight = nodesInLayer.reduce( - (sum, node, idx) => sum + node.metrics.height + (idx > 0 ? verticalSpacing : 0), - 0 - ) - - // Start Y position based on alignment - let yOffset: number - switch (alignment) { - case 'start': - yOffset = padding.y - break - case 'center': - // Center the layer vertically - yOffset = Math.max(padding.y, 300 - totalHeight / 2) - break - case 'end': - yOffset = 600 - totalHeight - padding.y - break - default: - yOffset = padding.y - break - } - - // Position each node in the layer - for (const node of nodesInLayer) { - node.position = { - x: xPosition, - y: yOffset, - } - - yOffset += node.metrics.height + verticalSpacing - } - } - - // Resolve any overlaps - resolveOverlaps(Array.from(layers.values()).flat(), verticalSpacing) -} - -function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void { - const MAX_ITERATIONS = 20 - let iteration = 0 - let hasOverlap = true - - while (hasOverlap && iteration < MAX_ITERATIONS) { - hasOverlap = false - iteration++ - - // Sort nodes by position for consistent processing - const sortedNodes = [...nodes].sort((a, b) => { - if (a.layer !== b.layer) return a.layer - b.layer - return a.position.y - b.position.y - }) - - for (let i = 0; i < sortedNodes.length; i++) { - for (let j = i + 1; j < sortedNodes.length; j++) { - const node1 = sortedNodes[i] - const node2 = sortedNodes[j] - - const box1 = createBoundingBox(node1.position, node1.metrics) - const box2 = createBoundingBox(node2.position, node2.metrics) - - // Check for overlap with margin - if (boxesOverlap(box1, box2, 30)) { - hasOverlap = true - - // If in same layer, shift vertically - if (node1.layer === node2.layer) { - const totalHeight = node1.metrics.height + node2.metrics.height + verticalSpacing - const midpoint = (node1.position.y + node2.position.y) / 2 - - node1.position.y = midpoint - node1.metrics.height / 2 - verticalSpacing / 2 - node2.position.y = midpoint + node2.metrics.height / 2 + verticalSpacing / 2 - } else { - // Different layers - shift the later one down - const requiredSpace = box1.y + box1.height + verticalSpacing - if (node2.position.y < requiredSpace) { - node2.position.y = requiredSpace - } - } - - logger.debug('Resolved overlap between blocks', { - block1: node1.id, - block2: node2.id, - samLayer: node1.layer === node2.layer, - iteration, - }) - } - } - } - } - - if (hasOverlap) { - logger.warn('Could not fully resolve all overlaps after max iterations', { - iterations: MAX_ITERATIONS, - }) - } -} diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index 1daabb2b9a..d9b7f977da 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -1,20 +1,17 @@ +import { CONTAINER_DIMENSIONS } from '@/lib/blocks/block-dimensions' import { createLogger } from '@/lib/logs/console/logger' -import { assignLayers, groupByLayer } from '@/lib/workflows/autolayout/layering' -import { calculatePositions } from '@/lib/workflows/autolayout/positioning' -import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types' import { CONTAINER_PADDING, - CONTAINER_PADDING_X, - CONTAINER_PADDING_Y, - DEFAULT_CONTAINER_HEIGHT, - DEFAULT_CONTAINER_WIDTH, + DEFAULT_HORIZONTAL_SPACING, + DEFAULT_VERTICAL_SPACING, +} from '@/lib/workflows/autolayout/constants' +import { layoutBlocksCore } from '@/lib/workflows/autolayout/core' +import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types' +import { filterLayoutEligibleBlockIds, getBlockMetrics, getBlocksByParent, isContainerType, - prepareBlockMetrics, - ROOT_PADDING_X, - ROOT_PADDING_Y, shouldSkipAutoLayout, } from '@/lib/workflows/autolayout/utils' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -27,12 +24,20 @@ export interface TargetedLayoutOptions extends LayoutOptions { horizontalSpacing?: number } +/** + * Applies targeted layout to only reposition changed blocks. + * Unchanged blocks act as anchors to preserve existing layout. + */ export function applyTargetedLayout( blocks: Record, edges: Edge[], options: TargetedLayoutOptions ): Record { - const { changedBlockIds, verticalSpacing = 200, horizontalSpacing = 550 } = options + const { + changedBlockIds, + verticalSpacing = DEFAULT_VERTICAL_SPACING, + horizontalSpacing = DEFAULT_HORIZONTAL_SPACING, + } = options if (!changedBlockIds || changedBlockIds.length === 0) { return blocks @@ -60,6 +65,9 @@ export function applyTargetedLayout( return blocksCopy } +/** + * Layouts a group of blocks (either root level or within a container) + */ function layoutGroup( parentId: string | null, childIds: string[], @@ -82,6 +90,7 @@ function layoutGroup( return } + // Determine which blocks need repositioning const requestedLayout = layoutEligibleChildIds.filter((id) => { const block = blocks[id] if (!block) return false @@ -92,7 +101,6 @@ function layoutGroup( const missingPositions = layoutEligibleChildIds.filter((id) => { const block = blocks[id] if (!block) return false - // Containers with missing positions should still get positioned return !hasPosition(block) }) const needsLayoutSet = new Set([...requestedLayout, ...missingPositions]) @@ -102,20 +110,19 @@ function layoutGroup( updateContainerDimensions(parentBlock, childIds, blocks) } - // Always update container dimensions even if no blocks need repositioning - // This ensures containers resize properly when children are added/removed if (needsLayout.length === 0) { return } + // Store old positions for anchor calculation const oldPositions = new Map() - for (const id of layoutEligibleChildIds) { const block = blocks[id] if (!block) continue oldPositions.set(id, { ...block.position }) } + // Compute layout positions using core function const layoutPositions = computeLayoutPositions( layoutEligibleChildIds, blocks, @@ -126,13 +133,13 @@ function layoutGroup( ) if (layoutPositions.size === 0) { - // No layout positions computed, but still update container dimensions if (parentBlock) { updateContainerDimensions(parentBlock, childIds, blocks) } return } + // Find anchor block (unchanged block with a layout position) let offsetX = 0 let offsetY = 0 @@ -147,14 +154,9 @@ function layoutGroup( offsetX = oldPos.x - newPos.x offsetY = oldPos.y - newPos.y } - } else { - // No anchor - positions from calculatePositions are already correct relative to padding - // Container positions are parent-relative, root positions are absolute - // The normalization in computeLayoutPositions already handled the padding offset - offsetX = 0 - offsetY = 0 } + // Apply new positions only to blocks that need layout for (const id of needsLayout) { const block = blocks[id] const newPos = layoutPositions.get(id) @@ -166,6 +168,9 @@ function layoutGroup( } } +/** + * Computes layout positions for a subset of blocks using the core layout + */ function computeLayoutPositions( childIds: string[], blocks: Record, @@ -187,92 +192,52 @@ function computeLayoutPositions( return new Map() } - const nodes = assignLayers(subsetBlocks, subsetEdges) - prepareBlockMetrics(nodes) - - const layoutOptions: LayoutOptions = parentBlock - ? { - horizontalSpacing: horizontalSpacing * 0.85, - verticalSpacing, - padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, - alignment: 'center', - } - : { - horizontalSpacing, - verticalSpacing, - padding: { x: ROOT_PADDING_X, y: ROOT_PADDING_Y }, - alignment: 'center', - } - - calculatePositions(groupByLayer(nodes), layoutOptions) - - // Now normalize positions to start from 0,0 relative to the container/root - 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) - } - - // Adjust all positions to be relative to the padding offset - const xOffset = (parentBlock ? CONTAINER_PADDING_X : ROOT_PADDING_X) - minX - const yOffset = (parentBlock ? CONTAINER_PADDING_Y : ROOT_PADDING_Y) - minY - - const positions = new Map() - for (const node of nodes.values()) { - positions.set(node.id, { - x: node.position.x + xOffset, - y: node.position.y + yOffset, - }) - } + const isContainer = !!parentBlock + const { nodes, dimensions } = layoutBlocksCore(subsetBlocks, subsetEdges, { + isContainer, + layoutOptions: { + horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing, + verticalSpacing, + alignment: 'center', + }, + }) + // Update parent container dimensions if applicable if (parentBlock) { - const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2 - const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2 - parentBlock.data = { ...parentBlock.data, - width: Math.max(calculatedWidth, DEFAULT_CONTAINER_WIDTH), - height: Math.max(calculatedHeight, DEFAULT_CONTAINER_HEIGHT), + width: Math.max(dimensions.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH), + height: Math.max(dimensions.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), } } - return positions -} - -function getBounds(positions: Map) { - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - - for (const pos of positions.values()) { - minX = Math.min(minX, pos.x) - minY = Math.min(minY, pos.y) + // Convert nodes to position map + const positions = new Map() + for (const node of nodes.values()) { + positions.set(node.id, { x: node.position.x, y: node.position.y }) } - return { minX, minY } + return positions } +/** + * Updates container dimensions based on children + */ function updateContainerDimensions( parentBlock: BlockState, childIds: string[], blocks: Record ): void { if (childIds.length === 0) { - // No children - use minimum dimensions parentBlock.data = { ...parentBlock.data, - width: DEFAULT_CONTAINER_WIDTH, - height: DEFAULT_CONTAINER_HEIGHT, + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, } parentBlock.layout = { ...parentBlock.layout, - measuredWidth: DEFAULT_CONTAINER_WIDTH, - measuredHeight: DEFAULT_CONTAINER_HEIGHT, + measuredWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + measuredHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, } return } @@ -300,14 +265,13 @@ function updateContainerDimensions( return } - // Match the regular autolayout's dimension calculation const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2 const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2 parentBlock.data = { ...parentBlock.data, - width: Math.max(calculatedWidth, DEFAULT_CONTAINER_WIDTH), - height: Math.max(calculatedHeight, DEFAULT_CONTAINER_HEIGHT), + width: Math.max(calculatedWidth, CONTAINER_DIMENSIONS.DEFAULT_WIDTH), + height: Math.max(calculatedHeight, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), } parentBlock.layout = { @@ -317,50 +281,11 @@ function updateContainerDimensions( } } +/** + * Checks if a block has a valid position + */ function hasPosition(block: BlockState): boolean { if (!block.position) return false const { x, y } = block.position return Number.isFinite(x) && Number.isFinite(y) } - -/** - * Estimate block heights for diff view by using current workflow measurements - * This provides better height estimates than using default values - */ -export function transferBlockHeights( - sourceBlocks: Record, - targetBlocks: Record -): void { - // Build a map of block type+name to heights from source - const heightMap = new Map() - - for (const [id, block] of Object.entries(sourceBlocks)) { - const key = `${block.type}:${block.name}` - heightMap.set(key, { - height: block.height || 100, - width: block.layout?.measuredWidth || 350, - }) - } - - // 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 - } - } - - logger.debug('Transferred block heights from source workflow', { - sourceCount: Object.keys(sourceBlocks).length, - targetCount: Object.keys(targetBlocks).length, - heightsMapped: heightMap.size, - }) -} diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index 28845032f7..52b6f3b712 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -1,34 +1,56 @@ +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/blocks/block-dimensions' +import { + AUTO_LAYOUT_EXCLUDED_TYPES, + CONTAINER_BLOCK_TYPES, + CONTAINER_PADDING, + CONTAINER_PADDING_X, + CONTAINER_PADDING_Y, + ROOT_PADDING_X, + ROOT_PADDING_Y, +} from '@/lib/workflows/autolayout/constants' import type { BlockMetrics, BoundingBox, GraphNode } from '@/lib/workflows/autolayout/types' -import { TriggerUtils } from '@/lib/workflows/triggers' import type { BlockState } from '@/stores/workflows/workflow/types' -export const DEFAULT_BLOCK_WIDTH = 350 -export const DEFAULT_BLOCK_HEIGHT = 100 -export const DEFAULT_CONTAINER_WIDTH = 500 -export const DEFAULT_CONTAINER_HEIGHT = 300 -const DEFAULT_PADDING = 40 +// Re-export layout constants for backwards compatibility +export { + CONTAINER_PADDING, + CONTAINER_PADDING_X, + CONTAINER_PADDING_Y, + ROOT_PADDING_X, + ROOT_PADDING_Y, +} -export const CONTAINER_PADDING = 150 -export const CONTAINER_PADDING_X = 180 -export const CONTAINER_PADDING_Y = 100 -export const ROOT_PADDING_X = 150 -export const ROOT_PADDING_Y = 150 +// Re-export block dimensions for backwards compatibility +export const DEFAULT_BLOCK_WIDTH = BLOCK_DIMENSIONS.FIXED_WIDTH +export const DEFAULT_BLOCK_HEIGHT = BLOCK_DIMENSIONS.MIN_HEIGHT +export const DEFAULT_CONTAINER_WIDTH = CONTAINER_DIMENSIONS.DEFAULT_WIDTH +export const DEFAULT_CONTAINER_HEIGHT = CONTAINER_DIMENSIONS.DEFAULT_HEIGHT +/** + * 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 } -const AUTO_LAYOUT_EXCLUDED_TYPES = new Set(['note']) - +/** + * Checks if a block type is a container (loop or parallel) + */ export function isContainerType(blockType: string): boolean { - return blockType === 'loop' || blockType === 'parallel' + 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 @@ -40,34 +62,40 @@ export function filterLayoutEligibleBlockIds( }) } +/** + * 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, DEFAULT_CONTAINER_WIDTH) + resolveNumeric(block.data?.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH) ) const containerHeight = Math.max( measuredHeight ?? 0, - resolveNumeric(block.data?.height, DEFAULT_CONTAINER_HEIGHT) + resolveNumeric(block.data?.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT) ) return { width: containerWidth, height: containerHeight, - minWidth: DEFAULT_CONTAINER_WIDTH, - minHeight: DEFAULT_CONTAINER_HEIGHT, - paddingTop: DEFAULT_PADDING, - paddingBottom: DEFAULT_PADDING, - paddingLeft: DEFAULT_PADDING, - paddingRight: DEFAULT_PADDING, + 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, } } +/** + * Gets metrics for a regular (non-container) block + */ function getRegularBlockMetrics(block: BlockState): BlockMetrics { - const minWidth = DEFAULT_BLOCK_WIDTH - const minHeight = DEFAULT_BLOCK_HEIGHT + const minWidth = BLOCK_DIMENSIONS.FIXED_WIDTH + const minHeight = BLOCK_DIMENSIONS.MIN_HEIGHT const measuredH = block.layout?.measuredHeight ?? block.height const measuredW = block.layout?.measuredWidth @@ -79,13 +107,16 @@ function getRegularBlockMetrics(block: BlockState): BlockMetrics { height, minWidth, minHeight, - paddingTop: DEFAULT_PADDING, - paddingBottom: DEFAULT_PADDING, - paddingLeft: DEFAULT_PADDING, - paddingRight: DEFAULT_PADDING, + 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) @@ -94,12 +125,18 @@ export function getBlockMetrics(block: BlockState): BlockMetrics { return getRegularBlockMetrics(block) } +/** + * Prepares metrics for all nodes in a graph + */ export function prepareBlockMetrics(nodes: Map): 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 @@ -112,6 +149,9 @@ export function createBoundingBox( } } +/** + * 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 || @@ -121,6 +161,9 @@ export function boxesOverlap(box1: BoundingBox, box2: BoundingBox, margin = 0): ) } +/** + * Groups blocks by their parent container + */ export function getBlocksByParent(blocks: Record): { root: string[] children: Map @@ -144,10 +187,81 @@ export function getBlocksByParent(blocks: Record): { return { root, children } } -export function isStarterBlock(block: BlockState): boolean { - if (TriggerUtils.isTriggerBlock({ type: block.type, triggerMode: block.triggerMode })) { - return true +/** + * Normalizes node positions to start from a given padding offset. + * Returns the bounding box dimensions of the normalized layout. + */ +export function normalizePositions( + nodes: Map, + 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, + } } - return false + 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, + targetBlocks: Record +): void { + // Build a map of block type+name to heights from source + const heightMap = new Map() + + 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 + } + } } diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 13dda724c4..4e9b137dac 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -693,11 +693,14 @@ export class WorkflowDiffEngine { }) const { applyTargetedLayout } = await import('@/lib/workflows/autolayout') + const { DEFAULT_HORIZONTAL_SPACING, DEFAULT_VERTICAL_SPACING } = await import( + '@/lib/workflows/autolayout/constants' + ) const layoutedBlocks = applyTargetedLayout(finalBlocks, finalProposedState.edges, { changedBlockIds: impactedBlockArray, - horizontalSpacing: 550, - verticalSpacing: 200, + horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, + verticalSpacing: DEFAULT_VERTICAL_SPACING, }) Object.entries(layoutedBlocks).forEach(([id, layoutBlock]) => { @@ -738,23 +741,12 @@ export class WorkflowDiffEngine { const { applyAutoLayout: applyNativeAutoLayout } = await import( '@/lib/workflows/autolayout' ) - - const autoLayoutOptions = { - horizontalSpacing: 550, - verticalSpacing: 200, - padding: { - x: 150, - y: 150, - }, - alignment: 'center' as const, - } + const { DEFAULT_LAYOUT_OPTIONS } = await import('@/lib/workflows/autolayout/constants') const layoutResult = applyNativeAutoLayout( finalBlocks, finalProposedState.edges, - finalProposedState.loops || {}, - finalProposedState.parallels || {}, - autoLayoutOptions + DEFAULT_LAYOUT_OPTIONS ) if (layoutResult.success && layoutResult.blocks) {