diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java index 06c7fb52..dd2bb484 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java @@ -185,48 +185,66 @@ public class ElkDiagramRenderer implements DiagramRenderer { // ---------------------------------------------------------------- private LayoutResult computeLayout(RouteGraph graph, Direction rootDirection) { - ElkGraphFactory factory = ElkGraphFactory.eINSTANCE; + LayoutContext ctx = new LayoutContext(ElkGraphFactory.eINSTANCE); - // Create root node for main flow - ElkNode rootNode = factory.createElkNode(); - rootNode.setIdentifier("root"); - rootNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); - rootNode.setProperty(CoreOptions.DIRECTION, rootDirection); - rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING); - rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING); - rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN); - rootNode.setProperty(CoreOptions.EDGE_ROUTING, EdgeRouting.POLYLINE); - rootNode.setProperty(org.eclipse.elk.alg.layered.options.LayeredOptions.NODE_PLACEMENT_STRATEGY, - NodePlacementStrategy.LINEAR_SEGMENTS); + // 1. Partition graph nodes into main flow vs handler sections + Map nodeById = buildNodeIndex(graph); + List mainNodes = new ArrayList<>(); + List handlerNodes = new ArrayList<>(); + partitionNodes(graph, nodeById, mainNodes, handlerNodes); - // Track which nodes are children of a compound (at any depth) - Set childNodeIds = new HashSet<>(); + // 2. Build ELK graphs — main flow + separate handler roots + ElkNode rootNode = createElkRoot("root", rootDirection, 1.0, ctx); + for (RouteNode rn : mainNodes) { + if (!ctx.elkNodeMap.containsKey(rn.getId())) { + createElkNodeRecursive(rn, rootNode, ctx); + } + } - // Create ELK nodes recursively — compounds contain their children - Map elkNodeMap = new HashMap<>(); - Map nodeColors = new HashMap<>(); - Set compoundNodeIds = new HashSet<>(); + List handlerRoots = new ArrayList<>(); + for (RouteNode rn : handlerNodes) { + ElkNode hr = createElkRoot("handler-root-" + rn.getId(), rootDirection, 0.5, ctx); + createElkNodeRecursive(rn, hr, ctx); + handlerRoots.add(hr); + } - // Build a lookup from the flat nodes list (graph.getNodes() contains - // duplicates — children appear both nested AND top-level). Use this for - // resolving edge endpoints to RouteNode objects. + // 3. Create ELK edges (filtering DO_TRY internals and cross-root edges) + createElkEdges(graph, ctx); + + // 4. Run layout engine + RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine(); + engine.layout(rootNode, new BasicProgressMonitor()); + for (ElkNode hr : handlerRoots) { + engine.layout(hr, new BasicProgressMonitor()); + } + + // 5. Post-process: fix DO_TRY section ordering and widths + postProcessDoTrySections(ctx); + + // 6. Extract positioned result + return extractLayout(graph, rootNode, handlerRoots, ctx); + } + + /** Build a lookup from the flat nodes list (deduplicates nested + top-level entries). */ + private Map buildNodeIndex(RouteGraph graph) { Map nodeById = new HashMap<>(); if (graph.getNodes() != null) { for (RouteNode rn : graph.getNodes()) { nodeById.put(rn.getId(), rn); } } + return nodeById; + } - // Separate main flow from handler sections using graph structure: - // - Start from graph.getRoot() and walk FLOW edges for main flow - // - Handler sections are top-level nodes of HANDLER_SECTION_TYPES - // This avoids the problem of the flat nodes list containing duplicates. + /** + * Separate main flow nodes from handler sections by walking FLOW edges + * from the graph root. Handler sections (onException, onCompletion, etc.) + * are placed in their own list for independent layout. + */ + private void partitionNodes(RouteGraph graph, Map nodeById, + List mainNodes, List handlerNodes) { Set mainNodeIds = new HashSet<>(); - List mainNodes = new ArrayList<>(); - List handlerNodes = new ArrayList<>(); - // Collect main flow node IDs by walking from root along non-ERROR edges. - // graph.getRoot() provides the entry point; ERROR edges lead to handler sections. if (graph.getRoot() != null && graph.getEdges() != null) { mainNodeIds.add(graph.getRoot().getId()); boolean changed = true; @@ -235,7 +253,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { for (RouteEdge re : graph.getEdges()) { if (re.getEdgeType() == RouteEdge.EdgeType.ERROR) continue; if (mainNodeIds.contains(re.getSource()) && !mainNodeIds.contains(re.getTarget())) { - // Don't follow edges INTO handler compounds RouteNode target = nodeById.get(re.getTarget()); if (target != null && target.getType() != null && HANDLER_SECTION_TYPES.contains(target.getType())) { @@ -248,7 +265,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { } } - // Build node lists — deduplicate (flat list has duplicates) Set seen = new HashSet<>(); if (graph.getNodes() != null) { for (RouteNode rn : graph.getNodes()) { @@ -258,124 +274,91 @@ public class ElkDiagramRenderer implements DiagramRenderer { && rn.getChildren() != null && !rn.getChildren().isEmpty()) { handlerNodes.add(rn); } else if (mainNodeIds.isEmpty() || mainNodeIds.contains(rn.getId())) { - // When no root is available (legacy/test graphs), include all - // non-handler nodes as main flow (fallback to old behavior) mainNodes.add(rn); } } } + } - // Process main flow nodes into the root ELK graph - Set doTryNodeIds = new HashSet<>(); - Map> doTrySectionOrder = new LinkedHashMap<>(); - for (RouteNode rn : mainNodes) { - if (!elkNodeMap.containsKey(rn.getId())) { - createElkNodeRecursive(rn, rootNode, factory, elkNodeMap, nodeColors, - compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder); + /** Create a configured ELK root node for the layered algorithm. */ + private ElkNode createElkRoot(String identifier, Direction direction, + double spacingScale, LayoutContext ctx) { + ElkNode root = ctx.factory.createElkNode(); + root.setIdentifier(identifier); + root.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); + root.setProperty(CoreOptions.DIRECTION, direction); + root.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING * spacingScale); + root.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * spacingScale); + root.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN); + root.setProperty(CoreOptions.EDGE_ROUTING, EdgeRouting.POLYLINE); + root.setProperty(org.eclipse.elk.alg.layered.options.LayeredOptions.NODE_PLACEMENT_STRATEGY, + NodePlacementStrategy.LINEAR_SEGMENTS); + return root; + } + + /** Create ELK edges, skipping DO_TRY-to-descendant and cross-root edges. */ + private void createElkEdges(RouteGraph graph, LayoutContext ctx) { + if (graph.getEdges() == null) return; + for (RouteEdge re : graph.getEdges()) { + ElkNode sourceElk = ctx.elkNodeMap.get(re.getSource()); + ElkNode targetElk = ctx.elkNodeMap.get(re.getTarget()); + if (sourceElk == null || targetElk == null) continue; + + // Skip edges from DO_TRY to its own children (keep continuation edges) + if (ctx.doTryNodeIds.contains(re.getSource()) && isDescendantOf(targetElk, sourceElk)) { + continue; } + // Skip edges that cross ELK root boundaries + if (getElkRoot(sourceElk) != getElkRoot(targetElk)) continue; + + ElkEdge elkEdge = ctx.factory.createElkEdge(); + elkEdge.setContainingNode(findCommonParent(sourceElk, targetElk)); + elkEdge.getSources().add(sourceElk); + elkEdge.getTargets().add(targetElk); } + } - // Process handler sections into their OWN separate ELK root graphs - List handlerRoots = new ArrayList<>(); - for (RouteNode rn : handlerNodes) { - ElkNode handlerRoot = factory.createElkNode(); - handlerRoot.setIdentifier("handler-root-" + rn.getId()); - handlerRoot.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); - handlerRoot.setProperty(CoreOptions.DIRECTION, rootDirection); - handlerRoot.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING * 0.5); - handlerRoot.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * 0.5); - handlerRoot.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN); - handlerRoot.setProperty(CoreOptions.EDGE_ROUTING, EdgeRouting.POLYLINE); - handlerRoot.setProperty(org.eclipse.elk.alg.layered.options.LayeredOptions.NODE_PLACEMENT_STRATEGY, - NodePlacementStrategy.LINEAR_SEGMENTS); - - createElkNodeRecursive(rn, handlerRoot, factory, elkNodeMap, nodeColors, - compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder); - handlerRoots.add(handlerRoot); - } - - // Create ELK edges — skip edges that cross between different ELK root graphs - if (graph.getEdges() != null) { - for (RouteEdge re : graph.getEdges()) { - ElkNode sourceElk = elkNodeMap.get(re.getSource()); - ElkNode targetElk = elkNodeMap.get(re.getTarget()); - if (sourceElk == null || targetElk == null) { - continue; - } - - // Skip edges from DO_TRY to its own children (entry + handler edges). - // Keep edges from DO_TRY to nodes OUTSIDE it (continuation edges). - if (doTryNodeIds.contains(re.getSource())) { - if (isDescendantOf(targetElk, sourceElk)) { - continue; - } - } - - // Skip edges that cross ELK root boundaries - ElkNode sourceRoot = getElkRoot(sourceElk); - ElkNode targetRoot = getElkRoot(targetElk); - if (sourceRoot != targetRoot) { - continue; - } - - ElkNode containingNode = findCommonParent(sourceElk, targetElk); - ElkEdge elkEdge = factory.createElkEdge(); - elkEdge.setContainingNode(containingNode); - elkEdge.getSources().add(sourceElk); - elkEdge.getTargets().add(targetElk); - } - } - - // Run layout - RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine(); - engine.layout(rootNode, new BasicProgressMonitor()); - for (ElkNode handlerRoot : handlerRoots) { - engine.layout(handlerRoot, new BasicProgressMonitor()); - } - - // Post-process DO_TRY compounds: re-stack sections in correct order - // and stretch all sections to the same width - // (ELK doesn't reliably order disconnected children within a compound) - for (Map.Entry> entry : doTrySectionOrder.entrySet()) { + /** + * Post-process DO_TRY compounds after layout: re-stack sections in correct + * vertical order (try_body → doFinally → doCatch) and stretch to uniform width. + * ELK doesn't reliably order disconnected children within a compound. + */ + private void postProcessDoTrySections(LayoutContext ctx) { + for (Map.Entry> entry : ctx.doTrySectionOrder.entrySet()) { List orderedIds = entry.getValue(); List sections = new ArrayList<>(); for (String id : orderedIds) { - ElkNode section = elkNodeMap.get(id); + ElkNode section = ctx.elkNodeMap.get(id); if (section != null) sections.add(section); } if (sections.size() < 2) continue; - // Re-stack in correct vertical order double startY = sections.stream().mapToDouble(ElkNode::getY).min().orElse(0); - double spacing = NODE_SPACING * 0.4; // matches DO_TRY spacing + double spacing = NODE_SPACING * 0.4; double currentY = startY; for (ElkNode section : sections) { section.setY(currentY); currentY += section.getHeight() + spacing; } - // Stretch all sections to the widest one's width double maxWidth = sections.stream().mapToDouble(ElkNode::getWidth).max().orElse(0); for (ElkNode section : sections) { section.setWidth(maxWidth); } } + } + /** Extract positioned nodes, edges, and bounding box from the ELK layout result. */ + private LayoutResult extractLayout(RouteGraph graph, ElkNode rootNode, + List handlerRoots, LayoutContext ctx) { // Extract positioned nodes List positionedNodes = new ArrayList<>(); - Map compoundInfos = new HashMap<>(); - if (graph.getNodes() != null) { for (RouteNode rn : graph.getNodes()) { - if (childNodeIds.contains(rn.getId())) { - continue; - } - ElkNode elkNode = elkNodeMap.get(rn.getId()); + if (ctx.childNodeIds.contains(rn.getId())) continue; + ElkNode elkNode = ctx.elkNodeMap.get(rn.getId()); if (elkNode == null) continue; - - ElkNode coordRoot = getElkRoot(elkNode); - positionedNodes.add(extractPositionedNode(rn, elkNode, elkNodeMap, - compoundNodeIds, compoundInfos, coordRoot)); + positionedNodes.add(extractPositionedNode(rn, elkNode, getElkRoot(elkNode), ctx)); } } @@ -388,25 +371,17 @@ public class ElkDiagramRenderer implements DiagramRenderer { for (ElkEdge elkEdge : allEdges) { String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier(); String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier(); - ElkNode edgeRoot = getElkRoot(elkEdge.getContainingNode()); List points = new ArrayList<>(); for (ElkEdgeSection section : elkEdge.getSections()) { - points.add(new double[]{ - section.getStartX() + getAbsoluteX(elkEdge.getContainingNode(), edgeRoot), - section.getStartY() + getAbsoluteY(elkEdge.getContainingNode(), edgeRoot) - }); + double cx = getAbsoluteX(elkEdge.getContainingNode(), edgeRoot); + double cy = getAbsoluteY(elkEdge.getContainingNode(), edgeRoot); + points.add(new double[]{section.getStartX() + cx, section.getStartY() + cy}); for (ElkBendPoint bp : section.getBendPoints()) { - points.add(new double[]{ - bp.getX() + getAbsoluteX(elkEdge.getContainingNode(), edgeRoot), - bp.getY() + getAbsoluteY(elkEdge.getContainingNode(), edgeRoot) - }); + points.add(new double[]{bp.getX() + cx, bp.getY() + cy}); } - points.add(new double[]{ - section.getEndX() + getAbsoluteX(elkEdge.getContainingNode(), edgeRoot), - section.getEndY() + getAbsoluteY(elkEdge.getContainingNode(), edgeRoot) - }); + points.add(new double[]{section.getEndX() + cx, section.getEndY() + cy}); } String label = ""; @@ -418,32 +393,24 @@ public class ElkDiagramRenderer implements DiagramRenderer { } } } - positionedEdges.add(new PositionedEdge(sourceId, targetId, label, points)); } - // Note: normalization (shifting to 0,0 origin) is handled per-section - // in the frontend's useDiagramData.ts, which separates main flow from - // handler sections and normalizes each independently. - - // Compute bounding box from all positioned nodes and edges - double totalWidth = 0; - double totalHeight = 0; + // Compute bounding box + double totalWidth = 0, totalHeight = 0; for (PositionedNode pn : allNodes(positionedNodes)) { - double right = pn.x() + pn.width(); - double bottom = pn.y() + pn.height(); - if (right > totalWidth) totalWidth = right; - if (bottom > totalHeight) totalHeight = bottom; + totalWidth = Math.max(totalWidth, pn.x() + pn.width()); + totalHeight = Math.max(totalHeight, pn.y() + pn.height()); } for (PositionedEdge pe : positionedEdges) { for (double[] pt : pe.points()) { - if (pt[0] > totalWidth) totalWidth = pt[0]; - if (pt[1] > totalHeight) totalHeight = pt[1]; + totalWidth = Math.max(totalWidth, pt[0]); + totalHeight = Math.max(totalHeight, pt[1]); } } DiagramLayout layout = new DiagramLayout(totalWidth, totalHeight, positionedNodes, positionedEdges); - return new LayoutResult(layout, nodeColors, compoundInfos); + return new LayoutResult(layout, ctx.nodeColors, ctx.compoundInfos); } // ---------------------------------------------------------------- @@ -559,23 +526,20 @@ public class ElkDiagramRenderer implements DiagramRenderer { * with their children nested inside. Non-compound nodes become leaf nodes. */ private void createElkNodeRecursive( - RouteNode rn, ElkNode parentElk, ElkGraphFactory factory, - Map elkNodeMap, Map nodeColors, - Set compoundNodeIds, Set childNodeIds, - Set doTryNodeIds, Map> doTrySectionOrder) { + RouteNode rn, ElkNode parentElk, LayoutContext ctx) { boolean isCompound = rn.getType() != null && COMPOUND_TYPES.contains(rn.getType()) && rn.getChildren() != null && !rn.getChildren().isEmpty(); - ElkNode elkNode = factory.createElkNode(); + ElkNode elkNode = ctx.factory.createElkNode(); elkNode.setIdentifier(rn.getId()); elkNode.setParent(parentElk); if (isCompound && rn.getType() == NodeType.DO_TRY) { // DO_TRY: vertical container with a virtual _TRY_BODY wrapper for the try body // and DO_CATCH/DO_FINALLY as separate children below - doTryNodeIds.add(rn.getId()); - compoundNodeIds.add(rn.getId()); + ctx.doTryNodeIds.add(rn.getId()); + ctx.compoundNodeIds.add(rn.getId()); elkNode.setWidth(200); elkNode.setHeight(100); elkNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); @@ -606,7 +570,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { // Virtual _TRY_BODY wrapper if (!tryBodyChildren.isEmpty()) { String wrapperId = rn.getId() + "._try_body"; - ElkNode wrapper = factory.createElkNode(); + ElkNode wrapper = ctx.factory.createElkNode(); wrapper.setIdentifier(wrapperId); wrapper.setParent(elkNode); wrapper.setWidth(200); @@ -617,39 +581,26 @@ public class ElkDiagramRenderer implements DiagramRenderer { wrapper.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * 0.5); wrapper.setProperty(CoreOptions.PADDING, new org.eclipse.elk.core.math.ElkPadding(8, 8, 8, 8)); - compoundNodeIds.add(wrapperId); - elkNodeMap.put(wrapperId, wrapper); + ctx.compoundNodeIds.add(wrapperId); + ctx.elkNodeMap.put(wrapperId, wrapper); sectionOrder.add(wrapperId); for (RouteNode child : tryBodyChildren) { - childNodeIds.add(child.getId()); - createElkNodeRecursive(child, wrapper, factory, elkNodeMap, nodeColors, - compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder); + ctx.childNodeIds.add(child.getId()); + createElkNodeRecursive(child, wrapper, ctx); } } - // DO_FINALLY sections (middle) - for (RouteNode child : handlerChildren) { - if (child.getType() == NodeType.DO_FINALLY) { - childNodeIds.add(child.getId()); - createElkNodeRecursive(child, elkNode, factory, elkNodeMap, nodeColors, - compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder); - sectionOrder.add(child.getId()); - } - } - // DO_CATCH sections (bottom) - for (RouteNode child : handlerChildren) { - if (child.getType() == NodeType.DO_CATCH) { - childNodeIds.add(child.getId()); - createElkNodeRecursive(child, elkNode, factory, elkNodeMap, nodeColors, - compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder); - sectionOrder.add(child.getId()); - } + // Handler sections in order: DO_FINALLY (middle), then DO_CATCH (bottom) + for (RouteNode handler : orderedHandlerChildren(handlerChildren)) { + ctx.childNodeIds.add(handler.getId()); + createElkNodeRecursive(handler, elkNode, ctx); + sectionOrder.add(handler.getId()); } - doTrySectionOrder.put(rn.getId(), sectionOrder); + ctx.doTrySectionOrder.put(rn.getId(), sectionOrder); } else if (isCompound) { - compoundNodeIds.add(rn.getId()); + ctx.compoundNodeIds.add(rn.getId()); elkNode.setWidth(200); elkNode.setHeight(100); elkNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); @@ -661,17 +612,16 @@ public class ElkDiagramRenderer implements DiagramRenderer { COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING)); for (RouteNode child : rn.getChildren()) { - childNodeIds.add(child.getId()); - createElkNodeRecursive(child, elkNode, factory, elkNodeMap, nodeColors, - compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder); + ctx.childNodeIds.add(child.getId()); + createElkNodeRecursive(child, elkNode, ctx); } } else { elkNode.setWidth(NODE_WIDTH); elkNode.setHeight(NODE_HEIGHT); } - elkNodeMap.put(rn.getId(), elkNode); - nodeColors.put(rn.getId(), colorForType(rn.getType())); + ctx.elkNodeMap.put(rn.getId(), elkNode); + ctx.nodeColors.put(rn.getId(), colorForType(rn.getType())); } /** @@ -679,29 +629,26 @@ public class ElkDiagramRenderer implements DiagramRenderer { * All coordinates are absolute (relative to the ELK root). */ private PositionedNode extractPositionedNode( - RouteNode rn, ElkNode elkNode, Map elkNodeMap, - Set compoundNodeIds, Map compoundInfos, - ElkNode rootNode) { + RouteNode rn, ElkNode elkNode, ElkNode rootNode, LayoutContext ctx) { double absX = getAbsoluteX(elkNode, rootNode); double absY = getAbsoluteY(elkNode, rootNode); List children = List.of(); - if (compoundNodeIds.contains(rn.getId()) && rn.getChildren() != null) { + if (ctx.compoundNodeIds.contains(rn.getId()) && rn.getChildren() != null) { children = new ArrayList<>(); if (rn.getType() == NodeType.DO_TRY) { // DO_TRY: extract virtual _TRY_BODY wrapper first, then handler children String wrapperId = rn.getId() + "._try_body"; - ElkNode wrapperElk = elkNodeMap.get(wrapperId); + ElkNode wrapperElk = ctx.elkNodeMap.get(wrapperId); if (wrapperElk != null) { List wrapperChildren = new ArrayList<>(); for (RouteNode child : rn.getChildren()) { if (child.getType() != NodeType.DO_CATCH && child.getType() != NodeType.DO_FINALLY) { - ElkNode childElk = elkNodeMap.get(child.getId()); + ElkNode childElk = ctx.elkNodeMap.get(child.getId()); if (childElk != null) { - wrapperChildren.add(extractPositionedNode(child, childElk, elkNodeMap, - compoundNodeIds, compoundInfos, rootNode)); + wrapperChildren.add(extractPositionedNode(child, childElk, rootNode, ctx)); } } } @@ -711,37 +658,24 @@ public class ElkDiagramRenderer implements DiagramRenderer { getAbsoluteY(wrapperElk, rootNode), wrapperElk.getWidth(), wrapperElk.getHeight(), wrapperChildren)); - compoundInfos.put(wrapperId, new CompoundInfo(wrapperId, Color.WHITE)); + ctx.compoundInfos.put(wrapperId, new CompoundInfo(wrapperId, Color.WHITE)); } - // Handler children: DO_FINALLY first, then DO_CATCH - for (RouteNode child : rn.getChildren()) { - if (child.getType() == NodeType.DO_FINALLY) { - ElkNode childElk = elkNodeMap.get(child.getId()); - if (childElk != null) { - children.add(extractPositionedNode(child, childElk, elkNodeMap, - compoundNodeIds, compoundInfos, rootNode)); - } - } - } - for (RouteNode child : rn.getChildren()) { - if (child.getType() == NodeType.DO_CATCH) { - ElkNode childElk = elkNodeMap.get(child.getId()); - if (childElk != null) { - children.add(extractPositionedNode(child, childElk, elkNodeMap, - compoundNodeIds, compoundInfos, rootNode)); - } + // Handler children in order: DO_FINALLY first, then DO_CATCH + for (RouteNode handler : orderedHandlerChildren(rn.getChildren())) { + ElkNode childElk = ctx.elkNodeMap.get(handler.getId()); + if (childElk != null) { + children.add(extractPositionedNode(handler, childElk, rootNode, ctx)); } } } else { for (RouteNode child : rn.getChildren()) { - ElkNode childElk = elkNodeMap.get(child.getId()); + ElkNode childElk = ctx.elkNodeMap.get(child.getId()); if (childElk != null) { - children.add(extractPositionedNode(child, childElk, elkNodeMap, - compoundNodeIds, compoundInfos, rootNode)); + children.add(extractPositionedNode(child, childElk, rootNode, ctx)); } } } - compoundInfos.put(rn.getId(), new CompoundInfo(rn.getId(), colorForType(rn.getType()))); + ctx.compoundInfos.put(rn.getId(), new CompoundInfo(rn.getId(), colorForType(rn.getType()))); } return new PositionedNode( @@ -758,7 +692,18 @@ public class ElkDiagramRenderer implements DiagramRenderer { // ELK graph helpers // ---------------------------------------------------------------- - /** Walk up to the top-level root of an ELK node's hierarchy. */ + /** Return handler children in section order: DO_FINALLY first, then DO_CATCH. */ + private static List orderedHandlerChildren(List children) { + List ordered = new ArrayList<>(); + for (RouteNode c : children) { + if (c.getType() == NodeType.DO_FINALLY) ordered.add(c); + } + for (RouteNode c : children) { + if (c.getType() == NodeType.DO_CATCH) ordered.add(c); + } + return ordered; + } + /** Check if 'child' is a descendant of 'ancestor' in the ELK node hierarchy. */ private boolean isDescendantOf(ElkNode child, ElkNode ancestor) { ElkNode current = child.getParent(); @@ -847,7 +792,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { return all; } - /** Recursively collect all node IDs from a tree. */ /** Collect IDs of all RouteNode descendants (for handler separation). */ private void collectDescendantIds(List nodes, Set ids) { for (RouteNode n : nodes) { @@ -878,4 +822,20 @@ public class ElkDiagramRenderer implements DiagramRenderer { ) {} private record CompoundInfo(String nodeId, Color color) {} + + /** Mutable state accumulated during ELK graph construction and extraction. */ + private static class LayoutContext { + final ElkGraphFactory factory; + final Map elkNodeMap = new HashMap<>(); + final Map nodeColors = new HashMap<>(); + final Set compoundNodeIds = new HashSet<>(); + final Set childNodeIds = new HashSet<>(); + final Set doTryNodeIds = new HashSet<>(); + final Map> doTrySectionOrder = new LinkedHashMap<>(); + final Map compoundInfos = new HashMap<>(); + + LayoutContext(ElkGraphFactory factory) { + this.factory = factory; + } + } } diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/diagram/ElkDiagramRendererTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/diagram/ElkDiagramRendererTest.java index df8c770c..4b5fbeaf 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/diagram/ElkDiagramRendererTest.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/diagram/ElkDiagramRendererTest.java @@ -172,6 +172,98 @@ class ElkDiagramRendererTest { assertEquals(2, choiceNode.children().size(), "Choice node should have 2 children (when, otherwise)"); } + /** + * Build a DO_TRY graph: from -> doTry(try: [process, log], doFinally: [cleanup], doCatch: [errorLog]) -> to + */ + private RouteGraph buildDoTryGraph() { + RouteGraph graph = new RouteGraph("try-catch-route"); + graph.setExtractedAt(Instant.now()); + graph.setVersion(1); + + RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "timer:tick"); + RouteNode doTry = new RouteNode("node-2", NodeType.DO_TRY, "doTry"); + RouteNode process = new RouteNode("node-3", NodeType.PROCESSOR, "process"); + RouteNode log1 = new RouteNode("node-4", NodeType.LOG, "log:tryBody"); + RouteNode doFinally = new RouteNode("node-5", NodeType.DO_FINALLY, "doFinally"); + RouteNode cleanup = new RouteNode("node-6", NodeType.LOG, "log:cleanup"); + RouteNode doCatch = new RouteNode("node-7", NodeType.DO_CATCH, "doCatch"); + RouteNode errorLog = new RouteNode("node-8", NodeType.LOG, "log:error"); + RouteNode to = new RouteNode("node-9", NodeType.TO, "log:done"); + + doFinally.setChildren(List.of(cleanup)); + doCatch.setChildren(List.of(errorLog)); + doTry.setChildren(List.of(process, log1, doFinally, doCatch)); + + graph.setRoot(from); + graph.setNodes(List.of(from, doTry, process, log1, doFinally, cleanup, doCatch, errorLog, to)); + graph.setEdges(List.of( + new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-3", "node-4", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-2", "node-5", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-5", "node-6", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-2", "node-7", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-7", "node-8", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-2", "node-9", RouteEdge.EdgeType.FLOW) + )); + + return graph; + } + + @Test + void layoutJson_doTryGraph_sectionsInCorrectOrder() { + DiagramLayout layout = renderer.layoutJson(buildDoTryGraph()); + + assertNotNull(layout); + + // Find the DO_TRY compound node + PositionedNode doTryNode = layout.nodes().stream() + .filter(n -> "node-2".equals(n.id())) + .findFirst() + .orElseThrow(() -> new AssertionError("DO_TRY node not found")); + + assertNotNull(doTryNode.children(), "DO_TRY should have children"); + assertFalse(doTryNode.children().isEmpty(), "DO_TRY should have non-empty children"); + + // Find sections by ID pattern + PositionedNode tryBody = doTryNode.children().stream() + .filter(n -> n.id() != null && n.id().contains("._try_body")) + .findFirst().orElse(null); + PositionedNode finallySection = doTryNode.children().stream() + .filter(n -> "node-5".equals(n.id())) + .findFirst().orElse(null); + PositionedNode catchSection = doTryNode.children().stream() + .filter(n -> "node-7".equals(n.id())) + .findFirst().orElse(null); + + assertNotNull(tryBody, "Try body wrapper should exist"); + assertNotNull(finallySection, "doFinally section should exist"); + assertNotNull(catchSection, "doCatch section should exist"); + + // Verify vertical order: tryBody.y < doFinally.y < doCatch.y + assertTrue(tryBody.y() < finallySection.y(), + "Try body (y=" + tryBody.y() + ") should be above doFinally (y=" + finallySection.y() + ")"); + assertTrue(finallySection.y() < catchSection.y(), + "doFinally (y=" + finallySection.y() + ") should be above doCatch (y=" + catchSection.y() + ")"); + } + + @Test + void layoutJson_doTryGraph_sectionsHaveSameWidth() { + DiagramLayout layout = renderer.layoutJson(buildDoTryGraph()); + + PositionedNode doTryNode = layout.nodes().stream() + .filter(n -> "node-2".equals(n.id())) + .findFirst() + .orElseThrow(() -> new AssertionError("DO_TRY node not found")); + + List sections = doTryNode.children(); + double firstWidth = sections.get(0).width(); + for (PositionedNode section : sections) { + assertEquals(firstWidth, section.width(), 0.1, + "All sections should have the same width, but " + section.id() + " differs"); + } + } + @Test void renderSvg_compoundGraph_producesValidSvg() { String svg = renderer.renderSvg(buildCompoundGraph());