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