This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 de4c199e47c3 CAMEL-26348: camel-diagram - a route diagram image draws
the steps as cards (#28575)
de4c199e47c3 is described below
commit de4c199e47c3bb8676b9e7512edc4f213376f00f
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 28 21:35:39 2026 +0100
CAMEL-26348: camel-diagram - a route diagram image draws the steps as cards
(#28575)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
---
.../org/apache/camel/catalog/docs/diagram.adoc | 40 ++-
.../camel-diagram/src/main/docs/diagram.adoc | 40 ++-
.../src/main/docs/route-diagram-card.png | Bin 0 -> 41066 bytes
.../camel/diagram/DefaultRouteDiagramDumper.java | 11 +-
.../apache/camel/diagram/RouteDiagramIcons.java | 377 +++++++++++++++++++++
.../camel/diagram/RouteDiagramLayoutEngine.java | 113 +++++-
.../apache/camel/diagram/RouteDiagramRenderer.java | 154 ++++++---
.../camel/diagram/THIRD-PARTY-NOTICES.txt | 2 +-
.../camel/diagram/RouteDiagramCardStyleTest.java | 224 ++++++++++++
.../modules/ROOT/images/route-diagram-card.png | 1 +
.../ROOT/images/images/route-diagram-sample.png | Bin 18833 -> 26381 bytes
.../ROOT/images/jbang/route-diagram-dark.png | Bin 151329 -> 0 bytes
.../ROOT/images/jbang/route-diagram-light.png | Bin 144838 -> 0 bytes
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 28 ++
.../camel-jbang-cmd-route-diagram.adoc | 2 +-
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../core/commands/action/CamelHistoryAction.java | 3 +-
.../commands/action/CamelRouteDiagramAction.java | 22 +-
.../dsl/jbang/core/commands/process/ListError.java | 3 +-
.../action/CamelRouteDiagramActionRunningTest.java | 52 +++
.../jbang/core/commands/mcp/RouteDiagramTools.java | 4 +-
.../core/commands/mcp/RouteDiagramToolsTest.java | 32 ++
22 files changed, 1043 insertions(+), 67 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
index 65116747f00f..7bc0eddc8571 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
@@ -71,6 +71,8 @@ List<RouteInfo> routes =
RouteDiagramHelper.parseRoutes(jsonObject);
// Layout and render
RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+// the renderer draws the steps as cards, so lay them out as cards
+engine.setCardStyle(true);
RouteDiagramRenderer renderer = new RouteDiagramRenderer();
LayoutRoute lr = engine.layoutRoute(routes.get(0),
RouteDiagramLayoutEngine.PADDING);
@@ -132,9 +134,9 @@ frame below its steps, and in topology mode the routes
reached only on error in
The following built-in themes are available:
-* `dark` - dark background (default)
-* `light` - light background
-* `transparent` - transparent background
+* `dark` - dark background, dark cards (default)
+* `light` - light background, white cards
+* `transparent` - transparent background, white cards
Custom colors can be specified using the format:
@@ -143,6 +145,14 @@ Custom colors can be specified using the format:
bg=#1e1e1e:from=#2e7d32:to=#1565c0:eip=#8957e5:choice=#d29922
----
+A custom spec is on top of the `dark` theme. The colors of the cards are set
with `card` (the background of a card),
+`cardBorder`, `cardText` (the kind of step) and `cardDetail` (its detail):
+
+[source,properties]
+----
+bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e
+----
+
Color values can be `#hex` codes or ANSI color names (e.g., `seagreen`,
`steelblue`).
To use dark theme
@@ -152,6 +162,27 @@ To use dark theme
camel cmd route-diagram MyRoute.java --theme=dark
----
+=== Steps as Cards
+
+An image of a route draws each step as a card: an icon for the kind of step,
in a circle of the color of its kind,
+the kind in bold (such as `setBody`), and its detail below (such as `groovy:
body.items`, from the code
+`setBody[groovy{body.items}]`). A step without detail, such as `choice`, shows
only its kind.
+
+[source,bash]
+----
+camel cmd route-diagram orders.camel.yaml --output=orders.png
+----
+
+`camel cmd route-diagram` saves a `.png` file in the `light` theme when no
`--theme` is given (in the terminal it shows
+the `unicode` text diagram), and so does the `camel_render_route_diagram` MCP
tool.
+
+image::ROOT:route-diagram-card.png[A route diagram: a file read, a groovy
mapping, an http call and a JMS send as white cards]
+
+With `--node-label=both` (the default of `camel cmd route-diagram`) a step
with a description shows the description
+and then the detail below its kind.
+
+The icons are from https://lucide.dev[Lucide] (ISC License), the same as the
embeddable web components.
+
== ASCII Art Rendering
The ASCII art renderer produces plain text diagrams using box-drawing
characters.
@@ -410,7 +441,8 @@ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNod
String diagram = renderer.renderDiagramAnsi(layoutRoutes, totalHeight,
highlightedNodes, style);
// For PNG rendering
-RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(nodeWidth,
fontSize);
+RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(
+ engine.getNodeWidth(), fontSize * RouteDiagramLayoutEngine.SCALE,
false);
BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight,
colors, highlightedNodes, style);
----
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc
b/components/camel-diagram/src/main/docs/diagram.adoc
index 65116747f00f..7bc0eddc8571 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -71,6 +71,8 @@ List<RouteInfo> routes =
RouteDiagramHelper.parseRoutes(jsonObject);
// Layout and render
RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+// the renderer draws the steps as cards, so lay them out as cards
+engine.setCardStyle(true);
RouteDiagramRenderer renderer = new RouteDiagramRenderer();
LayoutRoute lr = engine.layoutRoute(routes.get(0),
RouteDiagramLayoutEngine.PADDING);
@@ -132,9 +134,9 @@ frame below its steps, and in topology mode the routes
reached only on error in
The following built-in themes are available:
-* `dark` - dark background (default)
-* `light` - light background
-* `transparent` - transparent background
+* `dark` - dark background, dark cards (default)
+* `light` - light background, white cards
+* `transparent` - transparent background, white cards
Custom colors can be specified using the format:
@@ -143,6 +145,14 @@ Custom colors can be specified using the format:
bg=#1e1e1e:from=#2e7d32:to=#1565c0:eip=#8957e5:choice=#d29922
----
+A custom spec is on top of the `dark` theme. The colors of the cards are set
with `card` (the background of a card),
+`cardBorder`, `cardText` (the kind of step) and `cardDetail` (its detail):
+
+[source,properties]
+----
+bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e
+----
+
Color values can be `#hex` codes or ANSI color names (e.g., `seagreen`,
`steelblue`).
To use dark theme
@@ -152,6 +162,27 @@ To use dark theme
camel cmd route-diagram MyRoute.java --theme=dark
----
+=== Steps as Cards
+
+An image of a route draws each step as a card: an icon for the kind of step,
in a circle of the color of its kind,
+the kind in bold (such as `setBody`), and its detail below (such as `groovy:
body.items`, from the code
+`setBody[groovy{body.items}]`). A step without detail, such as `choice`, shows
only its kind.
+
+[source,bash]
+----
+camel cmd route-diagram orders.camel.yaml --output=orders.png
+----
+
+`camel cmd route-diagram` saves a `.png` file in the `light` theme when no
`--theme` is given (in the terminal it shows
+the `unicode` text diagram), and so does the `camel_render_route_diagram` MCP
tool.
+
+image::ROOT:route-diagram-card.png[A route diagram: a file read, a groovy
mapping, an http call and a JMS send as white cards]
+
+With `--node-label=both` (the default of `camel cmd route-diagram`) a step
with a description shows the description
+and then the detail below its kind.
+
+The icons are from https://lucide.dev[Lucide] (ISC License), the same as the
embeddable web components.
+
== ASCII Art Rendering
The ASCII art renderer produces plain text diagrams using box-drawing
characters.
@@ -410,7 +441,8 @@ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNod
String diagram = renderer.renderDiagramAnsi(layoutRoutes, totalHeight,
highlightedNodes, style);
// For PNG rendering
-RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(nodeWidth,
fontSize);
+RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(
+ engine.getNodeWidth(), fontSize * RouteDiagramLayoutEngine.SCALE,
false);
BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight,
colors, highlightedNodes, style);
----
diff --git a/components/camel-diagram/src/main/docs/route-diagram-card.png
b/components/camel-diagram/src/main/docs/route-diagram-card.png
new file mode 100644
index 000000000000..da37b2da2c66
Binary files /dev/null and
b/components/camel-diagram/src/main/docs/route-diagram-card.png differ
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
index 7b14ced2d825..ed6025863ccb 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
@@ -197,8 +197,14 @@ public class DefaultRouteDiagramDumper extends
ServiceSupport implements CamelCo
boolean errors, List<ErrorPath> paths) {
RouteDiagramRenderer renderer = new RouteDiagramRenderer(
nodeWidth * RouteDiagramLayoutEngine.SCALE, fontSize *
RouteDiagramLayoutEngine.SCALE, metrics);
+ if (theme == null) {
+ theme = "transparent";
+ }
+ theme = theme.toLowerCase();
+ RouteDiagramRenderer.DiagramColors colors =
RouteDiagramRenderer.DiagramColors.parse(theme);
RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
nodeWidth, fontSize,
RouteDiagramLayoutEngine.NodeLabelMode.valueOf(nodeLabel.toUpperCase()));
+ engine.setCardStyle(true);
List<RouteDiagramLayoutEngine.LayoutRoute> layoutRoutes = new
ArrayList<>();
int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -208,11 +214,6 @@ public class DefaultRouteDiagramDumper extends
ServiceSupport implements CamelCo
layoutRoutes.add(lr);
currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
}
- if (theme == null) {
- theme = "transparent";
- }
- theme = theme.toLowerCase();
- RouteDiagramRenderer.DiagramColors colors =
RouteDiagramRenderer.DiagramColors.parse(theme);
return renderer.renderDiagram(layoutRoutes, currentY, colors,
highlightedNodeIds, highlightStyle);
}
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java
new file mode 100644
index 000000000000..fc9efe86a884
--- /dev/null
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java
@@ -0,0 +1,377 @@
+/*
+ * 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.diagram;
+
+import java.awt.Shape;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Arc2D;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Line2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.RoundRectangle2D;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The icons of the steps in the card style of a route diagram: Lucide icons
(https://lucide.dev, ISC License; the same
+ * icons as the camel-route-diagram web component), as outlines on a 24x24
grid to stroke with round caps and joins.
+ */
+final class RouteDiagramIcons {
+
+ /** The size of the grid the icons are drawn on. */
+ static final int GRID = 24;
+
+ private static final Map<String, Shape> ICONS = new HashMap<>();
+
+ static {
+ ICONS.put("workflow", shape(rect(3, 3, 8, 8, 2), path("M7 11v4a2 2 0 0
0 2 2h4"), rect(13, 13, 8, 8, 2)));
+ ICONS.put("log-in", shape(path("M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2
2h-4"),
+ polyline(10, 17, 15, 12, 10, 7), line(15, 12, 3, 12)));
+ ICONS.put("log-out", shape(path("M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1
2-2h4"),
+ polyline(16, 17, 21, 12, 16, 7), line(21, 12, 9, 12)));
+ ICONS.put("file-text", shape(path("M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0
2 2h12a2 2 0 0 0 2-2V7Z"),
+ path("M14 2v6h6"), path("M16 13H8"), path("M16 17H8"),
path("M10 9H8")));
+ ICONS.put("git-branch", shape(line(6, 3, 6, 15), circle(18, 6, 3),
circle(6, 18, 3),
+ path("M18 9a9 9 0 0 1-9 9")));
+ ICONS.put("corner-down-right", shape(polyline(15, 10, 20, 15, 15, 20),
path("M4 4v7a4 4 0 0 0 4 4h12")));
+ ICONS.put("split", shape(path("M16 3h5v5"), path("M8 3H3v5"),
+ path("M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"), path("m15 9
6-6")));
+ ICONS.put("merge", shape(path("m8 6 4-4 4 4"), path("M12 2v10.3a4 4 0
0 1-1.172 2.872L4 22"),
+ path("m20 22-5-5")));
+ ICONS.put("shield", shape(path("M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0
1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1"
+ + "c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0
1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z")));
+ ICONS.put("alert-triangle", shape(path("m21.73 18-8-14a2 2 0 0 0-3.48
0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"),
+ path("M12 9v4"), path("M12 17h.01")));
+ ICONS.put("flag", shape(path("M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4
1-5-2-8-2-4 1-4 1z"), line(4, 22, 4, 15)));
+ ICONS.put("zap", shape(path("M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0
0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7"
+ + "a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0
1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z")));
+ ICONS.put("braces", shape(path("M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2
2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"),
+ path("M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0
1-2-2V5a2 2 0 0 0-2-2h-1")));
+ ICONS.put("heading", shape(path("M6 12h12"), path("M6 20V4"),
path("M18 20V4")));
+ ICONS.put("x", shape(path("M18 6 6 18"), path("m6 6 12 12")));
+ ICONS.put("repeat", shape(path("m17 2 4 4-4 4"), path("M3 11v-1a4 4 0
0 1 4-4h14"), path("m7 22-4-4 4-4"),
+ path("M21 13v1a4 4 0 0 1-4 4H3")));
+ ICONS.put("code", shape(path("m16 18 6-6-6-6"), path("m8 6-6 6 6 6")));
+ ICONS.put("clock", shape(circle(12, 12, 10), polyline(12, 6, 12, 12,
16, 14)));
+ ICONS.put("filter", shape(path("M22 3H2l8 9.46V19l4 2v-8.54L22 3z")));
+ ICONS.put("rotate-cw", shape(path("M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1
6.74 2.74L21 8"), path("M21 3v5h-5")));
+ ICONS.put("box", shape(path("M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2
0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4"
+ + "a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"),
+ path("m3.3 7 8.7 5 8.7-5"), path("M12 22V12")));
+ }
+
+ private RouteDiagramIcons() {
+ }
+
+ /** The names of the icons, for testing. */
+ static List<String> names() {
+ return List.copyOf(ICONS.keySet());
+ }
+
+ /** The icon by its Lucide name, or null. */
+ static Shape icon(String name) {
+ return ICONS.get(name);
+ }
+
+ /** The icon of a step, by its type (such as setBody); the box icon for a
step without one of its own. */
+ static Shape iconFor(String type) {
+ return ICONS.get(iconName(type));
+ }
+
+ static String iconName(String type) {
+ if (type == null) {
+ return "box";
+ }
+ return switch (type) {
+ case "route" -> "workflow";
+ case "from" -> "log-in";
+ case "to", "toD", "wireTap", "enrich", "pollEnrich", "kamelet" ->
"log-out";
+ case "log" -> "file-text";
+ case "choice", "switch" -> "git-branch";
+ case "when", "otherwise" -> "corner-down-right";
+ case "doTry" -> "shield";
+ case "doCatch", "onException" -> "alert-triangle";
+ case "doFinally", "onCompletion" -> "flag";
+ case "multicast", "split", "recipientList", "routingSlip",
"dynamicRouter", "loadBalance" -> "split";
+ case "aggregate" -> "merge";
+ case "circuitBreaker" -> "zap";
+ case "setBody", "transform" -> "braces";
+ case "setHeader", "setHeaders", "setProperty", "setVariable",
"setVariables" -> "heading";
+ case "removeHeader", "removeHeaders", "removeProperty",
"removeProperties", "removeVariable" -> "x";
+ case "marshal", "unmarshal", "convertBodyTo", "convertHeaderTo",
"convertVariableTo" -> "repeat";
+ case "bean", "process", "script" -> "code";
+ case "delay", "throttle" -> "clock";
+ case "filter", "validate" -> "filter";
+ case "loop" -> "rotate-cw";
+ default -> "box";
+ };
+ }
+
+ private static Shape shape(Shape... parts) {
+ Path2D.Double answer = new Path2D.Double();
+ for (Shape part : parts) {
+ answer.append(part, false);
+ }
+ return answer;
+ }
+
+ private static Shape rect(double x, double y, double w, double h, double
r) {
+ return new RoundRectangle2D.Double(x, y, w, h, 2 * r, 2 * r);
+ }
+
+ private static Shape circle(double cx, double cy, double r) {
+ return new Ellipse2D.Double(cx - r, cy - r, 2 * r, 2 * r);
+ }
+
+ private static Shape line(double x1, double y1, double x2, double y2) {
+ return new Line2D.Double(x1, y1, x2, y2);
+ }
+
+ private static Shape polyline(double... points) {
+ Path2D.Double p = new Path2D.Double();
+ p.moveTo(points[0], points[1]);
+ for (int i = 2; i < points.length; i += 2) {
+ p.lineTo(points[i], points[i + 1]);
+ }
+ return p;
+ }
+
+ /**
+ * The outline of SVG path data: the commands M, L, H, V, C, S, A and Z,
absolute and relative, as Lucide writes
+ * them (numbers run together such as {@code 1.1.9}, a command repeated by
more numbers).
+ */
+ static Path2D path(String d) {
+ PathParser parser = new PathParser(d);
+ return parser.parse();
+ }
+
+ private static final class PathParser {
+ private final String d;
+ private int pos;
+ private final Path2D.Double path = new Path2D.Double();
+ private double x;
+ private double y;
+ private double startX;
+ private double startY;
+ // the second control point of the last curve, for the reflection of S
+ private double ctrlX;
+ private double ctrlY;
+ private boolean lastCurve;
+
+ PathParser(String d) {
+ this.d = d;
+ }
+
+ Path2D parse() {
+ char cmd = 0;
+ skipSeparators();
+ while (pos < d.length()) {
+ char c = d.charAt(pos);
+ if (Character.isLetter(c)) {
+ cmd = c;
+ pos++;
+ } else if (cmd == 0) {
+ throw new IllegalArgumentException("Path data must start
with a command: " + d);
+ }
+ cmd = command(cmd);
+ skipSeparators();
+ }
+ return path;
+ }
+
+ /** Runs one command, and gives the command its further numbers repeat
(a moveto repeats as a lineto). */
+ private char command(char cmd) {
+ boolean rel = Character.isLowerCase(cmd);
+ double ox = rel ? x : 0;
+ double oy = rel ? y : 0;
+ boolean curve = false;
+ char next = cmd;
+ switch (Character.toUpperCase(cmd)) {
+ case 'M' -> {
+ x = ox + number();
+ y = oy + number();
+ path.moveTo(x, y);
+ startX = x;
+ startY = y;
+ next = rel ? 'l' : 'L';
+ }
+ case 'L' -> {
+ x = ox + number();
+ y = oy + number();
+ path.lineTo(x, y);
+ }
+ case 'H' -> {
+ x = ox + number();
+ path.lineTo(x, y);
+ }
+ case 'V' -> {
+ y = oy + number();
+ path.lineTo(x, y);
+ }
+ case 'C' -> {
+ double x1 = ox + number();
+ double y1 = oy + number();
+ ctrlX = ox + number();
+ ctrlY = oy + number();
+ x = ox + number();
+ y = oy + number();
+ path.curveTo(x1, y1, ctrlX, ctrlY, x, y);
+ curve = true;
+ }
+ case 'S' -> {
+ double x1 = lastCurve ? 2 * x - ctrlX : x;
+ double y1 = lastCurve ? 2 * y - ctrlY : y;
+ ctrlX = ox + number();
+ ctrlY = oy + number();
+ x = ox + number();
+ y = oy + number();
+ path.curveTo(x1, y1, ctrlX, ctrlY, x, y);
+ curve = true;
+ }
+ case 'A' -> {
+ double rx = number();
+ double ry = number();
+ double rotation = number();
+ boolean large = flag();
+ boolean sweep = flag();
+ double ex = ox + number();
+ double ey = oy + number();
+ arc(rx, ry, rotation, large, sweep, ex, ey);
+ x = ex;
+ y = ey;
+ }
+ case 'Z' -> {
+ path.closePath();
+ x = startX;
+ y = startY;
+ next = 0;
+ }
+ default -> throw new IllegalArgumentException("Unsupported
path command " + cmd + " in: " + d);
+ }
+ lastCurve = curve;
+ return next;
+ }
+
+ /** An elliptical arc from the current point, as SVG gives it
(endpoint parameters, F.6.5 of SVG 1.1). */
+ private void arc(double rx, double ry, double rotation, boolean large,
boolean sweep, double ex, double ey) {
+ if (x == ex && y == ey) {
+ return;
+ }
+ rx = Math.abs(rx);
+ ry = Math.abs(ry);
+ if (rx == 0 || ry == 0) {
+ path.lineTo(ex, ey);
+ return;
+ }
+ double phi = Math.toRadians(rotation);
+ double cos = Math.cos(phi);
+ double sin = Math.sin(phi);
+ double dx = (x - ex) / 2;
+ double dy = (y - ey) / 2;
+ double x1 = cos * dx + sin * dy;
+ double y1 = -sin * dx + cos * dy;
+ // radii too small to reach the end point are scaled up
+ double lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
+ if (lambda > 1) {
+ rx *= Math.sqrt(lambda);
+ ry *= Math.sqrt(lambda);
+ }
+ double num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1
* x1;
+ double den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
+ double coef = Math.sqrt(Math.max(0, num / den)) * (large == sweep
? -1 : 1);
+ double cx1 = coef * rx * y1 / ry;
+ double cy1 = -coef * ry * x1 / rx;
+ double cx = cos * cx1 - sin * cy1 + (x + ex) / 2;
+ double cy = sin * cx1 + cos * cy1 + (y + ey) / 2;
+ double theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry);
+ double delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1)
/ rx, (-y1 - cy1) / ry);
+ if (!sweep && delta > 0) {
+ delta -= 2 * Math.PI;
+ } else if (sweep && delta < 0) {
+ delta += 2 * Math.PI;
+ }
+ // Arc2D counts angles counterclockwise on screen, SVG clockwise
(the y axis points down)
+ Arc2D.Double arc = new Arc2D.Double(
+ cx - rx, cy - ry, 2 * rx, 2 * ry, -Math.toDegrees(theta),
-Math.toDegrees(delta), Arc2D.OPEN);
+ Shape shape = phi == 0 ? arc :
AffineTransform.getRotateInstance(phi, cx, cy).createTransformedShape(arc);
+ path.append(shape, true);
+ }
+
+ private static double angle(double ux, double uy, double vx, double
vy) {
+ return Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
+ }
+
+ private boolean flag() {
+ skipSeparators();
+ char c = d.charAt(pos++);
+ if (c != '0' && c != '1') {
+ throw new IllegalArgumentException("Expected an arc flag at "
+ (pos - 1) + " in: " + d);
+ }
+ return c == '1';
+ }
+
+ private double number() {
+ skipSeparators();
+ int start = pos;
+ if (pos < d.length() && (d.charAt(pos) == '-' || d.charAt(pos) ==
'+')) {
+ pos++;
+ }
+ boolean dot = false;
+ boolean digits = false;
+ boolean exponent = false;
+ while (pos < d.length()) {
+ char c = d.charAt(pos);
+ if (Character.isDigit(c)) {
+ digits = true;
+ pos++;
+ } else if (c == '.' && !dot) {
+ dot = true;
+ pos++;
+ } else if ((c == 'e' || c == 'E') && digits && !exponent &&
exponentDigits(pos + 1)) {
+ // an exponent only with its digits (1e-3); an e without
them ends the number
+ exponent = true;
+ dot = true;
+ pos++;
+ if (d.charAt(pos) == '-' || d.charAt(pos) == '+') {
+ pos++;
+ }
+ } else {
+ break;
+ }
+ }
+ if (pos == start) {
+ throw new IllegalArgumentException("Expected a number at " +
pos + " in: " + d);
+ }
+ return Double.parseDouble(d.substring(start, pos));
+ }
+
+ /** Whether the digits of an exponent start at the index, after an
optional sign. */
+ private boolean exponentDigits(int i) {
+ if (i < d.length() && (d.charAt(i) == '-' || d.charAt(i) == '+')) {
+ i++;
+ }
+ return i < d.length() && Character.isDigit(d.charAt(i));
+ }
+
+ private void skipSeparators() {
+ while (pos < d.length() && (Character.isWhitespace(d.charAt(pos))
|| d.charAt(pos) == ',')) {
+ pos++;
+ }
+ }
+ }
+}
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
index 85f31f1a2415..40281d9ea2a7 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
@@ -24,6 +24,8 @@ import java.text.BreakIterator;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
/**
* Layout engine that builds a tree from flat route node lists and computes
positions for diagram rendering.
@@ -41,6 +43,16 @@ public class RouteDiagramLayoutEngine {
private static final int MIN_BOX_WIDTH = 80;
private static final int DEFAULT_NODE_HEIGHT = 32;
+ /** The space between the border of a card and its icon, and its text. */
+ public static final int CARD_PAD = 10 * SCALE;
+ /** The space above and below the text of a card. */
+ public static final int CARD_PAD_Y = 9 * SCALE;
+ /** The diameter of the circle of the icon of a card. */
+ public static final int CARD_ICON_SIZE = 24 * SCALE;
+ /** Where the text of a card starts, from its left border: after the icon.
*/
+ public static final int CARD_TEXT_INSET = CARD_PAD + CARD_ICON_SIZE +
CARD_PAD;
+ private static final Pattern CARD_EXPRESSION =
Pattern.compile("^([A-Za-z][\\w-]*)\\{(.*)\\}$", Pattern.DOTALL);
+
public enum NodeLabelMode {
CODE,
DESCRIPTION,
@@ -52,8 +64,10 @@ public class RouteDiagramLayoutEngine {
private final int nodeTextPadding;
private final int baseNodeHeight;
private final FontMetrics fontMetrics;
+ private final FontMetrics titleMetrics;
private final NodeLabelMode nodeLabelMode;
private boolean tableLayout;
+ private boolean cardStyle;
static final Set<String> BRANCHING_EIPS = Set.of(
"choice", "multicast", "doTry", "loadBalance", "recipientList",
"circuitBreaker");
@@ -120,15 +134,35 @@ public class RouteDiagramLayoutEngine {
try {
g.setFont(new Font("SansSerif", Font.PLAIN, fontSize * SCALE));
this.fontMetrics = g.getFontMetrics();
+ g.setFont(cardTitleFont(fontSize * SCALE));
+ this.titleMetrics = g.getFontMetrics();
} finally {
g.dispose();
}
}
+ /** The font of the title of a card: bold, a size larger than the text of
the nodes. */
+ public static Font cardTitleFont(int fontSizeScaled) {
+ return new Font("SansSerif", Font.BOLD, fontSizeScaled + SCALE);
+ }
+
public int getNodeWidth() {
return nodeWidth;
}
+ /**
+ * Lays the nodes out as cards: a title (the kind of step, such as
setBody) and the detail below it (such as
+ * {@code groovy: body.items}), after an icon. The first of the {@link
LayoutNode#wrappedLines} is the title. On for
+ * an image ({@link RouteDiagramRenderer} draws cards), off (the default)
for the ASCII and Unicode diagrams.
+ */
+ public void setCardStyle(boolean cardStyle) {
+ this.cardStyle = cardStyle;
+ }
+
+ public boolean isCardStyle() {
+ return cardStyle;
+ }
+
/**
* Draws the {@link #TABLE_EIPS} as one wide node with a row per child
instead of a chain of boxes. Off by default:
* a renderer turns it on when it draws the rows ({@link
LayoutNode#tableRow}).
@@ -298,7 +332,10 @@ public class RouteDiagramLayoutEngine {
}
private List<String> wrapLabel(String label) {
- int maxTextWidth = nodeWidth - nodeTextPadding;
+ return wrapLabel(label, nodeWidth - nodeTextPadding);
+ }
+
+ private List<String> wrapLabel(String label, int maxTextWidth) {
if (fontMetrics.stringWidth(label) <= maxTextWidth) {
return List.of(label);
}
@@ -368,6 +405,69 @@ public class RouteDiagramLayoutEngine {
return baseNodeHeight + (lineCount - 1) * lineSpacing;
}
+ /** The lines of a card: its title, then its detail wrapped to the width
beside the icon, at most three lines. */
+ private List<String> cardLines(NodeInfo info) {
+ List<String> label = cardLabel(info, nodeLabelMode);
+ List<String> lines = new ArrayList<>();
+ lines.add(label.get(0));
+ int maxTextWidth = nodeWidth - CARD_TEXT_INSET - CARD_PAD;
+ List<String> detail = new ArrayList<>();
+ for (String text : label.subList(1, label.size())) {
+ detail.addAll(wrapLabel(text, maxTextWidth));
+ }
+ if (detail.size() > MAX_WRAP_LINES) {
+ // the renderer cuts the last line with an ellipsis
+ String rest = String.join(" ", detail.subList(MAX_WRAP_LINES - 1,
detail.size()));
+ detail = new ArrayList<>(detail.subList(0, MAX_WRAP_LINES - 1));
+ detail.add(rest);
+ }
+ lines.addAll(detail);
+ return lines;
+ }
+
+ private int computeCardHeight(List<String> lines) {
+ int textHeight = titleMetrics.getHeight() + (lines.size() - 1) *
fontMetrics.getHeight();
+ return Math.max(baseNodeHeight, Math.max(textHeight, CARD_ICON_SIZE) +
2 * CARD_PAD_Y);
+ }
+
+ /**
+ * The text of a node as a card: the kind of step first (such as {@code
setBody}), then its detail (such as
+ * {@code groovy: body.items} for the code {@code
setBody[groovy{body.items}]}), or its description, by the label
+ * mode. A step without detail (such as {@code choice}) has only its kind.
+ */
+ public static List<String> cardLabel(NodeInfo info, NodeLabelMode mode) {
+ String code = cleanLabel(info.code);
+ String kind = code;
+ String detail = "";
+ int bracket = code.indexOf('[');
+ if (bracket > 0 && code.endsWith("]")) {
+ kind = code.substring(0, bracket);
+ detail = code.substring(bracket + 1, code.length() - 1);
+ Matcher m = CARD_EXPRESSION.matcher(detail);
+ if (m.matches()) {
+ // an expression in its language: groovy{body.items} as
groovy: body.items
+ detail = m.group(1) + ": " + m.group(2);
+ }
+ }
+ if (kind.isEmpty() && info.type != null) {
+ kind = info.type;
+ }
+ boolean hasDesc = info.description != null &&
!info.description.isBlank() && !info.description.equals(code);
+ List<String> below = switch (mode) {
+ case DESCRIPTION -> List.of(hasDesc ? info.description : detail);
+ case BOTH -> hasDesc ? List.of(info.description, detail) :
List.of(detail);
+ default -> List.of(detail);
+ };
+ List<String> lines = new ArrayList<>();
+ lines.add(kind);
+ for (String line : below) {
+ if (!line.isBlank()) {
+ lines.add(line);
+ }
+ }
+ return lines;
+ }
+
public static List<String> resolveLabel(NodeInfo info, NodeLabelMode mode)
{
String code = cleanLabel(info.code);
boolean hasDesc = info.description != null &&
!info.description.isBlank();
@@ -474,9 +574,14 @@ public class RouteDiagramLayoutEngine {
ln.id = node.info.id;
ln.x = nodeX;
ln.y = y;
- ln.wrappedLines = resolveLabel(node.info, nodeLabelMode).stream()
- .flatMap(s -> wrapLabel(s).stream()).toList();
- ln.height = computeNodeHeight(ln.wrappedLines);
+ if (cardStyle) {
+ ln.wrappedLines = cardLines(node.info);
+ ln.height = computeCardHeight(ln.wrappedLines);
+ } else {
+ ln.wrappedLines = resolveLabel(node.info, nodeLabelMode).stream()
+ .flatMap(s -> wrapLabel(s).stream()).toList();
+ ln.height = computeNodeHeight(ln.wrappedLines);
+ }
ln.treeNode = node;
node.layoutNode = ln;
lr.nodes.add(ln);
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
index 0977012212bb..a9a1782bed8e 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
@@ -17,6 +17,7 @@
package org.apache.camel.diagram;
import java.awt.*;
+import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.HashMap;
@@ -66,45 +67,57 @@ public class RouteDiagramRenderer {
private final int nodeWidth;
private final int fontSizeNode;
private final int fontSizeLabel;
- private final int nodeTextPadding;
private final boolean metrics;
+ // the steps are cards (card, cardBorder, cardText for the kind,
cardDetail for the detail) with an icon in a
+ // circle of the color of the kind of step (from, to, eip, ...); text is
the text in the nodes of the topology
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";
+ +
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+ +
":card=#151b23:cardBorder=#3d444d:cardText=#f0f6fc:cardDetail=#9198a1";
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";
+ +
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+ +
":card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e";
+ // white cards, readable on any background
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";
+ +
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+ +
":card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e";
private static final Map<String, String> COLOR_PRESETS = Map.of(
"dark", DARK_COLORS,
"light", LIGHT_COLORS,
"transparent", TRANSPARENT_COLORS);
+ /** The size of the icon inside its circle on a card. */
+ private static final int CARD_ICON_GLYPH = 14 * SCALE;
+ private static final float CARD_ICON_STROKE_WIDTH = 1.4f * SCALE;
+ private static final Color CARD_SHADOW = new Color(0, 0, 0, 22);
+ private static final Color CARD_SHADOW_SOFT = new Color(0, 0, 0, 10);
+
public RouteDiagramRenderer() {
- this(RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * SCALE,
- RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE * SCALE,
- new RouteDiagramLayoutEngine().getNodeTextPadding(),
+ this(RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * SCALE,
RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE * SCALE,
false);
}
public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, boolean
metrics) {
- this(nodeWidth, fontSizeScaled, new RouteDiagramLayoutEngine(
- nodeWidth / SCALE, fontSizeScaled /
SCALE).getNodeTextPadding(),
- metrics);
- }
-
- public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, int
nodeTextPadding, boolean metrics) {
this.nodeWidth = nodeWidth;
this.fontSizeNode = fontSizeScaled;
this.fontSizeLabel = fontSizeScaled + SCALE;
- this.nodeTextPadding = nodeTextPadding;
this.metrics = metrics;
}
+ /**
+ * @param nodeTextPadding not used: the text of a card starts after
its icon
+ * ({@link
RouteDiagramLayoutEngine#CARD_TEXT_INSET})
+ * @deprecated use {@link #RouteDiagramRenderer(int, int,
boolean)}
+ */
+ @Deprecated(since = "4.24.0")
+ public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, int
nodeTextPadding, boolean metrics) {
+ this(nodeWidth, fontSizeScaled, metrics);
+ }
+
public static class DiagramColors {
private Color bg;
private Color text;
@@ -120,9 +133,18 @@ public class RouteDiagramRenderer {
private Color nodeTransform;
private Color nodeProcessor;
private Color nodeExternal;
-
+ private Color cardBg;
+ private Color cardBorder;
+ private Color cardText;
+ private Color cardDetail;
+
+ /**
+ * The colors of a theme: a preset (dark, light, transparent) or a
spec of {@code key=color} pairs separated by
+ * colons, on top of the dark theme.
+ */
public static DiagramColors parse(String spec) {
- String resolved = COLOR_PRESETS.getOrDefault(spec, spec);
+ // no spec is the dark theme (the presets are an immutable map,
which cannot look up null)
+ String resolved = spec == null ? DARK_COLORS :
COLOR_PRESETS.getOrDefault(spec, spec);
Map<String, String> map = new HashMap<>();
for (String entry : DARK_COLORS.split(":")) {
int eq = entry.indexOf('=');
@@ -151,6 +173,10 @@ public class RouteDiagramRenderer {
c.nodeTransform = parseColor(map.getOrDefault("transform",
"#00838f"));
c.nodeProcessor = parseColor(map.getOrDefault("processor",
"#d84315"));
c.nodeExternal = parseColor(map.getOrDefault("external",
"#0277bd"));
+ c.cardBg = parseColor(map.getOrDefault("card", "#151b23"));
+ c.cardBorder = parseColor(map.getOrDefault("cardBorder",
"#3d444d"));
+ c.cardText = parseColor(map.getOrDefault("cardText", "#f0f6fc"));
+ c.cardDetail = parseColor(map.getOrDefault("cardDetail",
"#9198a1"));
return c;
}
@@ -227,6 +253,22 @@ public class RouteDiagramRenderer {
public Color getNodeExternal() {
return nodeExternal;
}
+
+ public Color getCardBg() {
+ return cardBg;
+ }
+
+ public Color getCardBorder() {
+ return cardBorder;
+ }
+
+ public Color getCardText() {
+ return cardText;
+ }
+
+ public Color getCardDetail() {
+ return cardDetail;
+ }
}
public BufferedImage renderDiagram(
@@ -494,42 +536,76 @@ public class RouteDiagramRenderer {
drawArrowFromMerge(g, to, colors, false, null);
}
+ /**
+ * A step as a card: a card with a soft shadow, the icon of its kind in a
circle of the color of the kind, its title
+ * in bold and its detail below (laid out by {@link
RouteDiagramLayoutEngine#setCardStyle}).
+ */
private void drawNode(Graphics2D g, LayoutNode node, DiagramColors colors,
boolean highlightFail) {
- Color color = getNodeColor(node.type, colors);
+ int x = node.x;
+ int y = node.y;
+ int h = node.height;
- g.setColor(color);
- g.fillRoundRect(node.x, node.y, nodeWidth, node.height, ARC, ARC);
+ g.setColor(CARD_SHADOW_SOFT);
+ g.fillRoundRect(x - SCALE, y + SCALE, nodeWidth + 2 * SCALE, h + 2 *
SCALE, ARC + 2 * SCALE, ARC + 2 * SCALE);
+ g.setColor(CARD_SHADOW);
+ g.fillRoundRect(x, y + SCALE, nodeWidth, h, ARC, ARC);
+ g.setColor(colors.getCardBg());
+ g.fillRoundRect(x, y, nodeWidth, h, ARC, ARC);
if (highlightFail) {
g.setColor(HIGHLIGHT_FAIL_COLOR);
g.setStroke(new BasicStroke(HIGHLIGHT_STROKE_WIDTH));
} else {
- g.setColor(color.brighter());
+ g.setColor(colors.getCardBorder());
g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH));
}
- g.drawRoundRect(node.x, node.y, nodeWidth, node.height, ARC, ARC);
-
- g.setColor(colors.getText());
- g.setFont(new Font("SansSerif", Font.PLAIN, fontSizeNode));
- FontMetrics fm = g.getFontMetrics();
+ g.drawRoundRect(x, y, nodeWidth, h, ARC, ARC);
+
+ // the icon of the kind of step, in a circle of its color
+ int d = RouteDiagramLayoutEngine.CARD_ICON_SIZE;
+ int circleX = x + RouteDiagramLayoutEngine.CARD_PAD;
+ int circleY = y + (h - d) / 2;
+ g.setColor(getNodeColor(node.type, colors));
+ g.fillOval(circleX, circleY, d, d);
+ double scale = (double) CARD_ICON_GLYPH / RouteDiagramIcons.GRID;
+ AffineTransform at = new AffineTransform();
+ at.translate(circleX + (d - CARD_ICON_GLYPH) / 2.0, circleY + (d -
CARD_ICON_GLYPH) / 2.0);
+ at.scale(scale, scale);
+ g.setColor(Color.WHITE);
+ g.setStroke(new BasicStroke(CARD_ICON_STROKE_WIDTH,
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+
g.draw(at.createTransformedShape(RouteDiagramIcons.iconFor(node.type)));
+ g.setStroke(new BasicStroke(STROKE_WIDTH));
+ // the title, and the detail below it, centered in the height of the
card
List<String> lines = node.wrappedLines;
+ int textX = x + RouteDiagramLayoutEngine.CARD_TEXT_INSET;
+ int maxWidth = nodeWidth - RouteDiagramLayoutEngine.CARD_TEXT_INSET -
RouteDiagramLayoutEngine.CARD_PAD;
+ g.setFont(RouteDiagramLayoutEngine.cardTitleFont(fontSizeNode));
+ FontMetrics titleFm = g.getFontMetrics();
+ Font detailFont = new Font("SansSerif", Font.PLAIN, fontSizeNode);
+ FontMetrics detailFm = g.getFontMetrics(detailFont);
+ int textHeight = titleFm.getHeight() + (lines.size() - 1) *
detailFm.getHeight();
+ int baseline = y + (h - textHeight) / 2 + titleFm.getAscent();
+ g.setColor(colors.getCardText());
+ g.drawString(fit(titleFm, lines.get(0), maxWidth), textX, baseline);
+ g.setFont(detailFont);
+ g.setColor(colors.getCardDetail());
+ baseline += titleFm.getDescent() + detailFm.getAscent();
+ for (int i = 1; i < lines.size(); i++) {
+ g.drawString(fit(detailFm, lines.get(i), maxWidth), textX,
baseline);
+ baseline += detailFm.getHeight();
+ }
+ }
- int lineHeight = fm.getHeight();
- int totalTextHeight = lines.size() * lineHeight;
- int startY = node.y + (node.height - totalTextHeight) / 2 +
fm.getAscent();
-
- for (int i = 0; i < lines.size(); i++) {
- String text = lines.get(i);
- if (fm.stringWidth(text) > nodeWidth - nodeTextPadding) {
- while (text.length() > 1 && fm.stringWidth(text + "…") >
nodeWidth - nodeTextPadding) {
- text = text.substring(0, text.length() - 1);
- }
- text = text + "…";
- }
- int textX = node.x + (nodeWidth - fm.stringWidth(text)) / 2;
- g.drawString(text, textX, startY + i * lineHeight);
+ /** The text cut with an ellipsis when it is wider than the given width. */
+ private static String fit(FontMetrics fm, String text, int maxWidth) {
+ if (fm.stringWidth(text) <= maxWidth) {
+ return text;
+ }
+ while (text.length() > 1 && fm.stringWidth(text + "…") > maxWidth) {
+ text = text.substring(0, text.length() - 1);
}
+ return text + "…";
}
private void drawArrow(
diff --git
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
index 1a892479e696..2959df604243 100644
---
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
+++
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
@@ -1,7 +1,7 @@
camel-route-diagram bundles the following third-party content.
--------------------------------------------------------------------------------
-Lucide (icon SVG paths inlined in camel-route-diagram.js):
+Lucide (icon SVG paths inlined in camel-route-diagram.js, and in
RouteDiagramIcons.java for the card style of the PNG diagrams):
--------------------------------------------------------------------------------
ISC License
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java
new file mode 100644
index 000000000000..b9910c6d1b42
--- /dev/null
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java
@@ -0,0 +1,224 @@
+/*
+ * 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.diagram;
+
+import java.awt.Color;
+import java.awt.Shape;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.util.List;
+
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeLabelMode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.diagram.RouteDiagramRenderer.DiagramColors;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * The card style of a route diagram: the steps as cards with an icon, a title
and the detail below it.
+ */
+class RouteDiagramCardStyleTest {
+
+ private static NodeInfo node(String type, String code, int level) {
+ NodeInfo n = new NodeInfo();
+ n.type = type;
+ n.id = type + level;
+ n.code = code;
+ n.level = level;
+ return n;
+ }
+
+ /** The route of the payload mode design: a file read, a groovy mapping,
an http call and a JMS send. */
+ private static RouteInfo orders() {
+ RouteInfo r = new RouteInfo();
+ r.routeId = "orders";
+ r.source = "orders.camel.yaml";
+ r.nodes.add(node("route", "route[orders]", 0));
+ r.nodes.add(node("from", "from[file:orders]", 1));
+ r.nodes.add(node("setBody", "setBody[groovy{body.items}]", 2));
+ r.nodes.add(node("to", "to[http:pricing]", 2));
+ r.nodes.add(node("setHeader", "setHeader[skus]", 2));
+ r.nodes.add(node("to", "to[jms:priced]", 2));
+ return r;
+ }
+
+ @Test
+ void themesHaveCardColors() {
+
assertThat(DiagramColors.parse("light").getCardBg()).isEqualTo(Color.WHITE);
+
assertThat(DiagramColors.parse("transparent").getCardBg()).isEqualTo(Color.WHITE);
+
assertThat(DiagramColors.parse("dark").getCardBg()).isNotEqualTo(Color.WHITE);
+ // a custom spec is on top of the dark theme, and can set the colors
of the cards
+ DiagramColors custom = DiagramColors.parse("bg=#ffffff:card=#fafafa");
+ assertThat(custom.getCardBg()).isEqualTo(new Color(0xfafafa));
+
assertThat(custom.getCardText()).isEqualTo(DiagramColors.parse("dark").getCardText());
+ }
+
+ @Test
+ void noSpecIsTheDarkTheme() {
+ DiagramColors none = DiagramColors.parse(null);
+
assertThat(none.getBg()).isEqualTo(DiagramColors.parse("dark").getBg());
+
assertThat(none.getCardBg()).isEqualTo(DiagramColors.parse("dark").getCardBg());
+ }
+
+ @Test
+ void cardLabelSplitsKindAndDetail() {
+ assertThat(RouteDiagramLayoutEngine.cardLabel(node("setBody",
"setBody[groovy{body.items}]", 1),
+ NodeLabelMode.CODE)).containsExactly("setBody", "groovy:
body.items");
+ assertThat(RouteDiagramLayoutEngine.cardLabel(node("from",
"from[file:orders]", 1), NodeLabelMode.CODE))
+ .containsExactly("from", "file:orders");
+ assertThat(RouteDiagramLayoutEngine.cardLabel(node("setHeader",
"setHeader[skus]", 1), NodeLabelMode.CODE))
+ .containsExactly("setHeader", "skus");
+ // a step without detail has only its kind
+ assertThat(RouteDiagramLayoutEngine.cardLabel(node("choice", "choice",
1), NodeLabelMode.CODE))
+ .containsExactly("choice");
+ // a predicate keeps its text, only the language moves in front
+ assertThat(RouteDiagramLayoutEngine.cardLabel(node("when",
"when[simple{${header.kind} == 0}]", 1),
+ NodeLabelMode.CODE)).containsExactly("when", "simple:
${header.kind} == 0");
+ }
+
+ @Test
+ void cardLabelByMode() {
+ NodeInfo n = node("to", "to[kafka:orders]", 1);
+ n.description = "Publish the order";
+ assertThat(RouteDiagramLayoutEngine.cardLabel(n,
NodeLabelMode.CODE)).containsExactly("to", "kafka:orders");
+ assertThat(RouteDiagramLayoutEngine.cardLabel(n,
NodeLabelMode.DESCRIPTION))
+ .containsExactly("to", "Publish the order");
+ assertThat(RouteDiagramLayoutEngine.cardLabel(n, NodeLabelMode.BOTH))
+ .containsExactly("to", "Publish the order", "kafka:orders");
+
+ // without a description the detail is shown in every mode
+ NodeInfo plain = node("to", "to[kafka:orders]", 1);
+ assertThat(RouteDiagramLayoutEngine.cardLabel(plain,
NodeLabelMode.DESCRIPTION))
+ .containsExactly("to", "kafka:orders");
+ assertThat(RouteDiagramLayoutEngine.cardLabel(plain,
NodeLabelMode.BOTH))
+ .containsExactly("to", "kafka:orders");
+ }
+
+ @Test
+ void cardLayoutGivesTitleAndDetail() {
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ engine.setCardStyle(true);
+ LayoutRoute lr = engine.layoutRoute(orders(),
RouteDiagramLayoutEngine.PADDING);
+
+ LayoutNode setBody = lr.nodes.stream().filter(n ->
"setBody".equals(n.type)).findFirst().orElseThrow();
+ assertThat(setBody.wrappedLines).containsExactly("setBody", "groovy:
body.items");
+ // a card with a detail is taller than one with only its title
+ LayoutNode route = lr.nodes.get(0);
+ assertThat(route.wrappedLines).containsExactly("route", "orders");
+
+ // the plain box keeps the whole label (how it wraps depends on the
fonts of the platform)
+ RouteDiagramLayoutEngine plain = new RouteDiagramLayoutEngine();
+ LayoutRoute plainRoute = plain.layoutRoute(orders(),
RouteDiagramLayoutEngine.PADDING);
+ assertThat(String.join("",
plainRoute.nodes.get(2).wrappedLines)).isEqualTo("setBody[groovy{body.items}]");
+ assertThat(setBody.height).isGreaterThan(plain.getBaseNodeHeight());
+ }
+
+ @Test
+ void longDetailWrapsToThreeLines() {
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ engine.setCardStyle(true);
+ RouteInfo r = new RouteInfo();
+ r.routeId = "long";
+ r.nodes.add(node("route", "route[long]", 0));
+ r.nodes.add(node("to",
"to[https:www.example.com/a/very/long/path/that/does/not/fit/on/one/line/of/a/card"
+ +
"?with=many&query=parameters&and=more&of=them&until=it&takes=several&lines=to&show]",
+ 1));
+ LayoutRoute lr = engine.layoutRoute(r,
RouteDiagramLayoutEngine.PADDING);
+ List<String> lines = lr.nodes.get(1).wrappedLines;
+ assertThat(lines.get(0)).isEqualTo("to");
+ // the title and at most three lines of detail; the renderer cuts the
last one
+ assertThat(lines).hasSize(4);
+ }
+
+ @Test
+ void everyIconHasAnOutlineOnTheGrid() {
+ assertThat(RouteDiagramIcons.names()).hasSizeGreaterThan(15);
+ for (String name : RouteDiagramIcons.names()) {
+ Rectangle2D bounds = RouteDiagramIcons.icon(name).getBounds2D();
+ assertThat(bounds.getWidth()).as(name).isPositive();
+ assertThat(bounds.getHeight()).as(name).isPositive();
+ assertThat(bounds.getMinX()).as(name).isGreaterThanOrEqualTo(0.5);
+ assertThat(bounds.getMinY()).as(name).isGreaterThanOrEqualTo(0.5);
+
assertThat(bounds.getMaxX()).as(name).isLessThanOrEqualTo(RouteDiagramIcons.GRID
- 0.5);
+
assertThat(bounds.getMaxY()).as(name).isLessThanOrEqualTo(RouteDiagramIcons.GRID
- 0.5);
+ }
+ }
+
+ @Test
+ void iconOfEachKindOfStep() {
+ assertThat(RouteDiagramIcons.iconName("from")).isEqualTo("log-in");
+ assertThat(RouteDiagramIcons.iconName("to")).isEqualTo("log-out");
+ assertThat(RouteDiagramIcons.iconName("setBody")).isEqualTo("braces");
+
assertThat(RouteDiagramIcons.iconName("setHeader")).isEqualTo("heading");
+
assertThat(RouteDiagramIcons.iconName("choice")).isEqualTo("git-branch");
+ assertThat(RouteDiagramIcons.iconName("someNewEip")).isEqualTo("box");
+ assertThat(RouteDiagramIcons.iconName(null)).isEqualTo("box");
+ }
+
+ @Test
+ void pathDataAsLucideWritesIt() {
+ // numbers run together (1.1.9 is 1.1 and .9) and an arc repeated by
more numbers
+ Shape braces = RouteDiagramIcons.path("M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0
1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1");
+ Rectangle2D b = braces.getBounds2D();
+ assertThat(b.getMinX()).isEqualTo(3.0, within(0.01));
+ assertThat(b.getMaxX()).isEqualTo(8.0, within(0.01));
+ assertThat(b.getMinY()).isEqualTo(3.0, within(0.01));
+ assertThat(b.getMaxY()).isEqualTo(21.0, within(0.01));
+
+ // a half circle with the sweep flag bulges to the right of its chord
+ Rectangle2D arc = RouteDiagramIcons.path("M12 2a10 10 0 0 1 0
20").getBounds2D();
+ assertThat(arc.getMaxX()).isEqualTo(22.0, within(0.01));
+ assertThat(arc.getMinX()).isEqualTo(12.0, within(0.01));
+
+ // an exponent, and an e without its digits which is not a number
+ Rectangle2D exp = RouteDiagramIcons.path("M1e1 2.5E-1L20
2e+1").getBounds2D();
+ assertThat(exp.getMinX()).isEqualTo(10.0, within(0.01));
+ assertThat(exp.getMinY()).isEqualTo(0.25, within(0.01));
+ assertThat(exp.getMaxY()).isEqualTo(20.0, within(0.01));
+ assertThatThrownBy(() -> RouteDiagramIcons.path("M1e
2")).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("Expected a number at 2");
+
+ assertThatThrownBy(() -> RouteDiagramIcons.path("12
2")).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> RouteDiagramIcons.path("M12 2Q1 1 2
2")).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void renderCards() {
+ for (String theme : List.of("light", "dark", "transparent")) {
+ DiagramColors colors = DiagramColors.parse(theme);
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ engine.setCardStyle(true);
+ LayoutRoute lr = engine.layoutRoute(orders(),
RouteDiagramLayoutEngine.PADDING);
+ BufferedImage image = new
RouteDiagramRenderer().renderDiagram(List.of(lr), lr.maxY, colors);
+
+ // the card is filled with the card color, right of its text
+ LayoutNode setBody = lr.nodes.get(2);
+ int x = setBody.x + engine.getNodeWidth() -
RouteDiagramLayoutEngine.CARD_PAD / 2;
+ int y = setBody.y + setBody.height / 2;
+ assertThat(new Color(image.getRGB(x,
y))).as(theme).isEqualTo(colors.getCardBg());
+ // the circle of the icon has the color of the kind of step (a
transform)
+ int cx = setBody.x + RouteDiagramLayoutEngine.CARD_PAD + 2 *
RouteDiagramLayoutEngine.SCALE;
+ assertThat(new Color(image.getRGB(cx,
y))).as(theme).isEqualTo(colors.getNodeTransform());
+ }
+ }
+}
diff --git a/docs/components/modules/ROOT/images/route-diagram-card.png
b/docs/components/modules/ROOT/images/route-diagram-card.png
new file mode 120000
index 000000000000..d28d9654f6d0
--- /dev/null
+++ b/docs/components/modules/ROOT/images/route-diagram-card.png
@@ -0,0 +1 @@
+../../../../../components/camel-diagram/src/main/docs/route-diagram-card.png
\ No newline at end of file
diff --git
a/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png
b/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png
index 38b90972e375..92f73fc6903d 100644
Binary files
a/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png and
b/docs/user-manual/modules/ROOT/images/images/route-diagram-sample.png differ
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
deleted file mode 100644
index 70699e1b9348..000000000000
Binary files
a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png and
/dev/null 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
deleted file mode 100644
index 8ec2e522c132..000000000000
Binary files
a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png and
/dev/null differ
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 8c6f5613aff5..4b78b06a04c7 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2602,6 +2602,34 @@ cannot work out, such as a lambda or a processor, is
shown as a placeholder, and
listed (with `--json`, as an `unread` array in the JSON). Use the new
`--compile` option to compile the sources and
load their routes as before.
+=== camel-diagram - a route diagram image draws the steps as cards
+
+The PNG image of a route diagram draws each step as a card: an icon for the
kind of step, in a circle of the color of
+its kind, the kind in bold (such as `setBody`) and its detail below (such as
`groovy: body.items`). Before, a step was
+a box filled with the color of its kind, with its code
(`setBody[groovy{body.items}]`) in one line. This applies to
+the `dark`, `light` and `transparent` themes, in `camel cmd route-diagram`,
`camel get history --diagram`,
+`camel get error --diagram`, the `camel_render_route_diagram` MCP tool, the
`route-diagram` developer console
+(`format=png`) and the `RouteDiagramDumper`. A card is taller than a box, so
the image of the same route is taller.
+The ASCII and Unicode diagrams, the topology diagram and the web component do
not change.
+
+A custom color spec sets the colors of the cards with `card`, `cardBorder`,
`cardText` and `cardDetail`. The `text`
+color no longer applies to the steps of a route diagram (it still does to the
topology diagram).
+
+The `RouteDiagramRenderer(nodeWidth, fontSizeScaled, nodeTextPadding,
metrics)` constructor is deprecated, as the text
+of a card starts after its icon and `nodeTextPadding` is not used. Use
`RouteDiagramRenderer(nodeWidth, fontSizeScaled,
+metrics)` instead.
+
+A program that lays out routes with `RouteDiagramLayoutEngine` and draws them
with `RouteDiagramRenderer` must call
+`engine.setCardStyle(true)` before `layoutRoute`. Without it, the cards get
the height and text of the old boxes.
+
+`camel cmd route-diagram --output=diagram.png` without `--theme` now saves the
PNG image in the `light` theme. Before,
+the default theme was `unicode` also for a file, so the command saved the text
diagram in `diagram.txt` instead. A
+diagram in the terminal is still text (`unicode`) by default, and a file that
does not end with `.png` still gets the
+text diagram. Use `--theme=unicode` to save the text diagram.
+
+The `camel_render_route_diagram` MCP tool also draws in the `light` theme when
no theme is given (before, it failed
+without a theme).
+
=== camel-jbang - export to Quarkus keeps the names in `quarkus.*` keys
`camel export --runtime=quarkus` wrote every `quarkus.*` key except
`quarkus.hawtio.*` in dash case: an upper-case
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
index 29f4701ff341..998388542e89 100644
---
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
@@ -31,7 +31,7 @@ camel cmd route-diagram [options]
| `--metric` | Whether to include live metrics (only possible for running
Camel application) | true | boolean
| `--node-label` | What text to display in diagram nodes: code, description,
or both (default) | both | String
| `--output` | Save diagram to a file (PNG for image themes, text for ascii
theme) | | String
-| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) 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.
Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env
var. | unicode | String
+| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) 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.
Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env
var. Default: unicode in the terminal, and light when --output is a .png file.
| | String
| `--timeout` | How long to wait for the integration to answer, in millis or
as a duration such as 30s | 10000 | long
| `--watch` | Execute periodically and showing output fullscreen | | boolean
| `--width` | Image width in pixels (0 = auto) | 0 | int
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 42e987a3ac18..2b2de7207d08 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
@@ -3,7 +3,7 @@
{ "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType":
"java.lang.String", "type": "string" }, { "names": "--api-type", "description":
"API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType": [...]
{ "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": "How [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
index 056b02e7ef6f..05b5e049428b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelHistoryAction.java
@@ -448,6 +448,7 @@ public class CamelHistoryAction extends ActionWatchCommand {
// layout
RouteDiagramLayoutEngine engine
= new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH);
+ engine.setCardStyle(!isTextTheme());
List<LayoutRoute> layoutRoutes = new ArrayList<>();
int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -466,7 +467,7 @@ public class CamelHistoryAction extends ActionWatchCommand {
} else {
DiagramColors colors = DiagramColors.parse(theme);
RouteDiagramRenderer renderer = new RouteDiagramRenderer(
- engine.getNodeWidth(), 12 *
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(), false);
+ engine.getNodeWidth(), 12 *
RouteDiagramLayoutEngine.SCALE, false);
BufferedImage image;
try {
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
index 7b4d499a3252..3d757d43cb91 100644
---
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
@@ -26,6 +26,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import java.util.Stack;
import java.util.stream.Stream;
@@ -103,8 +104,8 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
+ "(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. Use
ascii/unicode for plain text output. "
- + "Can also be set via DIAGRAM_COLORS
env var.",
- defaultValue = "unicode")
+ + "Can also be set via DIAGRAM_COLORS
env var. "
+ + "Default: unicode in the terminal, and
light when --output is a .png file.")
String theme;
@CommandLine.Option(names = { "--font-size" },
@@ -171,6 +172,7 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
public Integer doCall() throws Exception {
System.setProperty("java.awt.headless", "true");
+ resolveTheme();
boolean textMode = isTextTheme();
if (!textMode) {
String colorSpec = System.getenv("DIAGRAM_COLORS");
@@ -204,6 +206,7 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
@Override
protected Integer doWatchCall() throws Exception {
+ resolveTheme();
// only attempt to resolve a running integration when a single token
is given (or none, defaulting to "*"):
// multiple tokens can only be a set of source files
String nameOrPid = files.isEmpty() ? "*" : files.get(0);
@@ -271,6 +274,8 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
NodeLabelMode labelMode = parseNodeLabelMode(nodeLabel);
RouteDiagramLayoutEngine engine = new
RouteDiagramLayoutEngine(boxWidth, fontSize, labelMode);
+ // an image draws the steps as cards: a title and its detail beside an
icon
+ engine.setCardStyle(!isTextTheme());
List<LayoutRoute> layoutRoutes = new ArrayList<>();
int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -309,8 +314,7 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
}
} else {
RouteDiagramRenderer renderer = new RouteDiagramRenderer(
- engine.getNodeWidth(), fontSize *
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(),
- pid > 0 && metric);
+ engine.getNodeWidth(), fontSize *
RouteDiagramLayoutEngine.SCALE, pid > 0 && metric);
BufferedImage image;
try {
@@ -465,6 +469,16 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
return RouteDiagramHelper.parseRoutes(jo);
}
+ /**
+ * The theme when none is given: a diagram in the terminal is text (an
image needs a terminal with a graphics
+ * protocol), and a diagram saved as a .png file is an image in the light
theme.
+ */
+ private void resolveTheme() {
+ if (theme == null || theme.isBlank()) {
+ theme = output != null &&
output.toLowerCase(Locale.ROOT).endsWith(".png") ? "light" : "unicode";
+ }
+ }
+
private boolean isTextTheme() {
return "ascii".equalsIgnoreCase(theme) ||
"unicode".equalsIgnoreCase(theme);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
index e6914be3dffb..ad429dd85229 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
@@ -387,6 +387,7 @@ public class ListError extends ProcessWatchCommand {
// layout
RouteDiagramLayoutEngine engine
= new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH);
+ engine.setCardStyle(!isTextTheme());
List<LayoutRoute> layoutRoutes = new ArrayList<>();
int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -405,7 +406,7 @@ public class ListError extends ProcessWatchCommand {
} else {
DiagramColors colors = DiagramColors.parse(theme);
RouteDiagramRenderer renderer = new RouteDiagramRenderer(
- engine.getNodeWidth(), 12 *
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(), false);
+ engine.getNodeWidth(), 12 *
RouteDiagramLayoutEngine.SCALE, false);
BufferedImage image;
try {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
index 3df2a593adc8..ac652c4384b2 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionRunningTest.java
@@ -16,10 +16,17 @@
*/
package org.apache.camel.dsl.jbang.core.commands.action;
+import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
+import javax.imageio.ImageIO;
+
+import org.apache.camel.diagram.RouteDiagramHelper;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeLabelMode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
@@ -121,6 +128,51 @@ class CamelRouteDiagramActionRunningTest extends
ActionCommandTestSupport {
assertThat(printer.getOutput()).contains("Diagram saved to: " +
txt.toAbsolutePath());
}
+ @Test
+ void imageDrawsTheStepsAsCards() throws Exception {
+ writeStatusFile(TEST_PID, "myApp");
+ Path light = dir.resolve("light.png");
+
+ assertThat(callWithResponse(command("--theme=light", "--output=" +
light)::doCall, structure("route1")))
+ .isZero();
+
+ // the route laid out as cards (a title and its detail), not as boxes
of one line of code
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(180,
12, NodeLabelMode.BOTH);
+ engine.setCardStyle(true);
+ RouteInfo route =
RouteDiagramHelper.parseRoutes(structure("route1")).get(0);
+ int height = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING).maxY + RouteDiagramLayoutEngine.V_GAP
+ + RouteDiagramLayoutEngine.PADDING;
+ assertThat(ImageIO.read(light.toFile()).getHeight()).isEqualTo(height);
+ }
+
+ @Test
+ void pngFileIsInTheLightThemeByDefault() throws Exception {
+ writeStatusFile(TEST_PID, "myApp");
+ Path light = dir.resolve("light.png");
+ Path byDefault = dir.resolve("default.png");
+
+ assertThat(callWithResponse(command("--theme=light", "--output=" +
light)::doCall, structure("route1")))
+ .isZero();
+ assertThat(callWithResponse(command("--output=" + byDefault)::doCall,
structure("route1"))).isZero();
+
+ // without a theme a .png file gets the image, not the text diagram
next to it
+ assertThat(dir.resolve("default.txt")).doesNotExist();
+ BufferedImage lightImage = ImageIO.read(light.toFile());
+ BufferedImage defaultImage = ImageIO.read(byDefault.toFile());
+ assertThat(defaultImage.getHeight()).isEqualTo(lightImage.getHeight());
+ assertThat(defaultImage.getRGB(0, 0)).isEqualTo(lightImage.getRGB(0,
0));
+ }
+
+ @Test
+ void otherFileIsTextByDefault() throws Exception {
+ writeStatusFile(TEST_PID, "myApp");
+ Path txt = dir.resolve("diagram.txt");
+
+ assertThat(callWithResponse(command("--output=" + txt)::doCall,
structure("route1"))).isZero();
+
+ assertThat(Files.readString(txt)).contains("route1", "─");
+ }
+
@Test
void anIntegrationWithoutRoutesIsAFailure() throws Exception {
writeStatusFile(TEST_PID, "myApp");
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
index d71340dc24fb..6c810a18bda2 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
@@ -46,7 +46,7 @@ public class RouteDiagramTools {
+ "Java routes are read by the Java DSL parser,
without compiling them; a step it cannot "
+ "work out (a lambda, a processor) is shown as a
placeholder, and the parts it could not read "
+ "are listed in the message. "
- + "For image themes (dark, light, transparent) the PNG
is written to disk and the absolute path "
+ + "For image themes (light, dark, transparent) the PNG
is written to disk and the absolute path "
+ "is returned. For text themes (ascii, unicode) the
diagram is returned directly as text so "
+ "it can be read and understood by the caller. "
+ "Useful to visualize a route structure for review,
documentation, or troubleshooting.")
@@ -55,7 +55,7 @@ public class RouteDiagramTools {
@ToolArg(description = "Optional output file path. For image
themes a PNG is written; for text themes a .txt "
+ "file is written. If not specified, a
temporary file is created.",
required = false) String outputFile,
- @ToolArg(description = "Color theme: 'dark' (default), 'light',
'transparent', 'ascii' (plain ASCII art), "
+ @ToolArg(description = "Color theme: 'light' (default), 'dark',
'transparent', 'ascii' (plain ASCII art), "
+ "'unicode' (box-drawing characters), or a
custom spec like "
+ "'bg=#1e1e1e:from=#2e7d32:to=#1565c0'. "
+ "Use 'ascii' or 'unicode' to get a text
diagram that can be read directly.",
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
index a257762b4bf6..95966074082d 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramToolsTest.java
@@ -16,9 +16,14 @@
*/
package org.apache.camel.dsl.jbang.core.commands.mcp;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import io.quarkiverse.mcp.server.ToolCallException;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RouteDiagramToolsTest {
@@ -70,4 +75,31 @@ class RouteDiagramToolsTest {
.isInstanceOf(ToolCallException.class)
.hasMessageContaining("does not exist");
}
+
+ @Test
+ void imageWithoutATheme(@TempDir Path dir) throws Exception {
+ Path source = dir.resolve("orders.camel.yaml");
+ Files.writeString(source, """
+ - route:
+ id: orders
+ from:
+ uri: timer:orders
+ steps:
+ - setBody:
+ constant: hello
+ - to:
+ uri: log:orders
+ """);
+ Path png = dir.resolve("orders.png");
+ RouteDiagramTools tools = new RouteDiagramTools();
+
+ RouteDiagramTools.RouteDiagramResult result =
tools.camel_render_route_diagram(
+ source.toString(), png.toString(), null, null, null, null,
null, null, null);
+
+ assertThat(result.success()).isTrue();
+
assertThat(result.outputFile()).isEqualTo(png.toAbsolutePath().toString());
+ // a PNG image, not the text diagram
+ assertThat(Files.readAllBytes(png)).startsWith(0x89, 'P', 'N', 'G');
+ assertThat(result.asciiDiagram()).isNull();
+ }
}