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

davsclaus pushed a commit to branch fix/camel-tui-endpoint
in repository https://gitbox.apache.org/repos/asf/camel.git

commit a8b3a7902cb341ffdaf5750be0a6766f9f25bedd
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun May 17 16:10:16 2026 +0200

    TUI: extract MirroredSparkline widget from inline endpoint chart rendering
    
    Move the custom mirrored bar chart into a standalone MirroredSparkline
    widget class. The widget renders two long[] time-series as sub-pixel
    vertical bars growing in opposite directions from a shared centre axis —
    matching the macOS Activity Monitor network/disk graph style. Supports
    optional y-axis labels, x-axis labels, configurable BarSet, and a Block
    wrapper. Reuses Sparkline.BarSet from the existing TamboUI Sparkline widget.
    
    The class is structured as a first-class TamboUI Widget (direct Buffer
    writes, builder pattern, full Javadoc including a comparison table against
    Sparkline) and is intended for upstream contribution to TamboUI under
    dev.tamboui.widgets.sparkline.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  | 119 +-----
 .../jbang/core/commands/tui/MirroredSparkline.java | 430 +++++++++++++++++++++
 2 files changed, 439 insertions(+), 110 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index a50117700b94..2f3da9f81ac9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -3041,7 +3041,7 @@ public class CamelMonitor extends CamelCommand {
                 .block(Block.builder().borderType(BorderType.ROUNDED).title(" 
Flow ").build())
                 .build(), hSplit.get(0));
 
-        // --- Right: sliding window waveform chart (in=green, out=blue, 20 
seconds) ---
+        // --- Right: 60-second sliding window chart (in=green up, out=blue 
down) ---
         LinkedList<Long> inHist = endpointInHistory.getOrDefault(pid, new 
LinkedList<>());
         LinkedList<Long> outHist = endpointOutHistory.getOrDefault(pid, new 
LinkedList<>());
 
@@ -3058,124 +3058,23 @@ public class CamelMonitor extends CamelCommand {
                 outArr[i] = outHist.get(idx);
             }
         }
-
-        long maxRate = 1;
-        for (int i = 0; i < renderPoints; i++) {
-            maxRate = Math.max(maxRate, Math.max(inArr[i], outArr[i]));
-        }
         long curIn = inArr[renderPoints - 1];
         long curOut = outArr[renderPoints - 1];
 
