improvement(autolayout): simplify code to use fixed block widths, height + refactor (#2112)

* improvement(autolayout): simplify code to use fixed block widths, height + refactor

* change to aliased imports
This commit is contained in:
Vikhyath Mondreti
2025-11-24 13:13:28 -08:00
committed by GitHub
parent c80827f21b
commit bbaf7e90f8
16 changed files with 767 additions and 753 deletions

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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.

View File

@@ -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<string, any>) {
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<string, any>) {
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<string, any>) {
// 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<string, any>) {
*/
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

View File

@@ -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,
},
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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<string, BlockState>,
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)
// Use the shared core layout function with container options
const { nodes, dimensions } = layoutBlocksCore(childBlocks, childEdges, {
isContainer: true,
layoutOptions: 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)
// Apply positions back to blocks
for (const node of nodes.values()) {
blocks[node.id].position = node.position
}
// Adjust all child positions to start at proper padding from container edges
const xOffset = CONTAINER_PADDING_X - minX
const yOffset = CONTAINER_PADDING_Y - minY
// Update container dimensions
const calculatedWidth = dimensions.width
const calculatedHeight = dimensions.height
for (const node of childNodes.values()) {
childBlocks[node.id].position = {
x: node.position.x + xOffset,
y: node.position.y + yOffset,
}
}
const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2
const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2
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 = {}

View File

@@ -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<string, BlockState>,
edges: Edge[]
): Map<string, GraphNode> {
const nodes = new Map<string, GraphNode>()
// 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<string, number>()
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<string>()
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<string, GraphNode>): Map<number, GraphNode[]> {
const layers = new Map<number, GraphNode[]>()
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<number, GraphNode[]>,
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<string, BlockState>,
edges: Edge[],
options: { isContainer: boolean; layoutOptions?: LayoutOptions }
): { nodes: Map<string, GraphNode>; 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 }
}

View File

@@ -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<string, BlockState>,
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<string, BlockState>, 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,
})
}
}
}

View File

@@ -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<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop> = {},
parallels: Record<string, Parallel> = {},
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<string, BlockState> = JSON.parse(JSON.stringify(blocks))
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
const rootBlocks: Record<string, BlockState> = {}
@@ -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<string, BlockState>,
edges: Edge[],
newBlockId: string,
options: AdjustmentOptions = {}
): LayoutResult {
try {
logger.info('Adjusting layout for new block', { newBlockId })
const blocksCopy: Record<string, BlockState> = 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'

View File

@@ -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<string, BlockState>,
edges: Edge[]
): Map<string, GraphNode> {
const nodes = new Map<string, GraphNode>()
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<string, number>()
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<string>()
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<string, GraphNode>): Map<number, GraphNode[]> {
const layers = new Map<number, GraphNode[]>()
for (const node of nodes.values()) {
if (!layers.has(node.layer)) {
layers.set(node.layer, [])
}
layers.get(node.layer)!.push(node)
}
return layers
}

View File

@@ -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<number, GraphNode[]>,
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,
})
}
}

View File

@@ -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<string, BlockState>,
edges: Edge[],
options: TargetedLayoutOptions
): Record<string, BlockState> {
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<string, { x: number; y: number }>()
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<string, BlockState>,
@@ -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<string, { x: number; y: number }>()
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),
}
}
// Convert nodes to position map
const positions = new Map<string, { x: number; y: number }>()
for (const node of nodes.values()) {
positions.set(node.id, { x: node.position.x, y: node.position.y })
}
return positions
}
function getBounds(positions: Map<string, { x: number; y: number }>) {
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)
}
return { minX, minY }
}
/**
* Updates container dimensions based on children
*/
function updateContainerDimensions(
parentBlock: BlockState,
childIds: string[],
blocks: Record<string, BlockState>
): 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<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 [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,
})
}

View File

@@ -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<string, BlockState>
@@ -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<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'>
@@ -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<string, BlockState>): {
root: string[]
children: Map<string, string[]>
@@ -144,10 +187,81 @@ export function getBlocksByParent(blocks: Record<string, BlockState>): {
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<string, GraphNode>,
options: { isContainer: boolean }
): { width: number; height: number } {
if (nodes.size === 0) {
return { width: 0, height: 0 }
}
return false
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
}
}
}

View File

@@ -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) {