This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch camel-jbang-diagram-zoom
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 9248e5e3999c57321aaab6cc5fb374151e1199c3
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 25 13:57:34 2026 +0100

    chore: camel-jbang - zoom levels and folding for the route diagram
    
    A large route takes a lot of screen. The route diagram gets three zoom
    levels (+ and -): detailed (as before), compact (a box with one line,
    the steps closer together) and bare (a mark and the text of each step
    on one row). z fits the most detail in which the whole route fits the
    screen. Space folds the block of the selected step into its first step,
    f folds the outermost blocks or unfolds all; a folded block is left out
    before the layout, so it gives its rows and columns back.
    
    The selected step stays selected and on its row of the screen. The
    level is kept for the next start, and shared by the Diagram and Routes
    tabs.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../ROOT/pages/camel-jbang-tui-diagram.adoc        |  24 +-
 .../core/commands/tui/AbstractDiagramTab.java      |  47 ++++
 .../jbang/core/commands/tui/DiagramSupport.java    |  33 ++-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    |   3 +
 .../jbang/core/commands/tui/MonitorContext.java    |   2 +
 .../jbang/core/commands/tui/RouteDiagramZoom.java  | 299 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/RoutesTab.java     |   4 +
 .../core/commands/tui/diagram/RouteDensity.java    | 181 +++++++++++++
 .../commands/tui/diagram/RouteDiagramWidget.java   | 210 +++++++++++++--
 .../src/main/resources/tui/help/diagram.md         |  12 +-
 .../src/main/resources/tui/help/routes.md          |   9 +
 .../core/commands/tui/RouteDiagramZoomTest.java    | 178 ++++++++++++
 .../commands/tui/diagram/RouteDensityTest.java     | 180 +++++++++++++
 13 files changed, 1136 insertions(+), 46 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc
index 6fc27a5b1c34..185de8ae945f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc
@@ -55,6 +55,24 @@ Press *t* to jump straight back to the topology from any 
depth.
 
 image::jbang/camel-tui-diagram-route.png[The checkout route drilled into, with 
the level bar on top]
 
+=== Zoom and Folding
+
+A large route takes a lot of screen. Press *-* to zoom out and *+* to zoom in, 
through three levels:
+
+* *detailed*: a box per step, with up to three lines of text
+* *compact*: a box per step with one line of text, and the steps closer 
together: about twice as many steps fit
+* *bare*: no boxes, each step is a mark and its text on one row (`▶` the from, 
`◆` a step with steps in it, `◇` a
+branch, `●` a step), with the counts beside the lines between them
+
+Press *z* for fit: the most detailed level in which the whole route fits the 
screen. The view bar shows the level
+(`zoom: compact`, or `zoom: fit bare`). Zooming keeps the selected step 
selected, on the same row of the screen, and
+the Info panel has the whole text of the selected step at every level.
+
+Press *Space* to fold a block (a choice, one of its branches, a split, a 
doTry, a filter...) into its first step,
+which then says how many steps are folded (`▸ 12 steps folded`); *Space* on a 
step inside a block folds that block, and
+*Space* on a folded step unfolds it. A folded block gives its rows and its 
columns back to the rest of the route.
+Press *f* to fold the outermost blocks of the route, and *f* again to unfold 
them all.
+
 === Diagram levels
 
 The Diagram tab shows one diagram at three zoom levels, and the line above it 
says which one you see:
@@ -86,7 +104,7 @@ labels and what capabilities achieve (the AI-assisted parts 
marked ✦), or rout
 endpoints and the entry points and systems of each capability. The business 
view is the default when
 the routes have descriptions, from their source or the AI project overview; 
*n*, the key of the former
 description toggle, does the same. *m* metrics and *e* external systems apply 
to the topology, *m*,
-*y* body types and *d* detail to a route. *g* group, in the topology, shows 
the architecture one level down: each route
+*y* body types, *+/-* zoom (see <<_zoom_and_folding,Zoom and Folding>>) and 
*d* detail to a route. *g* group, in the topology, shows the architecture one 
level down: each route
 box names its group (a route group, an AI capability marked ✦, shared services 
or utility), each group
 has the border colour of its box in the architecture, and a legend at the 
bottom of the topology lists
 the groups in their colours; it is on when the project has groups. *u* shows 
or hides the utility
@@ -247,6 +265,10 @@ camel-xml-io, while a Spring Boot or Quarkus application 
may not.
 | *t* | Jump to topology view
 | *m* | Toggle metrics overlay
 | *y* | In a route: show the body types each step has been reached with, 
beside the arrows into the steps
+| *+* / *-* | In a route: zoom in or out (detailed, compact, bare)
+| *z* | In a route: fit, the most detail in which the whole route fits the 
screen
+| *Space* | In a route: fold the block of the selected step, or unfold a 
folded step
+| *f* | In a route: fold the outermost blocks, or unfold all
 | *e* | External systems: in the topology off, edges, all; in the architecture 
off or edges
 | *a* | AI-assisted parts shown, or the facts alone; without a 
`camel-summary.md` yet, has the AI write one
 | *b* (or *n*) | Business view (labels, what capabilities achieve) or 
