Compare commits

...

1 Commits

Author SHA1 Message Date
twwu
62c793130b fix(workflow): improve node organization 2026-03-30 18:16:28 +08:00
4 changed files with 123 additions and 188 deletions

View File

@@ -28,7 +28,7 @@ const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn())
const mockGetLayoutByDagre = vi.hoisted(() => vi.fn())
const mockGetLayoutByELK = vi.hoisted(() => vi.fn())
const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes))
const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges))
@@ -112,7 +112,7 @@ vi.mock('../use-workflow-history', () => ({
vi.mock('../../utils', async importOriginal => ({
...(await importOriginal<typeof import('../../utils')>()),
getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args),
getLayoutByDagre: (...args: unknown[]) => mockGetLayoutByDagre(...args),
getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args),
initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges),
initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes),
}))
@@ -203,7 +203,7 @@ describe('use-workflow-interactions exports', () => {
['loop-child', { x: 40, y: 60, width: 100, height: 60 }],
]),
})
mockGetLayoutByDagre.mockResolvedValue({
mockGetLayoutByELK.mockResolvedValue({
nodes: new Map([
['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }],
['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }],

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { useWorkflowStore } from '../store'
import {
getLayoutByDagre,
getLayoutByELK,
getLayoutForChildNodes,
} from '../utils'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
@@ -49,7 +49,7 @@ export const useWorkflowOrganize = () => {
nodes,
getContainerSizeChanges(parentNodes, childLayoutsMap),
)
const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges)
const layout = await getLayoutByELK(nodesWithUpdatedSizes, edges)
const nextNodes = applyLayoutToNodes({
nodes: nodesWithUpdatedSizes,
layout,

View File

@@ -5,7 +5,7 @@ import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constan
import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants'
import { BlockEnum } from '../../types'
type ElkChild = Record<string, unknown> & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string }>, layoutOptions?: Record<string, string> }
type ElkChild = Record<string, unknown> & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string, layoutOptions?: Record<string, string> }>, layoutOptions?: Record<string, string> }
type ElkGraph = Record<string, unknown> & { id: string, children?: ElkChild[], edges?: Array<Record<string, unknown>> }
let layoutCallArgs: ElkGraph | null = null
@@ -32,7 +32,7 @@ vi.mock('elkjs/lib/elk.bundled.js', () => {
}
})
const { getLayoutByDagre, getLayoutForChildNodes } = await import('../elk-layout')
const { getLayoutByELK, getLayoutForChildNodes } = await import('../elk-layout')
function makeWorkflowNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
return createNode({
@@ -51,7 +51,7 @@ beforeEach(() => {
mockReturnOverride = null
})
describe('getLayoutByDagre', () => {
describe('getLayoutByELK', () => {
it('should return layout for simple linear graph', async () => {
const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
@@ -59,7 +59,7 @@ describe('getLayoutByDagre', () => {
]
const edges = [makeWorkflowEdge({ source: 'a', target: 'b' })]
const result = await getLayoutByDagre(nodes, edges)
const result = await getLayoutByELK(nodes, edges)
expect(result.nodes.size).toBe(2)
expect(result.nodes.has('a')).toBe(true)
@@ -74,7 +74,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'a' }),
]
const result = await getLayoutByDagre(nodes, [])
const result = await getLayoutByELK(nodes, [])
expect(result.nodes.size).toBe(1)
expect(result.nodes.has('child')).toBe(false)
})
@@ -85,7 +85,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'iter-start', type: CUSTOM_ITERATION_START_NODE, data: { type: BlockEnum.IterationStart, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
const result = await getLayoutByELK(nodes, [])
expect(result.nodes.size).toBe(1)
})
@@ -98,7 +98,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ source: 'a', target: 'b', data: { isInIteration: true, iteration_id: 'iter-1' } }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
expect(layoutCallArgs!.edges).toHaveLength(0)
})
@@ -107,7 +107,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(node, 'width')
Reflect.deleteProperty(node, 'height')
const result = await getLayoutByDagre([node], [])
const result = await getLayoutByELK([node], [])
expect(result.nodes.size).toBe(1)
const info = result.nodes.get('a')!
expect(info.width).toBe(244)
@@ -133,13 +133,13 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifElkNode.ports).toHaveLength(2)
expect(ifElkNode.layoutOptions!['elk.portConstraints']).toBe('FIXED_ORDER')
})
it('should use normal node for IfElse with single branch', async () => {
it('should build ports for IfElse even with single branch', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
@@ -149,9 +149,10 @@ describe('getLayoutByDagre', () => {
]
const edges = [makeWorkflowEdge({ source: 'if-1', target: 'b', sourceHandle: 'case-1' })]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifElkNode.ports).toBeUndefined()
expect(ifElkNode.ports).toHaveLength(1)
expect(ifElkNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST')
})
it('should build ports for HumanInput nodes with multiple branches', async () => {
@@ -168,12 +169,12 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiElkNode.ports).toHaveLength(2)
})
it('should use normal node for HumanInput with single branch', async () => {
it('should build ports for HumanInput even with single branch', async () => {
const nodes = [
makeWorkflowNode({
id: 'hi-1',
@@ -183,20 +184,21 @@ describe('getLayoutByDagre', () => {
]
const edges = [makeWorkflowEdge({ source: 'hi-1', target: 'b', sourceHandle: 'action-1' })]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiElkNode.ports).toBeUndefined()
expect(hiElkNode.ports).toHaveLength(1)
expect(hiElkNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST')
})
it('should normalise bounds so minX and minY start at 0', async () => {
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
const result = await getLayoutByDagre(nodes, [])
const result = await getLayoutByELK(nodes, [])
expect(result.bounds.minX).toBe(0)
expect(result.bounds.minY).toBe(0)
})
it('should return empty layout when no nodes match filter', async () => {
const result = await getLayoutByDagre([], [])
const result = await getLayoutByELK([], [])
expect(result.nodes.size).toBe(0)
expect(result.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 })
})
@@ -225,7 +227,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'y', sourceHandle: 'case-b' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
const portIds = ifNode.ports!.map((p: { id: string }) => p.id)
expect(portIds[portIds.length - 1]).toContain('false')
@@ -247,7 +249,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e-a2', source: 'hi-1', target: 'y', sourceHandle: 'a2' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
const portIds = hiNode.ports!.map((p: { id: string }) => p.id)
expect(portIds[portIds.length - 1]).toContain('__timeout')
@@ -267,7 +269,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const portEdges = layoutCallArgs!.edges!.filter((e: Record<string, unknown>) => e.sourcePort)
expect(portEdges.length).toBeGreaterThan(0)
})
@@ -286,7 +288,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle')
const result = await getLayoutByDagre(nodes, [e1, e2])
const result = await getLayoutByELK(nodes, [e1, e2])
expect(result.nodes.size).toBeGreaterThan(0)
})
@@ -299,7 +301,7 @@ describe('getLayoutByDagre', () => {
})
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
const result = await getLayoutByDagre(nodes, [])
const result = await getLayoutByELK(nodes, [])
const info = result.nodes.get('a')!
expect(info.x).toBe(0)
expect(info.y).toBe(0)
@@ -326,7 +328,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
const result = await getLayoutByELK(nodes, [])
expect(result.nodes.get('a')!.layer).toBe(0)
expect(result.nodes.get('b')!.layer).toBe(1)
})
@@ -354,7 +356,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'nested-1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'nested-2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
const result = await getLayoutByELK(nodes, [])
expect(result.nodes.has('nested-1')).toBe(true)
expect(result.nodes.has('nested-2')).toBe(true)
})
@@ -372,7 +374,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'visible', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'also-visible', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
const result = await getLayoutByELK(nodes, [])
expect(result.nodes.size).toBe(2)
})
@@ -390,7 +392,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'other-unknown' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2)
})
@@ -409,7 +411,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: 'another-unknown' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2)
})
@@ -428,7 +430,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle')
await getLayoutByDagre(nodes, [e1, e2])
await getLayoutByELK(nodes, [e1, e2])
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2)
})
@@ -447,7 +449,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle')
await getLayoutByDagre(nodes, [e1, e2])
await getLayoutByELK(nodes, [e1, e2])
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2)
})
@@ -463,7 +465,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2)
})
@@ -479,7 +481,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2)
})
@@ -492,7 +494,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ source: 'x', target: 'y', data: { isInLoop: true, loop_id: 'loop-1' } }),
]
await getLayoutByDagre(nodes, edges)
await getLayoutByELK(nodes, edges)
expect(layoutCallArgs!.edges).toHaveLength(0)
})
})

