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/04/30 07:33:12 UTC
(camel) branch main updated: CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal
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 6b103ea78335 CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal
6b103ea78335 is described below
commit 6b103ea783357819372c960b6c7a3ab680c33bb1
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Thu Apr 30 09:33:05 2026 +0200
CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal
- New `camel cmd route-diagram` command that renders visual diagrams of Camel routes
- Inline image rendering via Kitty, iTerm2, Sixel protocols with text-based tree fallback
- Supports --output for PNG export, --filter for route selection, --theme for color customization
- Layout engine handles branching EIPs (choice, multicast, doTry) with merge lines
- Color palette based on GitHub Primer design system (dark/light/transparent presets)
- Also adds diagram view (`d` key) in `camel tui monitor` Routes tab
---
.../ROOT/images/jbang/route-diagram-dark.png | Bin 0 -> 151329 bytes
.../ROOT/images/jbang/route-diagram-light.png | Bin 0 -> 144838 bytes
.../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 | 213 +++++++++
.../commands/action/RouteDiagramLayoutEngine.java | 262 +++++++++++
.../core/commands/action/RouteDiagramRenderer.java | 419 ++++++++++++++++++
.../action/CamelRouteDiagramActionTest.java | 481 +++++++++++++++++++++
10 files changed, 1407 insertions(+), 1 deletion(-)
diff --git a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png
new file mode 100644
index 000000000000..70699e1b9348
Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png differ
diff --git a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png
new file mode 100644
index 000000000000..8ec2e522c132
Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png differ
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..29848d309f16
--- /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). Values can be #hex or ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. Can also be set via DIAGRAM_COLORS env var. | dark | String
+| `--width` | Image width in pixels (0 = auto) | 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 fe71d31564b3..5c2c7c9f22ae 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..e78f65cf5843
--- /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,213 @@
+/*
+ * 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.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.imageio.ImageIO;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramRenderer.DiagramColors;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.support.PatternHelper;
+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 {
+
+ @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 (0 = auto)", 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). Values can be #hex or "
+ + "ANSI color names (e.g. from=seagreen:to=steelblue). "
+ + "Use bg= for transparent. Can also be set via DIAGRAM_COLORS env var.",
+ defaultValue = "dark")
+ String theme;
+
+ public CamelRouteDiagramAction(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ System.setProperty("java.awt.headless", "true");
+
+ String colorSpec = System.getenv("DIAGRAM_COLORS");
+ DiagramColors 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);
+
+ Path outputFile = prepareAction(Long.toString(pid), "route-structure", root -> {
+ root.put("filter", "*");
+ root.put("brief", false);
+ });
+
+ try {
+ 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");
+ return 0;
+ }
+
+ if (filter != null) {
+ routes = routes.stream()
+ .filter(r -> (r.routeId != null && PatternHelper.matchPattern(r.routeId, filter))
+ || (r.source != null && PatternHelper.matchPattern(r.source, filter)))
+ .toList();
+ }
+
+ if (routes.isEmpty()) {
+ printer().println("No routes match filter: " + filter);
+ return 0;
+ }
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+
+ List<LayoutRoute> layoutRoutes = new ArrayList<>();
+ int currentY = RouteDiagramLayoutEngine.PADDING;
+ for (RouteInfo route : routes) {
+ LayoutRoute lr = engine.layoutRoute(route, currentY);
+ layoutRoutes.add(lr);
+ currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
+ }
+
+ java.awt.image.BufferedImage image;
+ try {
+ image = renderer.renderDiagram(layoutRoutes, currentY, colors);
+ } catch (IllegalStateException e) {
+ printer().println(e.getMessage());
+ return 1;
+ }
+
+ if (output != null) {
+ File file = new File(output);
+ File parentDir = file.getParentFile();
+ if (parentDir != null) {
+ parentDir.mkdirs();
+ }
+ ImageIO.write(image, "PNG", file);
+ printer().println("Diagram saved to: " + file.getAbsolutePath());
+ } else {
+ try (Terminal terminal = TerminalBuilder.builder().system(true).build()) {
+ try {
+ 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.");
+ renderer.printTextDiagram(routes, printer());
+ }
+ } catch (IOException | UnsupportedOperationException e) {
+ printer().println("Failed to display diagram in terminal: " + e.getMessage());
+ printer().println("Falling back to text diagram.");
+ renderer.printTextDiagram(routes, printer());
+ }
+ }
+ }
+
+ return 0;
+ } finally {
+ PathUtils.deleteFile(outputFile);
+ }
+ }
+
+ 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"));
+ Integer level = line.getInteger("level");
+ node.level = level != null ? level : 0;
+ route.nodes.add(node);
+ }
+ }
+ routes.add(route);
+ }
+ return routes;
+ }
+}
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
new file mode 100644
index 000000000000..6b701acaf9d2
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java
@@ -0,0 +1,262 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+class RouteDiagramLayoutEngine {
+
+ static final int SCALE = 2;
+ static final int NODE_WIDTH = 180 * SCALE;
+ static final int NODE_HEIGHT = 32 * SCALE;
+ static final int H_GAP = 30 * SCALE;
+ static final int V_GAP = 40 * SCALE;
+ static final int PADDING = 30 * SCALE;
+ static final int LABEL_OFFSET = 24 * SCALE;
+
+ private static final Set<String> BRANCHING_EIPS = Set.of(
+ "choice", "multicast", "doTry", "loadBalance", "recipientList");
+
+ static class NodeInfo {
+ String type;
+ String code;
+ int level;
+ }
+
+ static class RouteInfo {
+ String routeId;
+ String source;
+ List<NodeInfo> nodes = new ArrayList<>();
+ }
+
+ static class TreeNode {
+ final NodeInfo info;
+ TreeNode parent;
+ List<TreeNode> children = new ArrayList<>();
+ int subtreeWidth;
+ LayoutNode layoutNode;
+
+ TreeNode(NodeInfo info) {
+ this.info = info;
+ }
+ }
+
+ static class LayoutNode {
+ String label;
+ String type;
+ int x;
+ int y;
+ LayoutNode parentNode;
+ TreeNode treeNode;
+ boolean connectFromMerge;
+ int mergeY;
+ int mergeCx;
+ }
+
+ static class LayoutRoute {
+ String routeId;
+ String source;
+ int labelY;
+ int maxX;
+ int maxY;
+ List<LayoutNode> nodes = new ArrayList<>();
+ }
+
+ 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) {
+ current.children.add(tn);
+ tn.parent = current;
+ } else if (ni.level == current.info.level) {
+ TreeNode parent = current.parent;
+ if (parent != null) {
+ parent.children.add(tn);
+ tn.parent = parent;
+ } else {
+ root.children.add(tn);
+ tn.parent = root;
+ }
+ } else {
+ 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;
+ }
+
+ static String cleanLabel(String code) {
+ if (code == null) {
+ return "";
+ }
+ return code.replaceFirst("^\\.", "");
+ }
+
+ 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 + LABEL_OFFSET;
+ return lr;
+ }
+
+ 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);
+ }
+ lr.maxX = maxX + PADDING;
+
+ return lr;
+ }
+
+ private int computeSubtreeWidth(TreeNode node) {
+ if (node.children.isEmpty()) {
+ node.subtreeWidth = NODE_WIDTH;
+ return node.subtreeWidth;
+ }
+
+ if (isBranchingEip(node.info.type)) {
+ 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 {
+ int maxChildWidth = NODE_WIDTH;
+ for (TreeNode child : node.children) {
+ maxChildWidth = Math.max(maxChildWidth, computeSubtreeWidth(child));
+ }
+ node.subtreeWidth = maxChildWidth;
+ }
+ return node.subtreeWidth;
+ }
+
+ 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 = cleanLabel(node.info.code);
+ ln.type = node.info.type;
+ ln.x = nodeX;
+ ln.y = y;
+ ln.treeNode = node;
+ node.layoutNode = ln;
+ lr.nodes.add(ln);
+
+ if (node.parent != null && node.parent.layoutNode != null) {
+ TreeNode parentNode = node.parent;
+ if (!isBranchingEip(parentNode.info.type)) {
+ int myIndex = parentNode.children.indexOf(node);
+ if (myIndex > 0) {
+ TreeNode prevSibling = parentNode.children.get(myIndex - 1);
+ if (isBranchingEip(prevSibling.info.type)) {
+ 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 {
+ 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)) {
+ 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 {
+ 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;
+ }
+ }
+ }
+ }
+
+ static 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));
+ }
+
+ static 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;
+ }
+
+ static boolean isBranchingEip(String type) {
+ return type != null && BRANCHING_EIPS.contains(type);
+ }
+}
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
new file mode 100644
index 000000000000..a49d024ed4d2
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java
@@ -0,0 +1,419 @@
+/*
+ * 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.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.TreeNode;
+import org.apache.camel.dsl.jbang.core.common.Printer;
+
+import static org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NODE_HEIGHT;
+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.V_GAP;
+
+class RouteDiagramRenderer {
+
+ 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;
+ 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;
+ private static final int LABEL_TEXT_BASELINE = 14 * SCALE;
+ static final int MAX_IMAGE_DIMENSION = 16384;
+
+ private static final String DARK_COLORS
+ = "bg=#0d1117:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
+ + ":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+ private static final String LIGHT_COLORS
+ = "bg=#f6f8fa:text=#f0f6fc:arrow=#59636e:label=#1f2328:from=#238636:to=#1f6feb:eip=#8957e5"
+ + ":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+ private static final String TRANSPARENT_COLORS
+ = "bg=:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
+ + ":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+
+ private static final Map<String, String> COLOR_PRESETS = Map.of(
+ "dark", DARK_COLORS,
+ "light", LIGHT_COLORS,
+ "transparent", TRANSPARENT_COLORS);
+
+ static class DiagramColors {
+ private Color bg;
+ private Color text;
+ private Color arrow;
+ private Color routeLabel;
+ private Color nodeFrom;
+ private Color nodeTo;
+ private Color nodeEip;
+ private Color nodeChoice;
+ private Color nodeDefault;
+ private Color nodeTransform;
+ private Color nodeProcessor;
+
+ static DiagramColors parse(String spec) {
+ String resolved = COLOR_PRESETS.getOrDefault(spec, spec);
+ Map<String, String> map = new HashMap<>();
+ for (String entry : DARK_COLORS.split(":")) {
+ int eq = entry.indexOf('=');
+ if (eq > 0) {
+ map.put(entry.substring(0, eq), entry.substring(eq + 1));
+ }
+ }
+ 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"));
+ c.nodeTransform = parseColor(map.getOrDefault("transform", "#00838f"));
+ c.nodeProcessor = parseColor(map.getOrDefault("processor", "#d84315"));
+ return c;
+ }
+
+ private static Color parseColor(String value) {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ if (value.startsWith("#")) {
+ try {
+ return new Color(Integer.parseInt(value.substring(1), 16));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ Integer idx = org.jline.utils.Colors.rgbColor(value);
+ if (idx != null) {
+ return new Color(org.jline.utils.Colors.rgbColor(idx.intValue()));
+ }
+ return null;
+ }
+
+ Color getBg() {
+ return bg;
+ }
+
+ Color getText() {
+ return text;
+ }
+
+ Color getArrow() {
+ return arrow;
+ }
+
+ Color getRouteLabel() {
+ return routeLabel;
+ }
+
+ Color getNodeFrom() {
+ return nodeFrom;
+ }
+
+ Color getNodeTo() {
+ return nodeTo;
+ }
+
+ Color getNodeEip() {
+ return nodeEip;
+ }
+
+ Color getNodeChoice() {
+ return nodeChoice;
+ }
+
+ Color getNodeDefault() {
+ return nodeDefault;
+ }
+
+ Color getNodeTransform() {
+ return nodeTransform;
+ }
+
+ Color getNodeProcessor() {
+ return nodeProcessor;
+ }
+ }
+
+ BufferedImage renderDiagram(List<LayoutRoute> layoutRoutes, int totalHeight, DiagramColors colors) {
+ int imgWidth = layoutRoutes.stream().mapToInt(lr -> lr.maxX).max().orElse(400) + PADDING;
+ int imgHeight = totalHeight + PADDING;
+
+ if (imgWidth > MAX_IMAGE_DIMENSION || imgHeight > MAX_IMAGE_DIMENSION) {
+ throw new IllegalStateException(
+ "Diagram too large (" + imgWidth / SCALE + "x" + imgHeight / SCALE
+ + " logical pixels). Use --filter to narrow the routes displayed.");
+ }
+
+ int imageType = colors.getBg() == null ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
+ BufferedImage image = new BufferedImage(imgWidth, imgHeight, imageType);
+ Graphics2D g = image.createGraphics();
+ try {
+ 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.getBg() != null) {
+ g.setColor(colors.getBg());
+ g.fillRect(0, 0, imgWidth, imgHeight);
+ }
+
+ for (LayoutRoute lr : layoutRoutes) {
+ drawRoute(g, lr, colors);
+ }
+ } finally {
+ g.dispose();
+ }
+ return image;
+ }
+
+ private void drawRoute(Graphics2D g, LayoutRoute lr, DiagramColors colors) {
+ g.setColor(colors.getRouteLabel());
+ 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 + LABEL_TEXT_BASELINE);
+
+ 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);
+ }
+ }
+
+ for (LayoutNode ln : lr.nodes) {
+ if (ln.parentNode != null) {
+ if (ln.connectFromMerge) {
+ drawArrowFromMerge(g, ln, colors);
+ } else {
+ drawArrow(g, ln.parentNode, ln, colors);
+ }
+ }
+ }
+
+ for (LayoutNode ln : lr.nodes) {
+ drawNode(g, ln, colors);
+ }
+ }
+
+ 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;
+ 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);
+ }
+ }
+
+ if (minCx < maxCx) {
+ g.drawLine(minCx, mergeY, maxCx, mergeY);
+ }
+
+ 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, DiagramColors colors) {
+ g.setColor(colors.getArrow());
+ g.setStroke(new BasicStroke(STROKE_WIDTH));
+
+ 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, DiagramColors colors) {
+ Color color = getNodeColor(node.type, colors);
+
+ g.setColor(color);
+ g.fillRoundRect(node.x, node.y, NODE_WIDTH, NODE_HEIGHT, ARC, ARC);
+
+ g.setColor(color.brighter());
+ g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH));
+ g.drawRoundRect(node.x, node.y, NODE_WIDTH, NODE_HEIGHT, ARC, ARC);
+
+ g.setColor(colors.getText());
+ g.setFont(new Font("SansSerif", Font.PLAIN, FONT_SIZE_NODE));
+ FontMetrics fm = g.getFontMetrics();
+ String text = node.label;
+ while (fm.stringWidth(text) > NODE_WIDTH - NODE_TEXT_PADDING && text.length() > 4) {
+ 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, DiagramColors colors) {
+ g.setColor(colors.getArrow());
+ g.setStroke(new BasicStroke(STROKE_WIDTH));
+
+ 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, DiagramColors colors) {
+ if (type == null) {
+ return colors.getNodeDefault();
+ }
+ return switch (type) {
+ case "from" -> colors.getNodeFrom();
+ case "to", "toD", "wireTap", "enrich", "pollEnrich" -> colors.getNodeTo();
+ case "choice", "when", "otherwise" -> colors.getNodeChoice();
+ case "marshal", "unmarshal", "transform", "setBody", "setHeader", "setProperty",
+ "convertBodyTo", "removeHeader", "removeHeaders", "removeProperty", "removeProperties" ->
+ colors.getNodeTransform();
+ case "bean", "process", "log", "script", "delay" -> colors.getNodeProcessor();
+ case "filter", "split", "aggregate", "multicast", "recipientList",
+ "routingSlip", "dynamicRouter", "loadBalance",
+ "circuitBreaker", "saga", "doTry", "doCatch", "doFinally",
+ "onException", "onCompletion", "intercept",
+ "loop", "resequence", "throttle", "kamelet", "pipeline", "threads" ->
+ colors.getNodeEip();
+ default -> colors.getNodeDefault();
+ };
+ }
+
+ void printTextDiagram(List<RouteInfo> routes, Printer printer) {
+ for (RouteInfo route : routes) {
+ printer.println();
+ String header = "Route: " + route.routeId;
+ if (route.source != null && !route.source.isEmpty()) {
+ header += " (" + route.source + ")";
+ }
+ printer.println(header);
+
+ TreeNode tree = RouteDiagramLayoutEngine.buildTree(route.nodes);
+ if (tree != null) {
+ printTreeNode(tree, "", true, true, printer);
+ }
+ printer.println();
+ }
+ }
+
+ private void printTreeNode(TreeNode node, String prefix, boolean isLast, boolean isRoot, Printer printer) {
+ String connector;
+ if (isRoot) {
+ connector = " ";
+ } else if (isLast) {
+ connector = "└── ";
+ } else {
+ connector = "├── ";
+ }
+
+ String typeTag = node.info.type != null ? "[" + node.info.type + "] " : "";
+ String code = RouteDiagramLayoutEngine.cleanLabel(node.info.code);
+
+ printer.println(prefix + connector + typeTag + code);
+
+ String childPrefix;
+ if (isRoot) {
+ childPrefix = prefix + " ";
+ } else if (isLast) {
+ childPrefix = prefix + " ";
+ } else {
+ childPrefix = prefix + "│ ";
+ }
+
+ for (int i = 0; i < node.children.size(); i++) {
+ printTreeNode(node.children.get(i), childPrefix, i == node.children.size() - 1, false, printer);
+ }
+ }
+}
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
new file mode 100644
index 000000000000..9e6aceea6ee7
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java
@@ -0,0 +1,481 @@
+/*
+ * 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.Color;
+import java.awt.image.BufferedImage;
+import java.util.List;
+
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.TreeNode;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramRenderer.DiagramColors;
+import org.apache.camel.dsl.jbang.core.common.Printer;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CamelRouteDiagramActionTest {
+
+ @Test
+ void testBuildTreeEmpty() {
+ assertNull(RouteDiagramLayoutEngine.buildTree(List.of()));
+ }
+
+ @Test
+ void testBuildTreeSingleNode() {
+ List<NodeInfo> nodes = List.of(node("from", "timer:tick", 0));
+ TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+ assertNotNull(root);
+ assertEquals("from", root.info.type);
+ assertTrue(root.children.isEmpty());
+ }
+
+ @Test
+ void testBuildTreeSequential() {
+ List<NodeInfo> nodes = List.of(
+ node("from", "timer:tick", 0),
+ node("to", "log:a", 1),
+ node("to", "log:b", 1));
+ TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+ assertNotNull(root);
+ assertEquals(2, root.children.size());
+ assertEquals("log:a", root.children.get(0).info.code);
+ assertEquals("log:b", root.children.get(1).info.code);
+ }
+
+ @Test
+ void testBuildTreeBranching() {
+ List<NodeInfo> nodes = List.of(
+ node("from", "timer:tick", 0),
+ node("choice", "choice()", 1),
+ node("when", "when(simple(...))", 2),
+ node("to", "log:a", 3),
+ node("otherwise", "otherwise()", 2),
+ node("to", "log:b", 3));
+ TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+ assertNotNull(root);
+ assertEquals(1, root.children.size());
+ TreeNode choice = root.children.get(0);
+ assertEquals("choice", choice.info.type);
+ assertEquals(2, choice.children.size());
+ assertEquals("when", choice.children.get(0).info.type);
+ assertEquals("otherwise", choice.children.get(1).info.type);
+ assertEquals(1, choice.children.get(0).children.size());
+ assertEquals(1, choice.children.get(1).children.size());
+ }
+
+ @Test
+ void testBuildTreeWalkUpMultipleLevels() {
+ List<NodeInfo> nodes = List.of(
+ node("from", "timer:tick", 0),
+ node("choice", "choice()", 1),
+ node("when", "when(...)", 2),
+ node("to", "log:deep", 3),
+ node("to", "log:after-choice", 1));
+ TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+ assertNotNull(root);
+ assertEquals(2, root.children.size());
+ assertEquals("choice", root.children.get(0).info.type);
+ assertEquals("log:after-choice", root.children.get(1).info.code);
+ }
+
+ @Test
+ void testBuildTreeDeeplyNested() {
+ List<NodeInfo> nodes = List.of(
+ node("from", "timer:tick", 0),
+ node("choice", "choice()", 1),
+ node("when", "when(a)", 2),
+ node("choice", "choice()", 3),
+ node("when", "when(b)", 4),
+ node("to", "log:deep", 5),
+ node("to", "log:end", 1));
+ TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+ assertNotNull(root);
+ assertEquals(2, root.children.size());
+ TreeNode outerChoice = root.children.get(0);
+ assertEquals("choice", outerChoice.info.type);
+ TreeNode when = outerChoice.children.get(0);
+ assertEquals(1, when.children.size());
+ TreeNode innerChoice = when.children.get(0);
+ assertEquals("choice", innerChoice.info.type);
+ assertEquals("log:end", root.children.get(1).info.code);
+ }
+
+ @Test
+ void testBuildTreeFromOnly() {
+ List<NodeInfo> nodes = List.of(node("from", "timer:tick", 0));
+ TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+ assertNotNull(root);
+ assertEquals("from", root.info.type);
+ assertTrue(root.children.isEmpty());
+ }
+
+ @Test
+ void testColorPresetDark() {
+ DiagramColors colors = DiagramColors.parse("dark");
+ assertNotNull(colors.getBg());
+ assertNotNull(colors.getText());
+ assertNotNull(colors.getNodeFrom());
+ assertEquals(new Color(0x0d1117), colors.getBg());
+ assertEquals(new Color(0xf0f6fc), colors.getText());
+ }
+
+ @Test
+ void testColorPresetLight() {
+ DiagramColors colors = DiagramColors.parse("light");
+ assertEquals(new Color(0xf6f8fa), colors.getBg());
+ assertEquals(new Color(0xf0f6fc), colors.getText());
+ }
+
+ @Test
+ void testColorPresetTransparent() {
+ DiagramColors colors = DiagramColors.parse("transparent");
+ assertNull(colors.getBg());
+ assertNotNull(colors.getText());
+ }
+
+ @Test
+ void testColorCustomOverride() {
+ DiagramColors colors = DiagramColors.parse("bg=#ff0000:from=#00ff00");
+ assertEquals(new Color(0xff0000), colors.getBg());
+ assertEquals(new Color(0x00ff00), colors.getNodeFrom());
+ assertNotNull(colors.getText());
+ assertNotNull(colors.getNodeTo());
+ }
+
+ @Test
+ void testColorInvalidHexFallsBack() {
+ DiagramColors colors = DiagramColors.parse("bg=notacolor");
+ assertNull(colors.getBg());
+ }
+
+ @Test
+ void testColorNewCategories() {
+ DiagramColors colors = DiagramColors.parse("dark");
+ assertNotNull(colors.getNodeTransform());
+ assertNotNull(colors.getNodeProcessor());
+ assertEquals(new Color(0x1b7c83), colors.getNodeTransform());
+ assertEquals(new Color(0xbf4b8a), colors.getNodeProcessor());
+ }
+
+ @Test
+ void testColorCustomTransformProcessor() {
+ DiagramColors colors = DiagramColors.parse("transform=#aabbcc:processor=#ddeeff");
+ assertEquals(new Color(0xaabbcc), colors.getNodeTransform());
+ assertEquals(new Color(0xddeeff), colors.getNodeProcessor());
+ }
+
+ @Test
+ void testColorByName() {
+ DiagramColors colors = DiagramColors.parse("from=green:to=blue");
+ assertNotNull(colors.getNodeFrom());
+ assertNotNull(colors.getNodeTo());
+ }
+
+ @Test
+ void testColorInvalidNameFallsBack() {
+ DiagramColors colors = DiagramColors.parse("bg=nosuchcolor");
+ assertNull(colors.getBg());
+ }
+
+ @Test
+ void testCleanLabelNull() {
+ assertEquals("", RouteDiagramLayoutEngine.cleanLabel(null));
+ }
+
+ @Test
+ void testCleanLabelShort() {
+ assertEquals("log:hello", RouteDiagramLayoutEngine.cleanLabel("log:hello"));
+ }
+
+ @Test
+ void testCleanLabelLeadingDot() {
+ assertEquals("to(\"log:a\")", RouteDiagramLayoutEngine.cleanLabel(".to(\"log:a\")"));
+ }
+
+ @Test
+ void testCleanLabelPreservesLong() {
+ String longLabel = "to(\"http://very-long-endpoint-url-that-exceeds-forty-characters\")";
+ String result = RouteDiagramLayoutEngine.cleanLabel(longLabel);
+ assertEquals(longLabel, result);
+ }
+
+ @Test
+ void testLayoutSingleRoute() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("to", "log:a", 1));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+ assertEquals(2, lr.nodes.size());
+ assertTrue(lr.maxX > 0);
+ assertTrue(lr.maxY > 0);
+
+ LayoutNode fromNode = lr.nodes.get(0);
+ LayoutNode toNode = lr.nodes.get(1);
+ assertEquals("from", fromNode.type);
+ assertEquals("to", toNode.type);
+ assertEquals(fromNode.x, toNode.x);
+ assertTrue(toNode.y > fromNode.y);
+ }
+
+ @Test
+ void testLayoutBranchingRoute() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("choice", "choice()", 1));
+ route.nodes.add(node("when", "when(...)", 2));
+ route.nodes.add(node("to", "log:a", 3));
+ route.nodes.add(node("otherwise", "otherwise()", 2));
+ route.nodes.add(node("to", "log:b", 3));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+ assertEquals(6, lr.nodes.size());
+
+ LayoutNode whenNode = lr.nodes.get(2);
+ LayoutNode otherwiseNode = lr.nodes.get(4);
+ assertTrue(whenNode.x != otherwiseNode.x, "Branches should be at different x positions");
+ }
+
+ @Test
+ void testLayoutEmptyRoute() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "empty";
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route, 0);
+
+ assertTrue(lr.nodes.isEmpty());
+ assertTrue(lr.maxX > 0);
+ }
+
+ @Test
+ void testRenderDiagramProducesImage() {
+ System.setProperty("java.awt.headless", "true");
+
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("to", "log:a", 1));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+ DiagramColors colors = DiagramColors.parse("dark");
+ BufferedImage image = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP, colors);
+
+ assertNotNull(image);
+ assertTrue(image.getWidth() > 0);
+ assertTrue(image.getHeight() > 0);
+ assertEquals(BufferedImage.TYPE_INT_RGB, image.getType());
+ }
+
+ @Test
+ void testRenderDiagramTransparentBackground() {
+ System.setProperty("java.awt.headless", "true");
+
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+ DiagramColors colors = DiagramColors.parse("transparent");
+ BufferedImage image = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP, colors);
+
+ assertEquals(BufferedImage.TYPE_INT_ARGB, image.getType());
+ }
+
+ @Test
+ void testRenderDiagramExceedingMaxDimensionThrows() {
+ System.setProperty("java.awt.headless", "true");
+
+ LayoutRoute lr = new LayoutRoute();
+ lr.routeId = "huge";
+ lr.maxX = RouteDiagramRenderer.MAX_IMAGE_DIMENSION + 100;
+ lr.maxY = 100;
+
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+ DiagramColors colors = DiagramColors.parse("dark");
+ assertThrows(IllegalStateException.class,
+ () -> renderer.renderDiagram(List.of(lr), 100, colors));
+ }
+
+ @Test
+ void testTextDiagramSequential() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.source = "test.yaml";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("to", "log:a", 1));
+ route.nodes.add(node("to", "log:b", 1));
+
+ StringBuilder sb = new StringBuilder();
+ Printer printer = new CapturingPrinter(sb);
+
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+ renderer.printTextDiagram(List.of(route), printer);
+
+ String output = sb.toString();
+ assertTrue(output.contains("Route: route1 (test.yaml)"));
+ assertTrue(output.contains("[from] timer:tick"));
+ assertTrue(output.contains("[to] log:a"));
+ assertTrue(output.contains("[to] log:b"));
+ assertTrue(output.contains("├──") || output.contains("└──"));
+ }
+
+ @Test
+ void testTextDiagramBranching() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("choice", "choice()", 1));
+ route.nodes.add(node("when", "when(...)", 2));
+ route.nodes.add(node("to", "log:a", 3));
+ route.nodes.add(node("otherwise", "otherwise()", 2));
+ route.nodes.add(node("to", "log:b", 3));
+
+ StringBuilder sb = new StringBuilder();
+ Printer printer = new CapturingPrinter(sb);
+
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+ renderer.printTextDiagram(List.of(route), printer);
+
+ String output = sb.toString();
+ assertTrue(output.contains("[choice]"));
+ assertTrue(output.contains("[when]"));
+ assertTrue(output.contains("[otherwise]"));
+ assertTrue(output.contains("│"), "Should contain tree continuation characters");
+ }
+
+ @Test
+ void testTextDiagramEmptyRoute() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "empty";
+
+ StringBuilder sb = new StringBuilder();
+ Printer printer = new CapturingPrinter(sb);
+
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+ renderer.printTextDiagram(List.of(route), printer);
+
+ String output = sb.toString();
+ assertTrue(output.contains("Route: empty"));
+ }
+
+ @Test
+ void testParseRoutesEmpty() {
+ org.apache.camel.util.json.JsonObject jo = new org.apache.camel.util.json.JsonObject();
+ CamelRouteDiagramAction action = new CamelRouteDiagramAction(null);
+ List<RouteInfo> routes = action.parseRoutes(jo);
+ assertTrue(routes.isEmpty());
+ }
+
+ @Test
+ void testParseRoutesWithData() {
+ org.apache.camel.util.json.JsonObject line1 = new org.apache.camel.util.json.JsonObject();
+ line1.put("type", "from");
+ line1.put("code", "timer:tick");
+ line1.put("level", 0);
+
+ org.apache.camel.util.json.JsonObject line2 = new org.apache.camel.util.json.JsonObject();
+ line2.put("type", "to");
+ line2.put("code", "log:a");
+ line2.put("level", 1);
+
+ org.apache.camel.util.json.JsonArray code = new org.apache.camel.util.json.JsonArray();
+ code.add(line1);
+ code.add(line2);
+
+ org.apache.camel.util.json.JsonObject routeObj = new org.apache.camel.util.json.JsonObject();
+ routeObj.put("routeId", "route1");
+ routeObj.put("code", code);
+
+ org.apache.camel.util.json.JsonArray routesArr = new org.apache.camel.util.json.JsonArray();
+ routesArr.add(routeObj);
+
+ org.apache.camel.util.json.JsonObject jo = new org.apache.camel.util.json.JsonObject();
+ jo.put("routes", routesArr);
+
+ CamelRouteDiagramAction action = new CamelRouteDiagramAction(null);
+ List<RouteInfo> routes = action.parseRoutes(jo);
+
+ assertEquals(1, routes.size());
+ assertEquals("route1", routes.get(0).routeId);
+ assertEquals(2, routes.get(0).nodes.size());
+ assertEquals("from", routes.get(0).nodes.get(0).type);
+ assertEquals("timer:tick", routes.get(0).nodes.get(0).code);
+ }
+
+ private static NodeInfo node(String type, String code, int level) {
+ NodeInfo n = new NodeInfo();
+ n.type = type;
+ n.code = code;
+ n.level = level;
+ return n;
+ }
+
+ private static class CapturingPrinter implements Printer {
+ private final StringBuilder sb;
+
+ CapturingPrinter(StringBuilder sb) {
+ this.sb = sb;
+ }
+
+ @Override
+ public void println() {
+ sb.append('\n');
+ }
+
+ @Override
+ public void println(String line) {
+ sb.append(line).append('\n');
+ }
+
+ @Override
+ public void print(String output) {
+ sb.append(output);
+ }
+
+ @Override
+ public void printf(String format, Object... args) {
+ sb.append(String.format(format, args));
+ }
+ }
+}