technical view (route ids, endpoints)
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
index 7cd960f2cdc2..f28394a96b9a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
@@ -58,6 +58,7 @@ abstract class AbstractDiagramTab extends AbstractTab {
 
     protected AbstractDiagramTab(MonitorContext ctx) {
         super(ctx);
+        diagram.zoom = ctx.routeZoom;
     }
 
     boolean isShowDiagram() {
@@ -131,6 +132,9 @@ abstract class AbstractDiagramTab extends AbstractTab {
             gotoNodePopup.open(diagram.getEipNodeBoxes(), 
diagram.getSelectedEipNodeIndex());
             return true;
         }
+        if (routeNodes && handleZoomKeys(ke)) {
+            return true;
+        }
         if (!topologyMode && detailMode) {
             if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
                 detail.scrollBy(-10);
@@ -187,6 +191,49 @@ abstract class AbstractDiagramTab extends AbstractTab {
         return false;
     }
 
+    /**
+     * The zoom of the route diagram: + and - give the steps more or less 
room, z the most detail that fits the screen,
+     * space folds the block of the selected step (or unfolds it), f folds the 
outermost blocks or unfolds all. The
+     * selected step stays selected, on the same row of the screen.
+     */
+    private boolean handleZoomKeys(KeyEvent ke) {
+        RouteDiagramZoom zoom = diagram.zoom;
+        var selected = diagram.getSelectedEipNodeBox();
+        String anchorId = selected != null ? selected.nodeId() : null;
+        if (ke.isChar('+') || ke.isChar('=')) {
+            zoom.zoomIn();
+            ctx.backgroundExecutor.execute(zoom::persist);
+        } else if (ke.isChar('-')) {
+            zoom.zoomOut();
+            ctx.backgroundExecutor.execute(zoom::persist);
+        } else if (ke.isCharIgnoreCase('z')) {
+            zoom.toggleFit();
+            ctx.backgroundExecutor.execute(zoom::persist);
+        } else if (ke.isChar(' ')) {
+            String folded = zoom.toggleFold(drillDownRouteId, selected != null 
? selected.layoutNode() : null);
+            if (folded == null) {
+                // no block to fold
+                return true;
+            }
+            anchorId = folded;
+        } else if (ke.isChar('f')) {
+            zoom.toggleFoldAll(drillDownRouteId, 
diagram.getRouteLayout(drillDownRouteId));
+        } else {
+            return false;
+        }
+        int screenRow = 0;
+        for (var box : diagram.getEipNodeBoxes()) {
+            if (anchorId != null && anchorId.equals(box.nodeId())) {
+                screenRow = box.startRow() - diagram.getScrollY();
+                break;
+            }
+        }
+        zoom.anchor(diagram.getRouteLayout(drillDownRouteId), anchorId, 
screenRow);
+        diagram.endLoad();
+        reloadDiagram();
+        return true;
+    }
+
     /**
      * Esc on the diagram: closes the go to node popup or the source, else 
from a route goes back along the routes
      * followed, then to the topology. Returns false in the topology, where 
each tab has its own.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
index 7866de503162..dd06303792d1 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
@@ -64,6 +64,7 @@ import 
org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
 import org.apache.camel.dsl.jbang.core.commands.ai.IntegrationSummary;
+import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDensity;
 import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget;
 import 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget;
 import org.apache.camel.util.json.JsonArray;
@@ -131,6 +132,8 @@ class DiagramSupport {
     private TopologyLayoutResult topologyLayout;
     private int topologyNodeWidth;
     private java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> 
routeLayouts = Collections.emptyMap();
+    /** The zoom of the route diagram, and its folded blocks; the tabs of the 
route diagram share theirs. */
+    RouteDiagramZoom zoom = new RouteDiagramZoom();
     private String cachedPid;
     private volatile boolean preloading;
 
@@ -979,7 +982,7 @@ class DiagramSupport {
     }
 
     /** The error frame of a route in the route view: its onException blocks, 
where its failures go and come from. */
-    private static RouteErrorFrame errorFrame(
+    static RouteErrorFrame errorFrame(
             RouteDiagramLayoutEngine engine, 
RouteDiagramLayoutEngine.LayoutRoute lr,
             List<RouteDiagramLayoutEngine.RouteInfo> blocks, String routeId, 
List<TopologyErrorFrame.ErrorPath> paths) {
         return RouteErrorFrame.add(engine, lr, blocks,
@@ -1547,10 +1550,18 @@ class DiagramSupport {
         Map<String, String> linkable = 
computeLinkableEndpoints(currentRouteId);
         Map<String, String> routeDescs = showDescription ? 
computeRouteDescriptions() : Collections.emptyMap();
 
+        RouteDiagramZoom.Restore restore = zoom.restore(routeLayout);
+        if (restore != null) {
+            // zoomed or folded: the selected step stays on its row of the 
screen, unless the whole route fits on it
+            selectedEipNodeIndex = restore.index();
+            boolean fits = RouteDensity.rows(routeLayout) <= lastVisibleHeight;
+            scrollY = fits ? 0 : Math.max(0, restore.row() - 
restore.screenRow());
+        }
         // built again once the scroll is clamped to its size
         Supplier<RouteDiagramWidget> widgets = () -> new RouteDiagramWidget(
                 routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics, linkable,
                 showDescription, routeDescs)
+                
.withDensity(zoom.densityOf(routeLayout)).withFoldedSteps(zoom.foldedStepsOf(routeLayout))
                 .withErrorFrame(layoutShowErrorPaths ? 
routeErrorFrames.get(currentRouteId) : null)
                 .withErrorMark(errorMarkRouteIds().contains(currentRouteId))
                 .withBodyTypes(showBodyTypes).withBodyKindMarks(true);
@@ -2286,23 +2297,9 @@ class DiagramSupport {
                 RouteDiagramLayoutEngine.NodeLabelMode labelMode = 
showDescription
                         ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION
                         : RouteDiagramLayoutEngine.NodeLabelMode.CODE;
-                RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
-                        RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, 
RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
-                        labelMode);
-                engine.setTableLayout(true);
-                for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
-                    // the happy path; the onException clauses are not its 
steps, and are shown in the error frame
-                    RouteErrorFrame.Split split = RouteErrorFrame.split(r);
-                    RouteDiagramLayoutEngine.LayoutRoute lr = 
engine.layoutRoute(split.happy(), 0);
-                    normalizeRouteLayoutY(lr);
-                    if (showErrors) {
-                        RouteErrorFrame frame = errorFrame(engine, lr, 
split.blocks(), r.routeId, loadedErrorPaths);
-                        if (frame != null) {
-                            loadedRouteFrames.put(r.routeId, frame);
-                        }
-                    }
-                    routeMap.put(r.routeId, lr);
-                }
+                // folded and zoomed (+ - z space), with the error handling in 
its frame below
+                routeMap.putAll(zoom.layoutRoutes(
+                        routes, labelMode, showErrors, loadedErrorPaths, 
loadedRouteFrames, lastVisibleHeight));
             }
         }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index 3856d58299f3..c4926553ef51 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -511,6 +511,7 @@ class DiagramTab extends AbstractDiagramTab {
                 yield toggles;
             }
             case ROUTE -> List.of(view, new SubViewBar.Toggle("x", "errors", 
errorsToggle()),
+                    new SubViewBar.Toggle("+/-", "zoom", 
diagram.zoom.label(diagram.getRouteLayout(drillDownRouteId))),
                     new SubViewBar.Toggle("m", "metrics", metrics),
                     new SubViewBar.Toggle("y", "types", 
diagram.isShowBodyTypes() ? "on" : "off"),
                     new SubViewBar.Toggle("d", "detail", detailMode ? "on" : 
"off"));
@@ -661,6 +662,8 @@ class DiagramTab extends AbstractDiagramTab {
                 hint(spans, "Esc", "back");
                 hint(spans, "c", "source");
                 hint(spans, "g", "go to");
+                hint(spans, "+/-", "zoom");
+                hint(spans, "Space", "fold");
                 hint(spans, "s", "summary");
             } else if (!topologyMode) {
                 hint(spans, "Esc", "back");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index 4488a2f22881..08d1dd14806a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -61,6 +61,8 @@ class MonitorContext {
     });
     /** The structure of the routes the diagrams draw, asked of an integration 
again only when its routes change. */
     final DiagramTopology diagramTopology = new DiagramTopology();
+    /** The zoom and the folded blocks of the route diagram, one for the 
Diagram and the Routes tabs. */
+    final RouteDiagramZoom routeZoom = RouteDiagramZoom.persisted();
 
     volatile String selectedPid;
     volatile String lastSelectedName;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java
new file mode 100644
index 000000000000..781c57633f12
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java
@@ -0,0 +1,299 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+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.RouteInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode;
+import org.apache.camel.diagram.RouteErrorFrame;
+import org.apache.camel.diagram.TopologyErrorFrame;
+import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDensity;
+
+/**
+ * The zoom of the route diagram: how much room a step takes ({@link 
RouteDensity}), or the most detail that fits the
+ * screen, and the blocks folded into their first step. A folded block is left 
out of the route before the engine lays
+ * it out, so the steps after it move up and its branches give their columns 
back.
+ * <p>
+ * The keys change the zoom on the UI thread; the layout reads it on the 
background thread, so the state is volatile or
+ * concurrent. When the zoom changes, the selected step stays selected and on 
the same row of the screen.
+ */
+final class RouteDiagramZoom {
+
+    /** The zoom chosen last: a density, or fit; the folded blocks are not 
kept. */
+    static final String PROP_ROUTE_ZOOM = "camel.tui.routeZoom";
+
+    /** The selected step to select again once the route is laid out anew, and 
its row on the screen. */
+    record Restore(int index, int row, int screenRow) {
+    }
+
+    private volatile RouteDensity density = RouteDensity.DETAILED;
+    private volatile boolean fit;
+    /** The folded blocks of each route: the ids of their first steps. */
+    private final Map<String, Set<String>> folded = new ConcurrentHashMap<>();
+    /** What each layout was made with: its density, and the steps folded into 
each folded step. */
+    private final Map<LayoutRoute, RouteDensity> densities = 
Collections.synchronizedMap(new WeakHashMap<>());
+    private final Map<LayoutRoute, Map<String, Integer>> foldedSteps
+            = Collections.synchronizedMap(new WeakHashMap<>());
+    // the step to select again after the next layout, and the layout it was 
selected on
+    private LayoutRoute anchorLayout;
+    private String anchorNodeId;
+    private int anchorScreenRow;
+
+    /** The zoom with the density or fit chosen last time. */
+    static RouteDiagramZoom persisted() {
+        RouteDiagramZoom zoom = new RouteDiagramZoom();
+        try {
+            zoom.setting(TuiUserConfig.read(PROP_ROUTE_ZOOM));
+        } catch (RuntimeException e) {
+            // best-effort: the default zoom
+        }
+        return zoom;
+    }
+
+    /** Keeps the density or fit for the next start; file work, so not on the 
UI thread. */
+    void persist() {
+        try {
+            TuiUserConfig.write(PROP_ROUTE_ZOOM, setting());
+        } catch (RuntimeException e) {
+            // best-effort: a persist failure must not disrupt the TUI
+        }
+    }
+
+    /** The density, or fit; null for the default (detailed), so the file does 
not carry it. */
+    String setting() {
+        if (fit) {
+            return "fit";
+        }
+        return density == RouteDensity.DETAILED ? null : density.label();
+    }
+
+    void setting(String value) {
+        fit = "fit".equals(value);
+        density = RouteDensity.DETAILED;
+        for (RouteDensity d : RouteDensity.values()) {
+            if (d.label().equals(value)) {
+                density = d;
+            }
+        }
+    }
+
+    RouteDensity density() {
+        return density;
+    }
+
+    boolean isFit() {
+        return fit;
+    }
+
+    void zoomIn() {
+        fit = false;
+        density = density.zoomIn();
+    }
+
+    void zoomOut() {
+        fit = false;
+        density = density.zoomOut();
+    }
+
+    void toggleFit() {
+        fit = !fit;
+    }
+
+    /** What the zoom toggle of the view bar says: the density, and fit when 
it is chosen for the screen. */
+    String label(LayoutRoute shown) {
+        RouteDensity d = shown != null ? densityOf(shown) : density;
+        return fit ? "fit " + d.label() : d.label();
+    }
+
+    RouteDensity densityOf(LayoutRoute lr) {
+        RouteDensity d = densities.get(lr);
+        return d != null ? d : RouteDensity.DETAILED;
+    }
+
+    Map<String, Integer> foldedStepsOf(LayoutRoute lr) {
+        Map<String, Integer> steps = foldedSteps.get(lr);
+        return steps != null ? steps : Map.of();
+    }
+
+    /**
+     * Folds the block the step starts, or unfolds it when folded; a step 
without a block of its own folds the block it
+     * is in.
+     *
+     * @return the id of the step folded or unfolded, to select; null when 
there is no block to fold
+     */
+    String toggleFold(String routeId, LayoutNode node) {
+        if (routeId == null || node == null || node.treeNode == null || 
node.id == null) {
+            return null;
+        }
+        Set<String> ids = folded.computeIfAbsent(routeId, k -> 
ConcurrentHashMap.newKeySet());
+        if (ids.remove(node.id)) {
+            return node.id;
+        }
+        TreeNode block = node.treeNode;
+        while (block != null && !foldable(block)) {
+            block = block.parent;
+        }
+        if (block == null || block.info.id == null) {
+            return null;
+        }
+        ids.add(block.info.id);
+        return block.info.id;
+    }
+
+    /** Folds the outermost blocks of the route, or unfolds all of them when 
some are folded already. */
+    void toggleFoldAll(String routeId, LayoutRoute lr) {
+        Set<String> ids = folded.computeIfAbsent(routeId, k -> 
ConcurrentHashMap.newKeySet());
+        if (!ids.isEmpty()) {
+            ids.clear();
+            return;
+        }
+        if (lr != null && !lr.nodes.isEmpty() && lr.nodes.get(0).treeNode != 
null) {
+            foldOutermost(lr.nodes.get(0).treeNode, ids);
+        }
+    }
+
+    private static void foldOutermost(TreeNode node, Set<String> ids) {
+        for (TreeNode child : node.children) {
+            if (foldable(child) && child.info.id != null) {
+                ids.add(child.info.id);
+            } else {
+                foldOutermost(child, ids);
+            }
+        }
+    }
+
+    /** A step with steps in it, which is not the route or its from. */
+    private static boolean foldable(TreeNode node) {
+        return !node.children.isEmpty() && !node.table && node.info.type != 
null
+                && !"route".equals(node.info.type) && 
!"from".equals(node.info.type);
+    }
+
+    /** Remembers the selected step and its row on the screen, to keep it 
there once the route is laid out anew. */
+    void anchor(LayoutRoute shown, String nodeId, int screenRow) {
+        anchorLayout = shown;
+        anchorNodeId = nodeId;
+        anchorScreenRow = screenRow;
+    }
+
+    /** The step to select in the layout to draw, when it is the new layout 
the anchor waited for; else null. */
+    Restore restore(LayoutRoute lr) {
+        if (anchorNodeId == null || lr == null || lr == anchorLayout) {
+            return null;
+        }
+        String id = anchorNodeId;
+        anchorNodeId = null;
+        anchorLayout = null;
+        int index = 0;
+        for (LayoutNode ln : lr.nodes) {
+            if ("route".equals(ln.type)) {
+                continue;
+            }
+            if (id.equals(ln.id)) {
+                return new Restore(index, ln.y / RouteDensity.ROW, 
anchorScreenRow);
+            }
+            index++;
+        }
+        return null;
+    }
+
+    /**
+     * Lays out the routes with the zoom: the folded blocks left out, the 
steps on the rows of the density (with fit,
+     * the most detailed one in which the route fits the rows of the screen), 
the error handling in its frame below.
+     */
+    Map<String, LayoutRoute> layoutRoutes(
+            List<RouteInfo> routes, RouteDiagramLayoutEngine.NodeLabelMode 
labelMode, boolean showErrors,
+            List<TopologyErrorFrame.ErrorPath> errorPaths, Map<String, 
RouteErrorFrame> frames, int visibleRows) {
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
+                RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, 
RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE, labelMode);
+        // a Switch is drawn as a decision table, a row per case
+        engine.setTableLayout(true);
+        Map<String, LayoutRoute> answer = new LinkedHashMap<>();
+        for (RouteInfo r : routes) {
+            // the happy path; the onException clauses are not its steps, and 
are shown in the error frame
+            RouteErrorFrame.Split split = RouteErrorFrame.split(r);
+            Map<String, Integer> steps = new HashMap<>();
+            LayoutRoute lr = engine.layoutRoute(fold(split.happy(), steps), 0);
+            RouteDensity d = fit ? RouteDensity.DETAILED : density;
+            // each density places the steps from where the engine put the 
route, before it was moved to the top
+            int rootY = lr.nodes.isEmpty() ? 0 : lr.nodes.get(0).y;
+            d.apply(lr);
+            DiagramLayouts.normalizeRouteLayoutY(lr);
+            while (fit && d != RouteDensity.BARE && visibleRows > 0 && 
RouteDensity.rows(lr) > visibleRows) {
+                d = d.zoomOut();
+                lr.nodes.get(0).y = rootY;
+                d.apply(lr);
+                DiagramLayouts.normalizeRouteLayoutY(lr);
+            }
+            if (showErrors) {
+                RouteErrorFrame frame = DiagramSupport.errorFrame(engine, lr, 
split.blocks(), r.routeId, errorPaths);
+                if (frame != null) {
+                    frames.put(r.routeId, frame);
+                }
+            }
+            densities.put(lr, d);
+            foldedSteps.put(lr, steps);
+            answer.put(r.routeId, lr);
+        }
+        return answer;
+    }
+
+    /** The route without the steps of its folded blocks; counts the steps 
left out of each. */
+    private RouteInfo fold(RouteInfo route, Map<String, Integer> steps) {
+        Set<String> ids = folded.getOrDefault(route.routeId, Set.of());
+        if (ids.isEmpty()) {
+            return route;
+        }
+        RouteInfo answer = new RouteInfo();
+        answer.routeId = route.routeId;
+        answer.source = route.source;
+        answer.stat = route.stat;
+        Set<String> present = new HashSet<>();
+        int foldLevel = -1;
+        String foldId = null;
+        for (NodeInfo n : route.nodes) {
+            if (n.id != null) {
+                present.add(n.id);
+            }
+            if (foldLevel >= 0 && n.level > foldLevel) {
+                steps.merge(foldId, 1, Integer::sum);
+                continue;
+            }
+            foldLevel = -1;
+            answer.nodes.add(n);
+            if (n.id != null && ids.contains(n.id)) {
+                foldLevel = n.level;
+                foldId = n.id;
+            }
+        }
+        // a step no longer in the route (its source changed) is not folded 
any more
+        ids.retainAll(present);
+        return answer;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 9663de324354..04248a6e0591 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -735,6 +735,8 @@ class RoutesTab extends AbstractDiagramTab {
                         "e", "external",
                         EXTERNAL_LABELS[externalMode].trim().replace("[", 
"").replace("]", "")));
             } else if (!diagram.getEipNodeBoxes().isEmpty()) {
+                toggles.add(new SubViewBar.Toggle(
+                        "+/-", "zoom", 
diagram.zoom.label(diagram.getRouteLayout(drillDownRouteId))));
                 toggles.add(new SubViewBar.Toggle("y", "types", 
diagram.isShowBodyTypes() ? "on" : "off"));
                 toggles.add(new SubViewBar.Toggle("d", "detail", detailMode ? 
"on" : "off"));
             }
@@ -756,6 +758,8 @@ class RoutesTab extends AbstractDiagramTab {
                 hint(spans, "t", "topology");
                 hint(spans, TuiIcons.HINT_NAV, "navigate");
                 hint(spans, "g", "go to");
+                hint(spans, "+/-", "zoom");
+                hint(spans, "Space", "fold");
                 hint(spans, "PgUp/PgDn", detailMode ? "detail" : "page");
                 hint(spans, "c", "source");
             } else if (!topologyMode) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensity.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensity.java
new file mode 100644
index 000000000000..c671d9c9eb9f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensity.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui.diagram;
+
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode;
+
+import static 
org.apache.camel.diagram.RouteDiagramLayoutEngine.BRANCH_CHILD_TYPES;
+
+/**
+ * How much room a step of the route diagram takes: the zoom levels of a 
route. A terminal cannot make its font smaller,
+ * so zooming out draws a step with fewer rows and puts the steps closer 
together.
+ * <ul>
+ * <li>{@link #DETAILED}: a box with up to three lines of text, as the layout 
engine places it</li>
+ * <li>{@link #COMPACT}: a box with one line of text, an arrow of a row 
between the boxes</li>
+ * <li>{@link #BARE}: no box, a step is a mark and its text on one row</li>
+ * </ul>
+ * The engine keeps the columns of the steps; {@link #apply} places them again 
on the rows of the density, top down as
+ * the engine does, in its pixels (a row is {@link #ROW} of them). A block (a 
step with steps in it) takes a row above
+ * it for the top of its frame (compact), and a row below it for the bottom of 
its frame (compact) or a blank row
+ * (bare).
+ */
+public enum RouteDensity {
+
+    DETAILED("detailed", 0, 0, 0, 0, 0),
+    COMPACT("compact", 3, 1, 2, Px.ROW, Px.ROW),
+    BARE("bare", 1, 1, 2, 0, Px.ROW);
+
+    /** The pixels of the layout in a row of the terminal (the widget divides 
by it). */
+    public static final int ROW = Px.ROW;
+
+    private final String label;
+    private final int nodeRows;
+    private final int gap;
+    private final int branchGap;
+    private final int scopeTop;
+    private final int scopeBottom;
+
+    RouteDensity(String label, int nodeRows, int gapRows, int branchGapRows, 
int scopeTop, int scopeBottom) {
+        this.label = label;
+        this.nodeRows = nodeRows;
+        this.gap = gapRows * Px.ROW;
+        this.branchGap = branchGapRows * Px.ROW;
+        this.scopeTop = scopeTop;
+        this.scopeBottom = scopeBottom;
+    }
+
+    public String label() {
+        return label;
+    }
+
+    /** One level more detail, or this one when it is the most. */
+    public RouteDensity zoomIn() {
+        return this == DETAILED ? DETAILED : values()[ordinal() - 1];
+    }
+
+    /** One level less detail, or this one when it is the least. */
+    public RouteDensity zoomOut() {
+        return this == BARE ? BARE : values()[ordinal() + 1];
+    }
+
+    /** Whether the steps are drawn in boxes; the bare steps are a mark and a 
text, with no box around their blocks. */
+    public boolean boxed() {
+        return this != BARE;
+    }
+
+    /** The rows the diagram of the route takes. */
+    public static int rows(LayoutRoute lr) {
+        return lr.maxY / ROW;
+    }
+
+    /** Places the steps of a route laid out by the engine on the rows of this 
density; the detailed is the engine's. */
+    public void apply(LayoutRoute lr) {
+        if (this == DETAILED || lr.nodes.isEmpty() || lr.nodes.get(0).treeNode 
== null) {
+            return;
+        }
+        lr.maxY = 0;
+        LayoutNode root = lr.nodes.get(0);
+        place(root.treeNode, root.y, lr);
+    }
+
+    private void place(TreeNode node, int y, LayoutRoute lr) {
+        LayoutNode ln = node.layoutNode;
+        ln.y = y;
+        ln.height = height(node);
+        lr.maxY = Math.max(lr.maxY, y + ln.height);
+        if (node.table) {
+            // the rows are inside the table's box
+            for (TreeNode row : node.children) {
+                row.layoutNode.y = y;
+            }
+            return;
+        }
+        if (node.children.isEmpty()) {
+            return;
+        }
+        boolean branching = 
RouteDiagramLayoutEngine.isBranchingEip(node.info.type);
+        int childY = y + ln.height + (branching ? branchGap : gap);
+        if (branching) {
+            for (TreeNode child : node.children) {
+                boolean scope = !child.children.isEmpty() && 
!BRANCH_CHILD_TYPES.contains(child.info.type);
+                place(child, scope ? childY + scopeTop : childY, lr);
+            }
+            return;
+        }
+        int curY = childY;
+        for (int i = 0; i < node.children.size(); i++) {
+            TreeNode child = node.children.get(i);
+            boolean scope = RouteDiagramLayoutEngine.hasScope(child);
+            place(child, scope ? curY + scopeTop : curY, lr);
+            int bottom = scope ? blockBottom(child) + scopeBottom : 
bottom(child);
+            if (scope && i + 1 < node.children.size()) {
+                // the next step is reached from the bottom of the block's 
frame, not from its last step
+                LayoutNode next = node.children.get(i + 1).layoutNode;
+                if (next != null && next.connectFromMerge) {
+                    next.mergeY = blockBottom(child);
+                }
+            }
+            curY = bottom + gap;
+        }
+    }
+
+    private int height(TreeNode node) {
+        if (node.table) {
+            int rows = node.children.size();
+            int shown = Math.min(rows, RouteDiagramLayoutEngine.MAX_TABLE_ROWS)
+                        + (rows > RouteDiagramLayoutEngine.MAX_TABLE_ROWS ? 1 
: 0);
+            return (2 + shown) * ROW;
+        }
+        return nodeRows * ROW;
+    }
+
+    /**
+     * The bottom of a step and of all the steps in it, with the frames of the 
blocks in it: the row of the bottom of
+     * its own frame.
+     */
+    public int blockBottom(TreeNode node) {
+        int max = node.layoutNode.y + node.layoutNode.height;
+        if (node.table) {
+            return max;
+        }
+        for (TreeNode child : node.children) {
+            int bottom = RouteDiagramLayoutEngine.hasScope(child) ? 
blockBottom(child) + scopeBottom : blockBottom(child);
+            max = Math.max(max, bottom);
+        }
+        return max;
+    }
+
+    /** The bottom of a step that has no block of its own, and of the steps 
under it. */
+    private static int bottom(TreeNode node) {
+        int max = node.layoutNode != null ? node.layoutNode.y + 
node.layoutNode.height : 0;
+        if (node.table) {
+            return max;
+        }
+        for (TreeNode child : node.children) {
+            max = Math.max(max, bottom(child));
+        }
+        return max;
+    }
+
+    // an enum constant cannot refer to a static field of its own enum in its 
constructor
+    private static final class Px {
+        static final int ROW = 20;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
index b3b3d01a6611..bc6f6a186592 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
@@ -166,7 +166,7 @@ public class RouteDiagramWidget implements Widget {
 
         // Scope boxes (behind everything)
         for (LayoutNode ln : layoutRoute.nodes) {
-            if (!"route".equals(ln.type)
+            if (density.boxed() && !"route".equals(ln.type)
                     && ln.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(ln.treeNode)) {
                 drawScopeBox(buffer, area, ln);
             }
@@ -266,6 +266,20 @@ public class RouteDiagramWidget implements Widget {
 
     private RouteErrorFrame errorFrame;
     private boolean errorMark;
+    private RouteDensity density = RouteDensity.DETAILED;
+    private Map<String, Integer> foldedSteps = Map.of();
+
+    /** How much room a step takes: the layout must have been placed with the 
same density. */
+    public RouteDiagramWidget withDensity(RouteDensity density) {
+        this.density = density != null ? density : RouteDensity.DETAILED;
+        return this;
+    }
+
+    /** The folded steps, and how many steps are folded into each: a mark 
beside them says so. */
+    public RouteDiagramWidget withFoldedSteps(Map<String, Integer> 
foldedSteps) {
+        this.foldedSteps = foldedSteps != null ? foldedSteps : Map.of();
+        return this;
+    }
 
     /** The route has error handling: a dim mark on its from box, whether the 
error handling is shown or not. */
     public RouteDiagramWidget withErrorMark(boolean mark) {
@@ -430,10 +444,14 @@ public class RouteDiagramWidget implements Widget {
     }
 
     private void drawNode(Buffer buffer, Rect area, LayoutNode node) {
+        if (!density.boxed()) {
+            drawBareNode(buffer, area, node);
+            return;
+        }
         int col = toCol(node.x);
         int row = toRow(node.y);
         int innerWidth = boxWidth - 4;
-        List<String> lines = rewrapText(node, innerWidth);
+        List<String> lines = density == RouteDensity.COMPACT ? 
List.of(labelText(node)) : rewrapText(node, innerWidth);
         int height = 2 + lines.size();
 
         int nodeIdx = nodeBoxes.size();
@@ -490,7 +508,9 @@ public class RouteDiagramWidget implements Widget {
             int textCol = col + 2 + Math.max(0, (innerWidth - 
CharWidth.of(text)) / 2);
 
             // First line: [type] tag with EIP color, rest: label text
-            if (i == 0) {
+            if (density == RouteDensity.COMPACT) {
+                writeLabel(buffer, area, r, textCol, text, eipColor, selected);
+            } else if (i == 0) {
                 writeText(buffer, area, r, textCol, text, 
style(Style.EMPTY.fg(eipColor).bold(), selected));
             } else {
                 writeText(buffer, area, r, textCol, text, 
style(fromLabelStyle(), selected));
@@ -505,18 +525,9 @@ public class RouteDiagramWidget implements Widget {
         // Link indicator for nodes that connect to other routes
         String linkedRouteId = findLinkedRouteId(node);
         if (linkedRouteId != null) {
-            Style linkStyle = Theme.label().bold();
-            String name = linkedRouteId;
-            if (showDescription && "from".equals(node.type)) {
-                // a from box names its caller in words; a to box already 
shows the route it links to in words, so
-                // its marker names the route by id, as the breadcrumb does
-                String desc = routeDescriptions.get(linkedRouteId);
-                if (desc != null && !desc.isBlank()) {
-                    name = desc;
-                }
-            }
-            markers.add(new Marker(bottom, col + boxWidth, " ↵ " + name, 
linkStyle));
+            markers.add(new Marker(bottom, col + boxWidth, " ↵ " + 
linkName(node, linkedRouteId), Theme.label().bold()));
         }
+        addFoldedMarker(node, row, col + boxWidth);
         // where the input comes from and where a message leaves, set in the 
border so every box keeps its size:
         // in at the top left (AMQP ──▶), out at the bottom right (──▶ AMQP)
         if ("from".equals(node.type) && !external && node.treeNode != null && 
isScheduled(node.treeNode.info)) {
@@ -558,7 +569,7 @@ public class RouteDiagramWidget implements Widget {
     }
 
     private void drawMergeArrow(Buffer buffer, Rect area, LayoutNode to) {
-        int fromCx = toCol(to.mergeCx);
+        int fromCx = !density.boxed() && to.parentNode != null ? 
centerCol(to.parentNode) : toCol(to.mergeCx);
         int fromRow = toRow(to.mergeY);
         int toCx = centerCol(to);
         int toTop = getTopRow(to);
@@ -598,7 +609,12 @@ public class RouteDiagramWidget implements Widget {
             for (int r = fromRow; r < toRow - 1; r++) {
                 arrowChar(buffer, area, r, fromCx, vChar, edgeStyle);
             }
-            arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+            if (density.boxed()) {
+                arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+            } else {
+                // a branch straight below: through the line to the other 
branches
+                joinChar(buffer, area, toRow - 1, toCx, vChar, edgeStyle);
+            }
         } else {
             int midRow = fromRow + (toRow - fromRow) / 2;
 
@@ -608,18 +624,60 @@ public class RouteDiagramWidget implements Widget {
 
             int minC = Math.min(fromCx, toCx);
             int maxC = Math.max(fromCx, toCx);
-            for (int c = minC; c <= maxC; c++) {
-                arrowChar(buffer, area, midRow, c, hChar, edgeStyle);
+            if (density == RouteDensity.DETAILED) {
+                for (int c = minC; c <= maxC; c++) {
+                    arrowChar(buffer, area, midRow, c, hChar, edgeStyle);
+                }
+                arrowChar(buffer, area, midRow, fromCx, T_UP, edgeStyle);
+                arrowChar(buffer, area, midRow, toCx, T_DOWN, edgeStyle);
+            } else {
+                // the heads of the branches are on this row too: an arrow 
joins the lines already there
+                for (int c = minC; c <= maxC; c++) {
+                    if (c != toCx) {
+                        joinChar(buffer, area, midRow, c, hChar, edgeStyle);
+                    }
+                }
+                joinChar(buffer, area, midRow, fromCx, T_UP, edgeStyle);
+                joinChar(buffer, area, midRow, toCx, toCx > fromCx ? TR : TL, 
edgeStyle);
             }
 
-            arrowChar(buffer, area, midRow, fromCx, T_UP, edgeStyle);
-            arrowChar(buffer, area, midRow, toCx, T_DOWN, edgeStyle);
-
             for (int r = midRow + 1; r < toRow - 1; r++) {
                 arrowChar(buffer, area, r, toCx, vChar, edgeStyle);
             }
-            arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+            if (density.boxed()) {
+                arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+            } else if (toRow - 1 > midRow) {
+                arrowChar(buffer, area, toRow - 1, toCx, vChar, edgeStyle);
+            }
+        }
+    }
+
+    /**
+     * Draws a line character where the lines of other arrows may be: a 
junction, a corner turned into a junction, a
+     * head and a count stay.
+     */
+    private void joinChar(Buffer buffer, Rect area, int row, int col, char ch, 
Style style) {
+        int x = area.x() + col - scrollX;
+        int y = area.y() + row - scrollY;
+        if (x < area.left() || x >= area.right() || y < area.top() || y >= 
area.bottom()) {
+            return;
         }
+        String existing = buffer.get(x, y).symbol();
+        char e = existing.length() == 1 ? existing.charAt(0) : ' ';
+        char joined = ch;
+        if (Character.isDigit(e) || e == ARROW || e == CROSS) {
+            joined = e;
+        } else if (ch == H || ch == DASH_H) {
+            joined = e == TL || e == TR ? T_DOWN : e == T_DOWN || e == T_UP ? 
e : ch;
+        } else if (ch == V || ch == DASH_V) {
+            joined = e == H || e == DASH_H || e == T_UP || e == T_DOWN ? CROSS 
: ch;
+        } else if (ch == T_UP) {
+            joined = e == T_DOWN || e == V || e == DASH_V ? CROSS : T_UP;
+        } else if (ch == TL || ch == TR) {
+            joined = e == H || e == DASH_H || e == T_DOWN || (e == TL || e == 
TR) && e != ch ? T_DOWN
+                    : e == T_UP ? CROSS : ch;
+        }
+        arrowChar(buffer, area, row, col, joined, style);
     }
 
     private void arrowChar(Buffer buffer, Rect area, int row, int col, char 
ch, Style style) {
@@ -664,6 +722,11 @@ public class RouteDiagramWidget implements Widget {
         int row1 = toRow(bounds.minY - SCOPE_BOX_PAD);
         int col2 = toCol(bounds.maxX + SCOPE_BOX_PAD);
         int row2 = toRow(bounds.maxY + SCOPE_BOX_PAD);
+        if (density == RouteDensity.COMPACT) {
+            // a row above the block and a row below it
+            row1 = toRow(scopeNode.y) - 1;
+            row2 = toRow(density.blockBottom(tn));
+        }
 
         Color scopeColor = getEipColor(scopeNode.type);
         Style scopeStyle = Style.EMPTY.fg(scopeColor).dim();
@@ -690,13 +753,17 @@ public class RouteDiagramWidget implements Widget {
     }
 
     private int getTopRow(LayoutNode node) {
-        if (node.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(node.treeNode)) {
-            return toRow(node.y - SCOPE_BOX_PAD);
+        if (density.boxed() && node.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(node.treeNode)) {
+            return density == RouteDensity.COMPACT ? toRow(node.y) - 1 : 
toRow(node.y - SCOPE_BOX_PAD);
         }
         return toRow(node.y);
     }
 
     private int centerCol(LayoutNode node) {
+        if (!density.boxed() && node.width == 0) {
+            // a bare step: its mark, at the left
+            return toCol(node.x) + 1;
+        }
         return toCol(node.x + (node.width > 0 ? node.width : nodeWidth) / 2);
     }
 
@@ -704,7 +771,11 @@ public class RouteDiagramWidget implements Widget {
         if (node.treeNode != null && node.treeNode.table) {
             return 2 + tableVisibleRows(node.treeNode.children.size());
         }
-        return 2 + rewrapText(node, boxWidth - 4).size();
+        return switch (density) {
+            case BARE -> 1;
+            case COMPACT -> 3;
+            default -> 2 + rewrapText(node, boxWidth - 4).size();
+        };
     }
 
     /** The rows a table shows: its cases up to the limit, and a line that 
says there are more. */
@@ -871,6 +942,11 @@ public class RouteDiagramWidget implements Widget {
     }
 
     private List<String> rewrapText(LayoutNode node, int maxWidth) {
+        return wrapText(labelText(node), maxWidth);
+    }
+
+    /** The text of a step on one line: its code, or in the business view its 
description. */
+    private String labelText(LayoutNode node) {
         String label = String.join("", node.wrappedLines);
         if (showDescription) {
             if ("from".equals(node.type) && currentRouteLabel != null) {
@@ -885,7 +961,89 @@ public class RouteDiagramWidget implements Widget {
                 }
             }
         }
-        return wrapText(label, maxWidth);
+        return label;
+    }
+
+    /** The route a link marker names: by id, or a from box's caller in words 
in the business view. */
+    private String linkName(LayoutNode node, String linkedRouteId) {
+        if (showDescription && "from".equals(node.type)) {
+            // a from box names its caller in words; a to box already shows 
the route it links to in words, so
+            // its marker names the route by id, as the breadcrumb does
+            String desc = routeDescriptions.get(linkedRouteId);
+            if (desc != null && !desc.isBlank()) {
+                return desc;
+            }
+        }
+        return linkedRouteId;
+    }
+
+    private void addFoldedMarker(LayoutNode node, int row, int col) {
+        Integer steps = node.id != null ? foldedSteps.get(node.id) : null;
+        if (steps != null) {
+            markers.add(new Marker(
+                    row, col, " ▸ " + steps + (steps == 1 ? " step" : " 
steps") + " folded",
+                    Theme.muted()));
+        }
+    }
+
+    /** A text with its EIP (up to its first bracket) in the EIP's colour, and 
the rest as a label. */
+    private void writeLabel(Buffer buffer, Rect area, int row, int col, String 
text, Color eipColor, boolean selected) {
+        int open = text.indexOf('[');
+        String eip = open > 0 ? text.substring(0, open) : text;
+        writeText(buffer, area, row, col, eip, 
style(Style.EMPTY.fg(eipColor).bold(), selected));
+        if (open > 0) {
+            writeText(buffer, area, row, col + CharWidth.of(eip), 
text.substring(open), style(fromLabelStyle(), selected));
+        }
+    }
+
+    /**
+     * A step at the bare density: a mark of its kind and its text on one row, 
with no box; the arrows meet at the mark.
+     * Its text goes on up to half way to the next branch.
+     */
+    private void drawBareNode(Buffer buffer, Rect area, LayoutNode node) {
+        int col = toCol(node.x);
+        int row = toRow(node.y);
+        boolean selected = nodeBoxes.size() == selectedNodeIndex;
+        boolean external = isExternalEndpoint(node);
+        boolean highlighted = node.id != null && 
highlightNodeIds.contains(node.id);
+        Color eipColor;
+        if (highlighted) {
+            eipColor = highlightFailed ? highlightFailColor() : 
highlightOkColor();
+        } else {
+            eipColor = external ? externalColor() : getEipColor(node.type);
+        }
+        String text = DiagramText.fit(labelText(node), boxWidth + boxWidth / 2 
- 4);
+        writeText(buffer, area, row, col + 1, bareMark(node), 
style(Style.EMPTY.fg(eipColor).bold(), selected));
+        if (selected) {
+            setChar(buffer, area, row, col + 2, ' ', selectionStyle());
+        }
+        writeLabel(buffer, area, row, col + 3, text, eipColor, selected);
+        int end = col + 3 + CharWidth.of(text);
+
+        String linkedRouteId = findLinkedRouteId(node);
+        if (linkedRouteId != null) {
+            markers.add(new Marker(row, end, " ↵ " + linkName(node, 
linkedRouteId), Theme.label().bold()));
+            end += 3 + CharWidth.of(linkName(node, linkedRouteId));
+        }
+        addFoldedMarker(node, row, end);
+        nodeBoxes.add(new EipNodeBox(node.id, node.type, row, row, col, col + 
2 + CharWidth.of(text), node));
+    }
+
+    /** The mark of a bare step: where the route starts, a step that branches 
or has steps in it, a branch, a step. */
+    private String bareMark(LayoutNode node) {
+        if ("from".equals(node.type)) {
+            return "▶";
+        }
+        if (node.id != null && foldedSteps.containsKey(node.id)) {
+            return "⊞";
+        }
+        if (node.treeNode != null && BRANCH_CHILD_TYPES.contains(node.type)) {
+            return "◇";
+        }
+        if (node.treeNode != null && !node.treeNode.children.isEmpty()) {
+            return "◆";
+        }
+        return "●";
     }
 
     static List<String> wrapText(String text, int maxWidth) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md
index ecce7337aafb..450d0137a208 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md
@@ -91,7 +91,8 @@ say: no AI labels, notes or capabilities; it does not change 
the AI
 overview setting; with no summary yet it has the AI write one, as
 `/overview` does), `b` view (business: labels and what capabilities achieve;
 technical: route ids and endpoints), `m` metrics, `e` external, `y` types
-(the body types on the arrows of a route), `d` detail, `g` group, `u` utility 
(one setting for the architecture
+(the body types on the arrows of a route), `+/-` zoom (how much room a step of 
a route takes;
+fit when `z` chose it for the screen), `d` detail, `g` group, `u` utility (one 
setting for the architecture
 and the topology, so both show the same groups), `x` errors (the
 error handling in a frame below the routes; in the architecture
 a box of the routes reached only on error). The footer lists
@@ -240,6 +241,15 @@ you an at-a-glance view of where you are in the route.
 - `y` — show the body types each step has been reached with, beside the
   arrows into the steps
 - `g` — go to node (fuzzy search popup)
+- `+` / `-` — zoom in or out: detailed (boxes with up to three lines),
+  compact (boxes with one line, the steps closer together) and bare (a
+  mark and the text of each step on one row, no boxes); the selected
+  step stays where it is on the screen
+- `z` — fit: the most detail in which the whole route fits the screen
+- `Space` — fold the block of the selected step (a choice, a branch,
+  a split, a doTry...) into its first step, which says how many steps
+  are folded; `Space` on a folded step unfolds it
+- `f` — fold the outermost blocks of the route, or unfold all
 - `Esc` — go back (previous route or topology)
 - `t` — jump back to topology view
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md
index e35f9ce0101a..073e5346e231 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md
@@ -162,6 +162,15 @@ in the diagram.
 - `Enter` — jump to linked route (when `↵` indicator shown)
 - `d` — toggle detail panel (EIP/component catalog docs)
 - `g` — go to node (fuzzy search popup)
+- `+` / `-` — zoom in or out: detailed (boxes with up to three lines),
+  compact (boxes with one line, the steps closer together) and bare (a
+  mark and the text of each step on one row, no boxes); the selected
+  step stays where it is on the screen
+- `z` — fit: the most detail in which the whole route fits the screen
+- `Space` — fold the block of the selected step (a choice, a branch,
+  a split, a doTry...) into its first step, which says how many steps
+  are folded; `Space` on a folded step unfolds it
+- `f` — fold the outermost blocks of the route, or unfold all
 - `c` — show source code at selected node
 - `Esc` — go back (previous route or topology)
 - `t` — jump back to topology view
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoomTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoomTest.java
new file mode 100644
index 000000000000..35e42b1daf7a
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoomTest.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+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.dsl.jbang.core.commands.tui.diagram.RouteDensity;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RouteDiagramZoomTest {
+
+    private static NodeInfo node(String type, String id, int level) {
+        NodeInfo n = new NodeInfo();
+        n.type = type;
+        n.id = id;
+        n.level = level;
+        n.code = type + "[" + id + "]";
+        return n;
+    }
+
+    private static RouteInfo route() {
+        RouteInfo r = new RouteInfo();
+        r.routeId = "orders";
+        r.nodes.add(node("route", "orders", 0));
+        r.nodes.add(node("from", "from1", 1));
+        r.nodes.add(node("choice", "choice1", 2));
+        r.nodes.add(node("when", "when1", 3));
+        r.nodes.add(node("to", "to1", 4));
+        r.nodes.add(node("log", "log1", 4));
+        r.nodes.add(node("otherwise", "otherwise1", 3));
+        r.nodes.add(node("to", "to2", 4));
+        r.nodes.add(node("split", "split1", 2));
+        r.nodes.add(node("to", "to3", 3));
+        r.nodes.add(node("to", "to4", 2));
+        return r;
+    }
+
+    private static LayoutRoute layout(RouteDiagramZoom zoom, int visibleRows) {
+        return zoom.layoutRoutes(List.of(route()), NodeLabelMode.CODE, false, 
List.of(), new HashMap<>(), visibleRows)
+                .get("orders");
+    }
+
+    private static LayoutNode find(LayoutRoute lr, String id) {
+        return lr.nodes.stream().filter(n -> 
id.equals(n.id)).findFirst().orElseThrow();
+    }
+
+    private static List<String> ids(LayoutRoute lr) {
+        return lr.nodes.stream().map(n -> n.id).toList();
+    }
+
+    @Test
+    void spaceOnAStepFoldsTheBlockItIsIn() {
+        RouteDiagramZoom zoom = new RouteDiagramZoom();
+        LayoutRoute lr = layout(zoom, 0);
+
+        assertThat(zoom.toggleFold("orders", find(lr, 
"log1"))).isEqualTo("when1");
+        lr = layout(zoom, 0);
+        assertThat(ids(lr)).contains("when1", "otherwise1", 
"to2").doesNotContain("to1", "log1");
+        assertThat(zoom.foldedStepsOf(lr)).isEqualTo(Map.of("when1", 2));
+
+        // the choice folds its branches, and the branch folded in it stays 
folded when the choice opens again
+        assertThat(zoom.toggleFold("orders", find(lr, 
"choice1"))).isEqualTo("choice1");
+        lr = layout(zoom, 0);
+        assertThat(ids(lr)).contains("choice1", "split1", 
"to4").doesNotContain("when1", "to2");
+        assertThat(zoom.foldedStepsOf(lr)).isEqualTo(Map.of("choice1", 5));
+        zoom.toggleFold("orders", find(lr, "choice1"));
+        assertThat(zoom.foldedStepsOf(layout(zoom, 
0))).isEqualTo(Map.of("when1", 2));
+    }
+
+    @Test
+    void aStepOutsideAnyBlockHasNothingToFold() {
+        RouteDiagramZoom zoom = new RouteDiagramZoom();
+        LayoutRoute lr = layout(zoom, 0);
+        assertThat(zoom.toggleFold("orders", find(lr, "to4"))).isNull();
+        assertThat(zoom.toggleFold("orders", find(lr, "from1"))).isNull();
+    }
+
+    @Test
+    void foldAllFoldsTheOutermostBlocksThenUnfoldsAll() {
+        RouteDiagramZoom zoom = new RouteDiagramZoom();
+        zoom.toggleFoldAll("orders", layout(zoom, 0));
+        LayoutRoute lr = layout(zoom, 0);
+        assertThat(zoom.foldedStepsOf(lr)).isEqualTo(Map.of("choice1", 5, 
"split1", 1));
+
+        zoom.toggleFoldAll("orders", lr);
+        assertThat(zoom.foldedStepsOf(layout(zoom, 0))).isEmpty();
+    }
+
+    @Test
+    void zoomOutAndFit() {
+        RouteDiagramZoom zoom = new RouteDiagramZoom();
+        int detailed = RouteDensity.rows(layout(zoom, 0));
+        zoom.zoomOut();
+        LayoutRoute compact = layout(zoom, 0);
+        assertThat(zoom.densityOf(compact)).isEqualTo(RouteDensity.COMPACT);
+        assertThat(RouteDensity.rows(compact)).isLessThan(detailed);
+        zoom.zoomOut();
+        zoom.zoomOut();
+        assertThat(zoom.density()).isEqualTo(RouteDensity.BARE);
+        int bare = RouteDensity.rows(layout(zoom, 0));
+
+        // fit: the most detail that fits the rows
+        zoom.toggleFit();
+        assertThat(zoom.densityOf(layout(zoom, detailed + 
5))).isEqualTo(RouteDensity.DETAILED);
+        assertThat(zoom.densityOf(layout(zoom, 
RouteDensity.rows(compact)))).isEqualTo(RouteDensity.COMPACT);
+        LayoutRoute fitted = layout(zoom, bare);
+        assertThat(zoom.densityOf(fitted)).isEqualTo(RouteDensity.BARE);
+        // the route starts at the top, as without fit
+        assertThat(find(fitted, "from1").y).isEqualTo(40);
+        assertThat(zoom.label(layout(zoom, bare))).isEqualTo("fit bare");
+        // a zoom key leaves fit
+        zoom.zoomIn();
+        assertThat(zoom.isFit()).isFalse();
+        assertThat(zoom.density()).isEqualTo(RouteDensity.COMPACT);
+    }
+
+    @Test
+    void theSelectedStepIsSelectedAgainInTheNewLayout() {
+        RouteDiagramZoom zoom = new RouteDiagramZoom();
+        LayoutRoute before = layout(zoom, 0);
+        zoom.anchor(before, "split1", 7);
+        // not the layout it was anchored on yet
+        assertThat(zoom.restore(before)).isNull();
+
+        zoom.zoomOut();
+        LayoutRoute after = layout(zoom, 0);
+        RouteDiagramZoom.Restore restore = zoom.restore(after);
+        assertThat(restore).isNotNull();
+        // the boxes: from, choice, when, to1, log1, otherwise, to2, split
+        assertThat(restore.index()).isEqualTo(7);
+        assertThat(restore.row()).isEqualTo(find(after, "split1").y / 
RouteDensity.ROW);
+        assertThat(restore.screenRow()).isEqualTo(7);
+        // once
+        assertThat(zoom.restore(after)).isNull();
+    }
+
+    @Test
+    void theDensityOrFitIsKeptAsASetting() {
+        RouteDiagramZoom zoom = new RouteDiagramZoom();
+        assertThat(zoom.setting()).isNull();
+        zoom.zoomOut();
+        assertThat(zoom.setting()).isEqualTo("compact");
+        zoom.toggleFit();
+        assertThat(zoom.setting()).isEqualTo("fit");
+
+        RouteDiagramZoom again = new RouteDiagramZoom();
+        again.setting("bare");
+        assertThat(again.density()).isEqualTo(RouteDensity.BARE);
+        again.setting("fit");
+        assertThat(again.isFit()).isTrue();
+        again.setting("nonsense");
+        assertThat(again.density()).isEqualTo(RouteDensity.DETAILED);
+        assertThat(again.isFit()).isFalse();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensityTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensityTest.java
new file mode 100644
index 000000000000..8d5d48454cf3
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensityTest.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui.diagram;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The densities of the route diagram: the compact and the bare ones put the 
same steps on fewer rows.
+ */
+class RouteDensityTest {
+
+    static NodeInfo node(String type, String id, int level, String code) {
+        NodeInfo n = new NodeInfo();
+        n.type = type;
+        n.id = id;
+        n.level = level;
+        n.code = code;
+        return n;
+    }
+
+    static RouteInfo route() {
+        RouteInfo r = new RouteInfo();
+        r.routeId = "orders";
+        r.nodes.add(node("route", "orders", 0, "route[orders]"));
+        r.nodes.add(node("from", "from1", 1, "from[kafka:orders]"));
+        r.nodes.add(node("log", "log1", 2, "log[Received ${body}]"));
+        r.nodes.add(node("choice", "choice1", 2, "choice"));
+        r.nodes.add(node("when", "when1", 3, "when[${header.priority} == 
'high']"));
+        r.nodes.add(node("to", "to1", 4, "to[jms:queue:priority]"));
+        r.nodes.add(node("log", "log2", 4, "log[Priority order]"));
+        r.nodes.add(node("otherwise", "otherwise1", 3, "otherwise"));
+        r.nodes.add(node("to", "to2", 4, "to[jms:queue:normal]"));
+        r.nodes.add(node("split", "split1", 2, "split[${body.items}]"));
+        r.nodes.add(node("to", "to3", 3, "to[http:inventory/reserve]"));
+        r.nodes.add(node("log", "log3", 3, "log[Reserved ${body}]"));
+        r.nodes.add(node("to", "to4", 2, "to[kafka:orders-done]"));
+        return r;
+    }
+
+    static List<String> render(LayoutRoute lr, int nodeWidth, RouteDensity 
density, Map<String, Integer> folded) {
+        RouteDiagramWidget widget = new RouteDiagramWidget(
+                lr, nodeWidth, -1, 0, 0, false, Map.of(), false, Map.of(), 
Set.of(), false)
+                .withDensity(density).withFoldedSteps(folded);
+        Rect area = new Rect(0, 0, 160, 120);
+        Buffer buffer = Buffer.empty(area);
+        widget.render(area, buffer);
+        List<String> rows = new ArrayList<>();
+        for (int y = 0; y < area.height(); y++) {
+            StringBuilder sb = new StringBuilder();
+            for (int x = 0; x < area.width(); x++) {
+                sb.append(buffer.get(x, y).symbol());
+            }
+            rows.add(sb.toString().stripTrailing());
+        }
+        while (!rows.isEmpty() && rows.get(rows.size() - 1).isEmpty()) {
+            rows.remove(rows.size() - 1);
+        }
+        return rows;
+    }
+
+    private static List<String> render(RouteDensity density) {
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        engine.setTableLayout(true);
+        LayoutRoute lr = engine.layoutRoute(route(), 0);
+        density.apply(lr);
+        List<String> rows = render(lr, engine.getNodeWidth(), density, 
Map.of());
+        if (Boolean.getBoolean("routeDensity.print")) {
+            System.out.println("---- " + density + " (" + rows.size() + " 
rows)");
+            rows.forEach(System.out::println);
+        }
+        return rows;
+    }
+
+    @Test
+    void theStepsTakeFewerRowsAsTheDiagramZoomsOut() {
+        List<String> detailed = render(RouteDensity.DETAILED);
+        List<String> compact = render(RouteDensity.COMPACT);
+        List<String> bare = render(RouteDensity.BARE);
+
+        assertThat(compact.size()).isLessThan(detailed.size() * 3 / 4);
+        assertThat(bare.size()).isLessThan(compact.size() * 3 / 4);
+        // every step is there at each density
+        for (List<String> rows : List.of(detailed, compact, bare)) {
+            String all = String.join("\n", rows);
+            
assertThat(all).contains("from[kafka:orders]").contains("choice").contains("otherwise").contains("split");
+            assertThat(all.split("to\\[", -1)).hasSize(5);
+        }
+    }
+
+    @Test
+    void aCompactStepIsABoxWithOneLine() {
+        String all = String.join("\n", render(RouteDensity.COMPACT));
+        assertThat(all).contains("┌").contains("▼");
+        // the code of a step is not wrapped onto a second line
+        assertThat(all).doesNotContain("priority] ");
+    }
+
+    @Test
+    void aBareStepIsAMarkAndItsText() {
+        List<String> rows = render(RouteDensity.BARE);
+        String all = String.join("\n", rows);
+        
assertThat(all).doesNotContain("└").doesNotContain("▼").doesNotContain("╌");
+        assertThat(rows).anyMatch(r -> r.contains("▶ from[kafka:orders]"));
+        assertThat(rows).anyMatch(r -> r.contains("◆ choice"));
+        assertThat(rows).anyMatch(r -> r.contains("◇ when"));
+        assertThat(rows).anyMatch(r -> r.contains("● to[kafka:orders-done]"));
+    }
+
+    @Test
+    void aStepTheBlockIsFoldedIntoSaysHowManyStepsAreFolded() {
+        RouteInfo r = route();
+        // the choice and its branches left out, as the zoom does with a 
folded block
+        r.nodes.removeIf(n -> n.level > 2 && List.of("when1", "to1", "log2", 
"otherwise1", "to2").contains(n.id));
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        engine.setTableLayout(true);
+        LayoutRoute lr = engine.layoutRoute(r, 0);
+        String all = String.join("\n", render(lr, engine.getNodeWidth(), 
RouteDensity.DETAILED, Map.of("choice1", 5)));
+        assertThat(all).contains("▸ 5 steps 
folded").doesNotContain("jms:queue:priority");
+    }
+
+    private static List<String> renderMulticast(RouteDensity density) {
+        RouteInfo r = new RouteInfo();
+        r.routeId = "fanout";
+        r.nodes.add(node("route", "fanout", 0, "route[fanout]"));
+        r.nodes.add(node("from", "from1", 1, "from[direct:in]"));
+        r.nodes.add(node("multicast", "multicast1", 2, "multicast"));
+        for (int i = 1; i <= 4; i++) {
+            r.nodes.add(node("to", "to" + i, 3, "to[direct:out" + i + "]"));
+        }
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(r, 0);
+        density.apply(lr);
+        return render(lr, engine.getNodeWidth(), density, Map.of());
+    }
+
+    private static long count(String row, char c) {
+        return row.chars().filter(ch -> ch == c).count();
+    }
+
+    @Test
+    void theArrowsOfManyBranchesJoinOnTheirRow() {
+        // bare: one line from the first branch to the last, a junction at 
each branch between
+        String bare = renderMulticast(RouteDensity.BARE).stream().filter(row 
-> row.contains("┴")).findFirst()
+                .orElseThrow();
+        assertThat(count(bare, '┌')).isEqualTo(1);
+        assertThat(count(bare, '┐')).isEqualTo(1);
+        assertThat(count(bare, '┬')).isEqualTo(2);
+        // compact: the heads of all the branches are on the row of the line
+        String compact = 
renderMulticast(RouteDensity.COMPACT).stream().filter(row -> 
row.contains("┴")).findFirst()
+                .orElseThrow();
+        assertThat(count(compact, '▼')).isEqualTo(4);
+    }
+}

Reply via email to