refactor: simplify ElkDiagramRenderer layout code
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 59s
CI / docker (push) Successful in 40s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s

- Introduce LayoutContext to bundle 8 accumulator params into 1 object
- Extract computeLayout (261 lines) into 6 focused sub-methods:
  buildNodeIndex, partitionNodes, createElkRoot, createElkEdges,
  postProcessDoTrySections, extractLayout
- Consolidate duplicated DO_TRY handler iteration via orderedHandlerChildren
- De-duplicate ELK root configuration (main + handler roots)
- Add DO_TRY test cases for section ordering and uniform width
- Clean up orphaned Javadoc comments

No behavioral changes. 882 → 841 lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-28 12:20:08 +01:00
parent 065517f032
commit 90be1875e0
2 changed files with 265 additions and 213 deletions

View File

@@ -185,48 +185,66 @@ public class ElkDiagramRenderer implements DiagramRenderer {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
private LayoutResult computeLayout(RouteGraph graph, Direction rootDirection) { private LayoutResult computeLayout(RouteGraph graph, Direction rootDirection) {
ElkGraphFactory factory = ElkGraphFactory.eINSTANCE; LayoutContext ctx = new LayoutContext(ElkGraphFactory.eINSTANCE);
// Create root node for main flow // 1. Partition graph nodes into main flow vs handler sections
ElkNode rootNode = factory.createElkNode(); Map<String, RouteNode> nodeById = buildNodeIndex(graph);
rootNode.setIdentifier("root"); List<RouteNode> mainNodes = new ArrayList<>();
rootNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); List<RouteNode> handlerNodes = new ArrayList<>();
rootNode.setProperty(CoreOptions.DIRECTION, rootDirection); partitionNodes(graph, nodeById, mainNodes, handlerNodes);
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);
// Track which nodes are children of a compound (at any depth) // 2. Build ELK graphs — main flow + separate handler roots
Set<String> childNodeIds = new HashSet<>(); 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 List<ElkNode> handlerRoots = new ArrayList<>();
Map<String, ElkNode> elkNodeMap = new HashMap<>(); for (RouteNode rn : handlerNodes) {
Map<String, Color> nodeColors = new HashMap<>(); ElkNode hr = createElkRoot("handler-root-" + rn.getId(), rootDirection, 0.5, ctx);
Set<String> compoundNodeIds = new HashSet<>(); createElkNodeRecursive(rn, hr, ctx);
handlerRoots.add(hr);
}
// Build a lookup from the flat nodes list (graph.getNodes() contains // 3. Create ELK edges (filtering DO_TRY internals and cross-root edges)
// duplicates — children appear both nested AND top-level). Use this for createElkEdges(graph, ctx);
// resolving edge endpoints to RouteNode objects.
// 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<String, RouteNode> buildNodeIndex(RouteGraph graph) {
Map<String, RouteNode> nodeById = new HashMap<>(); Map<String, RouteNode> nodeById = new HashMap<>();
if (graph.getNodes() != null) { if (graph.getNodes() != null) {
for (RouteNode rn : graph.getNodes()) { for (RouteNode rn : graph.getNodes()) {
nodeById.put(rn.getId(), rn); 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 * Separate main flow nodes from handler sections by walking FLOW edges
// - Handler sections are top-level nodes of HANDLER_SECTION_TYPES * from the graph root. Handler sections (onException, onCompletion, etc.)
// This avoids the problem of the flat nodes list containing duplicates. * are placed in their own list for independent layout.
*/
private void partitionNodes(RouteGraph graph, Map<String, RouteNode> nodeById,
List<RouteNode> mainNodes, List<RouteNode> handlerNodes) {
Set<String> mainNodeIds = new HashSet<>(); Set<String> mainNodeIds = new HashSet<>();
List<RouteNode> mainNodes = new ArrayList<>();
List<RouteNode> 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) { if (graph.getRoot() != null && graph.getEdges() != null) {
mainNodeIds.add(graph.getRoot().getId()); mainNodeIds.add(graph.getRoot().getId());
boolean changed = true; boolean changed = true;
@@ -235,7 +253,6 @@ public class ElkDiagramRenderer implements DiagramRenderer {
for (RouteEdge re : graph.getEdges()) { for (RouteEdge re : graph.getEdges()) {
if (re.getEdgeType() == RouteEdge.EdgeType.ERROR) continue; if (re.getEdgeType() == RouteEdge.EdgeType.ERROR) continue;
if (mainNodeIds.contains(re.getSource()) && !mainNodeIds.contains(re.getTarget())) { if (mainNodeIds.contains(re.getSource()) && !mainNodeIds.contains(re.getTarget())) {
// Don't follow edges INTO handler compounds
RouteNode target = nodeById.get(re.getTarget()); RouteNode target = nodeById.get(re.getTarget());
if (target != null && target.getType() != null if (target != null && target.getType() != null
&& HANDLER_SECTION_TYPES.contains(target.getType())) { && HANDLER_SECTION_TYPES.contains(target.getType())) {
@@ -248,7 +265,6 @@ public class ElkDiagramRenderer implements DiagramRenderer {
} }
} }
// Build node lists — deduplicate (flat list has duplicates)
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
if (graph.getNodes() != null) { if (graph.getNodes() != null) {
for (RouteNode rn : graph.getNodes()) { for (RouteNode rn : graph.getNodes()) {
@@ -258,124 +274,91 @@ public class ElkDiagramRenderer implements DiagramRenderer {
&& rn.getChildren() != null && !rn.getChildren().isEmpty()) { && rn.getChildren() != null && !rn.getChildren().isEmpty()) {
handlerNodes.add(rn); handlerNodes.add(rn);
} else if (mainNodeIds.isEmpty() || mainNodeIds.contains(rn.getId())) { } 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); mainNodes.add(rn);
} }
} }
} }
}
// Process main flow nodes into the root ELK graph /** Create a configured ELK root node for the layered algorithm. */
Set<String> doTryNodeIds = new HashSet<>(); private ElkNode createElkRoot(String identifier, Direction direction,
Map<String, List<String>> doTrySectionOrder = new LinkedHashMap<>(); double spacingScale, LayoutContext ctx) {
for (RouteNode rn : mainNodes) { ElkNode root = ctx.factory.createElkNode();
if (!elkNodeMap.containsKey(rn.getId())) { root.setIdentifier(identifier);
createElkNodeRecursive(rn, rootNode, factory, elkNodeMap, nodeColors, root.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder); 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<ElkNode> handlerRoots = new ArrayList<>(); * Post-process DO_TRY compounds after layout: re-stack sections in correct
for (RouteNode rn : handlerNodes) { * vertical order (try_body → doFinally → doCatch) and stretch to uniform width.
ElkNode handlerRoot = factory.createElkNode(); * ELK doesn't reliably order disconnected children within a compound.
handlerRoot.setIdentifier("handler-root-" + rn.getId()); */
handlerRoot.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); private void postProcessDoTrySections(LayoutContext ctx) {
handlerRoot.setProperty(CoreOptions.DIRECTION, rootDirection); for (Map.Entry<String, List<String>> entry : ctx.doTrySectionOrder.entrySet()) {
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<String, List<String>> entry : doTrySectionOrder.entrySet()) {
List<String> orderedIds = entry.getValue(); List<String> orderedIds = entry.getValue();
List<ElkNode> sections = new ArrayList<>(); List<ElkNode> sections = new ArrayList<>();
for (String id : orderedIds) { for (String id : orderedIds) {
ElkNode section = elkNodeMap.get(id); ElkNode section = ctx.elkNodeMap.get(id);
if (section != null) sections.add(section); if (section != null) sections.add(section);
} }
if (sections.size() < 2) continue; if (sections.size() < 2) continue;
// Re-stack in correct vertical order
double startY = sections.stream().mapToDouble(ElkNode::getY).min().orElse(0); 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; double currentY = startY;
for (ElkNode section : sections) { for (ElkNode section : sections) {
section.setY(currentY); section.setY(currentY);
currentY += section.getHeight() + spacing; currentY += section.getHeight() + spacing;
} }
// Stretch all sections to the widest one's width
double maxWidth = sections.stream().mapToDouble(ElkNode::getWidth).max().orElse(0); double maxWidth = sections.stream().mapToDouble(ElkNode::getWidth).max().orElse(0);
for (ElkNode section : sections) { for (ElkNode section : sections) {
section.setWidth(maxWidth); section.setWidth(maxWidth);
} }
} }
}
/** Extract positioned nodes, edges, and bounding box from the ELK layout result. */
private LayoutResult extractLayout(RouteGraph graph, ElkNode rootNode,
List<ElkNode> handlerRoots, LayoutContext ctx) {
// Extract positioned nodes // Extract positioned nodes
List<PositionedNode> positionedNodes = new ArrayList<>(); List<PositionedNode> positionedNodes = new ArrayList<>();
Map<String, CompoundInfo> compoundInfos = new HashMap<>();
if (graph.getNodes() != null) { if (graph.getNodes() != null) {
for (RouteNode rn : graph.getNodes()) { for (RouteNode rn : graph.getNodes()) {
if (childNodeIds.contains(rn.getId())) { if (ctx.childNodeIds.contains(rn.getId())) continue;
continue; ElkNode elkNode = ctx.elkNodeMap.get(rn.getId());
}
ElkNode elkNode = elkNodeMap.get(rn.getId());
if (elkNode == null) continue; if (elkNode == null) continue;
positionedNodes.add(extractPositionedNode(rn, elkNode, getElkRoot(elkNode), ctx));
ElkNode coordRoot = getElkRoot(elkNode);
positionedNodes.add(extractPositionedNode(rn, elkNode, elkNodeMap,
compoundNodeIds, compoundInfos, coordRoot));
} }
} }
@@ -388,25 +371,17 @@ public class ElkDiagramRenderer implements DiagramRenderer {
for (ElkEdge elkEdge : allEdges) { for (ElkEdge elkEdge : allEdges) {
String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier(); String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier();
String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier(); String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier();
ElkNode edgeRoot = getElkRoot(elkEdge.getContainingNode()); ElkNode edgeRoot = getElkRoot(elkEdge.getContainingNode());
List<double[]> points = new ArrayList<>(); List<double[]> points = new ArrayList<>();
for (ElkEdgeSection section : elkEdge.getSections()) { for (ElkEdgeSection section : elkEdge.getSections()) {
points.add(new double[]{ double cx = getAbsoluteX(elkEdge.getContainingNode(), edgeRoot);
section.getStartX() + getAbsoluteX(elkEdge.getContainingNode(), edgeRoot), double cy = getAbsoluteY(elkEdge.getContainingNode(), edgeRoot);
section.getStartY() + getAbsoluteY(elkEdge.getContainingNode(), edgeRoot) points.add(new double[]{section.getStartX() + cx, section.getStartY() + cy});
});
for (ElkBendPoint bp : section.getBendPoints()) { for (ElkBendPoint bp : section.getBendPoints()) {
points.add(new double[]{ points.add(new double[]{bp.getX() + cx, bp.getY() + cy});
bp.getX() + getAbsoluteX(elkEdge.getContainingNode(), edgeRoot),
bp.getY() + getAbsoluteY(elkEdge.getContainingNode(), edgeRoot)
});
} }
points.add(new double[]{ points.add(new double[]{section.getEndX() + cx, section.getEndY() + cy});
section.getEndX() + getAbsoluteX(elkEdge.getContainingNode(), edgeRoot),
section.getEndY() + getAbsoluteY(elkEdge.getContainingNode(), edgeRoot)
});
} }
String label = ""; String label = "";
@@ -418,32 +393,24 @@ public class ElkDiagramRenderer implements DiagramRenderer {
} }
} }
} }
positionedEdges.add(new PositionedEdge(sourceId, targetId, label, points)); positionedEdges.add(new PositionedEdge(sourceId, targetId, label, points));
} }
// Note: normalization (shifting to 0,0 origin) is handled per-section // Compute bounding box
// in the frontend's useDiagramData.ts, which separates main flow from double totalWidth = 0, totalHeight = 0;
// handler sections and normalizes each independently.
// Compute bounding box from all positioned nodes and edges
double totalWidth = 0;
double totalHeight = 0;
for (PositionedNode pn : allNodes(positionedNodes)) { for (PositionedNode pn : allNodes(positionedNodes)) {
double right = pn.x() + pn.width(); totalWidth = Math.max(totalWidth, pn.x() + pn.width());
double bottom = pn.y() + pn.height(); totalHeight = Math.max(totalHeight, pn.y() + pn.height());
if (right > totalWidth) totalWidth = right;
if (bottom > totalHeight) totalHeight = bottom;
} }
for (PositionedEdge pe : positionedEdges) { for (PositionedEdge pe : positionedEdges) {
for (double[] pt : pe.points()) { for (double[] pt : pe.points()) {
if (pt[0] > totalWidth) totalWidth = pt[0]; totalWidth = Math.max(totalWidth, pt[0]);
if (pt[1] > totalHeight) totalHeight = pt[1]; totalHeight = Math.max(totalHeight, pt[1]);
} }
} }
DiagramLayout layout = new DiagramLayout(totalWidth, totalHeight, positionedNodes, positionedEdges); 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. * with their children nested inside. Non-compound nodes become leaf nodes.
*/ */
private void createElkNodeRecursive( private void createElkNodeRecursive(
RouteNode rn, ElkNode parentElk, ElkGraphFactory factory, RouteNode rn, ElkNode parentElk, LayoutContext ctx) {
Map<String, ElkNode> elkNodeMap, Map<String, Color> nodeColors,
Set<String> compoundNodeIds, Set<String> childNodeIds,
Set<String> doTryNodeIds, Map<String, List<String>> doTrySectionOrder) {
boolean isCompound = rn.getType() != null && COMPOUND_TYPES.contains(rn.getType()) boolean isCompound = rn.getType() != null && COMPOUND_TYPES.contains(rn.getType())
&& rn.getChildren() != null && !rn.getChildren().isEmpty(); && rn.getChildren() != null && !rn.getChildren().isEmpty();
ElkNode elkNode = factory.createElkNode(); ElkNode elkNode = ctx.factory.createElkNode();
elkNode.setIdentifier(rn.getId()); elkNode.setIdentifier(rn.getId());
elkNode.setParent(parentElk); elkNode.setParent(parentElk);
if (isCompound && rn.getType() == NodeType.DO_TRY) { if (isCompound && rn.getType() == NodeType.DO_TRY) {
// DO_TRY: vertical container with a virtual _TRY_BODY wrapper for the try body // DO_TRY: vertical container with a virtual _TRY_BODY wrapper for the try body
// and DO_CATCH/DO_FINALLY as separate children below // and DO_CATCH/DO_FINALLY as separate children below
doTryNodeIds.add(rn.getId()); ctx.doTryNodeIds.add(rn.getId());
compoundNodeIds.add(rn.getId()); ctx.compoundNodeIds.add(rn.getId());
elkNode.setWidth(200); elkNode.setWidth(200);
elkNode.setHeight(100); elkNode.setHeight(100);
elkNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); elkNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
@@ -606,7 +570,7 @@ public class ElkDiagramRenderer implements DiagramRenderer {
// Virtual _TRY_BODY wrapper // Virtual _TRY_BODY wrapper
if (!tryBodyChildren.isEmpty()) { if (!tryBodyChildren.isEmpty()) {
String wrapperId = rn.getId() + "._try_body"; String wrapperId = rn.getId() + "._try_body";
ElkNode wrapper = factory.createElkNode(); ElkNode wrapper = ctx.factory.createElkNode();
wrapper.setIdentifier(wrapperId); wrapper.setIdentifier(wrapperId);
wrapper.setParent(elkNode); wrapper.setParent(elkNode);
wrapper.setWidth(200); 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.SPACING_EDGE_NODE, EDGE_SPACING * 0.5);
wrapper.setProperty(CoreOptions.PADDING, wrapper.setProperty(CoreOptions.PADDING,
new org.eclipse.elk.core.math.ElkPadding(8, 8, 8, 8)); new org.eclipse.elk.core.math.ElkPadding(8, 8, 8, 8));
compoundNodeIds.add(wrapperId); ctx.compoundNodeIds.add(wrapperId);
elkNodeMap.put(wrapperId, wrapper); ctx.elkNodeMap.put(wrapperId, wrapper);
sectionOrder.add(wrapperId); sectionOrder.add(wrapperId);
for (RouteNode child : tryBodyChildren) { for (RouteNode child : tryBodyChildren) {
childNodeIds.add(child.getId()); ctx.childNodeIds.add(child.getId());
createElkNodeRecursive(child, wrapper, factory, elkNodeMap, nodeColors, createElkNodeRecursive(child, wrapper, ctx);
compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder);
} }
} }
// DO_FINALLY sections (middle) // Handler sections in order: DO_FINALLY (middle), then DO_CATCH (bottom)
for (RouteNode child : handlerChildren) { for (RouteNode handler : orderedHandlerChildren(handlerChildren)) {
if (child.getType() == NodeType.DO_FINALLY) { ctx.childNodeIds.add(handler.getId());
childNodeIds.add(child.getId()); createElkNodeRecursive(handler, elkNode, ctx);
createElkNodeRecursive(child, elkNode, factory, elkNodeMap, nodeColors, sectionOrder.add(handler.getId());
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());
}
} }
doTrySectionOrder.put(rn.getId(), sectionOrder); ctx.doTrySectionOrder.put(rn.getId(), sectionOrder);
} else if (isCompound) { } else if (isCompound) {
compoundNodeIds.add(rn.getId()); ctx.compoundNodeIds.add(rn.getId());
elkNode.setWidth(200); elkNode.setWidth(200);
elkNode.setHeight(100); elkNode.setHeight(100);
elkNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); 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)); COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING));
for (RouteNode child : rn.getChildren()) { for (RouteNode child : rn.getChildren()) {
childNodeIds.add(child.getId()); ctx.childNodeIds.add(child.getId());
createElkNodeRecursive(child, elkNode, factory, elkNodeMap, nodeColors, createElkNodeRecursive(child, elkNode, ctx);
compoundNodeIds, childNodeIds, doTryNodeIds, doTrySectionOrder);
} }
} else { } else {
elkNode.setWidth(NODE_WIDTH); elkNode.setWidth(NODE_WIDTH);
elkNode.setHeight(NODE_HEIGHT); elkNode.setHeight(NODE_HEIGHT);
} }
elkNodeMap.put(rn.getId(), elkNode); ctx.elkNodeMap.put(rn.getId(), elkNode);
nodeColors.put(rn.getId(), colorForType(rn.getType())); 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). * All coordinates are absolute (relative to the ELK root).
*/ */
private PositionedNode extractPositionedNode( private PositionedNode extractPositionedNode(
RouteNode rn, ElkNode elkNode, Map<String, ElkNode> elkNodeMap, RouteNode rn, ElkNode elkNode, ElkNode rootNode, LayoutContext ctx) {
Set<String> compoundNodeIds, Map<String, CompoundInfo> compoundInfos,
ElkNode rootNode) {
double absX = getAbsoluteX(elkNode, rootNode); double absX = getAbsoluteX(elkNode, rootNode);
double absY = getAbsoluteY(elkNode, rootNode); double absY = getAbsoluteY(elkNode, rootNode);
List<PositionedNode> children = List.of(); List<PositionedNode> children = List.of();
if (compoundNodeIds.contains(rn.getId()) && rn.getChildren() != null) { if (ctx.compoundNodeIds.contains(rn.getId()) && rn.getChildren() != null) {
children = new ArrayList<>(); children = new ArrayList<>();
if (rn.getType() == NodeType.DO_TRY) { if (rn.getType() == NodeType.DO_TRY) {
// DO_TRY: extract virtual _TRY_BODY wrapper first, then handler children // DO_TRY: extract virtual _TRY_BODY wrapper first, then handler children
String wrapperId = rn.getId() + "._try_body"; String wrapperId = rn.getId() + "._try_body";
ElkNode wrapperElk = elkNodeMap.get(wrapperId); ElkNode wrapperElk = ctx.elkNodeMap.get(wrapperId);
if (wrapperElk != null) { if (wrapperElk != null) {
List<PositionedNode> wrapperChildren = new ArrayList<>(); List<PositionedNode> wrapperChildren = new ArrayList<>();
for (RouteNode child : rn.getChildren()) { for (RouteNode child : rn.getChildren()) {
if (child.getType() != NodeType.DO_CATCH && child.getType() != NodeType.DO_FINALLY) { 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) { if (childElk != null) {
wrapperChildren.add(extractPositionedNode(child, childElk, elkNodeMap, wrapperChildren.add(extractPositionedNode(child, childElk, rootNode, ctx));
compoundNodeIds, compoundInfos, rootNode));
} }
} }
} }
@@ -711,37 +658,24 @@ public class ElkDiagramRenderer implements DiagramRenderer {
getAbsoluteY(wrapperElk, rootNode), getAbsoluteY(wrapperElk, rootNode),
wrapperElk.getWidth(), wrapperElk.getHeight(), wrapperElk.getWidth(), wrapperElk.getHeight(),
wrapperChildren)); 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 // Handler children in order: DO_FINALLY first, then DO_CATCH
for (RouteNode child : rn.getChildren()) { for (RouteNode handler : orderedHandlerChildren(rn.getChildren())) {
if (child.getType() == NodeType.DO_FINALLY) { ElkNode childElk = ctx.elkNodeMap.get(handler.getId());
ElkNode childElk = elkNodeMap.get(child.getId()); if (childElk != null) {
if (childElk != null) { children.add(extractPositionedNode(handler, childElk, rootNode, ctx));
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));
}
} }
} }
} else { } else {
for (RouteNode child : rn.getChildren()) { for (RouteNode child : rn.getChildren()) {
ElkNode childElk = elkNodeMap.get(child.getId()); ElkNode childElk = ctx.elkNodeMap.get(child.getId());
if (childElk != null) { if (childElk != null) {
children.add(extractPositionedNode(child, childElk, elkNodeMap, children.add(extractPositionedNode(child, childElk, rootNode, ctx));
compoundNodeIds, compoundInfos, rootNode));
} }
} }
} }
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( return new PositionedNode(
@@ -758,7 +692,18 @@ public class ElkDiagramRenderer implements DiagramRenderer {
// ELK graph helpers // 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<RouteNode> orderedHandlerChildren(List<RouteNode> children) {
List<RouteNode> 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. */ /** Check if 'child' is a descendant of 'ancestor' in the ELK node hierarchy. */
private boolean isDescendantOf(ElkNode child, ElkNode ancestor) { private boolean isDescendantOf(ElkNode child, ElkNode ancestor) {
ElkNode current = child.getParent(); ElkNode current = child.getParent();
@@ -847,7 +792,6 @@ public class ElkDiagramRenderer implements DiagramRenderer {
return all; return all;
} }
/** Recursively collect all node IDs from a tree. */
/** Collect IDs of all RouteNode descendants (for handler separation). */ /** Collect IDs of all RouteNode descendants (for handler separation). */
private void collectDescendantIds(List<RouteNode> nodes, Set<String> ids) { private void collectDescendantIds(List<RouteNode> nodes, Set<String> ids) {
for (RouteNode n : nodes) { for (RouteNode n : nodes) {
@@ -878,4 +822,20 @@ public class ElkDiagramRenderer implements DiagramRenderer {
) {} ) {}
private record CompoundInfo(String nodeId, Color color) {} 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<String, ElkNode> elkNodeMap = new HashMap<>();
final Map<String, Color> nodeColors = new HashMap<>();
final Set<String> compoundNodeIds = new HashSet<>();
final Set<String> childNodeIds = new HashSet<>();
final Set<String> doTryNodeIds = new HashSet<>();
final Map<String, List<String>> doTrySectionOrder = new LinkedHashMap<>();
final Map<String, CompoundInfo> compoundInfos = new HashMap<>();
LayoutContext(ElkGraphFactory factory) {
this.factory = factory;
}
}
} }

View File

@@ -172,6 +172,98 @@ class ElkDiagramRendererTest {
assertEquals(2, choiceNode.children().size(), "Choice node should have 2 children (when, otherwise)"); 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<PositionedNode> 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 @Test
void renderSvg_compoundGraph_producesValidSvg() { void renderSvg_compoundGraph_producesValidSvg() {
String svg = renderer.renderSvg(buildCompoundGraph()); String svg = renderer.renderSvg(buildCompoundGraph());