View File

@@ -18,9 +18,6 @@ import {
BlockEnum,
} from '@/app/components/workflow/types'
// Although the file name refers to Dagre, the implementation now relies on ELK's layered algorithm.
// Keep the export signatures unchanged to minimise the blast radius while we migrate the layout stack.
const elk = new ELK()
const DEFAULT_NODE_WIDTH = 244
@@ -41,7 +38,6 @@ const ROOT_LAYOUT_OPTIONS = {
// === Port Configuration ===
'elk.portConstraints': 'FIXED_ORDER',
'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES',
'elk.port.side': 'SOUTH',
// === Node Placement - Best quality ===
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
@@ -278,32 +274,16 @@ const collectLayout = (graph: ElkNode, predicate: (id: string) => boolean): Layo
}
}
/**
* Build If/Else node with ELK native Ports instead of dummy nodes
* This is the recommended approach for handling multiple branches
*/
const buildIfElseWithPorts = (
ifElseNode: Node,
edges: Edge[],
): { node: ElkNodeShape, portMap: Map<string, string> } | null => {
const childEdges = edges.filter(edge => edge.source === ifElseNode.id)
if (childEdges.length <= 1)
return null
// Sort child edges according to case order
const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => {
const sortIfElseOutEdges = (ifElseNode: Node, outEdges: Edge[]): Edge[] => {
return [...outEdges].sort((edgeA, edgeB) => {
const handleA = edgeA.sourceHandle
const handleB = edgeB.sourceHandle
if (handleA && handleB) {
const cases = (ifElseNode.data as IfElseNodeType).cases || []
const isAElse = handleA === 'false'
const isBElse = handleB === 'false'
if (isAElse)
if (handleA === 'false')
return 1
if (isBElse)
if (handleB === 'false')
return -1
const indexA = cases.findIndex((c: CaseItem) => c.case_id === handleA)
@@ -315,67 +295,20 @@ const buildIfElseWithPorts = (
return 0
})
// Create ELK ports for each branch
const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({
id: `${ifElseNode.id}-port-${edge.sourceHandle || index}`,
layoutOptions: {
'port.side': 'EAST', // Ports on the right side (matching 'RIGHT' direction)
'port.index': String(index),
},
}))
// Build port mapping: sourceHandle -> portId
const portMap = new Map<string, string>()
sortedChildEdges.forEach((edge, index) => {
const portId = `${ifElseNode.id}-port-${edge.sourceHandle || index}`
portMap.set(edge.id, portId)
})
return {
node: {
id: ifElseNode.id,
width: ifElseNode.width ?? DEFAULT_NODE_WIDTH,
height: ifElseNode.height ?? DEFAULT_NODE_HEIGHT,
ports,
layoutOptions: {
'elk.portConstraints': 'FIXED_ORDER',
},
},
portMap,
}
}
/**
* Build Human Input node with ELK native Ports for multiple branches
* Handles user actions as branches with __timeout as the last fixed branch
*/
const buildHumanInputWithPorts = (
humanInputNode: Node,
edges: Edge[],
): { node: ElkNodeShape, portMap: Map<string, string> } | null => {
const childEdges = edges.filter(edge => edge.source === humanInputNode.id)
if (childEdges.length <= 1)
return null
// Sort child edges: user actions first (by order), then __timeout last
const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => {
const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] => {
return [...outEdges].sort((edgeA, edgeB) => {
const handleA = edgeA.sourceHandle
const handleB = edgeB.sourceHandle
if (handleA && handleB) {
const userActions = (humanInputNode.data as HumanInputNodeType).user_actions || []
const isATimeout = handleA === '__timeout'
const isBTimeout = handleB === '__timeout'
// __timeout should always be last
if (isATimeout)
if (handleA === '__timeout')
return 1
if (isBTimeout)
if (handleB === '__timeout')
return -1
// Sort by user_actions order
const indexA = userActions.findIndex(action => action.id === handleA)
const indexB = userActions.findIndex(action => action.id === handleB)
@@ -385,35 +318,6 @@ const buildHumanInputWithPorts = (
return 0
})
// Create ELK ports for each branch
const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({
id: `${humanInputNode.id}-port-${edge.sourceHandle || index}`,
layoutOptions: {
'port.side': 'EAST',
'port.index': String(index),
},
}))
// Build port mapping: edge.id -> portId
const portMap = new Map<string, string>()
sortedChildEdges.forEach((edge, index) => {
const portId = `${humanInputNode.id}-port-${edge.sourceHandle || index}`
portMap.set(edge.id, portId)
})
return {
node: {
id: humanInputNode.id,
width: humanInputNode.width ?? DEFAULT_NODE_WIDTH,
height: humanInputNode.height ?? DEFAULT_NODE_HEIGHT,
ports,
layoutOptions: {
'elk.portConstraints': 'FIXED_ORDER',
},
},
portMap,
}
}
const normaliseBounds = (layout: LayoutResult): LayoutResult => {
@@ -448,58 +352,87 @@ const normaliseBounds = (layout: LayoutResult): LayoutResult => {
}
}
export const getLayoutByDagre = async (originNodes: Node[], originEdges: Edge[]): Promise<LayoutResult> => {
export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): Promise<LayoutResult> => {
edgeCounter = 0
const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE)
const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop))
const elkNodes: ElkNodeShape[] = []
const elkEdges: ElkEdgeShape[] = []
// Track which edges have been processed for If/Else nodes with ports
const edgeToPortMap = new Map<string, string>()
// Build nodes with ports for If/Else and Human Input nodes
nodes.forEach((node) => {
if (node.data.type === BlockEnum.IfElse) {
const portsResult = buildIfElseWithPorts(node, edges)
if (portsResult) {
// Use node with ports
elkNodes.push(portsResult.node)
// Store port mappings for edges
portsResult.portMap.forEach((portId, edgeId) => {
edgeToPortMap.set(edgeId, portId)
})
}
else {
// No multiple branches, use normal node
elkNodes.push(toElkNode(node))
}
}
else if (node.data.type === BlockEnum.HumanInput) {
const portsResult = buildHumanInputWithPorts(node, edges)
if (portsResult) {
// Use node with ports
elkNodes.push(portsResult.node)
// Store port mappings for edges
portsResult.portMap.forEach((portId, edgeId) => {
edgeToPortMap.set(edgeId, portId)
})
}
else {
// No multiple branches, use normal node
elkNodes.push(toElkNode(node))
}
}
else {
elkNodes.push(toElkNode(node))
}
const outEdgesByNode = new Map<string, Edge[]>()
const inEdgesByNode = new Map<string, Edge[]>()
edges.forEach((edge) => {
if (!outEdgesByNode.has(edge.source))
outEdgesByNode.set(edge.source, [])
outEdgesByNode.get(edge.source)!.push(edge)
if (!inEdgesByNode.has(edge.target))
inEdgesByNode.set(edge.target, [])
inEdgesByNode.get(edge.target)!.push(edge)
})
// Build edges with port connections
edges.forEach((edge) => {
const sourcePort = edgeToPortMap.get(edge.id)
elkEdges.push(createEdge(edge.source, edge.target, sourcePort))
const elkNodes: ElkNodeShape[] = []
const elkEdges: ElkEdgeShape[] = []
const sourcePortMap = new Map<string, string>()
const targetPortMap = new Map<string, string>()
const sortedOutEdgesByNode = new Map<string, Edge[]>()
nodes.forEach((node) => {
const inEdges = inEdgesByNode.get(node.id) || []
let outEdges = outEdgesByNode.get(node.id) || []
if (node.data.type === BlockEnum.IfElse)
outEdges = sortIfElseOutEdges(node, outEdges)
else if (node.data.type === BlockEnum.HumanInput)
outEdges = sortHumanInputOutEdges(node, outEdges)
sortedOutEdgesByNode.set(node.id, outEdges)
const ports: ElkPortShape[] = []
inEdges.forEach((edge, index) => {
const portId = `${node.id}-in-${index}`
ports.push({
id: portId,
layoutOptions: {
'elk.port.side': 'WEST',
'elk.port.index': String(index),
},
})
targetPortMap.set(edge.id, portId)
})
outEdges.forEach((edge, index) => {
const portId = `${node.id}-out-${edge.sourceHandle || index}`
ports.push({
id: portId,
layoutOptions: {
'elk.port.side': 'EAST',
'elk.port.index': String(index),
},
})
sourcePortMap.set(edge.id, portId)
})
elkNodes.push({
id: node.id,
width: node.width ?? DEFAULT_NODE_WIDTH,
height: node.height ?? DEFAULT_NODE_HEIGHT,
...(ports.length > 0 && {
ports,
layoutOptions: { 'elk.portConstraints': 'FIXED_ORDER' },
}),
})
})
// Build edges in sorted per-node order so PREFER_EDGES aligns with port order
nodes.forEach((node) => {
const outEdges = sortedOutEdgesByNode.get(node.id) || []
outEdges.forEach((edge) => {
elkEdges.push(createEdge(
edge.source,
edge.target,
sourcePortMap.get(edge.id),
targetPortMap.get(edge.id),
))
})
})
const graph = {