diff --git a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts index 1a3c52ec2d..54eb289abe 100644 --- a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts +++ b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts @@ -486,6 +486,242 @@ describe('getLayoutByELK', () => { expect(hiNode.ports).toHaveLength(2) }) + it('should build ports for QuestionClassifier sorted by classes order', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'qc-1', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'cls-a', name: 'A' }, { id: 'cls-b', name: 'B' }, { id: 'cls-c', name: 'C' }], + }, + }), + makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'z', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e-c', source: 'qc-1', target: 'z', sourceHandle: 'cls-c' }), + makeWorkflowEdge({ id: 'e-a', source: 'qc-1', target: 'x', sourceHandle: 'cls-a' }), + makeWorkflowEdge({ id: 'e-b', source: 'qc-1', target: 'y', sourceHandle: 'cls-b' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + const portIds = qcNode.ports!.map((p: { id: string }) => p.id) + expect(portIds).toEqual([ + 'qc-1-out-cls-a', + 'qc-1-out-cls-b', + 'qc-1-out-cls-c', + ]) + }) + + it('should build ports for QuestionClassifier with single class', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'qc-1', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'cls-only', name: 'Only' }], + }, + }), + makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'x', sourceHandle: 'cls-only' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + expect(qcNode.ports).toHaveLength(1) + expect(qcNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST') + }) + + it('should only create output (EAST) ports, not input (WEST) ports', async () => { + const nodes = [ + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'c', data: { type: BlockEnum.End, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'a', target: 'b' }), + makeWorkflowEdge({ id: 'e2', source: 'b', target: 'c' }), + ] + + await getLayoutByELK(nodes, edges) + layoutCallArgs!.children!.forEach((child: ElkChild) => { + if (child.ports) { + child.ports.forEach((port) => { + expect(port.layoutOptions!['elk.port.side']).toBe('EAST') + }) + } + }) + const endNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'c')! + expect(endNode.ports).toBeUndefined() + }) + + it('should order children array by DFS following port order', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'if-1', + data: { + type: BlockEnum.IfElse, + title: '', + desc: '', + cases: [{ case_id: 'case-a', logical_operator: 'and', conditions: [] }], + }, + }), + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ id: 'branch-a', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'branch-else', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'if-1' }), + makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'branch-else', sourceHandle: 'false' }), + makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'branch-a', sourceHandle: 'case-a' }), + makeWorkflowEdge({ source: 'branch-a', target: 'end' }), + makeWorkflowEdge({ source: 'branch-else', target: 'end' }), + ] + + await getLayoutByELK(nodes, edges) + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + // DFS from start: start → if-1 → branch-a (case-a first) → end → branch-else + const idxA = childIds.indexOf('branch-a') + const idxElse = childIds.indexOf('branch-else') + expect(idxA).toBeLessThan(idxElse) + }) + + it('should order children by DFS across nested branching nodes', async () => { + const nodes = [ + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'qc-1', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'c1', name: 'C1' }, { id: 'c2', name: 'C2' }], + }, + }), + makeWorkflowNode({ id: 'upper', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'lower', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'qc-1' }), + makeWorkflowEdge({ id: 'e-c2', source: 'qc-1', target: 'lower', sourceHandle: 'c2' }), + makeWorkflowEdge({ id: 'e-c1', source: 'qc-1', target: 'upper', sourceHandle: 'c1' }), + ] + + await getLayoutByELK(nodes, edges) + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + // DFS: start → qc-1 → upper (c1 first) → lower (c2 second) + expect(childIds.indexOf('upper')).toBeLessThan(childIds.indexOf('lower')) + }) + + it('should handle QuestionClassifier with no classes property', async () => { + const nodes = [ + makeWorkflowNode({ id: 'qc-1', data: { type: BlockEnum.QuestionClassifier, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'b', sourceHandle: 'cls-1' }), + makeWorkflowEdge({ id: 'e2', source: 'qc-1', target: 'c', sourceHandle: 'cls-2' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + expect(qcNode.ports).toHaveLength(2) + }) + + it('should handle QuestionClassifier edges where handle not found in classes', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'qc-1', + data: { type: BlockEnum.QuestionClassifier, title: '', desc: '', classes: [{ id: 'known', name: 'K' }] }, + }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'b', sourceHandle: 'unknown-1' }), + makeWorkflowEdge({ id: 'e2', source: 'qc-1', target: 'c', sourceHandle: 'unknown-2' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + expect(qcNode.ports).toHaveLength(2) + }) + + it('should include disconnected nodes in the layout', async () => { + const nodes = [ + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ id: 'connected', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'isolated', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'connected' }), + ] + + await getLayoutByELK(nodes, edges) + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + expect(childIds).toContain('isolated') + expect(childIds).toHaveLength(3) + }) + + it('should build edges in DFS order matching port order', async () => { + const nodes = [ + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'if-1', + data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-a' }] }, + }), + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'if-1' }), + makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'b', sourceHandle: 'false' }), + makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'a', sourceHandle: 'case-a' }), + ] + + await getLayoutByELK(nodes, edges) + const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }> + const ifEdges = elkEdges.filter(e => e.sources[0] === 'if-1') + expect(ifEdges[0].targets[0]).toBe('a') + expect(ifEdges[1].targets[0]).toBe('b') + }) + + it('should keep edges for components where every node has an incoming edge', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'if-1', + data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-a' }] }, + }), + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'a', sourceHandle: 'case-a' }), + makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'b', sourceHandle: 'false' }), + makeWorkflowEdge({ id: 'e-back', source: 'a', target: 'if-1' }), + ] + + await getLayoutByELK(nodes, edges) + + const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }> + expect(elkEdges).toHaveLength(3) + expect(elkEdges).toEqual(expect.arrayContaining([ + expect.objectContaining({ sources: ['if-1'], targets: ['a'] }), + expect.objectContaining({ sources: ['if-1'], targets: ['b'] }), + expect.objectContaining({ sources: ['a'], targets: ['if-1'] }), + ])) + }) + it('should filter loop internal edges', async () => { const nodes = [ makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), @@ -650,6 +886,45 @@ describe('getLayoutForChildNodes', () => { expect(result!.nodes.size).toBe(2) }) + it('should build ports and DFS-order for branching nodes inside iteration', async () => { + const nodes = [ + makeWorkflowNode({ id: 'parent', data: { type: BlockEnum.Iteration, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'iter-start', + type: CUSTOM_ITERATION_START_NODE, + parentId: 'parent', + data: { type: BlockEnum.IterationStart, title: '', desc: '' }, + }), + makeWorkflowNode({ + id: 'qc-child', + parentId: 'parent', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'cls-1', name: 'C1' }, { id: 'cls-2', name: 'C2' }], + }, + }), + makeWorkflowNode({ id: 'upper', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'lower', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'iter-start', target: 'qc-child', data: { isInIteration: true, iteration_id: 'parent' } }), + makeWorkflowEdge({ id: 'e-c2', source: 'qc-child', target: 'lower', sourceHandle: 'cls-2', data: { isInIteration: true, iteration_id: 'parent' } }), + makeWorkflowEdge({ id: 'e-c1', source: 'qc-child', target: 'upper', sourceHandle: 'cls-1', data: { isInIteration: true, iteration_id: 'parent' } }), + ] + + await getLayoutForChildNodes('parent', nodes, edges) + + const qcElk = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-child')! + expect(qcElk.ports).toHaveLength(2) + expect(qcElk.ports![0].id).toContain('cls-1') + expect(qcElk.ports![1].id).toContain('cls-2') + + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + expect(childIds.indexOf('upper')).toBeLessThan(childIds.indexOf('lower')) + }) + it('should return original layout when bounds are not finite', async () => { mockReturnOverride = (graph: ElkGraph) => ({ ...graph, diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts index 9860bbc770..781416f3c4 100644 --- a/web/app/components/workflow/utils/elk-layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -1,6 +1,7 @@ import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api' import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types' +import type { QuestionClassifierNodeType, Topic } from '@/app/components/workflow/nodes/question-classifier/types' import type { Edge, Node, @@ -37,13 +38,13 @@ const ROOT_LAYOUT_OPTIONS = { // === Port Configuration === 'elk.portConstraints': 'FIXED_ORDER', - 'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', - // === Node Placement - Best quality === - 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + // === Node Placement - Balanced centering === + 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', 'elk.layered.nodePlacement.favorStraightEdges': 'true', - 'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5', - 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // === Edge Routing - Maximum quality === 'elk.edgeRouting': 'SPLINES', @@ -56,7 +57,7 @@ const ROOT_LAYOUT_OPTIONS = { 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', 'elk.layered.crossingMinimization.greedySwitchHierarchical.type': 'TWO_SIDED', - 'elk.layered.crossingMinimization.semiInteractive': 'true', + 'elk.layered.crossingMinimization.semiInteractive': 'false', 'elk.layered.crossingMinimization.hierarchicalSweepiness': '0.9', // === Layering Strategy - Best quality === @@ -115,11 +116,15 @@ const CHILD_LAYOUT_OPTIONS = { 'elk.spacing.edgeLabel': '8', 'elk.spacing.portPort': '15', - // === Node Placement - Best quality === - 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + // === Port Configuration === + 'elk.portConstraints': 'FIXED_ORDER', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', + + // === Node Placement - Balanced centering === + 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', 'elk.layered.nodePlacement.favorStraightEdges': 'true', - 'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5', - 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // === Edge Routing - Maximum quality === 'elk.edgeRouting': 'SPLINES', @@ -129,7 +134,7 @@ const CHILD_LAYOUT_OPTIONS = { // === Crossing Minimization - Aggressive === 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', - 'elk.layered.crossingMinimization.semiInteractive': 'true', + 'elk.layered.crossingMinimization.semiInteractive': 'false', // === Layering Strategy === 'elk.layered.layering.strategy': 'NETWORK_SIMPLEX', @@ -197,12 +202,6 @@ type ElkEdgeShape = { targetPort?: string } -const toElkNode = (node: Node): ElkNodeShape => ({ - id: node.id, - width: node.width ?? DEFAULT_NODE_WIDTH, - height: node.height ?? DEFAULT_NODE_HEIGHT, -}) - let edgeCounter = 0 const nextEdgeId = () => `elk-edge-${edgeCounter++}` @@ -297,6 +296,24 @@ const sortIfElseOutEdges = (ifElseNode: Node, outEdges: Edge[]): Edge[] => { }) } +const sortQuestionClassifierOutEdges = (classifierNode: Node, outEdges: Edge[]): Edge[] => { + return [...outEdges].sort((edgeA, edgeB) => { + const handleA = edgeA.sourceHandle + const handleB = edgeB.sourceHandle + + if (handleA && handleB) { + const classes = (classifierNode.data as QuestionClassifierNodeType).classes || [] + const indexA = classes.findIndex((t: Topic) => t.id === handleA) + const indexB = classes.findIndex((t: Topic) => t.id === handleB) + + if (indexA !== -1 && indexB !== -1) + return indexA - indexB + } + + return 0 + }) +} + const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] => { return [...outEdges].sort((edgeA, edgeB) => { const handleA = edgeA.sourceHandle @@ -352,63 +369,45 @@ const normaliseBounds = (layout: LayoutResult): LayoutResult => { } } -export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): Promise => { - 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)) - +/** + * Build ELK nodes with output ports (sorted for branching types) + * and edges ordered by a DFS traversal that follows port order. + */ +const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => { const outEdgesByNode = new Map() - const inEdgesByNode = new Map() 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) }) const elkNodes: ElkNodeShape[] = [] const elkEdges: ElkEdgeShape[] = [] const sourcePortMap = new Map() - const targetPortMap = new Map() const sortedOutEdgesByNode = new Map() 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.QuestionClassifier) + outEdges = sortQuestionClassifierOutEdges(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 ports: ElkPortShape[] = outEdges.map((edge, index) => { const portId = `${node.id}-out-${edge.sourceHandle || index}` - ports.push({ + sourcePortMap.set(edge.id, portId) + return { id: portId, layoutOptions: { 'elk.port.side': 'EAST', 'elk.port.index': String(index), }, - }) - sourcePortMap.set(edge.id, portId) + } }) elkNodes.push({ @@ -422,19 +421,51 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): }) }) - // Build edges in sorted per-node order so PREFER_EDGES aligns with port order - nodes.forEach((node) => { - const outEdges = sortedOutEdgesByNode.get(node.id) || [] + // DFS in port order to determine the definitive vertical ordering of nodes. + // forceNodeModelOrder makes ELK respect the children-array order within each layer. + const nodeIdSet = new Set(nodes.map(n => n.id)) + const visited = new Set() + const orderedIds: string[] = [] + + const dfs = (id: string) => { + if (visited.has(id) || !nodeIdSet.has(id)) + return + visited.add(id) + orderedIds.push(id) + const outEdges = sortedOutEdgesByNode.get(id) || [] + outEdges.forEach(e => dfs(e.target)) + } + + nodes.forEach((n) => { + if (!edges.some(e => e.target === n.id)) + dfs(n.id) + }) + nodes.forEach(n => dfs(n.id)) + + const nodeOrder = new Map(orderedIds.map((id, i) => [id, i])) + elkNodes.sort((a, b) => (nodeOrder.get(a.id) ?? 0) - (nodeOrder.get(b.id) ?? 0)) + + orderedIds.forEach((id) => { + const outEdges = sortedOutEdgesByNode.get(id) || [] outEdges.forEach((edge) => { elkEdges.push(createEdge( edge.source, edge.target, sourcePortMap.get(edge.id), - targetPortMap.get(edge.id), )) }) }) + return { elkNodes, elkEdges } +} + +export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): Promise => { + 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, elkEdges } = buildPortAwareGraph(nodes, edges) + const graph = { id: 'workflow-root', layoutOptions: ROOT_LAYOUT_OPTIONS, @@ -443,7 +474,6 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): } const layoutedGraph = await elk.layout(graph) - // No need to filter dummy nodes anymore, as we're using ports const layout = collectLayout(layoutedGraph, () => true) return normaliseBounds(layout) } @@ -532,8 +562,7 @@ export const getLayoutForChildNodes = async ( || (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId), ) - const elkNodes: ElkNodeShape[] = nodes.map(toElkNode) - const elkEdges: ElkEdgeShape[] = edges.map(edge => createEdge(edge.source, edge.target)) + const { elkNodes, elkEdges } = buildPortAwareGraph(nodes, edges) const graph = { id: parentNodeId,