You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by gn...@apache.org on 2026/03/24 15:06:36 UTC

(camel) branch peppered-rock created (now 3445a17c1cf8)

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

gnodet pushed a change to branch peppered-rock
in repository https://gitbox.apache.org/repos/asf/camel.git


      at 3445a17c1cf8 CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal

This branch includes the following new commits:

     new 3445a17c1cf8 CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(camel) 01/01: CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal

Posted by gn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 3445a17c1cf83f721f4b313754893f06a864de9a
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Tue Mar 24 16:06:19 2026 +0100

    CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal
    
    Add a lightweight `camel cmd route-diagram` command that renders Camel route
    diagrams directly in the terminal using JLine 4's graphics protocol support
    (Kitty/Sixel/iTerm2 inline images).
    
    Features:
    - Tree-based layout with horizontal branching for EIPs (choice, multicast, doTry, etc.)
    - Merge lines showing branch convergence
    - Color-coded nodes by type (from, to, EIP, choice, default)
    - Customizable color themes via --theme/--colors option or DIAGRAM_COLORS env var
    - Built-in presets: dark, light, transparent
    - PNG file export via --output option
    - Fallback to text-based diagram for terminals without graphics support
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
 .../camel-jbang-cmd-route-diagram.adoc             |  29 +
 .../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc |   1 +
 .../META-INF/camel-jbang-commands-metadata.json    |   2 +-
 .../dsl/jbang/core/commands/CamelJBangMain.java    |   1 +
 .../commands/action/CamelRouteDiagramAction.java   | 750 +++++++++++++++++++++
 5 files changed, 782 insertions(+), 1 deletion(-)

diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
new file mode 100644
index 000000000000..73ac68717fde
--- /dev/null
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
@@ -0,0 +1,29 @@
+
+// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE
+= camel cmd route-diagram
+
+Display Camel route diagram in the terminal
+
+
+== Usage
+
+[source,bash]
+----
+camel cmd route-diagram [options]
+----
+
+
+
+== Options
+
+[cols="2,5,1,2",options="header"]
+|===
+| Option | Description | Default | Type
+| `--filter` | Filter route by filename or route id |  | String
+| `--output` | Save diagram to a PNG file instead of displaying in terminal |  | String
+| `--theme,--colors` | Color theme preset (dark, light, transparent) or custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Use bg= for transparent. | dark | String
+| `--width` | Image width in pixels | 0 | int
+| `-h,--help` | Display the help and sub-commands |  | boolean
+|===
+
+
diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
index 0d0c1ec7544b..5ce6c69d3117 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
@@ -28,6 +28,7 @@ camel cmd [options]
 | xref:jbang-commands/camel-jbang-cmd-reload.adoc[reload] | Trigger reloading Camel
 | xref:jbang-commands/camel-jbang-cmd-reset-stats.adoc[reset-stats] | Reset performance statistics
 | xref:jbang-commands/camel-jbang-cmd-resume-route.adoc[resume-route] | Resume Camel routes
+| xref:jbang-commands/camel-jbang-cmd-route-diagram.adoc[route-diagram] | Display Camel route diagram in the terminal
 | xref:jbang-commands/camel-jbang-cmd-route-structure.adoc[route-structure] | Dump Camel route structure
 | xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to endpoints
 | xref:jbang-commands/camel-jbang-cmd-start-group.adoc[start-group] | Start Camel route groups
diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 9dc900fd33d4..773b795085d5 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -2,7 +2,7 @@
   "commands": [
     { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind source and sink Kamelets as a new Camel integration", "deprecated": true, "sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": [ { "names": "--error-handler", "description": "Add error handler (none|log|sink:<endpoint>). Sink endpoints are expected in the format [[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet name.", "javaType": "java.lang.String", "type": "stri [...]
     { "name": "catalog", "fullName": "catalog", "description": "List artifacts from Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "component", "fullName": "catalog component", "description": "List components from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
-    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...]
+    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...]
     { "name": "completion", "fullName": "completion", "description": "Generate completion script for bash\/zsh", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ] },
     { "name": "config", "fullName": "config", "description": "Get and set user configuration values", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "config get", "description": "Display user configuration value", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
     { "name": "debug", "fullName": "debug", "description": "Debug local Camel integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", "options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": "--background", "description": "Run in the background", "defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": "--background-wait", "description": "To  [...]
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index bd03af9ee39c..477769c86125 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -108,6 +108,7 @@ public class CamelJBangMain implements Callable<Integer> {
                         .addSubcommand("reload", new CommandLine(new CamelReloadAction(this)))
                         .addSubcommand("reset-stats", new CommandLine(new CamelResetStatsAction(this)))
                         .addSubcommand("resume-route", new CommandLine(new CamelRouteResumeAction(this)))
+                        .addSubcommand("route-diagram", new CommandLine(new CamelRouteDiagramAction(this)))
                         .addSubcommand("route-structure", new CommandLine(new CamelRouteStructureAction(this)))
                         .addSubcommand("send", new CommandLine(new CamelSendAction(this)))
                         .addSubcommand("start-group", new CommandLine(new CamelRouteGroupStartAction(this)))
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
new file mode 100644
index 000000000000..ffee35281ecf
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
@@ -0,0 +1,750 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import javax.imageio.ImageIO;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import org.jline.terminal.impl.TerminalGraphics;
+import org.jline.terminal.impl.TerminalGraphicsManager;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "route-diagram", description = "Display Camel route diagram in the terminal", sortOptions = false,
+         showDefaultValues = true)
+public class CamelRouteDiagramAction extends ActionBaseCommand {
+
+    // Render at 2x for crisp text on terminal image protocols
+    private static final int SCALE = 2;
+    private static final int NODE_WIDTH = 180 * SCALE;
+    private static final int NODE_HEIGHT = 32 * SCALE;
+    private static final int H_GAP = 30 * SCALE;
+    private static final int V_GAP = 40 * SCALE;
+    private static final int PADDING = 30 * SCALE;
+    private static final int ARC = 14 * SCALE;
+    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;
+
+    // Color scheme keys: bg, text, arrow, label, from, to, eip, choice, default
+    // Format: "key=#rrggbb:key=#rrggbb:..." or a preset name (dark, light, transparent)
+    // Use bg= (empty) for transparent background
+    private static final String DARK_COLORS
+            = "bg=#1e1e1e:text=#ffffff:arrow=#b4b4b4:label=#c8c8c8:from=#2e7d32:to=#1565c0:eip=#9c27b0:choice=#e65100:default=#455a64";
+    private static final String LIGHT_COLORS
+            = "bg=#f5f5f5:text=#1e1e1e:arrow=#646464:label=#505050:from=#388e3c:to=#1976d2:eip=#ab47bc:choice=#f57c00:default=#78909c";
+    private static final String TRANSPARENT_COLORS
+            = "bg=:text=#ffffff:arrow=#b4b4b4:label=#c8c8c8:from=#2e7d32:to=#1565c0:eip=#9c27b0:choice=#e65100:default=#455a64";
+
+    private static final Map<String, String> COLOR_PRESETS = Map.of(
+            "dark", DARK_COLORS,
+            "light", LIGHT_COLORS,
+            "transparent", TRANSPARENT_COLORS);
+
+    static class DiagramColors {
+        Color bg, text, arrow, routeLabel;
+        Color nodeFrom, nodeTo, nodeEip, nodeChoice, nodeDefault;
+
+        static DiagramColors parse(String spec) {
+            // Resolve preset aliases
+            String resolved = COLOR_PRESETS.getOrDefault(spec, spec);
+            Map<String, String> map = new HashMap<>();
+            // Start with dark defaults
+            for (String entry : DARK_COLORS.split(":")) {
+                int eq = entry.indexOf('=');
+                if (eq > 0) {
+                    map.put(entry.substring(0, eq), entry.substring(eq + 1));
+                }
+            }
+            // Override with user values
+            for (String entry : resolved.split(":")) {
+                int eq = entry.indexOf('=');
+                if (eq > 0) {
+                    map.put(entry.substring(0, eq), entry.substring(eq + 1));
+                }
+            }
+            DiagramColors c = new DiagramColors();
+            c.bg = parseColor(map.get("bg"));
+            c.text = parseColor(map.getOrDefault("text", "#ffffff"));
+            c.arrow = parseColor(map.getOrDefault("arrow", "#b4b4b4"));
+            c.routeLabel = parseColor(map.getOrDefault("label", "#c8c8c8"));
+            c.nodeFrom = parseColor(map.getOrDefault("from", "#2e7d32"));
+            c.nodeTo = parseColor(map.getOrDefault("to", "#1565c0"));
+            c.nodeEip = parseColor(map.getOrDefault("eip", "#9c27b0"));
+            c.nodeChoice = parseColor(map.getOrDefault("choice", "#e65100"));
+            c.nodeDefault = parseColor(map.getOrDefault("default", "#455a64"));
+            return c;
+        }
+
+        private static Color parseColor(String hex) {
+            if (hex == null || hex.isEmpty()) {
+                return null; // transparent
+            }
+            if (hex.startsWith("#")) {
+                hex = hex.substring(1);
+            }
+            return new Color(Integer.parseInt(hex, 16));
+        }
+    }
+
+    // EIP types that create horizontal branches (their direct children are laid out side by side)
+    private static final Set<String> BRANCHING_EIPS = Set.of(
+            "choice", "multicast", "doTry", "loadBalance", "recipientList");
+
+    @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
+    String name = "*";
+
+    @CommandLine.Option(names = { "--filter" },
+                        description = "Filter route by filename or route id")
+    String filter;
+
+    @CommandLine.Option(names = { "--width" },
+                        description = "Image width in pixels", defaultValue = "0")
+    int width;
+
+    @CommandLine.Option(names = { "--output" },
+                        description = "Save diagram to a PNG file instead of displaying in terminal")
+    String output;
+
+    @CommandLine.Option(names = { "--theme", "--colors" },
+                        description = "Color theme preset (dark, light, transparent) or custom colors "
+                                      + "(e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Use bg= for transparent.",
+                        defaultValue = "dark")
+    String theme = "dark";
+
+    private DiagramColors colors;
+
+    public CamelRouteDiagramAction(CamelJBangMain main) {
+        super(main);
+    }
+
+    @Override
+    public Integer doCall() throws Exception {
+        String colorSpec = System.getenv("DIAGRAM_COLORS");
+        colors = DiagramColors.parse(colorSpec != null ? colorSpec : theme);
+
+        List<Long> pids = findPids(name);
+        if (pids.isEmpty()) {
+            return 1;
+        } else if (pids.size() > 1) {
+            printer().println("Name or pid " + name + " matches " + pids.size()
+                              + " running Camel integrations. Specify a name or PID that matches exactly one.");
+            return 1;
+        }
+
+        long pid = pids.get(0);
+
+        // Fetch route structure from the running Camel integration
+        Path outputFile = prepareAction(Long.toString(pid), "route-structure", root -> {
+            root.put("filter", "*");
+            root.put("brief", false);
+        });
+
+        JsonObject jo = getJsonObject(outputFile);
+        if (jo == null) {
+            printer().println("Response from running Camel with PID " + pid + " not received within 5 seconds");
+            return 1;
+        }
+
+        List<RouteInfo> routes = parseRoutes(jo);
+        if (routes.isEmpty()) {
+            printer().println("No routes found");
+            PathUtils.deleteFile(outputFile);
+            return 0;
+        }
+
+        // Filter routes if needed
+        if (filter != null) {
+            routes.removeIf(r -> !r.routeId.contains(filter) && !r.source.contains(filter));
+        }
+
+        if (routes.isEmpty()) {
+            printer().println("No routes match filter: " + filter);
+            PathUtils.deleteFile(outputFile);
+            return 0;
+        }
+
+        // Render diagram
+        BufferedImage image = renderDiagram(routes);
+
+        if (output != null) {
+            // Save to file
+            File file = new File(output);
+            ImageIO.write(image, "PNG", file);
+            printer().println("Diagram saved to: " + file.getAbsolutePath());
+        } else {
+            // Display using JLine terminal graphics
+            try (Terminal terminal = TerminalBuilder.builder().system(true).build()) {
+                Optional<TerminalGraphics> protocol = TerminalGraphicsManager.getBestProtocol(terminal);
+                if (protocol.isPresent()) {
+                    TerminalGraphics.ImageOptions opts = new TerminalGraphics.ImageOptions()
+                            .preserveAspectRatio(true);
+                    if (width > 0) {
+                        opts.width(width);
+                    }
+                    protocol.get().displayImage(terminal, image, opts);
+                    terminal.writer().println();
+                    terminal.flush();
+                } else {
+                    printer().println("Terminal does not support graphics protocols (Kitty, iTerm2, or Sixel).");
+                    printer().println(
+                            "Try running in a supported terminal: Kitty, iTerm2, WezTerm, Ghostty, or VS Code.");
+                    printTextDiagram(routes);
+                }
+            }
+        }
+
+        PathUtils.deleteFile(outputFile);
+        return 0;
+    }
+
+    // ------- Parsing -------
+
+    private List<RouteInfo> parseRoutes(JsonObject jo) {
+        List<RouteInfo> routes = new ArrayList<>();
+        JsonArray arr = (JsonArray) jo.get("routes");
+        if (arr == null) {
+            return routes;
+        }
+
+        for (int i = 0; i < arr.size(); i++) {
+            JsonObject o = (JsonObject) arr.get(i);
+            RouteInfo route = new RouteInfo();
+            route.routeId = o.getString("routeId");
+            route.source = CamelRouteStructureAction.extractSourceName(o.getString("source"));
+
+            List<JsonObject> lines = o.getCollection("code");
+            if (lines != null) {
+                for (JsonObject line : lines) {
+                    NodeInfo node = new NodeInfo();
+                    node.type = line.getString("type");
+                    node.code = Jsoner.unescape(line.getString("code"));
+                    node.level = line.getInteger("level");
+                    node.id = line.getString("id");
+                    route.nodes.add(node);
+                }
+            }
+            routes.add(route);
+        }
+        return routes;
+    }
+
+    // ------- Tree building -------
+
+    /**
+     * Build a tree from the flat node list using level to determine parent-child relationships.
+     */
+    static TreeNode buildTree(List<NodeInfo> nodes) {
+        if (nodes.isEmpty()) {
+            return null;
+        }
+        TreeNode root = new TreeNode(nodes.get(0));
+        TreeNode current = root;
+
+        for (int i = 1; i < nodes.size(); i++) {
+            NodeInfo ni = nodes.get(i);
+            TreeNode tn = new TreeNode(ni);
+
+            if (ni.level > current.info.level) {
+                // Child of current
+                current.children.add(tn);
+                tn.parent = current;
+            } else if (ni.level == current.info.level) {
+                // Sibling of current
+                TreeNode parent = current.parent;
+                if (parent != null) {
+                    parent.children.add(tn);
+                    tn.parent = parent;
+                } else {
+                    root.children.add(tn);
+                    tn.parent = root;
+                }
+            } else {
+                // Walk up to find the right parent
+                TreeNode ancestor = current.parent;
+                while (ancestor != null && ancestor.info.level >= ni.level) {
+                    ancestor = ancestor.parent;
+                }
+                if (ancestor != null) {
+                    ancestor.children.add(tn);
+                    tn.parent = ancestor;
+                } else {
+                    root.children.add(tn);
+                    tn.parent = root;
+                }
+            }
+            current = tn;
+        }
+        return root;
+    }
+
+    // ------- Layout (Hawtio-style) -------
+
+    private LayoutRoute layoutRoute(RouteInfo route, int startY) {
+        LayoutRoute lr = new LayoutRoute();
+        lr.routeId = route.routeId;
+        lr.source = route.source;
+        lr.labelY = startY;
+
+        TreeNode tree = buildTree(route.nodes);
+        if (tree == null) {
+            lr.maxX = PADDING + NODE_WIDTH;
+            lr.maxY = startY + 24 * SCALE;
+            return lr;
+        }
+
+        computeSubtreeWidth(tree);
+        assignPositions(tree, PADDING, startY + 24 * SCALE, tree.subtreeWidth, lr);
+
+        int maxX = 0;
+        for (LayoutNode ln : lr.nodes) {
+            maxX = Math.max(maxX, ln.x + NODE_WIDTH);
+        }
+        lr.maxX = maxX + PADDING;
+
+        return lr;
+    }
+
+    /**
+     * Compute the width each subtree needs (in pixels) for horizontal layout.
+     */
+    private int computeSubtreeWidth(TreeNode node) {
+        if (node.children.isEmpty()) {
+            node.subtreeWidth = NODE_WIDTH;
+            return node.subtreeWidth;
+        }
+
+        if (isBranchingEip(node.info.type)) {
+            // Branches are laid out side by side
+            int totalWidth = 0;
+            for (int i = 0; i < node.children.size(); i++) {
+                if (i > 0) {
+                    totalWidth += H_GAP;
+                }
+                totalWidth += computeSubtreeWidth(node.children.get(i));
+            }
+            node.subtreeWidth = Math.max(NODE_WIDTH, totalWidth);
+        } else {
+            // Sequential: the width is the max of all children
+            int maxChildWidth = NODE_WIDTH;
+            for (TreeNode child : node.children) {
+                maxChildWidth = Math.max(maxChildWidth, computeSubtreeWidth(child));
+            }
+            node.subtreeWidth = maxChildWidth;
+        }
+        return node.subtreeWidth;
+    }
+
+    /**
+     * Assign x,y positions to each node in the tree.
+     *
+     * @param parentWidth the available width from the parent's subtree (used to center sequential children)
+     */
+    private void assignPositions(TreeNode node, int x, int y, int parentWidth, LayoutRoute lr) {
+        int availableWidth = Math.max(node.subtreeWidth, parentWidth);
+        int nodeX = x + (availableWidth - NODE_WIDTH) / 2;
+
+        LayoutNode ln = new LayoutNode();
+        ln.label = truncateLabel(node.info.code);
+        ln.type = node.info.type;
+        ln.x = nodeX;
+        ln.y = y;
+        ln.treeNode = node;
+        node.layoutNode = ln;
+        lr.nodes.add(ln);
+
+        // Connect to parent
+        if (node.parent != null && node.parent.layoutNode != null) {
+            TreeNode parentNode = node.parent;
+            if (!isBranchingEip(parentNode.info.type)) {
+                // Sequential: connect to previous sibling or parent
+                int myIndex = parentNode.children.indexOf(node);
+                if (myIndex > 0) {
+                    TreeNode prevSibling = parentNode.children.get(myIndex - 1);
+                    if (isBranchingEip(prevSibling.info.type)) {
+                        // Previous sibling was a branching EIP — connect from its merge point
+                        ln.connectFromMerge = true;
+                        ln.mergeY = findMaxY(prevSibling) + V_GAP / 2;
+                        ln.mergeCx = prevSibling.layoutNode.x + NODE_WIDTH / 2;
+                        ln.parentNode = prevSibling.layoutNode;
+                    } else {
+                        ln.parentNode = findLastLayoutNode(prevSibling);
+                    }
+                } else {
+                    ln.parentNode = parentNode.layoutNode;
+                }
+            } else {
+                // Branching: connect directly to parent
+                ln.parentNode = parentNode.layoutNode;
+            }
+        }
+
+        lr.maxY = Math.max(lr.maxY, y + NODE_HEIGHT + V_GAP);
+
+        if (node.children.isEmpty()) {
+            return;
+        }
+
+        int childY = y + NODE_HEIGHT + V_GAP;
+
+        if (isBranchingEip(node.info.type)) {
+            // Lay out children side by side horizontally
+            int childX = x + (availableWidth - node.subtreeWidth) / 2;
+            for (TreeNode child : node.children) {
+                assignPositions(child, childX, childY, child.subtreeWidth, lr);
+                childX += child.subtreeWidth + H_GAP;
+            }
+        } else {
+            // Sequential: stack children vertically
+            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;
+                // Extra gap after branching children for merge line
+                if (isBranchingEip(child.info.type) && i < node.children.size() - 1) {
+                    curY += V_GAP;
+                }
+            }
+        }
+    }
+
+    /**
+     * Find the last (deepest) layout node in a sequential subtree. For branching EIPs, returns the node itself
+     * (branches merge back at the parent).
+     */
+    private LayoutNode findLastLayoutNode(TreeNode node) {
+        if (node.children.isEmpty()) {
+            return node.layoutNode;
+        }
+        if (isBranchingEip(node.info.type)) {
+            return node.layoutNode;
+        }
+        return findLastLayoutNode(node.children.get(node.children.size() - 1));
+    }
+
+    private int findMaxY(TreeNode node) {
+        int maxY = node.layoutNode != null ? node.layoutNode.y + NODE_HEIGHT : 0;
+        for (TreeNode child : node.children) {
+            maxY = Math.max(maxY, findMaxY(child));
+        }
+        return maxY;
+    }
+
+    private boolean isBranchingEip(String type) {
+        return type != null && BRANCHING_EIPS.contains(type);
+    }
+
+    // ------- Rendering -------
+
+    private BufferedImage renderDiagram(List<RouteInfo> routes) {
+        List<LayoutRoute> layoutRoutes = new ArrayList<>();
+        int currentY = PADDING;
+
+        for (RouteInfo route : routes) {
+            LayoutRoute lr = layoutRoute(route, currentY);
+            layoutRoutes.add(lr);
+            currentY = lr.maxY + V_GAP;
+        }
+
+        int imgWidth = layoutRoutes.stream().mapToInt(lr -> lr.maxX).max().orElse(400) + PADDING;
+        int imgHeight = currentY + PADDING;
+
+        int imageType = colors.bg == null ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
+        BufferedImage image = new BufferedImage(imgWidth, imgHeight, imageType);
+        Graphics2D g = image.createGraphics();
+        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+        g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+        g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
+
+        if (colors.bg != null) {
+            g.setColor(colors.bg);
+            g.fillRect(0, 0, imgWidth, imgHeight);
+        }
+
+        for (LayoutRoute lr : layoutRoutes) {
+            drawRoute(g, lr);
+        }
+
+        g.dispose();
+        return image;
+    }
+
+    private void drawRoute(Graphics2D g, LayoutRoute lr) {
+        // Route label
+        g.setColor(colors.routeLabel);
+        g.setFont(new Font("SansSerif", Font.BOLD, FONT_SIZE_LABEL));
+        String label = lr.routeId;
+        if (lr.source != null && !lr.source.isEmpty()) {
+            label += " (" + lr.source + ")";
+        }
+        g.drawString(label, PADDING, lr.labelY + 14 * SCALE);
+
+        g.setStroke(new BasicStroke(1.5f * SCALE));
+
+        // Draw merge lines for branching nodes
+        for (LayoutNode ln : lr.nodes) {
+            if (isBranchingEip(ln.type) && ln.treeNode != null && !ln.treeNode.children.isEmpty()) {
+                drawMergeLines(g, ln);
+            }
+        }
+
+        // Draw arrows
+        for (LayoutNode ln : lr.nodes) {
+            if (ln.parentNode != null) {
+                if (ln.connectFromMerge) {
+                    drawArrowFromMerge(g, ln);
+                } else {
+                    drawArrow(g, ln.parentNode, ln);
+                }
+            }
+        }
+
+        // Draw nodes on top
+        for (LayoutNode ln : lr.nodes) {
+            drawNode(g, ln);
+        }
+    }
+
+    /**
+     * Draw merge lines below all branches of a branching EIP: vertical lines from each branch's last node down to a
+     * horizontal merge line, with a dot at center.
+     */
+    private void drawMergeLines(Graphics2D g, LayoutNode branchingNode) {
+        TreeNode tn = branchingNode.treeNode;
+        if (tn.children.isEmpty()) {
+            return;
+        }
+
+        // Only draw merge if there's a next sequential sibling
+        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 = findMaxY(tn);
+        int mergeY = branchesMaxY + V_GAP / 2;
+
+        g.setColor(colors.arrow);
+        g.setStroke(new BasicStroke(1.5f * SCALE));
+
+        // Draw vertical lines from each branch's last node down to merge Y
+        int minCx = Integer.MAX_VALUE;
+        int maxCx = Integer.MIN_VALUE;
+        for (TreeNode child : tn.children) {
+            LayoutNode lastNode = 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);
+            }
+        }
+
+        // Draw horizontal merge line
+        if (minCx < maxCx) {
+            g.drawLine(minCx, mergeY, maxCx, mergeY);
+        }
+
+        // Draw merge dot at center
+        int mergeCx = branchingNode.x + NODE_WIDTH / 2;
+        g.fillOval(mergeCx - MERGE_DOT, mergeY - MERGE_DOT, MERGE_DOT * 2, MERGE_DOT * 2);
+    }
+
+    private void drawArrowFromMerge(Graphics2D g, LayoutNode to) {
+        g.setColor(colors.arrow);
+        g.setStroke(new BasicStroke(1.5f * SCALE));
+
+        int toCx = to.x + NODE_WIDTH / 2;
+        int toTy = to.y;
+        int mergeCx = to.mergeCx;
+        int mergeY = to.mergeY;
+
+        if (mergeCx == toCx) {
+            g.drawLine(mergeCx, mergeY, toCx, toTy);
+        } 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);
+        }
+        drawArrowHead(g, toCx, toTy);
+    }
+
+    private void drawNode(Graphics2D g, LayoutNode node) {
+        Color color = getNodeColor(node.type);
+
+        g.setColor(color);
+        g.fillRoundRect(node.x, node.y, NODE_WIDTH, NODE_HEIGHT, ARC, ARC);
+
+        g.setColor(color.brighter());
+        g.setStroke(new BasicStroke(1.0f * SCALE));
+        g.drawRoundRect(node.x, node.y, NODE_WIDTH, NODE_HEIGHT, ARC, ARC);
+
+        g.setColor(colors.text);
+        g.setFont(new Font("SansSerif", Font.PLAIN, FONT_SIZE_NODE));
+        FontMetrics fm = g.getFontMetrics();
+        String text = node.label;
+        while (fm.stringWidth(text) > NODE_WIDTH - 16 * SCALE && text.length() > 3) {
+            text = text.substring(0, text.length() - 4) + "...";
+        }
+        int textX = node.x + (NODE_WIDTH - fm.stringWidth(text)) / 2;
+        int textY = node.y + (NODE_HEIGHT + fm.getAscent() - fm.getDescent()) / 2;
+        g.drawString(text, textX, textY);
+    }
+
+    private void drawArrow(Graphics2D g, LayoutNode from, LayoutNode to) {
+        g.setColor(colors.arrow);
+        g.setStroke(new BasicStroke(1.5f * SCALE));
+
+        int fromCx = from.x + NODE_WIDTH / 2;
+        int fromBy = from.y + NODE_HEIGHT;
+        int toCx = to.x + NODE_WIDTH / 2;
+        int toTy = to.y;
+
+        if (fromCx == toCx) {
+            g.drawLine(fromCx, fromBy, toCx, toTy);
+        } 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);
+        }
+        drawArrowHead(g, toCx, toTy);
+    }
+
+    private void drawArrowHead(Graphics2D g, int x, int y) {
+        int[] xPoints = { x - ARROW_SIZE, x, x + ARROW_SIZE };
+        int[] yPoints = { y - ARROW_SIZE, y, y - ARROW_SIZE };
+        g.fillPolygon(xPoints, yPoints, 3);
+    }
+
+    private Color getNodeColor(String type) {
+        if (type == null) {
+            return colors.nodeDefault;
+        }
+        return switch (type) {
+            case "from" -> colors.nodeFrom;
+            case "to", "toD", "wireTap", "enrich", "pollEnrich" -> colors.nodeTo;
+            case "choice", "when", "otherwise" -> colors.nodeChoice;
+            case "filter", "split", "aggregate", "multicast", "recipientList",
+                    "routingSlip", "dynamicRouter", "loadBalance",
+                    "circuitBreaker", "saga", "doTry", "doCatch", "doFinally",
+                    "onException", "onCompletion", "intercept",
+                    "loop", "resequence", "throttle" ->
+                colors.nodeEip;
+            default -> colors.nodeDefault;
+        };
+    }
+
+    private String truncateLabel(String code) {
+        if (code == null) {
+            return "";
+        }
+        code = code.replaceFirst("^\\.", "");
+        if (code.length() > 40) {
+            code = code.substring(0, 37) + "...";
+        }
+        return code;
+    }
+
+    private void printTextDiagram(List<RouteInfo> routes) {
+        for (RouteInfo route : routes) {
+            printer().println();
+            printer().printf("Route: %s (%s)%n", route.routeId, route.source);
+            printer().println("---");
+            for (NodeInfo node : route.nodes) {
+                String indent = "  ".repeat(node.level);
+                String prefix = node.level == 0 ? "[*] " : " -> ";
+                printer().printf("%s%s%s%n", indent, prefix, node.code);
+            }
+            printer().println();
+        }
+    }
+
+    // ------- Data classes -------
+
+    private static class RouteInfo {
+        String routeId;
+        String source;
+        List<NodeInfo> nodes = new ArrayList<>();
+    }
+
+    private static class NodeInfo {
+        String type;
+        String code;
+        int level;
+        String id;
+    }
+
+    static class TreeNode {
+        final NodeInfo info;
+        TreeNode parent;
+        List<TreeNode> children = new ArrayList<>();
+        int subtreeWidth;
+        LayoutNode layoutNode;
+
+        TreeNode(NodeInfo info) {
+            this.info = info;
+        }
+    }
+
+    private static class LayoutRoute {
+        String routeId;
+        String source;
+        int labelY;
+        int maxX;
+        int maxY;
+        List<LayoutNode> nodes = new ArrayList<>();
+    }
+
+    private static class LayoutNode {
+        String label;
+        String type;
+        int x;
+        int y;
+        LayoutNode parentNode;
+        TreeNode treeNode;
+        boolean connectFromMerge;
+        int mergeY;
+        int mergeCx;
+    }
+}