mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
c80827f21b
commit
bbaf7e90f8
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
57
apps/sim/lib/blocks/block-dimensions.ts
Normal file
57
apps/sim/lib/blocks/block-dimensions.ts
Normal 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
|
||||
94
apps/sim/lib/workflows/autolayout/constants.ts
Normal file
94
apps/sim/lib/workflows/autolayout/constants.ts
Normal 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,
|
||||
}
|
||||
@@ -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 = {}
|
||||
|
||||
288
apps/sim/lib/workflows/autolayout/core.ts
Normal file
288
apps/sim/lib/workflows/autolayout/core.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user