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;