From 4efba61a9234373bfec5482f613ce8d0d4d25d42 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Sun, 17 May 2026 16:03:16 +0200 Subject: [PATCH 1/2] TUI: endpoints tab - flow panel, mirrored activity chart (60s window with x-axis legend) Add a flow panel below the endpoints table showing in/out traffic as an ASCII arrow diagram plus a 60-second sliding window chart. The chart uses a macOS Activity Monitor style mirrored layout: in-traffic (green) grows upward from centre, out-traffic (blue) grows downward. Sub-pixel Unicode block characters give smooth vertical resolution. X-axis labels (-60s, -45s, -30s, -15s, now) match the overview tab style. Co-Authored-By: Claude Sonnet 4.6 --- .../jbang/core/commands/tui/CamelMonitor.java | 262 +++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) 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 c45e08ebd1043..a50117700b945 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 @@ -108,6 +108,7 @@ public class CamelMonitor extends CamelCommand { private static final long VANISH_DURATION_MS = 6000; private static final long DEFAULT_REFRESH_MS = 100; private static final int MAX_SPARKLINE_POINTS = 60; + private static final int MAX_ENDPOINT_CHART_POINTS = 60; private static final int MAX_LOG_LINES = 3000; private static final int MAX_TRACES = 200; private static final int NUM_TABS = 9; @@ -168,6 +169,12 @@ public class CamelMonitor extends CamelCommand { // Track last time a sparkline point was recorded private final Map previousExchangesTime = new ConcurrentHashMap<>(); + // Endpoint in/out sliding window history per PID (one point per second, 20 points) + private final Map> endpointInHistory = new ConcurrentHashMap<>(); + private final Map> endpointOutHistory = new ConcurrentHashMap<>(); + private final Map> endpointSamples = new ConcurrentHashMap<>(); + private final Map previousEndpointTime = new ConcurrentHashMap<>(); + // Overview sort state private String overviewSort = "name"; private int overviewSortIndex = 1; @@ -2966,7 +2973,212 @@ private void renderEndpoints(Frame frame, Rect area) { .title(" Endpoints sort:" + endpointSort + (showOnlyRemote ? " remote" : "") + " ").build()) .build(); - frame.renderStatefulWidget(table, area, endpointTableState); + List chunks = Layout.vertical() + .constraints(Constraint.fill(), Constraint.length(12)) + .split(area); + + frame.renderStatefulWidget(table, chunks.get(0), endpointTableState); + + long inTotal = info.endpoints.stream() + .filter(ep -> "in".equals(ep.direction)) + .mapToLong(ep -> ep.hits) + .sum(); + long outTotal = info.endpoints.stream() + .filter(ep -> "out".equals(ep.direction)) + .mapToLong(ep -> ep.hits) + .sum(); + renderEndpointFlow(frame, chunks.get(1), inTotal, outTotal, info.name, info.pid); + } + + private void renderEndpointFlow(Frame frame, Rect area, long inTotal, long outTotal, String name, String pid) { + List hSplit = Layout.horizontal() + .constraints(Constraint.length(38), Constraint.fill()) + .split(area); + + // --- Left: ASCII flow diagram --- + int w = Math.max(10, hSplit.get(0).width() - 2); + + String label = name != null ? name : "INTEGRATION"; + if (CharWidth.of(label) > 20) { + label = CharWidth.truncateWithEllipsis(label, 20, CharWidth.TruncatePosition.END); + } + String box = "[ " + label + " ]"; + int boxLen = CharWidth.of(box); + + int sideLen = Math.max(4, (w - boxLen - 2) / 2); + String arm = "─".repeat(Math.max(1, sideLen - 1)); + String arrowStr = arm + "►"; + + String inStr = String.valueOf(inTotal); + String outStr = String.valueOf(outTotal); + + int inPad = Math.max(0, sideLen - inStr.length()); + int centerGap = boxLen + 2; + int outPad = Math.max(0, sideLen - outStr.length()); + + Style inStyle = Style.EMPTY.fg(Color.GREEN); + Style outStyle = Style.EMPTY.fg(Color.BLUE); + Style dimStyle = Style.EMPTY.dim(); + + List flowLines = new ArrayList<>(); + flowLines.add(Line.from( + Span.styled(" ".repeat(inPad) + inStr, inTotal > 0 ? inStyle : dimStyle), + Span.raw(" ".repeat(centerGap)), + Span.styled(outStr + " ".repeat(outPad), outTotal > 0 ? outStyle : dimStyle))); + flowLines.add(Line.from( + Span.styled(arrowStr, inTotal > 0 ? inStyle : dimStyle), + Span.raw(" "), + Span.styled(box, Style.EMPTY.fg(Color.CYAN).bold()), + Span.raw(" "), + Span.styled(arrowStr, outTotal > 0 ? outStyle : dimStyle))); + flowLines.add(Line.from( + Span.styled(" ".repeat(inPad) + "in", inTotal > 0 ? inStyle.dim() : dimStyle), + Span.raw(" ".repeat(centerGap)), + Span.styled("out" + " ".repeat(Math.max(0, outPad - 2)), outTotal > 0 ? outStyle.dim() : dimStyle))); + + frame.renderWidget(Paragraph.builder() + .text(Text.from(flowLines)) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Flow ").build()) + .build(), hSplit.get(0)); + + // --- Right: sliding window waveform chart (in=green, out=blue, 20 seconds) --- + LinkedList inHist = endpointInHistory.getOrDefault(pid, new LinkedList<>()); + LinkedList outHist = endpointOutHistory.getOrDefault(pid, new LinkedList<>()); + + int renderPoints = MAX_ENDPOINT_CHART_POINTS; + long[] inArr = new long[renderPoints]; + long[] outArr = new long[renderPoints]; + for (int i = 0; i < renderPoints; i++) { + int idx = inHist.size() - renderPoints + i; + if (idx >= 0) { + inArr[i] = inHist.get(idx); + } + idx = outHist.size() - renderPoints + i; + if (idx >= 0) { + 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 chartLines = new ArrayList<>(); + for (int r = 0; r < chartBodyRows; r++) { + List 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 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)) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(Title.from(chartTitle)).build()) + .build(), rightArea); } // ---- Tab 5: Log ---- @@ -3985,6 +4197,7 @@ private void refreshDataSync() { if (info != null) { infos.add(info); updateThroughputHistory(info); + updateEndpointHistory(info); } } }); @@ -4007,6 +4220,10 @@ private void refreshDataSync() { it.remove(); throughputHistory.remove(entry.getKey()); failedHistory.remove(entry.getKey()); + endpointInHistory.remove(entry.getKey()); + endpointOutHistory.remove(entry.getKey()); + endpointSamples.remove(entry.getKey()); + previousEndpointTime.remove(entry.getKey()); } else if (!livePids.contains(entry.getKey())) { IntegrationInfo ghost = entry.getValue().info; ghost.vanishing = true; @@ -4094,6 +4311,49 @@ private void updateThroughputHistory(IntegrationInfo info) { } } + private void updateEndpointHistory(IntegrationInfo info) { + long inTotal = info.endpoints.stream() + .filter(ep -> "in".equals(ep.direction)) + .mapToLong(ep -> ep.hits) + .sum(); + long outTotal = info.endpoints.stream() + .filter(ep -> "out".equals(ep.direction)) + .mapToLong(ep -> ep.hits) + .sum(); + + long now = System.currentTimeMillis(); + String pid = info.pid; + LinkedList samples = endpointSamples.computeIfAbsent(pid, k -> new LinkedList<>()); + samples.add(new long[] { now, inTotal, outTotal }); + + while (!samples.isEmpty() && now - samples.get(0)[0] > 1000) { + samples.remove(0); + } + + if (samples.size() >= 2) { + long[] oldest = samples.get(0); + long[] newest = samples.get(samples.size() - 1); + long deltaMs = newest[0] - oldest[0]; + long inRate = deltaMs > 0 ? (newest[1] - oldest[1]) * 1000 / deltaMs : 0; + long outRate = deltaMs > 0 ? (newest[2] - oldest[2]) * 1000 / deltaMs : 0; + + Long lastTime = previousEndpointTime.get(pid); + if (lastTime == null || now - lastTime >= 1000) { + previousEndpointTime.put(pid, now); + LinkedList inHist = endpointInHistory.computeIfAbsent(pid, k -> new LinkedList<>()); + inHist.add(Math.max(0, inRate)); + while (inHist.size() > MAX_ENDPOINT_CHART_POINTS) { + inHist.remove(0); + } + LinkedList outHist = endpointOutHistory.computeIfAbsent(pid, k -> new LinkedList<>()); + outHist.add(Math.max(0, outRate)); + while (outHist.size() > MAX_ENDPOINT_CHART_POINTS) { + outHist.remove(0); + } + } + } + } + // ---- Trace Data Loading ---- private void refreshTraceData(List pids) { From a8b3a7902cb341ffdaf5750be0a6766f9f25bedd Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Sun, 17 May 2026 16:10:16 +0200 Subject: [PATCH 2/2] TUI: extract MirroredSparkline widget from inline endpoint chart rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jbang/core/commands/tui/CamelMonitor.java | 119 +---- .../core/commands/tui/MirroredSparkline.java | 430 ++++++++++++++++++ 2 files changed, 439 insertions(+), 110 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MirroredSparkline.java 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 a50117700b945..2f3da9f81ac9f 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 @@ private void renderEndpointFlow(Frame frame, Rect area, long inTotal, long outTo .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 inHist = endpointInHistory.getOrDefault(pid, new LinkedList<>()); LinkedList outHist = endpointOutHistory.getOrDefault(pid, new LinkedList<>()); @@ -3058,124 +3058,23 @@ private void renderEndpointFlow(Frame frame, Rect area, long inTotal, long outTo 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 chartLines = new ArrayList<>(); - for (int r = 0; r < chartBodyRows; r++) { - List 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 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 0000000000000..5f60132fd3907 --- /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. + *

+ * The top series renders as bars growing upward from the centre; the bottom series renders as bars growing + * downward 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. + *

+ * Example usage: + * + *

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

Differences from {@link Sparkline}

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Feature comparison between Sparkline and MirroredSparkline
{@code Sparkline}{@code MirroredSparkline}
Series12 (top + bottom)
Growth directionalways upward from bottom rowtop grows up, bottom grows down, from a shared centre separator row
Height1 row (fixed at bottom of area)fills the full area height
Y-axis labelsnoneoptional: max / 0 / max at top, centre, and bottom rows
X-axis labelsnoneoptional label row rendered below the chart body
BarSetyes ({@link Sparkline.BarSet})yes (reuses {@link Sparkline.BarSet})
+ * + *

+ * 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 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 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. + *

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