This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch camel-23485-ascii-art-renderer in repository https://gitbox.apache.org/repos/asf/camel.git
commit ae686d9fe99645a9871f9b22cf919c0381f47a8c Author: Claus Ibsen <[email protected]> AuthorDate: Tue May 12 13:43:47 2026 +0200 CAMEL-23485: camel-diagram - Add ASCII art renderer Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --- .../camel/diagram/DefaultRouteDiagramDumper.java | 29 ++ .../camel/diagram/RouteDiagramAsciiRenderer.java | 291 +++++++++++++++++++++ .../org/apache/camel/diagram/RouteDiagramTest.java | 121 +++++++++ .../org/apache/camel/spi/RouteDiagramDumper.java | 20 ++ 4 files changed, 461 insertions(+) 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 364d579cf7dd..81ec387197d2 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 @@ -116,6 +116,16 @@ public class DefaultRouteDiagramDumper extends ServiceSupport implements CamelCo return renderImage(routes, theme.name(), fontSize, nodeWidth, nodeLabel.name(), metrics); } + @Override + public String dumpRoutesAsAsciiArt( + String filter, RouteDiagramDumper.NodeLabelMode nodeLabel, int nodeWidth) { + DevConsole dc = getCamelContext().getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class) + .resolveById("route-structure"); + JsonObject root = (JsonObject) dc.call(DevConsole.MediaType.JSON, Map.of("filter", filter)); + var routes = RouteDiagramHelper.parseRoutes(root); + return renderAscii(routes, nodeWidth, nodeLabel.name()); + } + @Override public String imageToBase64(BufferedImage image) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -152,4 +162,23 @@ public class DefaultRouteDiagramDumper extends ServiceSupport implements CamelCo return renderer.renderDiagram(layoutRoutes, currentY, colors); } + private static String renderAscii( + List<RouteDiagramLayoutEngine.RouteInfo> routes, int nodeWidth, String nodeLabel) { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine( + nodeWidth, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE, + RouteDiagramLayoutEngine.NodeLabelMode.valueOf(nodeLabel.toUpperCase())); + + List<RouteDiagramLayoutEngine.LayoutRoute> layoutRoutes = new ArrayList<>(); + int currentY = RouteDiagramLayoutEngine.PADDING; + for (RouteDiagramLayoutEngine.RouteInfo route : routes) { + RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(route, currentY); + layoutRoutes.add(lr); + currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP; + } + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer( + nodeWidth * RouteDiagramLayoutEngine.SCALE); + return renderer.renderDiagram(layoutRoutes, currentY); + } + } diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java new file mode 100644 index 000000000000..d79cf001bf54 --- /dev/null +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java @@ -0,0 +1,291 @@ +/* + * 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.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; + +import static org.apache.camel.diagram.RouteDiagramLayoutEngine.PADDING; + +/** + * Renders route diagrams as plain ASCII art text. + */ +public class RouteDiagramAsciiRenderer { + + private static final int MAX_WRAP_LINES = 3; + private static final int Y_SCALE = 20; + private static final int MIN_BOX_WIDTH = 16; + private static final int X_DIVISOR = 15; + + private final int nodeWidth; + private final int boxWidth; + + public RouteDiagramAsciiRenderer(int nodeWidth) { + this.nodeWidth = nodeWidth; + this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR); + } + + public int getBoxWidth() { + return boxWidth; + } + + public String renderDiagram(List<LayoutRoute> layoutRoutes, int totalHeight) { + int maxPixelX = layoutRoutes.stream() + .mapToInt(lr -> lr.maxX).max().orElse(nodeWidth) + PADDING; + int gridWidth = toCol(maxPixelX) + boxWidth + 4; + int gridHeight = totalHeight / Y_SCALE + 20; + + char[][] grid = new char[gridHeight][gridWidth]; + for (char[] row : grid) { + Arrays.fill(row, ' '); + } + + for (LayoutRoute lr : layoutRoutes) { + drawRoute(grid, lr); + } + + return gridToString(grid); + } + + private void drawRoute(char[][] grid, LayoutRoute lr) { + int labelRow = toRow(lr.labelY); + String label = lr.routeId; + if (lr.source != null && !lr.source.isEmpty()) { + label += " (" + lr.source + ")"; + } + drawText(grid, labelRow, toCol(PADDING), label); + + for (LayoutNode ln : lr.nodes) { + if (ln.parentNode != null) { + if (ln.connectFromMerge) { + drawMergeArrow(grid, ln); + } else { + drawArrow(grid, ln.parentNode, ln); + } + } + } + + for (LayoutNode ln : lr.nodes) { + drawNode(grid, ln); + } + } + + private void drawNode(char[][] grid, LayoutNode node) { + int col = toCol(node.x); + int row = toRow(node.y); + int innerWidth = boxWidth - 4; + List<String> lines = rewrapText(node, innerWidth); + int height = 2 + lines.size(); + + if (row + height >= grid.length) { + return; + } + + setChar(grid, row, col, '+'); + for (int c = col + 1; c < col + boxWidth - 1; c++) { + setChar(grid, row, c, '-'); + } + setChar(grid, row, col + boxWidth - 1, '+'); + + int bottom = row + height - 1; + setChar(grid, bottom, col, '+'); + for (int c = col + 1; c < col + boxWidth - 1; c++) { + setChar(grid, bottom, c, '-'); + } + setChar(grid, bottom, col + boxWidth - 1, '+'); + + for (int i = 0; i < lines.size(); i++) { + int r = row + 1 + i; + setChar(grid, r, col, '|'); + setChar(grid, r, col + boxWidth - 1, '|'); + for (int c = col + 1; c < col + boxWidth - 1; c++) { + setChar(grid, r, c, ' '); + } + String text = lines.get(i); + if (text.length() > innerWidth) { + text = text.substring(0, Math.max(1, innerWidth - 3)) + "..."; + } + int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 2); + drawText(grid, r, textCol, text); + } + } + + private void drawArrow(char[][] grid, LayoutNode from, LayoutNode to) { + int fromCx = centerCol(from); + int fromBottom = toRow(from.y) + boxHeight(from); + int toCx = centerCol(to); + int toTop = toRow(to.y); + + drawArrowPath(grid, fromCx, fromBottom, toCx, toTop); + } + + private void drawMergeArrow(char[][] grid, LayoutNode to) { + int fromCx = toCol(to.mergeCx); + int fromRow = toRow(to.mergeY); + int toCx = centerCol(to); + int toTop = toRow(to.y); + + drawArrowPath(grid, fromCx, fromRow, toCx, toTop); + } + + private void drawArrowPath(char[][] grid, int fromCx, int fromRow, int toCx, int toRow) { + if (fromRow >= toRow) { + return; + } + + if (fromCx == toCx) { + for (int r = fromRow; r < toRow - 1; r++) { + plotLine(grid, r, fromCx, '|'); + } + setChar(grid, toRow - 1, toCx, 'v'); + } else { + int midRow = fromRow + (toRow - fromRow) / 2; + + for (int r = fromRow; r < midRow; r++) { + plotLine(grid, r, fromCx, '|'); + } + + int minC = Math.min(fromCx, toCx); + int maxC = Math.max(fromCx, toCx); + for (int c = minC; c <= maxC; c++) { + plotLine(grid, midRow, c, '-'); + } + setChar(grid, midRow, fromCx, '+'); + setChar(grid, midRow, toCx, '+'); + + for (int r = midRow + 1; r < toRow - 1; r++) { + plotLine(grid, r, toCx, '|'); + } + setChar(grid, toRow - 1, toCx, 'v'); + } + } + + private int centerCol(LayoutNode node) { + return toCol(node.x + nodeWidth / 2); + } + + private int boxHeight(LayoutNode node) { + return 2 + rewrapText(node, boxWidth - 4).size(); + } + + private List<String> rewrapText(LayoutNode node, int maxWidth) { + String label = String.join("", node.wrappedLines); + return wrapText(label, maxWidth); + } + + static List<String> wrapText(String text, int maxWidth) { + if (maxWidth <= 0 || text.length() <= maxWidth) { + return List.of(text); + } + + List<String> lines = new ArrayList<>(); + String remaining = text; + + while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) { + if (remaining.length() <= maxWidth) { + lines.add(remaining); + remaining = ""; + break; + } + + int breakAt = -1; + for (int i = 0; i < maxWidth && i < remaining.length(); i++) { + char c = remaining.charAt(i); + if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') { + breakAt = i + 1; + } + } + if (breakAt <= 0) { + breakAt = maxWidth; + } + + lines.add(remaining.substring(0, breakAt).stripTrailing()); + remaining = remaining.substring(breakAt).stripLeading(); + } + + if (!remaining.isEmpty()) { + int lastIdx = lines.size() - 1; + String lastLine = lines.get(lastIdx); + if (lastLine.length() + remaining.length() <= maxWidth) { + lines.set(lastIdx, lastLine + remaining); + } else { + String combined = lastLine + remaining; + lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "..."); + } + } + + return lines; + } + + private int toCol(int pixelX) { + if (nodeWidth == 0) { + return 0; + } + return pixelX * boxWidth / nodeWidth; + } + + private int toRow(int pixelY) { + return pixelY / Y_SCALE; + } + + private void setChar(char[][] grid, int row, int col, char ch) { + if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) { + grid[row][col] = ch; + } + } + + private char getChar(char[][] grid, int row, int col) { + if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) { + return grid[row][col]; + } + return ' '; + } + + private void plotLine(char[][] grid, int row, int col, char ch) { + char current = getChar(grid, row, col); + if ((current == '|' && ch == '-') || (current == '-' && ch == '|')) { + setChar(grid, row, col, '+'); + } else { + setChar(grid, row, col, ch); + } + } + + private void drawText(char[][] grid, int row, int col, String text) { + for (int i = 0; i < text.length(); i++) { + setChar(grid, row, col + i, text.charAt(i)); + } + } + + private String gridToString(char[][] grid) { + int lastRow = 0; + for (int r = 0; r < grid.length; r++) { + if (!new String(grid[r]).isBlank()) { + lastRow = r; + } + } + StringBuilder sb = new StringBuilder(); + for (int r = 0; r <= lastRow; r++) { + sb.append(new String(grid[r]).stripTrailing()); + sb.append('\n'); + } + return sb.toString(); + } +} diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java index 80e8f2359541..40d662a0b330 100644 --- a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java @@ -852,6 +852,127 @@ class RouteDiagramTest { assertNull(RouteDiagramHelper.extractSourceName("")); } + @Test + void testAsciiDiagramSequential() { + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.nodes.add(node("from", "timer:tick", 0)); + route.nodes.add(node("to", "log:a", 1)); + route.nodes.add(node("to", "log:b", 1)); + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth()); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("route1")); + assertTrue(result.contains("timer:tick")); + assertTrue(result.contains("log:a")); + assertTrue(result.contains("log:b")); + assertTrue(result.contains("+")); + assertTrue(result.contains("v")); + } + + @Test + void testAsciiDiagramBranching() { + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.nodes.add(node("from", "timer:tick", 0)); + route.nodes.add(node("choice", "choice()", 1)); + route.nodes.add(node("when", "when(...)", 2)); + route.nodes.add(node("to", "log:a", 3)); + route.nodes.add(node("otherwise", "otherwise()", 2)); + route.nodes.add(node("to", "log:b", 3)); + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth()); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("route1")); + assertTrue(result.contains("timer:tick")); + assertTrue(result.contains("choice()")); + assertTrue(result.contains("when(...)")); + assertTrue(result.contains("otherwise()")); + assertTrue(result.contains("log:a")); + assertTrue(result.contains("log:b")); + // branching arrows use horizontal lines + assertTrue(result.contains("-"), "Branching should contain horizontal arrow lines"); + } + + @Test + void testAsciiDiagramEmptyRoute() { + RouteInfo route = new RouteInfo(); + route.routeId = "empty"; + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth()); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("empty")); + } + + @Test + void testAsciiDiagramWithSource() { + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.source = "test.yaml"; + route.nodes.add(node("from", "timer:tick", 0)); + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth()); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("route1 (test.yaml)")); + } + + @Test + void testAsciiDiagramLongLabel() { + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.nodes.add(node("from", "kafka:my-topic?brokers=localhost:9092&groupId=myGroup", 0)); + route.nodes.add(node("to", "log:a", 1)); + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth()); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("kafka:")); + assertTrue(result.contains("log:a")); + } + + @Test + void testAsciiWrapTextShort() { + List<String> lines = RouteDiagramAsciiRenderer.wrapText("timer:tick", 20); + assertEquals(1, lines.size()); + assertEquals("timer:tick", lines.get(0)); + } + + @Test + void testAsciiWrapTextWrap() { + List<String> lines = RouteDiagramAsciiRenderer.wrapText("kafka:my-topic?brokers=localhost:9092", 20); + assertTrue(lines.size() > 1, "Long text should wrap"); + String rejoined = String.join("", lines); + assertTrue(rejoined.contains("kafka:")); + assertTrue(rejoined.contains("9092")); + } + + @Test + void testAsciiWrapTextTruncate() { + String veryLong = "a]".repeat(60); + List<String> lines = RouteDiagramAsciiRenderer.wrapText(veryLong, 20); + assertTrue(lines.size() <= 3, "Should not exceed 3 lines"); + assertTrue(lines.get(lines.size() - 1).endsWith("..."), "Truncated text should end with ..."); + } + private static NodeInfo node(String type, String code, int level) { return node(type, code, level, null); } diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java b/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java index 7060cc5d0da2..79a6f842229f 100644 --- a/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java +++ b/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java @@ -89,4 +89,24 @@ public interface RouteDiagramDumper { */ String imageToBase64(BufferedImage image) throws IOException; + /** + * Dumps the routes as ASCII art text + * + * @param filter to filter routes + */ + default String dumpRoutesAsAsciiArt(String filter) { + return dumpRoutesAsAsciiArt(filter, NodeLabelMode.CODE, 180); + } + + /** + * Dumps the routes as ASCII art text + * + * @param filter to filter routes + * @param nodeLabel what information to display in the nodes + * @param nodeWidth the width in pixels of the node boxes + */ + default String dumpRoutesAsAsciiArt(String filter, NodeLabelMode nodeLabel, int nodeWidth) { + throw new UnsupportedOperationException(); + } + }
