This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 85ee26d2057d CAMEL-23408: Fix route diagram rendering with scope boxes 
for EIPs
85ee26d2057d is described below

commit 85ee26d2057d60dd9bd7dea030857be94097db3d
Author: Guillaume Nodet <[email protected]>
AuthorDate: Tue May 5 00:53:31 2026 +0200

    CAMEL-23408: Fix route diagram rendering with scope boxes for EIPs
    
    - Draw solid rounded boxes around branching and scope EIPs to visually 
group sub-routes
    - Arrows enter at box top border and exit at box bottom border
    - Dynamic scope detection via hasScope() — no hardcoded scope EIP list
    - Proper nested box handling with margins (e.g., choice inside doTry)
    - Consistent V_GAP spacing between boxes and adjacent nodes
    - Image margins computed from actual box extent for proper centering
    - Arrow lines end at arrowhead midpoint for cleaner rendering
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---
 .../commands/action/RouteDiagramLayoutEngine.java  | 101 ++++++++++++++++++---
 .../core/commands/action/RouteDiagramRenderer.java |  71 +++++----------
 .../action/CamelRouteDiagramActionTest.java        |  91 +++++++++++++++++++
 3 files changed, 204 insertions(+), 59 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java
index 6b701acaf9d2..bfc74051503e 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java
@@ -28,10 +28,17 @@ class RouteDiagramLayoutEngine {
     static final int H_GAP = 30 * SCALE;
     static final int V_GAP = 40 * SCALE;
     static final int PADDING = 30 * SCALE;
+    static final int SCOPE_BOX_PAD = 14 * SCALE;
     static final int LABEL_OFFSET = 24 * SCALE;
 
     private static final Set<String> BRANCHING_EIPS = Set.of(
-            "choice", "multicast", "doTry", "loadBalance", "recipientList");
+            "choice", "multicast", "doTry", "loadBalance", "recipientList", 
"circuitBreaker");
+
+    private static final Set<String> BRANCH_CHILD_TYPES = Set.of(
+            "when", "otherwise", "doCatch", "doFinally", "onFallback");
+
+    private static final Set<String> STRUCTURAL_TYPES = Set.of(
+            "route", "from");
 
     static class NodeInfo {
         String type;
@@ -142,11 +149,26 @@ class RouteDiagramLayoutEngine {
         computeSubtreeWidth(tree);
         assignPositions(tree, PADDING, startY + LABEL_OFFSET, 
tree.subtreeWidth, lr);
 
-        int maxX = 0;
-        for (LayoutNode ln : lr.nodes) {
-            maxX = Math.max(maxX, ln.x + NODE_WIDTH);
+        int[] extent = {
+                tree.layoutNode.x, tree.layoutNode.y,
+                tree.layoutNode.x + NODE_WIDTH, tree.layoutNode.y + 
NODE_HEIGHT };
+        for (TreeNode child : tree.children) {
+            expandBoundsForBox(child, extent);
+        }
+
+        if (extent[0] < PADDING) {
+            int shift = PADDING - extent[0];
+            for (LayoutNode ln : lr.nodes) {
+                ln.x += shift;
+                if (ln.connectFromMerge) {
+                    ln.mergeCx += shift;
+                }
+            }
+            extent[2] += shift;
         }
-        lr.maxX = maxX + PADDING;
+
+        lr.maxX = extent[2];
+        lr.maxY = Math.max(lr.maxY, extent[3]);
 
         return lr;
     }
@@ -195,9 +217,16 @@ class RouteDiagramLayoutEngine {
                 int myIndex = parentNode.children.indexOf(node);
                 if (myIndex > 0) {
                     TreeNode prevSibling = parentNode.children.get(myIndex - 
1);
-                    if (isBranchingEip(prevSibling.info.type)) {
+                    if (hasScope(prevSibling)) {
                         ln.connectFromMerge = true;
-                        ln.mergeY = findMaxY(prevSibling) + V_GAP / 2;
+                        int[] boxBounds = {
+                                prevSibling.layoutNode.x, 
prevSibling.layoutNode.y,
+                                prevSibling.layoutNode.x + NODE_WIDTH,
+                                prevSibling.layoutNode.y + NODE_HEIGHT };
+                        for (TreeNode c : prevSibling.children) {
+                            expandBoundsForBox(c, boxBounds);
+                        }
+                        ln.mergeY = boxBounds[3] + SCOPE_BOX_PAD;
                         ln.mergeCx = prevSibling.layoutNode.x + NODE_WIDTH / 2;
                         ln.parentNode = prevSibling.layoutNode;
                     } else {
@@ -211,7 +240,7 @@ class RouteDiagramLayoutEngine {
             }
         }
 
-        lr.maxY = Math.max(lr.maxY, y + NODE_HEIGHT + V_GAP);
+        lr.maxY = Math.max(lr.maxY, y + NODE_HEIGHT);
 
         if (node.children.isEmpty()) {
             return;
@@ -222,17 +251,29 @@ class RouteDiagramLayoutEngine {
         if (isBranchingEip(node.info.type)) {
             int childX = x + (availableWidth - node.subtreeWidth) / 2;
             for (TreeNode child : node.children) {
-                assignPositions(child, childX, childY, child.subtreeWidth, lr);
+                int adjustedY = childY;
+                if (!child.children.isEmpty() && 
!BRANCH_CHILD_TYPES.contains(child.info.type)) {
+                    adjustedY += SCOPE_BOX_PAD;
+                }
+                assignPositions(child, childX, adjustedY, child.subtreeWidth, 
lr);
                 childX += child.subtreeWidth + H_GAP;
             }
         } else {
             int curY = childY;
             for (int i = 0; i < node.children.size(); i++) {
                 TreeNode child = node.children.get(i);
-                assignPositions(child, x, curY, availableWidth, lr);
-                curY = findMaxY(child) + V_GAP;
-                if (isBranchingEip(child.info.type) && i < 
node.children.size() - 1) {
-                    curY += V_GAP;
+                int adjustedY = hasScope(child) ? curY + SCOPE_BOX_PAD : curY;
+                assignPositions(child, x, adjustedY, availableWidth, lr);
+                if (hasScope(child)) {
+                    int[] cb = {
+                            child.layoutNode.x, child.layoutNode.y,
+                            child.layoutNode.x + NODE_WIDTH, 
child.layoutNode.y + NODE_HEIGHT };
+                    for (TreeNode c : child.children) {
+                        expandBoundsForBox(c, cb);
+                    }
+                    curY = cb[3] + SCOPE_BOX_PAD + V_GAP;
+                } else {
+                    curY = findMaxY(child) + V_GAP;
                 }
             }
         }
@@ -259,4 +300,38 @@ class RouteDiagramLayoutEngine {
     static boolean isBranchingEip(String type) {
         return type != null && BRANCHING_EIPS.contains(type);
     }
+
+    static boolean hasScope(TreeNode node) {
+        return node.parent != null
+                && !node.children.isEmpty()
+                && !BRANCH_CHILD_TYPES.contains(node.info.type)
+                && !STRUCTURAL_TYPES.contains(node.info.type);
+    }
+
+    static void expandBoundsForBox(TreeNode node, int[] bounds) {
+        boolean hasOwnBox = hasScope(node);
+
+        if (hasOwnBox) {
+            int[] inner = {
+                    node.layoutNode.x, node.layoutNode.y,
+                    node.layoutNode.x + NODE_WIDTH, node.layoutNode.y + 
NODE_HEIGHT };
+            for (TreeNode child : node.children) {
+                expandBoundsForBox(child, inner);
+            }
+            bounds[0] = Math.min(bounds[0], inner[0] - SCOPE_BOX_PAD);
+            bounds[1] = Math.min(bounds[1], inner[1] - SCOPE_BOX_PAD);
+            bounds[2] = Math.max(bounds[2], inner[2] + SCOPE_BOX_PAD);
+            bounds[3] = Math.max(bounds[3], inner[3] + SCOPE_BOX_PAD);
+        } else {
+            if (node.layoutNode != null) {
+                bounds[0] = Math.min(bounds[0], node.layoutNode.x);
+                bounds[1] = Math.min(bounds[1], node.layoutNode.y);
+                bounds[2] = Math.max(bounds[2], node.layoutNode.x + 
NODE_WIDTH);
+                bounds[3] = Math.max(bounds[3], node.layoutNode.y + 
NODE_HEIGHT);
+            }
+            for (TreeNode child : node.children) {
+                expandBoundsForBox(child, bounds);
+            }
+        }
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java
index a49d024ed4d2..0ee3712b4b5e 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java
@@ -37,6 +37,7 @@ import static 
org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayout
 import static 
org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NODE_WIDTH;
 import static 
org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.PADDING;
 import static 
org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.SCALE;
+import static 
org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.SCOPE_BOX_PAD;
 import static 
org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.V_GAP;
 
 class RouteDiagramRenderer {
@@ -45,7 +46,6 @@ class RouteDiagramRenderer {
     private static final int FONT_SIZE_LABEL = 13 * SCALE;
     private static final int FONT_SIZE_NODE = 12 * SCALE;
     private static final int ARROW_SIZE = 6 * SCALE;
-    private static final int MERGE_DOT = 5 * SCALE;
     private static final float STROKE_WIDTH = 1.5f * SCALE;
     private static final float BORDER_STROKE_WIDTH = 1.0f * SCALE;
     private static final int NODE_TEXT_PADDING = 16 * SCALE;
@@ -218,9 +218,8 @@ class RouteDiagramRenderer {
         g.setStroke(new BasicStroke(STROKE_WIDTH));
 
         for (LayoutNode ln : lr.nodes) {
-            if (RouteDiagramLayoutEngine.isBranchingEip(ln.type) && 
ln.treeNode != null
-                    && !ln.treeNode.children.isEmpty()) {
-                drawMergeLines(g, ln, colors);
+            if (ln.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(ln.treeNode)) {
+                drawScopeBox(g, ln, colors);
             }
         }
 
@@ -239,46 +238,22 @@ class RouteDiagramRenderer {
         }
     }
 
-    private void drawMergeLines(Graphics2D g, LayoutNode branchingNode, 
DiagramColors colors) {
-        TreeNode tn = branchingNode.treeNode;
-        if (tn.children.isEmpty()) {
-            return;
-        }
-
-        TreeNode parentNode = tn.parent;
-        if (parentNode == null) {
-            return;
-        }
-        int myIndex = parentNode.children.indexOf(tn);
-        if (myIndex < 0 || myIndex >= parentNode.children.size() - 1) {
-            return;
-        }
-
-        int branchesMaxY = RouteDiagramLayoutEngine.findMaxY(tn);
-        int mergeY = branchesMaxY + V_GAP / 2;
-
-        g.setColor(colors.getArrow());
-        g.setStroke(new BasicStroke(STROKE_WIDTH));
-
-        int minCx = Integer.MAX_VALUE;
-        int maxCx = Integer.MIN_VALUE;
+    private void drawScopeBox(Graphics2D g, LayoutNode scopeNode, 
DiagramColors colors) {
+        TreeNode tn = scopeNode.treeNode;
+        int[] bounds = { scopeNode.x, scopeNode.y, scopeNode.x + NODE_WIDTH, 
scopeNode.y + NODE_HEIGHT };
         for (TreeNode child : tn.children) {
-            LayoutNode lastNode = 
RouteDiagramLayoutEngine.findLastLayoutNode(child);
-            if (lastNode != null) {
-                int cx = lastNode.x + NODE_WIDTH / 2;
-                int by = lastNode.y + NODE_HEIGHT;
-                g.drawLine(cx, by, cx, mergeY);
-                minCx = Math.min(minCx, cx);
-                maxCx = Math.max(maxCx, cx);
-            }
+            RouteDiagramLayoutEngine.expandBoundsForBox(child, bounds);
         }
 
-        if (minCx < maxCx) {
-            g.drawLine(minCx, mergeY, maxCx, mergeY);
-        }
+        int boxX = bounds[0] - SCOPE_BOX_PAD;
+        int boxY = bounds[1] - SCOPE_BOX_PAD;
+        int boxW = bounds[2] - bounds[0] + 2 * SCOPE_BOX_PAD;
+        int boxH = bounds[3] - bounds[1] + 2 * SCOPE_BOX_PAD;
 
-        int mergeCx = branchingNode.x + NODE_WIDTH / 2;
-        g.fillOval(mergeCx - MERGE_DOT, mergeY - MERGE_DOT, MERGE_DOT * 2, 
MERGE_DOT * 2);
+        Color c = colors.getArrow();
+        g.setColor(new Color(c.getRed(), c.getGreen(), c.getBlue(), 140));
+        g.setStroke(new BasicStroke(STROKE_WIDTH));
+        g.drawRoundRect(boxX, boxY, boxW, boxH, ARC, ARC);
     }
 
     private void drawArrowFromMerge(Graphics2D g, LayoutNode to, DiagramColors 
colors) {
@@ -286,17 +261,19 @@ class RouteDiagramRenderer {
         g.setStroke(new BasicStroke(STROKE_WIDTH));
 
         int toCx = to.x + NODE_WIDTH / 2;
-        int toTy = to.y;
+        int toTy = to.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(to.treeNode)
+                ? to.y - SCOPE_BOX_PAD
+                : to.y;
         int mergeCx = to.mergeCx;
         int mergeY = to.mergeY;
 
         if (mergeCx == toCx) {
-            g.drawLine(mergeCx, mergeY, toCx, toTy);
+            g.drawLine(mergeCx, mergeY, toCx, toTy - ARROW_SIZE / 2);
         } else {
             int midY = mergeY + (toTy - mergeY) / 2;
             g.drawLine(mergeCx, mergeY, mergeCx, midY);
             g.drawLine(mergeCx, midY, toCx, midY);
-            g.drawLine(toCx, midY, toCx, toTy);
+            g.drawLine(toCx, midY, toCx, toTy - ARROW_SIZE / 2);
         }
         drawArrowHead(g, toCx, toTy);
     }
@@ -330,15 +307,17 @@ class RouteDiagramRenderer {
         int fromCx = from.x + NODE_WIDTH / 2;
         int fromBy = from.y + NODE_HEIGHT;
         int toCx = to.x + NODE_WIDTH / 2;
-        int toTy = to.y;
+        int toTy = to.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(to.treeNode)
+                ? to.y - SCOPE_BOX_PAD
+                : to.y;
 
         if (fromCx == toCx) {
-            g.drawLine(fromCx, fromBy, toCx, toTy);
+            g.drawLine(fromCx, fromBy, toCx, toTy - ARROW_SIZE / 2);
         } else {
             int midY = fromBy + V_GAP / 2;
             g.drawLine(fromCx, fromBy, fromCx, midY);
             g.drawLine(fromCx, midY, toCx, midY);
-            g.drawLine(toCx, midY, toCx, toTy);
+            g.drawLine(toCx, midY, toCx, toTy - ARROW_SIZE / 2);
         }
         drawArrowHead(g, toCx, toTy);
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java
index 9e6aceea6ee7..c59a5d033612 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java
@@ -443,6 +443,97 @@ class CamelRouteDiagramActionTest {
         assertEquals("timer:tick", routes.get(0).nodes.get(0).code);
     }
 
+    @Test
+    void testChoiceInsideDoTryNoSpuriousMergeConnection() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "timer:tryChoiceInside", 0));
+        route.nodes.add(node("setHeader", "setHeader[type]", 1));
+        route.nodes.add(node("doTry", "doTry", 1));
+        route.nodes.add(node("choice", "choice()", 2));
+        route.nodes.add(node("when", "when(header(type) == A)", 3));
+        route.nodes.add(node("log", "log[Type A]", 4));
+        route.nodes.add(node("otherwise", "otherwise()", 3));
+        route.nodes.add(node("log", "log[Other type]", 4));
+        route.nodes.add(node("throwException", 
"throwException[java.lang.Exception]", 4));
+        route.nodes.add(node("doCatch", "doCatch[java.lang.Exception]", 2));
+        route.nodes.add(node("log", "log[Err: ${exception.message}]", 3));
+        route.nodes.add(node("log", "log[Do other processing...]", 1));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, 
RouteDiagramLayoutEngine.PADDING);
+
+        LayoutNode logAfter = lr.nodes.get(lr.nodes.size() - 1);
+        assertEquals("log[Do other processing...]", logAfter.label);
+        assertTrue(logAfter.connectFromMerge, "Should connect from doTry merge 
point");
+
+        LayoutNode choiceNode = lr.nodes.stream()
+                .filter(n -> "choice".equals(n.type))
+                .findFirst().orElseThrow();
+        TreeNode choiceTn = choiceNode.treeNode;
+        
assertTrue(RouteDiagramLayoutEngine.isBranchingEip(choiceTn.parent.info.type),
+                "Choice parent (doTry) should be a branching EIP");
+    }
+
+    @Test
+    void testFilterAsScopeEip() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "direct:start", 0));
+        route.nodes.add(node("setBody", "setBody[simple{Hello}]", 1));
+        route.nodes.add(node("filter", "filter[{header(x) == value}]", 1));
+        route.nodes.add(node("removeHeader", "removeHeader[x-another-header]", 
2));
+        route.nodes.add(node("to", "to[log:after]", 1));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, 
RouteDiagramLayoutEngine.PADDING);
+
+        assertEquals(5, lr.nodes.size());
+
+        LayoutNode toNode = lr.nodes.get(4);
+        assertEquals("to[log:after]", toNode.label);
+        assertTrue(toNode.connectFromMerge, "Node after filter should connect 
from merge point");
+
+        LayoutNode filterNode = lr.nodes.get(2);
+        assertTrue(RouteDiagramLayoutEngine.hasScope(filterNode.treeNode));
+
+        LayoutNode removeNode = lr.nodes.get(3);
+        assertEquals(filterNode.x, removeNode.x, "Filter child should be in 
same column (sequential)");
+    }
+
+    @Test
+    void testSplitAsScopeEip() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "direct:start", 0));
+        route.nodes.add(node("split", "split[body()]", 1));
+        route.nodes.add(node("log", "log[${body}]", 2));
+        route.nodes.add(node("to", "to[mock:end]", 1));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, 
RouteDiagramLayoutEngine.PADDING);
+
+        LayoutNode toNode = lr.nodes.get(3);
+        assertTrue(toNode.connectFromMerge, "Node after split should connect 
from merge point");
+    }
+
+    @Test
+    void testFilterLastChildNoMerge() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "direct:start", 0));
+        route.nodes.add(node("filter", "filter[{header(x)}]", 1));
+        route.nodes.add(node("log", "log[filtered]", 2));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, 
RouteDiagramLayoutEngine.PADDING);
+
+        assertEquals(3, lr.nodes.size());
+        LayoutNode logNode = lr.nodes.get(2);
+        assertNull(logNode.parentNode == null ? null : 
(logNode.connectFromMerge ? "merge" : null),
+                "Filter as last child should not produce merge connection on 
its children");
+    }
+
     private static NodeInfo node(String type, String code, int level) {
         NodeInfo n = new NodeInfo();
         n.type = type;

Reply via email to