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 <[email protected]>
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 <[email protected]>
---
 .../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;
+    }
+}


Reply via email to