-        // Custom mirrored bar chart: in grows up from centre, out grows down 
— macOS Activity Monitor style
-        Rect rightArea = hSplit.get(1);
-        int innerH = Math.max(3, rightArea.height() - 2);
-        int innerW = Math.max(1, rightArea.width() - 2);
-        // Reserve last row for x-axis labels
-        int chartBodyRows = Math.max(2, innerH - 1);
-        int halfH = Math.max(1, (chartBodyRows - 1) / 2);
-        int centerRow = halfH;
-        int yLabelW = 4; // fixed width to avoid layout jitter
-        int chartW = Math.max(1, innerW - yLabelW);
-        int ticks = Math.min(renderPoints, chartW);
-
-        // Sub-pixel block characters: index 0=space, 1=▁ … 8=█
-        String[] BARS = { " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" };
-
-        List<Line> chartLines = new ArrayList<>();
-        for (int r = 0; r < chartBodyRows; r++) {
-            List<Span> rowSpans = new ArrayList<>();
-
-            // Y-axis label column (fixed 4 chars, dimmed)
-            String yLabel;
-            if (r == 0) {
-                yLabel = maxRate > 9999 ? "999+" : String.format("%4d", 
maxRate);
-            } else if (r == centerRow) {
-                yLabel = "   0";
-            } else if (r == chartBodyRows - 1) {
-                yLabel = maxRate > 9999 ? "999+" : String.format("%4d", 
maxRate);
-            } else {
-                yLabel = "    ";
-            }
-            rowSpans.add(Span.styled(yLabel, Style.EMPTY.dim()));
-
-            for (int t = 0; t < ticks; t++) {
-                int dataIdx = renderPoints - ticks + t;
-                long inVal = dataIdx >= 0 ? inArr[dataIdx] : 0;
-                long outVal = dataIdx >= 0 ? outArr[dataIdx] : 0;
-
-                if (r < centerRow) {
-                    // In section: bar grows upward from centre (row 
centerRow-1) toward row 0
-                    int rowOffset = centerRow - 1 - r; // 0 = nearest centre, 
halfH-1 = top
-                    long barPx = inVal * halfH * 8 / maxRate;
-                    long threshold = (long) rowOffset * 8;
-                    String ch;
-                    if (barPx >= threshold + 8) {
-                        ch = "█";
-                    } else if (barPx > threshold) {
-                        ch = BARS[(int) (barPx - threshold)];
-                    } else {
-                        ch = " ";
-                    }
-                    rowSpans.add(Span.styled(ch, Style.EMPTY.fg(Color.GREEN)));
-                } else if (r == centerRow) {
-                    // Centre separator
-                    rowSpans.add(Span.styled("─", Style.EMPTY.dim()));
-                } else {
-                    // Out section: bar grows downward from centre (row 
centerRow+1) toward row chartBodyRows-1
-                    int rowOffset = r - centerRow - 1; // 0 = nearest centre, 
halfH-1 = bottom
-                    long barPx = outVal * halfH * 8 / maxRate;
-                    long threshold = (long) rowOffset * 8;
-                    String ch;
-                    if (barPx >= threshold + 8) {
-                        ch = "█";
-                    } else if (barPx > threshold) {
-                        ch = BARS[(int) (barPx - threshold)];
-                    } else {
-                        ch = " ";
-                    }
-                    rowSpans.add(Span.styled(ch, Style.EMPTY.fg(Color.BLUE)));
-                }
-            }
-            chartLines.add(Line.from(rowSpans));
-        }
-
-        // X-axis label row: markers at -60s, -45s, -30s, -15s, now
-        char[] xChars = new char[chartW];
-        for (int i = 0; i < chartW; i++) {
-            xChars[i] = ' ';
-        }
-        int[][] xMarkers = {
-                { 0, ticks },
-                { ticks / 4, ticks - ticks / 4 },
-                { ticks / 2, ticks / 2 },
-                { 3 * ticks / 4, ticks / 4 },
-                { ticks - 1, 0 }
-        };
-        for (int[] m : xMarkers) {
-            int col = m[0];
-            int secsAgo = m[1];
-            if (col >= chartW) {
-                continue;
-            }
-            String lbl = secsAgo == 0 ? "now" : "-" + secsAgo + "s";
-            int start = secsAgo == 0 ? Math.max(0, col - lbl.length() + 1) : 
col;
-            for (int k = 0; k < lbl.length() && start + k < chartW; k++) {
-                xChars[start + k] = lbl.charAt(k);
-            }
-        }
-        List<Span> xSpans = new ArrayList<>();
-        xSpans.add(Span.raw(" ".repeat(yLabelW)));
-        xSpans.add(Span.styled(new String(xChars), Style.EMPTY.dim()));
-        chartLines.add(Line.from(xSpans));
-
         Line chartTitle = Line.from(
                 Span.styled("▬", Style.EMPTY.fg(Color.GREEN)),
                 Span.raw(String.format(" in:%-4d ", curIn)),
                 Span.styled("▬", Style.EMPTY.fg(Color.BLUE)),
                 Span.raw(String.format(" out:%-4d msg/s", curOut)));
 
