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 c9f506b1c04549af499e7a70160054f4e351b961 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Oct 25 15:32:26 2026 +0100 chore: camel-jbang - the topology zooms as the route diagram does With many routes, such as with the utility routes shown, the topology takes a lot of screen. +, - and z zoom it as they zoom a route: a compact route is a box of one line with its count in the border, a bare route a mark in the colour of its group, its name and its count on one row; the layers come closer together and the boxes narrower, and fit takes the most detail in which the topology fits the screen. The topology is laid out in DiagramLayouts, and the remote steps are marked there too, so DiagramSupport shrinks. 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 | 23 ++-- .../core/commands/tui/AbstractDiagramTab.java | 11 +- .../jbang/core/commands/tui/DiagramLayouts.java | 50 +++++++- .../jbang/core/commands/tui/DiagramSupport.java | 74 +++++------- .../dsl/jbang/core/commands/tui/DiagramTab.java | 2 + .../jbang/core/commands/tui/RouteDiagramZoom.java | 8 +- .../dsl/jbang/core/commands/tui/RoutesTab.java | 2 + .../tui/diagram/TopologyDiagramWidget.java | 92 ++++++++++++++- .../core/commands/tui/diagram/TopologyZoom.java | 108 +++++++++++++++++ .../src/main/resources/tui/help/diagram.md | 4 + .../src/main/resources/tui/help/routes.md | 4 + .../jbang/core/commands/tui/SourceSizeTest.java | 5 +- .../jbang/core/commands/tui/TopologyZoomTest.java | 129 +++++++++++++++++++++ 13 files changed, 448 insertions(+), 64 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 abf9e9b8f72b..1d31a5f81ab3 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 @@ -57,7 +57,8 @@ image::jbang/camel-tui-diagram-route.png[The checkout route drilled into, with t === Zoom and Folding -A large route takes a lot of screen. Press *-* to zoom out and *+* to zoom in, through three levels: +A large route takes a lot of screen. Press *-* to zoom out and *+* to zoom in, through three levels (the topology zooms +too, see below): * *detailed*: a box per step, with up to three lines of text * *compact* (the default): a box per step with one line of text, and the steps closer together: about twice as many @@ -65,11 +66,17 @@ 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 -The level you choose is kept for the next start. Press *z* for fit: the most detailed level in which the whole route fits the screen. The view bar shows the level and -where it is among the three, counted up as *+* zooms in (`zoom: bare 1/3`, `compact 2/3`, `detailed 3/3`, or -`zoom: fit bare 1/3`), and the title of the diagram how many steps -the route has, so you can tell a large route at a glance, and how many of them are folded (`38 steps · 12 folded`). 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 *z* for fit: the most detailed level in which the whole route fits the screen. The level you choose is kept for +the next start. The view bar shows the level and where it is among the three, counted up as *+* zooms in +(`zoom: bare 1/3`, `compact 2/3`, `detailed 3/3`, or `zoom: fit bare 1/3`). The title of the diagram says how many +steps the route has, so you can tell a large route at a glance, and how many of them are folded +(`· 38 steps · 12 folded`). 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. + +The topology zooms the same way, which helps when it has many routes, such as with the utility routes shown: a compact +route is a box of one line with its count in the border, a bare route a mark in the colour of its group, its name and +its count on one row, and the layers come closer together and the boxes narrower. With fit, the topology takes the most +detailed level in which it fits the screen. 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 @@ -269,8 +276,8 @@ 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 +| *+* / *-* | In a route and the topology: zoom in or out (detailed, compact, bare) +| *z* | In a route and the topology: fit, the most detail in which it fits the screen | *Space* | In a route: fold the block of the selected step, or unfold a folded step | *f* | In a route: fold all the blocks | *F* | In a route: expand all the blocks 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 5f20173f244c..cf68b49cc3d1 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 @@ -144,7 +144,7 @@ abstract class AbstractDiagramTab extends AbstractTab { gotoNodePopup.open(diagram.getEipNodeBoxes(), diagram.getSelectedEipNodeIndex()); return true; } - if (routeNodes && handleZoomKeys(ke)) { + if ((routeNodes || topologyMode) && handleZoomKeys(ke)) { return true; } if (!topologyMode && detailMode) { @@ -221,6 +221,9 @@ abstract class AbstractDiagramTab extends AbstractTab { } else if (ke.isCharIgnoreCase('z')) { zoom.toggleFit(); ctx.backgroundExecutor.execute(zoom::persist); + } else if (topologyMode) { + // the topology zooms, its blocks are the routes' own + return false; } else if (ke.isChar(' ')) { String folded = zoom.toggleFold(drillDownRouteId, selected != null ? selected.layoutNode() : null); if (folded == null) { @@ -235,6 +238,12 @@ abstract class AbstractDiagramTab extends AbstractTab { } else { return false; } + if (topologyMode) { + // the route selected stays selected (the topology keeps its selection when it is laid out again) + diagram.endLoad(); + reloadDiagram(); + return true; + } int screenRow = 0; for (var box : diagram.getEipNodeBoxes()) { if (anchorId != null && anchorId.equals(box.nodeId())) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramLayouts.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramLayouts.java index a8042a40efda..4945901e22ce 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramLayouts.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramLayouts.java @@ -16,16 +16,22 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.util.ArrayList; import java.util.List; import java.util.Set; import org.apache.camel.diagram.RouteDiagramLayoutEngine; +import org.apache.camel.diagram.TopologyAsciiRenderer; +import org.apache.camel.diagram.TopologyErrorFrame; +import org.apache.camel.diagram.TopologyLayoutEngine; +import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo; import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode; import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult; +import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo; /** - * What {@link DiagramSupport} does to a layout before it draws it: the diagram moved up to the top, and the steps a - * highlighted step is inside highlighted too. + * What {@link DiagramSupport} does to a layout before it draws it: the diagram moved up to the top, the steps a + * highlighted step is inside highlighted too, and the steps that send to an external system marked. */ final class DiagramLayouts { @@ -55,6 +61,46 @@ final class DiagramLayouts { } } + /** Marks the steps that send to an external system of the topology as remote: the diagram draws them as an edge. */ + static void markRemote(List<RouteDiagramLayoutEngine.RouteInfo> routes, Set<String> externalUris) { + if (externalUris.isEmpty()) { + return; + } + for (RouteDiagramLayoutEngine.RouteInfo r : routes) { + for (RouteDiagramLayoutEngine.NodeInfo ni : r.nodes) { + if (!ni.remote) { + String baseUri = DiagramSupport.getBaseUri(ni); + if (baseUri != null && externalUris.contains(baseUri)) { + ni.remote = true; + } + } + } + } + } + + /** + * Lays out the topology with boxes the given number of columns wide, and the routes reached only on error (when + * any) in a frame below: no layout when there are neither. + */ + static TopologyErrorFrame.Placed layoutTopology( + List<TopologyNodeInfo> nodes, List<TopologyEdgeInfo> edges, List<TopologyNodeInfo> errorNodes, + TopologyErrorFrame.Externals externals, List<TopologyErrorFrame.ErrorPath> paths, int columns) { + TopologyLayoutEngine engine = DiagramSupport.engineFor(columns); + TopologyLayoutResult result = null; + if (!nodes.isEmpty()) { + result = engine.layout(nodes, edges); + normalizeTopologyLayoutY(result); + } + if (errorNodes.isEmpty()) { + return new TopologyErrorFrame.Placed(result, null); + } + if (result == null) { + result = new TopologyLayoutResult(new ArrayList<>(), new ArrayList<>(), 0, 0); + } + return TopologyErrorFrame.place(result, errorNodes, externals, paths, engine.getNodeWidth(), + engine.getNodeHeight(), TopologyAsciiRenderer.ROW_HEIGHT); + } + static void normalizeTopologyLayoutY(TopologyLayoutResult result) { if (result.nodes.isEmpty()) { return; 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 f04310b81e56..cd713faefec8 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 @@ -67,6 +67,7 @@ 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.dsl.jbang.core.commands.tui.diagram.TopologyZoom; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; @@ -132,6 +133,13 @@ class DiagramSupport { private TopologyLayoutResult topologyLayout; private int topologyNodeWidth; private java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeLayouts = Collections.emptyMap(); + /** The density the topology was laid out with: the zoom's, or with fit the one chosen for the screen. */ + private RouteDensity topologyDensity = RouteDensity.DETAILED; + + RouteDensity getTopologyDensity() { + return topologyDensity; + } + /** 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; @@ -1103,7 +1111,7 @@ class DiagramSupport { .withNodeLines(nodeLines) .withGroups(groupTags, groupColors) .withErrorLayer(layoutShowErrorPaths ? errorLayer : null) - .withErrorMarks(errorMarkRouteIds()); + .withErrorMarks(errorMarkRouteIds()).withDensity(topologyDensity); int totalRows = widget.getTotalRows(); int totalCols = widget.getTotalCols(); @@ -1125,7 +1133,7 @@ class DiagramSupport { .withNodeLines(nodeLines) .withGroups(groupTags, groupColors) .withErrorLayer(layoutShowErrorPaths ? errorLayer : null) - .withErrorMarks(errorMarkRouteIds()); + .withErrorMarks(errorMarkRouteIds()).withDensity(topologyDensity); List<Rect> vChunks = Layout.vertical() .constraints(Constraint.fill(), Constraint.length(1)) @@ -1699,19 +1707,7 @@ class DiagramSupport { return; } - Set<String> externalUris = parseExternalUris(jo); - if (!externalUris.isEmpty()) { - for (RouteDiagramLayoutEngine.RouteInfo r : routes) { - for (RouteDiagramLayoutEngine.NodeInfo ni : r.nodes) { - if (!ni.remote) { - String baseUri = getBaseUri(ni); - if (baseUri != null && externalUris.contains(baseUri)) { - ni.remote = true; - } - } - } - } - } + DiagramLayouts.markRemote(routes, parseExternalUris(jo)); stepLabels = RouteStepHints.apply(routes, aiSourceDirectory, showDescription); if (showDescription) { @@ -2222,6 +2218,7 @@ class DiagramSupport { boolean showErrors = showErrorPaths; List<TopologyErrorFrame.ErrorPath> loadedErrorPaths = List.of(); TopologyErrorFrame loadedErrorLayer = null; + RouteDensity topoDensity = RouteDensity.DETAILED; boolean loadedErrorPathsKnown = true; if (topoJson != null) { List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(topoJson); @@ -2249,23 +2246,22 @@ class DiagramSupport { : new TopologyErrorFrame.Taken(List.of(), TopologyErrorFrame.Externals.NONE); List<TopologyNodeInfo> errorNodes = frameRoutes.routes(); removeRoutes(nodes, edges, hidden); - if (!nodes.isEmpty()) { - TopologyLayoutEngine engine = engineFor(boxColumns); - topoResult = engine.layout(nodes, edges); - normalizeTopologyLayoutY(topoResult); - nodeW = engine.getNodeWidth(); - } - if (showErrors && !errorNodes.isEmpty()) { - TopologyLayoutEngine engine = engineFor(boxColumns); - if (topoResult == null) { - topoResult = new TopologyLayoutResult(new ArrayList<>(), new ArrayList<>(), 0, 0); - nodeW = engine.getNodeWidth(); + // the zoom: a density, or with fit the most detail in which the topology fits the screen + topoDensity = zoom.isFit() ? RouteDensity.DETAILED : zoom.density(); + while (true) { + int columns = TopologyZoom.columns(topoDensity, boxColumns); + nodeW = engineFor(columns).getNodeWidth(); + TopologyErrorFrame.Placed placed = DiagramLayouts.layoutTopology( + nodes, edges, showErrors ? errorNodes : List.of(), frameRoutes.externals(), loadedErrorPaths, + columns); + TopologyZoom.Zoomed zoomed = TopologyZoom.apply(topoDensity, placed.layout(), placed.frame()); + topoResult = zoomed.layout(); + loadedErrorLayer = zoomed.frame(); + if (!zoom.isFit() || topoDensity == RouteDensity.BARE + || TopologyZoom.fits(topoResult, nodeW, lastVisibleHeight, lastVisibleWidth)) { + break; } - TopologyErrorFrame.Placed placed = TopologyErrorFrame.place( - topoResult, errorNodes, frameRoutes.externals(), loadedErrorPaths, nodeW, engine.getNodeHeight(), - TopologyAsciiRenderer.ROW_HEIGHT); - topoResult = placed.layout(); - loadedErrorLayer = placed.frame(); + topoDensity = topoDensity.zoomOut(); } if (topoResult != null) { topoNodes = topoResult.nodes; @@ -2280,19 +2276,7 @@ class DiagramSupport { List<RouteDiagramLayoutEngine.RouteInfo> routes = RouteDiagramHelper.parseRoutes(topoJson); if (!routes.isEmpty()) { // Enrich route nodes with remote flag from topology external endpoints - Set<String> externalUris = parseExternalUris(topoJson); - if (!externalUris.isEmpty()) { - for (RouteDiagramLayoutEngine.RouteInfo r : routes) { - for (RouteDiagramLayoutEngine.NodeInfo ni : r.nodes) { - if (!ni.remote) { - String baseUri = getBaseUri(ni); - if (baseUri != null && externalUris.contains(baseUri)) { - ni.remote = true; - } - } - } - } - } + DiagramLayouts.markRemote(routes, parseExternalUris(topoJson)); stepLabels = RouteStepHints.apply(routes, aiSourceDirectory, showDescription); if (showDescription) { BusinessEndpointLabels.apply(routes); @@ -2318,6 +2302,7 @@ class DiagramSupport { List<TopologyErrorFrame.ErrorPath> finalErrorPaths = loadedErrorPaths; TopologyErrorFrame finalErrorLayer = loadedErrorLayer; boolean finalErrorPathsKnown = loadedErrorPathsKnown; + RouteDensity finalTopoDensity = topoDensity; ctx.runner.runOnRenderThread(() -> { layoutHidden = hidden; layoutBoxColumns = boxColumns; @@ -2332,6 +2317,7 @@ class DiagramSupport { preloading = false; cachedPid = pid; topologyLayout = finalTopoResult; + topologyDensity = finalTopoDensity; topologyNodeWidth = finalNodeW; topologyNodes = finalTopoNodes; topologyEdges = finalTopoEdges; 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 fdd1e13fe753..f5bdbfd8be3b 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 @@ -484,6 +484,7 @@ class DiagramTab extends AbstractDiagramTab { toggles.add(new SubViewBar.Toggle("u", "utility", architecture.isShowUtility() ? "on" : "off")); } toggles.add(new SubViewBar.Toggle("x", "errors", errorsToggle())); + toggles.add(new SubViewBar.Toggle("+/-", "zoom", diagram.zoom.label(diagram.getTopologyDensity()))); toggles.add(new SubViewBar.Toggle("m", "metrics", metrics)); toggles.add(new SubViewBar.Toggle( "e", "external", @@ -652,6 +653,7 @@ class DiagramTab extends AbstractDiagramTab { } else if (!diagram.getNodeBoxes().isEmpty()) { hint(spans, "Enter", "drill-down"); hint(spans, "c", "source"); + hint(spans, "+/-", "zoom"); hint(spans, "s", "summary"); } else { diagram.renderFooterHints(spans); 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 index df3c69d5c000..5fac34d198de 100644 --- 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 @@ -139,8 +139,12 @@ final class RouteDiagramZoom { * when it is chosen for the screen. */ String label(LayoutRoute shown) { - RouteDensity d = shown != null ? densityOf(shown) : density; - String label = d.label() + " " + d.position(); + return label(shown != null ? densityOf(shown) : density); + } + + /** What the zoom toggle of the view bar says of the topology, laid out with the given density. */ + String label(RouteDensity shown) { + String label = shown.label() + " " + shown.position(); return fit ? "fit " + label : label; } 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 1d664d690c27..a8cff590fd08 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 @@ -733,6 +733,7 @@ class RoutesTab extends AbstractDiagramTab { if (diagramShown) { toggles.add(new SubViewBar.Toggle("m", "metrics", diagramMetrics ? "on" : "off")); if (topologyMode) { + toggles.add(new SubViewBar.Toggle("+/-", "zoom", diagram.zoom.label(diagram.getTopologyDensity()))); toggles.add(new SubViewBar.Toggle( "e", "external", EXTERNAL_LABELS[externalMode].trim().replace("[", "").replace("]", ""))); @@ -774,6 +775,7 @@ class RoutesTab extends AbstractDiagramTab { hint(spans, "Esc", "close"); hint(spans, TuiIcons.HINT_NAV, "navigate"); hint(spans, "Enter", "drill-down"); + hint(spans, "+/-", "zoom"); hint(spans, "PgUp/PgDn", "page"); hint(spans, "c", "source"); } else { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java index 2cd851d5cd3e..0a9a7bf4e88a 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java @@ -69,6 +69,7 @@ public class TopologyDiagramWidget implements Widget { private TopologyErrorFrame errorLayer; private Set<String> errorMarks = Set.of(); private Map<TopologyLayoutNode, List<String>> errorLabels; + private RouteDensity density = RouteDensity.DETAILED; public record NodeBox(String routeId, int startRow, int endRow, int startCol, int endCol, int layer) { } @@ -253,6 +254,15 @@ public class TopologyDiagramWidget implements Widget { return this; } + /** + * How much room a route takes ({@link TopologyZoom}): a box with its lines, a box of one line with the count in its + * border, or a mark and a name on a row. The layout must have been placed with the same density. + */ + public TopologyDiagramWidget withDensity(RouteDensity density) { + this.density = density != null ? density : RouteDensity.DETAILED; + return this; + } + /** The routes with error handling: a dim mark on their box, whether the error handling is shown or not. */ public TopologyDiagramWidget withErrorMarks(Set<String> routeIds) { this.errorMarks = routeIds != null ? routeIds : Set.of(); @@ -296,7 +306,7 @@ public class TopologyDiagramWidget implements Widget { /** A mark on the border of each route with error handling (shown in the frame, or hidden until x). */ private void drawErrorMarks(Buffer buffer, Rect area) { for (TopologyLayoutNode node : layout.nodes) { - if (node.routeId != null && errorMarks.contains(node.routeId) && !inFrame(node)) { + if (density.boxed() && node.routeId != null && errorMarks.contains(node.routeId) && !inFrame(node)) { int col = toCol(node.x) + boxWidth - 3; writeText(buffer, area, toRow(node.y), col, "\u26a1", errorStyle().dim()); } @@ -393,6 +403,10 @@ public class TopologyDiagramWidget implements Widget { List<NodeLine> own = ownLines(node); TextLines text = textLines(node, ext); + if (own == null && !density.boxed()) { + drawBareNode(buffer, area, node, text, ext); + return; + } List<String> lines = text.lines(); int aiLines = text.aiLines(); int idLine = text.idLine(); @@ -401,7 +415,14 @@ public class TopologyDiagramWidget implements Widget { if (own != null) { lines = new ArrayList<>(own.stream().map(NodeLine::text).toList()); tagLine = -1; + } else if (density == RouteDensity.COMPACT) { + // one line: the label (or the route id), the count goes in the border; the colour of the border is the group + lines = List.of(lines.get(0)); + aiLines = Math.min(aiLines, 1); + idLine = idLine == 0 ? 0 : -1; + tagLine = -1; } + boolean compact = own == null && density == RouteDensity.COMPACT; int height = 2 + lines.size(); int nodeIdx = nodeBoxes.size(); @@ -465,7 +486,7 @@ public class TopologyDiagramWidget implements Widget { writeText(buffer, area, r, textCol, lineText, style(own.get(i).style(), selected)); } else if (ext && i == 0) { writeText(buffer, area, r, textCol, lineText, style(dashedBorderStyle(), selected)); - } else if (showMetrics && i == lines.size() - 1 && node.exchangesTotal > 0) { + } else if (!compact && showMetrics && i == lines.size() - 1 && node.exchangesTotal > 0) { drawMetricsLine(buffer, area, r, textCol, lineText, node, selected); } else if (i == tagLine) { writeText(buffer, area, r, textCol, lineText, style(tag.style(), selected)); @@ -483,9 +504,69 @@ public class TopologyDiagramWidget implements Widget { } } + if (compact && showMetrics && (node.exchangesTotal > 0 || node.exchangesFailed > 0)) { + String count = count(node); + int countCol = col + boxWidth - 3 - CharWidth.of(count); + writeText(buffer, area, bottom, countCol - 1, " ", borderStyle); + drawMetricsLine(buffer, area, bottom, countCol, count, node, false); + writeText(buffer, area, bottom, countCol + CharWidth.of(count), " ", borderStyle); + } + nodeBoxes.add(new NodeBox(node.routeId, row, row + height - 1, col, col + boxWidth - 1, node.layer)); } + /** The messages of a route: ok, and failed after a slash. */ + private static String count(TopologyLayoutNode node) { + long ok = node.exchangesTotal - node.exchangesFailed; + StringBuilder sb = new StringBuilder(); + if (ok > 0) { + sb.append(ok); + } + if (node.exchangesFailed > 0) { + sb.append(sb.isEmpty() ? "" : "/").append(node.exchangesFailed); + } + return sb.toString(); + } + + /** + * A route at the bare density: a mark in the colour of its group, its label (or id) and its count on one row, with + * no box; the arrows meet at the mark. + */ + private void drawBareNode(Buffer buffer, Rect area, TopologyLayoutNode node, TextLines text, boolean ext) { + int col = toCol(node.x); + int row = toRow(node.y); + boolean selected = nodeBoxes.size() == selectedNodeIndex; + boolean highlighted = !ext && node.routeId != null && highlightRouteIds.contains(node.routeId); + Style markStyle; + if (highlighted) { + markStyle = Style.EMPTY.fg(highlightFailed ? highlightFailColor() : highlightOkColor()).bold(); + } else if (!ext && groupColors.containsKey(node.routeId)) { + markStyle = Style.EMPTY.fg(groupColors.get(node.routeId)).bold(); + } else { + markStyle = ext ? dashedBorderStyle() : routeIdStyle(); + } + String count = showMetrics ? count(node) : ""; + // up to half way to the next route of the layer + int room = Math.max(4, boxWidth + 4 - 3 - (count.isEmpty() ? 0 : CharWidth.of(count) + 1)); + String label = DiagramText.fit(text.lines().get(0), room); + Style labelStyle = ext ? dashedBorderStyle() : text.aiLines() > 0 ? aiStyle : highlighted ? markStyle : routeIdStyle(); + writeText(buffer, area, row, col + 1, ext ? "\u25cc" : "\u25cf", style(markStyle, selected)); + if (selected) { + setChar(buffer, area, row, col + 2, ' ', selectionStyle()); + } + writeText(buffer, area, row, col + 3, label, style(labelStyle, selected)); + int end = col + 3 + CharWidth.of(label); + if (!count.isEmpty()) { + drawMetricsLine(buffer, area, row, end + 1, count, node, false); + } + nodeBoxes.add(new NodeBox(node.routeId, row, row, col, end - 1, node.layer)); + } + + /** Where the arrows meet a route: the middle of its box, or the mark of a bare route. */ + private int anchorCol(TopologyLayoutNode node) { + return density.boxed() || ownLines(node) != null ? toCol(node.x + node.width / 2) : toCol(node.x) + 1; + } + private void drawMetricsLine( Buffer buffer, Rect area, int row, int col, String text, TopologyLayoutNode node, boolean selected) { @@ -505,9 +586,9 @@ public class TopologyDiagramWidget implements Widget { } private void drawEdge(Buffer buffer, Rect area, TopologyLayoutEdge edge) { - int fromCx = toCol(edge.from.x + edge.from.width / 2); + int fromCx = anchorCol(edge.from); int fromBottom = toRow(edge.from.y) + boxHeight(edge.from); - int toCx = toCol(edge.to.x + edge.to.width / 2); + int toCx = anchorCol(edge.to); int toTop = toRow(edge.to.y); if (fromBottom >= toTop) { @@ -575,6 +656,9 @@ public class TopologyDiagramWidget implements Widget { if (own != null) { return 2 + own.size(); } + if (density != RouteDensity.DETAILED) { + return TopologyZoom.boxRows(density); + } if (isExternal(node)) { int lines = 1; if (showMetrics && node.exchangesTotal > 0) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyZoom.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyZoom.java new file mode 100644 index 000000000000..224a40508c18 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyZoom.java @@ -0,0 +1,108 @@ +/* + * 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.TreeSet; + +import org.apache.camel.diagram.TopologyErrorFrame; +import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode; +import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult; + +/** + * The topology at a {@link RouteDensity}: the compact one draws a box of one line, the bare one a mark and a name on a + * row, so the layers come closer together and the boxes narrower. The engine lays the routes out in layers a box and a + * gap apart; {@link #apply} moves the layers (and the frame of the error handling below them) up to the rows of the + * density, keeping their order and the wider gap before a band. + */ +public final class TopologyZoom { + + /** The rows from one layer to the next in the engine's layout: its box and the gap below it. */ + private static final int ENGINE_STEP = 9; + /** The widest a box is drawn at the compact density, and the width of a bare step. */ + private static final int COMPACT_COLUMNS = 22; + private static final int BARE_COLUMNS = 16; + + private TopologyZoom() { + } + + /** The rows of a box at the density; the detailed box has as many as its lines need. */ + public static int boxRows(RouteDensity density) { + return density == RouteDensity.BARE ? 1 : 3; + } + + /** The rows from one layer to the next: a box, and room for the arrows that branch between them. */ + private static int step(RouteDensity density) { + return density == RouteDensity.BARE ? 4 : 6; + } + + /** How many columns wide the boxes are laid out: as wide as wanted when detailed, narrower when zoomed out. */ + public static int columns(RouteDensity density, int wanted) { + return switch (density) { + case DETAILED -> wanted; + case COMPACT -> Math.min(wanted, COMPACT_COLUMNS); + case BARE -> BARE_COLUMNS; + }; + } + + /** A topology at a density: its layout, and the frame of its error handling (or null). */ + public record Zoomed(TopologyLayoutResult layout, TopologyErrorFrame frame) { + } + + /** + * Moves the layers of a topology laid out by the engine up to the rows of the density, the error frame with them. + */ + public static Zoomed apply(RouteDensity density, TopologyLayoutResult layout, TopologyErrorFrame frame) { + if (density == RouteDensity.DETAILED || layout == null || layout.nodes.isEmpty()) { + return new Zoomed(layout, frame); + } + TreeSet<Integer> ys = new TreeSet<>(); + int engineBottom = 0; + for (TopologyLayoutNode n : layout.nodes) { + ys.add(n.y); + engineBottom = Math.max(engineBottom, n.y + n.height); + } + // the padding below the last layer, as the engine leaves it + int padding = Math.max(0, layout.totalHeight - engineBottom); + int top = ys.first(); + double ratio = (double) step(density) / ENGINE_STEP; + int bottom = 0; + for (TopologyLayoutNode n : layout.nodes) { + n.y = place(n.y, top, ratio); + bottom = Math.max(bottom, n.y + boxRows(density) * RouteDensity.ROW); + } + TopologyErrorFrame moved = frame != null + ? new TopologyErrorFrame(place(frame.topY(), top, ratio), frame.routeIds(), frame.paths()) : null; + return new Zoomed( + new TopologyLayoutResult(layout.nodes, layout.edges, layout.totalWidth, bottom + padding), moved); + } + + /** Whether a topology fits the rows and columns of the screen, so fit need not zoom out further. */ + public static boolean fits(TopologyLayoutResult layout, int nodeWidth, int rows, int cols) { + if (layout == null || nodeWidth == 0 || rows <= 0 || cols <= 0) { + return true; + } + // the widget draws a box nodeWidth / 15 columns wide, and at least 16 + int boxWidth = Math.max(16, nodeWidth / 15); + return layout.totalHeight / RouteDensity.ROW <= rows && layout.totalWidth * boxWidth / nodeWidth <= cols; + } + + /** A row of the engine's layout on the rows of the density, at the top of a row. */ + private static int place(int y, int top, double ratio) { + int rows = (int) Math.round((y - top) * ratio / RouteDensity.ROW); + return top + rows * RouteDensity.ROW; + } +} 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 7fca1ebd2ab5..2d8bbb0aef95 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 @@ -206,6 +206,10 @@ you an at-a-glance view of where you are in the route. - `↑↓←→` — navigate between route boxes - `Enter` — drill down into selected route - `c` — show route source code +- `+` / `-` — zoom in or out: detailed, compact (the default: boxes of + one line, the count in the border, the layers closer together) and + bare (a mark, the name and the count of each route on one row) +- `z` — fit: the most detail in which the whole topology fits the screen - `v` — next level: architecture, topology, route - `g` — show each route's group in the topology, its colour as in the architecture, with a legend of the groups at the bottom 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 763ddfd2a24a..19da55753744 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 @@ -152,6 +152,10 @@ in the diagram. - `↑↓←→` — navigate between route boxes - `Enter` — drill down into selected route - `c` — show route source code +- `+` / `-` — zoom in or out: detailed, compact (the default: boxes of + one line, the count in the border, the layers closer together) and + bare (a mark, the name and the count of each route on one row) +- `z` — fit: the most detail in which the whole topology fits the screen - `Esc` — close diagram (back to route table) - `m` — toggle metrics on/off - `e` — toggle external systems on/off diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java index f6c8c6296167..9b3501072ab4 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java @@ -60,9 +60,8 @@ class SourceSizeTest { AiPanel.runAgentLoop 159 AiUsageStats.render 166 CamelMonitor.java 2761 - DiagramSupport.java 2413 - DiagramSupport.loadAllDiagramsInBackground 180 - DiagramSupport.loadHighlightedNativeDiagramInBackground 212 + DiagramSupport.java 2399 + DiagramSupport.loadHighlightedNativeDiagramInBackground 200 EndpointsTab.renderContent 170 HistoryTab.java 2727 HistoryTab.renderDiagramInfoPanel 202 diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyZoomTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyZoomTest.java new file mode 100644 index 000000000000..a1556599aa9b --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyZoomTest.java @@ -0,0 +1,129 @@ +/* + * 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.ArrayList; +import java.util.List; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import org.apache.camel.diagram.TopologyLayoutEngine; +import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo; +import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult; +import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo; +import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDensity; +import org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget; +import org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyZoom; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The topology zooms as a route does: compact boxes of one line with the count in the border, bare routes a mark and a + * name on a row, the layers closer together and the boxes narrower. + */ +class TopologyZoomTest { + + private static TopologyNodeInfo route(String id, long total) { + TopologyNodeInfo n = new TopologyNodeInfo(); + n.routeId = id; + n.from = "direct:" + id; + n.nodeType = "route"; + n.exchangesTotal = total; + return n; + } + + private static TopologyEdgeInfo call(String from, String to) { + TopologyEdgeInfo e = new TopologyEdgeInfo(); + e.fromRouteId = from; + e.toRouteId = to; + e.endpoint = "direct:" + to; + return e; + } + + private record Laid(TopologyLayoutResult layout, int nodeWidth) { + } + + /** orders calls billing, shipping and notify; shipping calls tracking. */ + private static Laid layout(RouteDensity density) { + List<TopologyNodeInfo> nodes = new ArrayList<>( + List.of(route("orders", 42), route("billing", 40), + route("shipping", 41), route("notify", 42), route("tracking", 41))); + List<TopologyEdgeInfo> edges = new ArrayList<>( + List.of(call("orders", "billing"), call("orders", "shipping"), + call("orders", "notify"), call("shipping", "tracking"))); + TopologyLayoutEngine engine = DiagramSupport.engineFor(TopologyZoom.columns(density, 24)); + TopologyLayoutResult result = engine.layout(nodes, edges); + DiagramLayouts.normalizeTopologyLayoutY(result); + return new Laid(TopologyZoom.apply(density, result, null).layout(), engine.getNodeWidth()); + } + + private static String render(RouteDensity density) { + Laid laid = layout(density); + TopologyDiagramWidget widget = new TopologyDiagramWidget(laid.layout(), laid.nodeWidth(), -1, 0, 0, true, false) + .withDensity(density); + Rect area = new Rect(0, 0, 140, 40); + Buffer buffer = Buffer.empty(area); + widget.render(area, buffer); + return TuiTestHelper.bufferToString(buffer); + } + + private static int y(TopologyLayoutResult layout, String routeId) { + return layout.nodes.stream().filter(n -> routeId.equals(n.routeId)).mapToInt(n -> n.y).findFirst().orElseThrow(); + } + + @Test + void theLayersComeCloserTogetherAndTheBoxesNarrower() { + TopologyLayoutResult detailed = layout(RouteDensity.DETAILED).layout(); + TopologyLayoutResult compact = layout(RouteDensity.COMPACT).layout(); + TopologyLayoutResult bare = layout(RouteDensity.BARE).layout(); + + assertThat(compact.totalHeight).isLessThan(detailed.totalHeight); + assertThat(bare.totalHeight).isLessThan(compact.totalHeight); + assertThat(bare.totalWidth).isLessThan(detailed.totalWidth); + // the layers keep their order, each on a row of its own + for (TopologyLayoutResult l : List.of(compact, bare)) { + assertThat(y(l, "orders")).isLessThan(y(l, "billing")); + assertThat(y(l, "billing")).isEqualTo(y(l, "shipping")); + assertThat(y(l, "shipping")).isLessThan(y(l, "tracking")); + assertThat(l.nodes).allMatch(n -> n.y % RouteDensity.ROW == 0); + } + } + + @Test + void aCompactRouteIsABoxOfOneLineWithItsCountInTheBorder() { + String screen = render(RouteDensity.COMPACT); + assertThat(screen).contains("orders").contains("tracking").contains("▼"); + // the count in the bottom border, not on a line of its own + assertThat(screen.lines()).anyMatch(l -> l.contains("─ 42 ─┘")); + assertThat(screen).doesNotContain("(direct:orders)"); + } + + @Test + void aBareRouteIsAMarkItsNameAndItsCount() { + String screen = render(RouteDensity.BARE); + assertThat(screen).contains("● orders 42").contains("● tracking 41").doesNotContain("┌──"); + } + + @Test + void fitsTheScreen() { + Laid compact = layout(RouteDensity.COMPACT); + assertThat(TopologyZoom.fits(compact.layout(), compact.nodeWidth(), 100, 300)).isTrue(); + assertThat(TopologyZoom.fits(compact.layout(), compact.nodeWidth(), 3, 300)).isFalse(); + assertThat(TopologyZoom.fits(compact.layout(), compact.nodeWidth(), 100, 20)).isFalse(); + } +}
