From 7640fdf74293e038eb233a429feb00648d32d088 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 27 Jan 2026 17:02:27 -0800 Subject: [PATCH] feat(autolayout): add snap-to-grid support (#3031) * feat(autolayout): add snap-to-grid support * fix(autolayout): recalculate dimensions after grid snapping * fix(autolayout): correct dimension calculation and propagate gridSize --- .../api/workflows/[id]/autolayout/route.ts | 10 +- apps/sim/app/api/yaml/autolayout/route.ts | 108 ------------------ .../lib/workflows/autolayout/containers.ts | 4 +- apps/sim/lib/workflows/autolayout/core.ts | 44 ++----- apps/sim/lib/workflows/autolayout/index.ts | 10 +- apps/sim/lib/workflows/autolayout/targeted.ts | 42 +++---- apps/sim/lib/workflows/autolayout/types.ts | 1 + apps/sim/lib/workflows/autolayout/utils.ts | 61 +++++++++- 8 files changed, 93 insertions(+), 187 deletions(-) delete mode 100644 apps/sim/app/api/yaml/autolayout/route.ts diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 06e2c3313..a55c23da1 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -35,8 +35,7 @@ const AutoLayoutRequestSchema = z.object({ }) .optional() .default({}), - // Optional: if provided, use these blocks instead of loading from DB - // This allows using blocks with live measurements from the UI + gridSize: z.number().min(0).max(50).optional(), blocks: z.record(z.any()).optional(), edges: z.array(z.any()).optional(), loops: z.record(z.any()).optional(), @@ -53,7 +52,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id: workflowId } = await params try { - // Get the session const session = await getSession() if (!session?.user?.id) { logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) @@ -62,7 +60,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const userId = session.user.id - // Parse request body const body = await request.json() const layoutOptions = AutoLayoutRequestSchema.parse(body) @@ -70,7 +67,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId, }) - // Fetch the workflow to check ownership/access const accessContext = await getWorkflowAccessContext(workflowId, userId) const workflowData = accessContext?.workflow @@ -79,7 +75,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - // Check if user has permission to update this workflow const canUpdate = accessContext?.isOwner || (workflowData.workspaceId @@ -94,8 +89,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Use provided blocks/edges if available (with live measurements from UI), - // otherwise load from database let currentWorkflowData: NormalizedWorkflowData | null if (layoutOptions.blocks && layoutOptions.edges) { @@ -125,6 +118,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ y: layoutOptions.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, }, alignment: layoutOptions.alignment, + gridSize: layoutOptions.gridSize, } const layoutResult = applyAutoLayout( diff --git a/apps/sim/app/api/yaml/autolayout/route.ts b/apps/sim/app/api/yaml/autolayout/route.ts deleted file mode 100644 index 600212340..000000000 --- a/apps/sim/app/api/yaml/autolayout/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { generateRequestId } from '@/lib/core/utils/request' -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') - -const AutoLayoutRequestSchema = z.object({ - workflowState: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - loops: z.record(z.any()).optional().default({}), - parallels: z.record(z.any()).optional().default({}), - }), - options: z - .object({ - spacing: z - .object({ - horizontal: z.number().optional(), - vertical: z.number().optional(), - }) - .optional(), - alignment: z.enum(['start', 'center', 'end']).optional(), - padding: z - .object({ - x: z.number().optional(), - y: z.number().optional(), - }) - .optional(), - }) - .optional(), -}) - -export async function POST(request: NextRequest) { - const requestId = generateRequestId() - - try { - const body = await request.json() - const { workflowState, options } = AutoLayoutRequestSchema.parse(body) - - logger.info(`[${requestId}] Applying auto layout`, { - blockCount: Object.keys(workflowState.blocks).length, - edgeCount: workflowState.edges.length, - }) - - const autoLayoutOptions = { - horizontalSpacing: options?.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING, - verticalSpacing: options?.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING, - padding: { - x: options?.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, - y: options?.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, - }, - alignment: options?.alignment ?? 'center', - } - - const layoutResult = applyAutoLayout( - workflowState.blocks, - workflowState.edges, - autoLayoutOptions - ) - - if (!layoutResult.success || !layoutResult.blocks) { - logger.error(`[${requestId}] Auto layout failed:`, { - error: layoutResult.error, - }) - return NextResponse.json( - { - success: false, - errors: [layoutResult.error || 'Unknown auto layout error'], - }, - { status: 500 } - ) - } - - logger.info(`[${requestId}] Auto layout completed successfully:`, { - success: true, - blockCount: Object.keys(layoutResult.blocks).length, - }) - - const transformedResponse = { - success: true, - workflowState: { - blocks: layoutResult.blocks, - edges: workflowState.edges, - loops: workflowState.loops || {}, - parallels: workflowState.parallels || {}, - }, - } - - return NextResponse.json(transformedResponse) - } catch (error) { - logger.error(`[${requestId}] Auto layout failed:`, error) - - return NextResponse.json( - { - success: false, - errors: [error instanceof Error ? error.message : 'Unknown auto layout error'], - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/lib/workflows/autolayout/containers.ts b/apps/sim/lib/workflows/autolayout/containers.ts index 8beeea4ce..f1f68ebf5 100644 --- a/apps/sim/lib/workflows/autolayout/containers.ts +++ b/apps/sim/lib/workflows/autolayout/containers.ts @@ -34,6 +34,7 @@ export function layoutContainers( : DEFAULT_CONTAINER_HORIZONTAL_SPACING, verticalSpacing: options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING, padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, + gridSize: options.gridSize, } for (const [parentId, childIds] of children.entries()) { @@ -56,18 +57,15 @@ export function layoutContainers( continue } - // Use the shared core layout function with container options const { nodes, dimensions } = layoutBlocksCore(childBlocks, childEdges, { isContainer: true, layoutOptions: containerOptions, }) - // Apply positions back to blocks for (const node of nodes.values()) { blocks[node.id].position = node.position } - // Update container dimensions const calculatedWidth = dimensions.width const calculatedHeight = dimensions.height diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 9187d526b..014aa37ea 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -9,6 +9,7 @@ import { getBlockMetrics, normalizePositions, prepareBlockMetrics, + snapNodesToGrid, } from '@/lib/workflows/autolayout/utils' import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { EDGE } from '@/executor/constants' @@ -84,7 +85,6 @@ export function assignLayers( ): Map { const nodes = new Map() - // Initialize nodes for (const [id, block] of Object.entries(blocks)) { nodes.set(id, { id, @@ -97,7 +97,6 @@ export function assignLayers( }) } - // Build a map of target node -> edges coming into it (to check sourceHandle later) const incomingEdgesMap = new Map() for (const edge of edges) { if (!incomingEdgesMap.has(edge.target)) { @@ -106,7 +105,6 @@ export function assignLayers( incomingEdgesMap.get(edge.target)!.push(edge) } - // Build adjacency from edges for (const edge of edges) { const sourceNode = nodes.get(edge.source) const targetNode = nodes.get(edge.target) @@ -117,7 +115,6 @@ export function assignLayers( } } - // 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) { @@ -126,7 +123,6 @@ export function assignLayers( 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()) { @@ -144,8 +140,6 @@ export function assignLayers( const node = nodes.get(nodeId)! processed.add(nodeId) - // Calculate layer based on max incoming layer + 1 - // For edges from subflow ends, add the subflow's internal depth (minus 1 to avoid double-counting) if (node.incoming.size > 0) { let maxEffectiveLayer = -1 const incomingEdges = incomingEdgesMap.get(nodeId) || [] @@ -153,16 +147,11 @@ export function assignLayers( for (const incomingId of node.incoming) { const incomingNode = nodes.get(incomingId) if (incomingNode) { - // Find edges from this incoming node to check if it's a subflow end edge const edgesFromSource = incomingEdges.filter((e) => e.source === incomingId) let additionalDepth = 0 - // Check if any edge from this source is a subflow end edge const hasSubflowEndEdge = edgesFromSource.some(isSubflowEndEdge) if (hasSubflowEndEdge && subflowDepths) { - // Get the internal depth of the subflow - // Subtract 1 because the +1 at the end of layer calculation already accounts for one layer - // E.g., if subflow has 2 internal layers (depth=2), we add 1 extra so total offset is 2 const depth = subflowDepths.get(incomingId) ?? 1 additionalDepth = Math.max(0, depth - 1) } @@ -174,7 +163,6 @@ export function assignLayers( node.layer = maxEffectiveLayer + 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) @@ -185,7 +173,6 @@ export function assignLayers( } } - // 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 }) @@ -224,7 +211,6 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v hasOverlap = false iteration++ - // Group nodes by layer for same-layer overlap resolution const nodesByLayer = new Map() for (const node of nodes) { if (!nodesByLayer.has(node.layer)) { @@ -233,11 +219,9 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v nodesByLayer.get(node.layer)!.push(node) } - // Process each layer independently for (const [layer, layerNodes] of nodesByLayer) { if (layerNodes.length < 2) continue - // Sort by Y position for consistent processing layerNodes.sort((a, b) => a.position.y - b.position.y) for (let i = 0; i < layerNodes.length - 1; i++) { @@ -302,7 +286,6 @@ export function calculatePositions( const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b) - // Calculate max width for each layer const layerWidths = new Map() for (const layerNum of layerNumbers) { const nodesInLayer = layers.get(layerNum)! @@ -310,7 +293,6 @@ export function calculatePositions( layerWidths.set(layerNum, maxWidth) } - // Calculate cumulative X positions for each layer based on actual widths const layerXPositions = new Map() let cumulativeX = padding.x @@ -319,7 +301,6 @@ export function calculatePositions( cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing } - // Build a flat map of all nodes for quick lookups const allNodes = new Map() for (const nodesInLayer of layers.values()) { for (const node of nodesInLayer) { @@ -327,7 +308,6 @@ export function calculatePositions( } } - // Build incoming edges map for handle lookups const incomingEdgesMap = new Map() for (const edge of edges) { if (!incomingEdgesMap.has(edge.target)) { @@ -336,20 +316,16 @@ export function calculatePositions( incomingEdgesMap.get(edge.target)!.push(edge) } - // Position nodes layer by layer, aligning with connected predecessors for (const layerNum of layerNumbers) { const nodesInLayer = layers.get(layerNum)! const xPosition = layerXPositions.get(layerNum)! - // Separate containers and non-containers const containersInLayer = nodesInLayer.filter(isContainerBlock) const nonContainersInLayer = nodesInLayer.filter((n) => !isContainerBlock(n)) - // For the first layer (layer 0), position sequentially from padding.y if (layerNum === 0) { let yOffset = padding.y - // Sort containers by height for visual balance containersInLayer.sort((a, b) => b.metrics.height - a.metrics.height) for (const node of containersInLayer) { @@ -361,7 +337,6 @@ export function calculatePositions( yOffset += CONTAINER_VERTICAL_CLEARANCE } - // Sort non-containers by outgoing connections nonContainersInLayer.sort((a, b) => b.outgoing.size - a.outgoing.size) for (const node of nonContainersInLayer) { @@ -371,9 +346,7 @@ export function calculatePositions( continue } - // For subsequent layers, align with connected predecessors (handle-to-handle) for (const node of [...containersInLayer, ...nonContainersInLayer]) { - // Find the bottommost predecessor handle Y (highest value) and align to it let bestSourceHandleY = -1 let bestEdge: Edge | null = null const incomingEdges = incomingEdgesMap.get(node.id) || [] @@ -381,7 +354,6 @@ export function calculatePositions( for (const edge of incomingEdges) { const predecessor = allNodes.get(edge.source) if (predecessor) { - // Calculate actual source handle Y position based on block type and handle const sourceHandleOffset = getSourceHandleYOffset(predecessor.block, edge.sourceHandle) const sourceHandleY = predecessor.position.y + sourceHandleOffset @@ -392,20 +364,16 @@ export function calculatePositions( } } - // If no predecessors found (shouldn't happen for layer > 0), use padding if (bestSourceHandleY < 0) { bestSourceHandleY = padding.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET } - // Calculate the target handle Y offset for this node const targetHandleOffset = getTargetHandleYOffset(node.block, bestEdge?.targetHandle) - // Position node so its target handle aligns with the source handle Y node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset } } } - // Resolve vertical overlaps within layers (X overlaps prevented by cumulative positioning) resolveVerticalOverlaps(Array.from(layers.values()).flat(), verticalSpacing) } @@ -435,7 +403,7 @@ export function layoutBlocksCore( return { nodes: new Map(), dimensions: { width: 0, height: 0 } } } - const layoutOptions = + const layoutOptions: LayoutOptions = options.layoutOptions ?? (options.isContainer ? CONTAINER_LAYOUT_OPTIONS : DEFAULT_LAYOUT_OPTIONS) @@ -452,7 +420,13 @@ export function layoutBlocksCore( calculatePositions(layers, edges, layoutOptions) // 5. Normalize positions - const dimensions = normalizePositions(nodes, { isContainer: options.isContainer }) + let dimensions = normalizePositions(nodes, { isContainer: options.isContainer }) + + // 6. Snap to grid if gridSize is specified (recalculates dimensions) + const snappedDimensions = snapNodesToGrid(nodes, layoutOptions.gridSize) + if (snappedDimensions) { + dimensions = snappedDimensions + } return { nodes, dimensions } } diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts index 668366033..1346eec66 100644 --- a/apps/sim/lib/workflows/autolayout/index.ts +++ b/apps/sim/lib/workflows/autolayout/index.ts @@ -36,14 +36,13 @@ export function applyAutoLayout( const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING - // Pre-calculate container dimensions by laying out their children (bottom-up) - // This ensures accurate widths/heights before root-level layout prepareContainerDimensions( blocksCopy, edges, layoutBlocksCore, horizontalSpacing, - verticalSpacing + verticalSpacing, + options.gridSize ) const { root: rootBlockIds } = getBlocksByParent(blocksCopy) @@ -58,8 +57,6 @@ export function applyAutoLayout( (edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target) ) - // Calculate subflow depths before laying out root blocks - // This ensures blocks connected to subflow ends are positioned correctly const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers) if (Object.keys(rootBlocks).length > 0) { @@ -95,13 +92,12 @@ export function applyAutoLayout( } 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, + snapPositionToGrid, transferBlockHeights, } from '@/lib/workflows/autolayout/utils' diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index 08afa57a5..441f7681d 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { CONTAINER_PADDING, DEFAULT_HORIZONTAL_SPACING, @@ -14,12 +13,11 @@ import { isContainerType, prepareContainerDimensions, shouldSkipAutoLayout, + snapPositionToGrid, } from '@/lib/workflows/autolayout/utils' import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import type { BlockState } from '@/stores/workflows/workflow/types' -const logger = createLogger('AutoLayout:Targeted') - export interface TargetedLayoutOptions extends LayoutOptions { changedBlockIds: string[] verticalSpacing?: number @@ -39,6 +37,7 @@ export function applyTargetedLayout( changedBlockIds, verticalSpacing = DEFAULT_VERTICAL_SPACING, horizontalSpacing = DEFAULT_HORIZONTAL_SPACING, + gridSize, } = options if (!changedBlockIds || changedBlockIds.length === 0) { @@ -48,19 +47,17 @@ export function applyTargetedLayout( const changedSet = new Set(changedBlockIds) const blocksCopy: Record = JSON.parse(JSON.stringify(blocks)) - // Pre-calculate container dimensions by laying out their children (bottom-up) - // This ensures accurate widths/heights before root-level layout prepareContainerDimensions( blocksCopy, edges, layoutBlocksCore, horizontalSpacing, - verticalSpacing + verticalSpacing, + gridSize ) const groups = getBlocksByParent(blocksCopy) - // Calculate subflow depths before layout to properly position blocks after subflow ends const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers) layoutGroup( @@ -71,7 +68,8 @@ export function applyTargetedLayout( changedSet, verticalSpacing, horizontalSpacing, - subflowDepths + subflowDepths, + gridSize ) for (const [parentId, childIds] of groups.children.entries()) { @@ -83,7 +81,8 @@ export function applyTargetedLayout( changedSet, verticalSpacing, horizontalSpacing, - subflowDepths + subflowDepths, + gridSize ) } @@ -101,7 +100,8 @@ function layoutGroup( changedSet: Set, verticalSpacing: number, horizontalSpacing: number, - subflowDepths: Map + subflowDepths: Map, + gridSize?: number ): void { if (childIds.length === 0) return @@ -116,7 +116,6 @@ function layoutGroup( return } - // Determine which blocks need repositioning const requestedLayout = layoutEligibleChildIds.filter((id) => { const block = blocks[id] if (!block) return false @@ -141,7 +140,6 @@ function layoutGroup( return } - // Store old positions for anchor calculation const oldPositions = new Map() for (const id of layoutEligibleChildIds) { const block = blocks[id] @@ -149,8 +147,6 @@ function layoutGroup( oldPositions.set(id, { ...block.position }) } - // Compute layout positions using core function - // Only pass subflowDepths for root-level layout (not inside containers) const layoutPositions = computeLayoutPositions( layoutEligibleChildIds, blocks, @@ -158,7 +154,8 @@ function layoutGroup( parentBlock, horizontalSpacing, verticalSpacing, - parentId === null ? subflowDepths : undefined + parentId === null ? subflowDepths : undefined, + gridSize ) if (layoutPositions.size === 0) { @@ -168,7 +165,6 @@ function layoutGroup( return } - // Find anchor block (unchanged block with a layout position) let offsetX = 0 let offsetY = 0 @@ -185,20 +181,16 @@ function layoutGroup( } } - // Apply new positions only to blocks that need layout for (const id of needsLayout) { const block = blocks[id] const newPos = layoutPositions.get(id) if (!block || !newPos) continue - block.position = { - x: newPos.x + offsetX, - y: newPos.y + offsetY, - } + block.position = snapPositionToGrid({ x: newPos.x + offsetX, y: newPos.y + offsetY }, gridSize) } } /** - * Computes layout positions for a subset of blocks using the core layout + * Computes layout positions for a subset of blocks using the core layout function */ function computeLayoutPositions( childIds: string[], @@ -207,7 +199,8 @@ function computeLayoutPositions( parentBlock: BlockState | undefined, horizontalSpacing: number, verticalSpacing: number, - subflowDepths?: Map + subflowDepths?: Map, + gridSize?: number ): Map { const subsetBlocks: Record = {} for (const id of childIds) { @@ -228,11 +221,11 @@ function computeLayoutPositions( layoutOptions: { horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing, verticalSpacing, + gridSize, }, subflowDepths, }) - // Update parent container dimensions if applicable if (parentBlock) { parentBlock.data = { ...parentBlock.data, @@ -241,7 +234,6 @@ function computeLayoutPositions( } } - // 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 }) diff --git a/apps/sim/lib/workflows/autolayout/types.ts b/apps/sim/lib/workflows/autolayout/types.ts index 7f8cf7819..23e7ee995 100644 --- a/apps/sim/lib/workflows/autolayout/types.ts +++ b/apps/sim/lib/workflows/autolayout/types.ts @@ -7,6 +7,7 @@ export interface LayoutOptions { horizontalSpacing?: number verticalSpacing?: number padding?: { x: number; y: number } + gridSize?: number } export interface LayoutResult { diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index 8817063c9..bca3bb846 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -18,6 +18,61 @@ 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, + 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) */ @@ -314,6 +369,7 @@ export type LayoutFunction = ( horizontalSpacing?: number verticalSpacing?: number padding?: { x: number; y: number } + gridSize?: number } subflowDepths?: Map } @@ -329,13 +385,15 @@ export type LayoutFunction = ( * @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, edges: Edge[], layoutFn: LayoutFunction, horizontalSpacing: number, - verticalSpacing: number + verticalSpacing: number, + gridSize?: number ): void { const { children } = getBlocksByParent(blocks) @@ -402,6 +460,7 @@ export function prepareContainerDimensions( layoutOptions: { horizontalSpacing: horizontalSpacing * 0.85, verticalSpacing, + gridSize, }, })