-        frame.renderWidget(Paragraph.builder()
-                .text(Text.from(chartLines))
+        Rect rightArea = hSplit.get(1);
+        frame.renderWidget(MirroredSparkline.builder()
+                .topData(inArr)
+                .bottomData(outArr)
+                .topStyle(Style.EMPTY.fg(Color.GREEN))
+                .bottomStyle(Style.EMPTY.fg(Color.BLUE))
+                .xLabels("-" + renderPoints + "s", "-" + (renderPoints * 3 / 
4) + "s",
+                        "-" + (renderPoints / 2) + "s", "-" + (renderPoints / 
4) + "s", "now")
                 .block(Block.builder().borderType(BorderType.ROUNDED)
                         .title(Title.from(chartTitle)).build())
                 .build(), rightArea);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MirroredSparkline.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MirroredSparkline.java
new file mode 100644
index 000000000000..5f60132fd390
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MirroredSparkline.java
@@ -0,0 +1,430 @@
+/*
+ * 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.List;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.widget.Widget;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.sparkline.Sparkline;
+
+/**
+ * A mirrored sparkline widget that displays two time-series datasets as 
vertical bars growing in opposite directions
+ * from a shared centre axis.
+ * <p>
+ * The top series renders as bars growing <em>upward</em> from the centre; the 
bottom series renders as bars growing
+ * <em>downward</em> from the centre. Sub-pixel resolution is achieved using 
Unicode block characters (▁▂▃▄▅▆▇█), giving
+ * smooth visual gradation within a single character row. This layout matches 
the style of macOS Activity Monitor's
+ * network and disk activity graphs.
+ * <p>
+ * Example usage:
+ *
+ * <pre>{@code
+ * MirroredSparkline chart = MirroredSparkline.builder()
+ *         .topData(inRates)
+ *         .bottomData(outRates)
+ *         .topStyle(Style.EMPTY.fg(Color.GREEN))
+ *         .bottomStyle(Style.EMPTY.fg(Color.BLUE))
+ *         .xLabels("-60s", "-45s", "-30s", "-15s", "now")
+ *         .block(Block.builder().borderType(BorderType.ROUNDED)
+ *                 .title(Title.from("In / Out  msg/s")).build())
+ *         .build();
+ * }</pre>
+ *
+ * <h2>Differences from {@link Sparkline}</h2>
+ * <table>
+ * <caption>Feature comparison between Sparkline and 
MirroredSparkline</caption>
+ * <tr>
+ * <th></th>
+ * <th>{@code Sparkline}</th>
+ * <th>{@code MirroredSparkline}</th>
+ * </tr>
+ * <tr>
+ * <td>Series</td>
+ * <td>1</td>
+ * <td>2 (top + bottom)</td>
+ * </tr>
+ * <tr>
+ * <td>Growth direction</td>
+ * <td>always upward from bottom row</td>
+ * <td>top grows up, bottom grows down, from a shared centre separator row</td>
+ * </tr>
+ * <tr>
+ * <td>Height</td>
+ * <td>1 row (fixed at bottom of area)</td>
+ * <td>fills the full area height</td>
+ * </tr>
+ * <tr>
+ * <td>Y-axis labels</td>
+ * <td>none</td>
+ * <td>optional: max / 0 / max at top, centre, and bottom rows</td>
+ * </tr>
+ * <tr>
+ * <td>X-axis labels</td>
+ * <td>none</td>
+ * <td>optional label row rendered below the chart body</td>
+ * </tr>
+ * <tr>
+ * <td>BarSet</td>
+ * <td>yes ({@link Sparkline.BarSet})</td>
+ * <td>yes (reuses {@link Sparkline.BarSet})</td>
+ * </tr>
+ * </table>
+ *
+ * <p>
+ * This class is intended for contribution to the TamboUI project as a 
first-class widget under
+ * {@code dev.tamboui.widgets.sparkline}. The package and license header would 
change accordingly upon contribution.
+ */
+public final class MirroredSparkline implements Widget {
+
+    private static final int Y_LABEL_WIDTH = 4;
+    private static final Style DIM = Style.EMPTY.dim();
+    private static final String CENTRE_SEPARATOR = "─";
+
+    private final long[] topData;
+    private final long[] bottomData;
+    private final Style topStyle;
+    private final Style bottomStyle;
+    private final Long max;
+    private final Block block;
+    private final Sparkline.BarSet barSet;
+    private final boolean showYAxis;
+    private final String[] xLabels;
+
+    private MirroredSparkline(Builder builder) {
+        this.topData = builder.topData;
+        this.bottomData = builder.bottomData;
+        this.topStyle = builder.topStyle;
+        this.bottomStyle = builder.bottomStyle;
+        this.max = builder.max;
+        this.block = builder.block;
+        this.barSet = builder.barSet;
+        this.showYAxis = builder.showYAxis;
+        this.xLabels = builder.xLabels;
+    }
+
+    /**
+     * Creates a new builder.
+     *
+     * @return a new Builder
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    @Override
+    public void render(Rect area, Buffer buffer) {
+        if (area.isEmpty()) {
+            return;
+        }
+
+        Rect inner = area;
+        if (block != null) {
+            block.render(area, buffer);
+            inner = block.inner(area);
+        }
+
+        if (inner.isEmpty()) {
+            return;
+        }
+
+        int innerH = inner.height();
+        int innerW = inner.width();
+
+        boolean hasXAxis = xLabels != null && xLabels.length > 0;
+        // Reserve one row at the bottom for x-axis labels when configured
+        int chartBodyRows = hasXAxis ? Math.max(2, innerH - 1) : innerH;
+        int halfH = Math.max(1, (chartBodyRows - 1) / 2);
+        int centerRow = halfH;
+
+        int yLabelW = showYAxis ? Y_LABEL_WIDTH : 0;
+        int chartW = Math.max(1, innerW - yLabelW);
+
+        int dataLen = Math.max(topData.length, bottomData.length);
+        int ticks = Math.min(dataLen, chartW);
+
+        long effectiveMax = computeMax();
+
+        // --- Bar rows ---
+        for (int r = 0; r < chartBodyRows; r++) {
+            int y = inner.y() + r;
+
+            if (showYAxis) {
+                String label;
+                if (r == 0) {
+                    label = effectiveMax > 9999 ? "999+" : 
String.format("%4d", effectiveMax);
+                } else if (r == centerRow) {
+                    label = "   0";
+                } else if (r == chartBodyRows - 1) {
+                    label = effectiveMax > 9999 ? "999+" : 
String.format("%4d", effectiveMax);
+                } else {
+                    label = "    ";
+                }
+                buffer.setString(inner.x(), y, label, DIM);
+            }
+
+            for (int t = 0; t < ticks; t++) {
+                int x = inner.x() + yLabelW + t;
+                int dataIdx = dataLen - ticks + t;
+                long topVal = dataIdx >= 0 && dataIdx < topData.length ? 
topData[dataIdx] : 0;
+                long botVal = dataIdx >= 0 && dataIdx < bottomData.length ? 
bottomData[dataIdx] : 0;
+
+                String ch;
+                Style style;
+
+                if (r < centerRow) {
+                    // Top series: bars grow upward from the centre
+                    int rowOffset = centerRow - 1 - r; // 0 at the row nearest 
the centre
+                    long barPx = topVal * halfH * 8 / effectiveMax;
+                    long threshold = (long) rowOffset * 8;
+                    if (barPx >= threshold + 8) {
+                        ch = barSet.full();
+                    } else if (barPx > threshold) {
+                        ch = barSet.symbolForLevel((double) (barPx - 
threshold) / 8.0);
+                    } else {
+                        ch = barSet.empty();
+                    }
+                    style = topStyle;
+                } else if (r == centerRow) {
+                    ch = CENTRE_SEPARATOR;
+                    style = DIM;
+                } else {
+                    // Bottom series: bars grow downward from the centre
+                    int rowOffset = r - centerRow - 1; // 0 at the row nearest 
the centre
+                    long barPx = botVal * halfH * 8 / effectiveMax;
+                    long threshold = (long) rowOffset * 8;
+                    if (barPx >= threshold + 8) {
+                        ch = barSet.full();
+                    } else if (barPx > threshold) {
+                        ch = barSet.symbolForLevel((double) (barPx - 
threshold) / 8.0);
+                    } else {
+                        ch = barSet.empty();
+                    }
+                    style = bottomStyle;
+                }
+
+                buffer.setString(x, y, ch, style);
+            }
+        }
+
+        // --- X-axis label row ---
+        if (hasXAxis) {
+            int xAxisY = inner.y() + chartBodyRows;
+            char[] xChars = new char[chartW];
+            for (int i = 0; i < chartW; i++) {
+                xChars[i] = ' ';
+            }
+            // Distribute labels evenly across the tick range
+            for (int li = 0; li < xLabels.length; li++) {
+                String lbl = xLabels[li];
+                double fraction = xLabels.length > 1 ? (double) li / 
(xLabels.length - 1) : 0;
+                int col = (int) Math.round(fraction * (ticks - 1));
+                // Right-align the last label so it doesn't run past the right 
edge
+                int start = li == xLabels.length - 1
+                        ? Math.max(0, col - lbl.length() + 1)
+                        : col;
+                for (int k = 0; k < lbl.length() && start + k < chartW; k++) {
+                    xChars[start + k] = lbl.charAt(k);
+                }
+            }
+            if (showYAxis) {
+                buffer.setString(inner.x(), xAxisY, " ".repeat(yLabelW), DIM);
+            }
+            buffer.setString(inner.x() + yLabelW, xAxisY, new String(xChars), 
DIM);
+        }
+    }
+
+    private long computeMax() {
+        if (max != null) {
+            return Math.max(1, max);
+        }
+        long m = 1;
+        for (long v : topData) {
+            m = Math.max(m, v);
+        }
+        for (long v : bottomData) {
+            m = Math.max(m, v);
+        }
+        return m;
+    }
+
+    /**
+     * Builder for {@link MirroredSparkline}.
+     */
+    public static final class Builder {
+        private long[] topData = new long[0];
+        private long[] bottomData = new long[0];
+        private Style topStyle = Style.EMPTY;
+        private Style bottomStyle = Style.EMPTY;
+        private Long max;
+        private Block block;
+        private Sparkline.BarSet barSet = Sparkline.BarSet.NINE_LEVELS;
+        private boolean showYAxis = true;
+        private String[] xLabels;
+
+        private Builder() {
+        }
+
+        /**
+         * Sets the top series data (bars grow upward from centre).
+         *
+         * @param  data the data values
+         * @return      this builder
+         */
+        public Builder topData(long... data) {
+            this.topData = data != null ? data.clone() : new long[0];
+            return this;
+        }
+
+        /**
+         * Sets the top series data from a list (bars grow upward from centre).
+         *
+         * @param  data the data values
+         * @return      this builder
+         */
+        public Builder topData(List<Long> data) {
+            this.topData = data == null ? new long[0] : 
data.stream().mapToLong(Long::longValue).toArray();
+            return this;
+        }
+
+        /**
+         * Sets the bottom series data (bars grow downward from centre).
+         *
+         * @param  data the data values
+         * @return      this builder
+         */
+        public Builder bottomData(long... data) {
+            this.bottomData = data != null ? data.clone() : new long[0];
+            return this;
+        }
+
+        /**
+         * Sets the bottom series data from a list (bars grow downward from 
centre).
+         *
+         * @param  data the data values
+         * @return      this builder
+         */
+        public Builder bottomData(List<Long> data) {
+            this.bottomData = data == null ? new long[0] : 
data.stream().mapToLong(Long::longValue).toArray();
+            return this;
+        }
+
+        /**
+         * Sets the style for the top series bars.
+         *
+         * @param  style the style
+         * @return       this builder
+         */
+        public Builder topStyle(Style style) {
+            this.topStyle = style != null ? style : Style.EMPTY;
+            return this;
+        }
+
+        /**
+         * Sets the style for the bottom series bars.
+         *
+         * @param  style the style
+         * @return       this builder
+         */
+        public Builder bottomStyle(Style style) {
+            this.bottomStyle = style != null ? style : Style.EMPTY;
+            return this;
+        }
+
+        /**
+         * Sets an explicit maximum value for scaling both series. When not 
set the maximum value across both datasets
+         * is used.
+         *
+         * @param  max the maximum value
+         * @return     this builder
+         */
+        public Builder max(long max) {
+            this.max = max;
+            return this;
+        }
+
+        /**
+         * Clears an explicit maximum, reverting to auto-scaling from the data.
+         *
+         * @return this builder
+         */
+        public Builder autoMax() {
+            this.max = null;
+            return this;
+        }
+
+        /**
+         * Wraps the chart in a block (border + optional title).
+         *
+         * @param  block the block
+         * @return       this builder
+         */
+        public Builder block(Block block) {
+            this.block = block;
+            return this;
+        }
+
+        /**
+         * Sets the bar symbol set used for sub-pixel rendering.
+         *
+         * @param  barSet the bar set
+         * @return        this builder
+         */
+        public Builder barSet(Sparkline.BarSet barSet) {
+            this.barSet = barSet != null ? barSet : 
Sparkline.BarSet.NINE_LEVELS;
+            return this;
+        }
+
+        /**
+         * Controls whether a Y-axis label column is rendered on the left. 
Shows the shared maximum at the top and
+         * bottom rows and {@code 0} at the centre row. Defaults to {@code 
true}.
+         *
+         * @param  show whether to show the y-axis labels
+         * @return      this builder
+         */
+        public Builder showYAxis(boolean show) {
+            this.showYAxis = show;
+            return this;
+        }
+
+        /**
+         * Sets the x-axis labels rendered as a single row below the chart 
body. Labels are distributed evenly across
+         * the data range. The last label is right-aligned at its position so 
it does not overflow the right edge.
+         * <p>
+         * Example: {@code xLabels("-60s", "-45s", "-30s", "-15s", "now")}
+         *
+         * @param  labels the labels, distributed left-to-right
+         * @return        this builder
+         */
+        public Builder xLabels(String... labels) {
+            this.xLabels = labels != null ? labels.clone() : null;
+            return this;
+        }
+
+        /**
+         * Builds the widget.
+         *
+         * @return a new MirroredSparkline
+         */
+        public MirroredSparkline build() {
+            return new MirroredSparkline(this);
+        }
+    }
+}

Reply via email to