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();
+    }
+
 }

Reply via email to