This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26348-diagram-card-style in repository https://gitbox.apache.org/repos/asf/camel.git
commit 59d1e68452e7e1d4947dfc71e9a5782ab2e346cc Author: Claus Ibsen <[email protected]> AuthorDate: Tue Oct 27 16:41:44 2026 +0100 CAMEL-26348: camel-diagram - a card style for route diagrams The card and card-dark themes draw each step of a route diagram as a card: the icon of its kind in a circle of the color of the kind, the kind in bold (setBody) and its detail below (groovy: body.items, from the code setBody[groovy{body.items}]). A custom color spec draws cards with style=card. The icons are the Lucide icons of the camel-route-diagram web component, drawn with Java2D from their path data. The themes are available in camel cmd route-diagram, camel get history --diagram, camel get error --diagram and the camel_render_route_diagram MCP tool. The existing themes do not change. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/docs/diagram.adoc | 32 ++ .../camel-diagram/src/main/docs/diagram.adoc | 32 ++ .../camel/diagram/DefaultRouteDiagramDumper.java | 11 +- .../apache/camel/diagram/RouteDiagramIcons.java | 363 +++++++++++++++++++++ .../camel/diagram/RouteDiagramLayoutEngine.java | 113 ++++++- .../apache/camel/diagram/RouteDiagramRenderer.java | 145 +++++++- .../camel/diagram/THIRD-PARTY-NOTICES.txt | 2 +- .../camel/diagram/RouteDiagramCardStyleTest.java | 211 ++++++++++++ .../modules/ROOT/images/route-diagram-card.png | Bin 0 -> 41025 bytes .../camel-jbang-cmd-route-diagram.adoc | 2 +- .../jbang-commands/camel-jbang-get-error.adoc | 2 +- .../jbang-commands/camel-jbang-get-history.adoc | 2 +- .../META-INF/camel-jbang-commands-metadata.json | 4 +- .../core/commands/action/CamelHistoryAction.java | 3 +- .../commands/action/CamelRouteDiagramAction.java | 4 +- .../dsl/jbang/core/commands/process/ListError.java | 3 +- .../action/CamelRouteDiagramActionRunningTest.java | 20 ++ .../jbang/core/commands/mcp/RouteDiagramTools.java | 3 +- 18 files changed, 924 insertions(+), 28 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..42995806bb9a 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 @@ -135,6 +135,8 @@ The following built-in themes are available: * `dark` - dark background (default) * `light` - light background * `transparent` - transparent background +* `card` - each step as a white card (see <<_card_style,Card Style>>) +* `card-dark` - each step as a dark card Custom colors can be specified using the format: @@ -152,6 +154,36 @@ To use dark theme camel cmd route-diagram MyRoute.java --theme=dark ---- +=== Card Style + +The `card` and `card-dark` themes draw 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 --theme=card --output=orders.png +---- + +image::ROOT:route-diagram-card.png[A route diagram in the card style: 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. + +A custom color spec draws cards with the key `style=card`, and sets the colors of the cards with `card` (the +background of a card), `cardBorder`, `cardText` (the kind) and `cardDetail` (the detail): + +[source,properties] +---- +style=card:bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e +---- + +The icons are from https://lucide.dev[Lucide] (ISC License), the same as the embeddable web components. + +The card themes are for the PNG diagrams of the Camel CLI (`camel cmd route-diagram`, `camel get history --diagram`, +`camel get error --diagram`) and its MCP tool. The developer console and the `RouteDiagramDumper` keep the `dark`, `light` +and `transparent` themes. + == ASCII Art Rendering The ASCII art renderer produces plain text diagrams using box-drawing characters. diff --git a/components/camel-diagram/src/main/docs/diagram.adoc b/components/camel-diagram/src/main/docs/diagram.adoc index 65116747f00f..42995806bb9a 100644 --- a/components/camel-diagram/src/main/docs/diagram.adoc +++ b/components/camel-diagram/src/main/docs/diagram.adoc @@ -135,6 +135,8 @@ The following built-in themes are available: * `dark` - dark background (default) * `light` - light background * `transparent` - transparent background +* `card` - each step as a white card (see <<_card_style,Card Style>>) +* `card-dark` - each step as a dark card Custom colors can be specified using the format: @@ -152,6 +154,36 @@ To use dark theme camel cmd route-diagram MyRoute.java --theme=dark ---- +=== Card Style + +The `card` and `card-dark` themes draw 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 --theme=card --output=orders.png +---- + +image::ROOT:route-diagram-card.png[A route diagram in the card style: 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. + +A custom color spec draws cards with the key `style=card`, and sets the colors of the cards with `card` (the +background of a card), `cardBorder`, `cardText` (the kind) and `cardDetail` (the detail): + +[source,properties] +---- +style=card:bg=#ffffff:card=#fafafa:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e +---- + +The icons are from https://lucide.dev[Lucide] (ISC License), the same as the embeddable web components. + +The card themes are for the PNG diagrams of the Camel CLI (`camel cmd route-diagram`, `camel get history --diagram`, +`camel get error --diagram`) and its MCP tool. The developer console and the `RouteDiagramDumper` keep the `dark`, `light` +and `transparent` themes. + == ASCII Art Rendering The ASCII art renderer produces plain text diagrams using box-drawing characters. 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..b6a3daafda9d 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(colors.isCard()); 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..1045fb0689e2 --- /dev/null +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramIcons.java @@ -0,0 +1,363 @@ +/* + * 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; + while (pos < d.length()) { + char c = d.charAt(pos); + if (Character.isDigit(c)) { + pos++; + } else if (c == '.' && !dot) { + dot = true; + pos++; + } else if ((c == 'e' || c == 'E') && pos > start) { + pos++; + if (pos < d.length() && (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)); + } + + 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..6c370efdf0f8 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. Off by + * default: a renderer turns it on when it draws cards. + */ + 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..8587546daff0 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; @@ -79,10 +80,28 @@ public class RouteDiagramRenderer { = "bg=:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5" + ":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"; + // the card style: white (or dark) cards with an icon in a circle of the color of the kind of step + private static final String CARD_COLORS + = "style=card:bg=#f6f8fa:arrow=#8c959f:label=#1f2328:card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328" + + ":cardDetail=#59636e:from=#238636:to=#1f6feb:eip=#8957e5:choice=#d29922:default=#59636e" + + ":transform=#1b7c83:processor=#bf4b8a"; + private static final String CARD_DARK_COLORS + = "style=card:bg=#0d1117:arrow=#656c76:label=#d1d7e0:card=#151b23:cardBorder=#3d444d:cardText=#f0f6fc" + + ":cardDetail=#9198a1:from=#238636:to=#1f6feb:eip=#8957e5:choice=#d29922:default=#656c76" + + ":transform=#1b7c83:processor=#bf4b8a"; + private static final Map<String, String> COLOR_PRESETS = Map.of( "dark", DARK_COLORS, "light", LIGHT_COLORS, - "transparent", TRANSPARENT_COLORS); + "transparent", TRANSPARENT_COLORS, + "card", CARD_COLORS, + "card-dark", CARD_DARK_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, @@ -120,7 +139,16 @@ public class RouteDiagramRenderer { private Color nodeTransform; private Color nodeProcessor; private Color nodeExternal; - + private boolean card; + private Color cardBg; + private Color cardBorder; + private Color cardText; + private Color cardDetail; + + /** + * The colors of a theme: a preset (dark, light, transparent, card, card-dark) or a spec of {@code key=color} + * pairs separated by colons. The key {@code style=card} draws the steps as cards. + */ public static DiagramColors parse(String spec) { String resolved = COLOR_PRESETS.getOrDefault(spec, spec); Map<String, String> map = new HashMap<>(); @@ -151,6 +179,11 @@ 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.card = "card".equalsIgnoreCase(map.get("style")); + c.cardBg = parseColor(map.getOrDefault("card", "#ffffff")); + c.cardBorder = parseColor(map.getOrDefault("cardBorder", "#d0d7de")); + c.cardText = parseColor(map.getOrDefault("cardText", "#1f2328")); + c.cardDetail = parseColor(map.getOrDefault("cardDetail", "#59636e")); return c; } @@ -227,6 +260,30 @@ public class RouteDiagramRenderer { public Color getNodeExternal() { return nodeExternal; } + + /** + * Whether the steps are drawn as cards (the style key {@code style=card}): the layout engine must lay them out + * as cards too ({@link RouteDiagramLayoutEngine#setCardStyle}). + */ + public boolean isCard() { + return card; + } + + public Color getCardBg() { + return cardBg; + } + + public Color getCardBorder() { + return cardBorder; + } + + public Color getCardText() { + return cardText; + } + + public Color getCardDetail() { + return cardDetail; + } } public BufferedImage renderDiagram( @@ -495,6 +552,10 @@ public class RouteDiagramRenderer { } private void drawNode(Graphics2D g, LayoutNode node, DiagramColors colors, boolean highlightFail) { + if (colors.isCard()) { + drawCard(g, node, colors, highlightFail); + return; + } Color color = getNodeColor(node.type, colors); g.setColor(color); @@ -520,18 +581,84 @@ public class RouteDiagramRenderer { 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 + "…"; - } + String text = fit(fm, lines.get(i), nodeWidth - nodeTextPadding); int textX = node.x + (nodeWidth - fm.stringWidth(text)) / 2; g.drawString(text, textX, startY + i * lineHeight); } } + /** + * A step as a card: a white (or dark) 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 ({@link RouteDiagramLayoutEngine#setCardStyle}). + */ + private void drawCard(Graphics2D g, LayoutNode node, DiagramColors colors, boolean highlightFail) { + int x = node.x; + int y = node.y; + int h = node.height; + + 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(colors.getCardBorder()); + g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH)); + } + 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(); + } + } + + /** 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( Graphics2D g, LayoutNode from, LayoutNode to, DiagramColors colors, boolean highlighted, RouteDiagramHelper.HighlightStyle highlightStyle) { 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..7b35d5034aa9 --- /dev/null +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCardStyleTest.java @@ -0,0 +1,211 @@ +/* + * 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 cardPresetsDrawCards() { + assertThat(DiagramColors.parse("card").isCard()).isTrue(); + assertThat(DiagramColors.parse("card").getCardBg()).isEqualTo(Color.WHITE); + assertThat(DiagramColors.parse("card-dark").isCard()).isTrue(); + assertThat(DiagramColors.parse("card-dark").getCardBg()).isNotEqualTo(Color.WHITE); + assertThat(DiagramColors.parse("dark").isCard()).isFalse(); + assertThat(DiagramColors.parse("light").isCard()).isFalse(); + // a custom spec turns the card style on with style=card + DiagramColors custom = DiagramColors.parse("style=card:bg=#ffffff:card=#fafafa"); + assertThat(custom.isCard()).isTrue(); + assertThat(custom.getCardBg()).isEqualTo(new Color(0xfafafa)); + } + + @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"); + + RouteDiagramLayoutEngine plain = new RouteDiagramLayoutEngine(); + LayoutRoute plainRoute = plain.layoutRoute(orders(), RouteDiagramLayoutEngine.PADDING); + assertThat(plainRoute.nodes.get(2).wrappedLines).containsExactly("setBody[groovy{body.items}]"); + assertThat(setBody.height).isGreaterThan(plainRoute.nodes.get(2).height); + } + + @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)); + + 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("card", "card-dark")) { + 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 100644 index 000000000000..d06cfa3d941e Binary files /dev/null and b/docs/components/modules/ROOT/images/route-diagram-card.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 index 29f4701ff341..71f62d716aba 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, card, card-dark, 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 | `--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/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc index ff1de0e237bf..6460e462aba3 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc @@ -33,7 +33,7 @@ camel get error [options] | `--route` | Filter by route ID | | String | `--show` | Comma-separated detail sections to show: body, headers, properties, variables, history, stackTrace, or 'all' for all sections | | String | `--sort` | Sort by pid, name or age | pid | String -| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, or custom) | unicode | String +| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, card, card-dark, or custom) | unicode | String | `--watch` | Execute periodically and showing output fullscreen | | boolean | `-h,--help` | Display the help and sub-commands | | boolean |=== diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc index 0f642d305015..c99b5cd76209 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-history.adoc @@ -35,7 +35,7 @@ camel get history [options] | `--show-exchange-variables` | Show exchange variables in debug messages | true | boolean | `--show-headers` | Show message headers in debug messages | true | boolean | `--source` | Prefer to display source filename/code instead of IDs | | boolean -| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, or custom) | unicode | String +| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, card, card-dark, or custom) | unicode | String | `--timeout` | How long to wait for the integration to answer, in millis or as a duration such as 30s | 10000 | long | `--timestamp` | Print timestamp. | true | boolean | `--watch` | Execute periodically and showing output fullscreen | | boolean 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..3c96ebbe28ff 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 [...] @@ -15,7 +15,7 @@ { "name": "eval", "fullName": "eval", "description": "Evaluate Camel expressions and scripts", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.EvalCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "expression", "fullName": "eval expression", "description": "Evaluates Camel expression", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.EvalEx [...] { "name": "explain", "fullName": "explain", "description": "Explain what a Camel route does using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": "--api-key", "description": "API key for authentication. 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' (OpenAI-compatible), ' [...] { "name": "export", "fullName": "export", "description": "Export to other runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names": "--build-property", "description": "Maven build properties, ex. --build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { "names": "--camel-spring-boot-version", "description": "Camel version to use with Spring Boot", "javaType": "java.lang.String", "ty [...] - { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", "fullName": " [...] + { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", "fullName": " [...] { "name": "harden", "fullName": "harden", "description": "Suggest security hardening for Camel routes using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Harden", "options": [ { "names": "--api-key", "description": "API key for authentication. Also reads OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama' or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", [...] { "name": "hawtio", "fullName": "hawtio", "description": "Launch Hawtio web console", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.Hawtio", "options": [ { "names": "--host", "description": "Hostname to bind the Hawtio web console to", "defaultValue": "127.0.0.1", "javaType": "java.lang.String", "type": "string" }, { "names": "--open-url,--openUrl", "description": "To automatic open Hawtio web console in the web browser (without an interactive terminal the default [...] { "name": "infra", "fullName": "infra", "description": "List and Run external services for testing and prototyping", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.infra.InfraCommand", "options": [ { "names": "--json", "description": "Output in JSON Format", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "infra [...] 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..fc93ab392e6a 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 @@ -151,7 +151,7 @@ public class CamelHistoryAction extends ActionWatchCommand { boolean diagram; @CommandLine.Option(names = { "--theme" }, defaultValue = "unicode", - description = "Diagram color theme (ascii, unicode, dark, light, transparent, or custom)") + description = "Diagram color theme (ascii, unicode, dark, light, transparent, card, card-dark, or custom)") String theme = "unicode"; @CommandLine.Mixin @@ -448,6 +448,7 @@ public class CamelHistoryAction extends ActionWatchCommand { // layout RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH); + engine.setCardStyle(!isTextTheme() && DiagramColors.parse(theme).isCard()); List<LayoutRoute> layoutRoutes = new ArrayList<>(); int currentY = RouteDiagramLayoutEngine.PADDING; 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..79be0a657076 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 @@ -99,7 +99,7 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { String output; @CommandLine.Option(names = { "--theme" }, - description = "Color theme preset (dark, light, transparent, ascii, unicode) or custom colors " + description = "Color theme preset (dark, light, transparent, card, card-dark, 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. " @@ -271,6 +271,8 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { NodeLabelMode labelMode = parseNodeLabelMode(nodeLabel); RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(boxWidth, fontSize, labelMode); + // the card themes lay the steps out as cards: a title and its detail beside an icon + engine.setCardStyle(colors != null && colors.isCard()); List<LayoutRoute> layoutRoutes = new ArrayList<>(); int currentY = RouteDiagramLayoutEngine.PADDING; 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..d4556a39e7f8 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 @@ -118,7 +118,7 @@ public class ListError extends ProcessWatchCommand { boolean diagram; @CommandLine.Option(names = { "--theme" }, defaultValue = "unicode", - description = "Diagram color theme (ascii, unicode, dark, light, transparent, or custom)") + description = "Diagram color theme (ascii, unicode, dark, light, transparent, card, card-dark, or custom)") String theme = "unicode"; public ListError(CamelJBangMain main) { @@ -387,6 +387,7 @@ public class ListError extends ProcessWatchCommand { // layout RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(180, 12, NodeLabelMode.BOTH); + engine.setCardStyle(!isTextTheme() && DiagramColors.parse(theme).isCard()); List<LayoutRoute> layoutRoutes = new ArrayList<>(); int currentY = RouteDiagramLayoutEngine.PADDING; 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..41c1972a57f6 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,13 @@ */ 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.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 +124,23 @@ class CamelRouteDiagramActionRunningTest extends ActionCommandTestSupport { assertThat(printer.getOutput()).contains("Diagram saved to: " + txt.toAbsolutePath()); } + @Test + void cardThemeDrawsTheStepsAsCards() throws Exception { + writeStatusFile(TEST_PID, "myApp"); + Path light = dir.resolve("light.png"); + Path card = dir.resolve("card.png"); + + assertThat(callWithResponse(command("--theme=light", "--output=" + light)::doCall, structure("route1"))) + .isZero(); + assertThat(callWithResponse(command("--theme=card", "--output=" + card)::doCall, structure("route1"))) + .isZero(); + + // the cards have their title and detail on two lines, so the same route is taller than in the light theme + BufferedImage lightImage = ImageIO.read(light.toFile()); + BufferedImage cardImage = ImageIO.read(card.toFile()); + assertThat(cardImage.getHeight()).isGreaterThan(lightImage.getHeight()); + } + @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..cad504decc17 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 @@ -55,7 +55,8 @@ 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: 'dark' (default), 'light', 'transparent', 'card' (white cards with an icon, " + + "the kind of each step and its detail), 'card-dark', '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.",
