diff --git a/README.md b/README.md index ef0aed1..722d8d5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - **📈 Multi-Series Support**: Plot multiple named metrics simultaneously with dedicated styles and swatches. - **🏷️ Timeline Annotations**: Pin events, deploys, and alerts onto the sliding timeline with custom glyphs and vertical guidelines. - **📦 Live Legend Box**: Bordered legend overlay displaying swatches, names, and real-time values. +- **⚖️ Bidirectional & RX/TX Traffic**: Grafana-style zero baseline rulings, inverted mirror series, auto-symmetric centering, and adaptive byte rate formatters. - **🫧 Charm Native**: Fully customizable with Lip Gloss styles and built for Bubble Tea event loops. ## 🚀 Quick Start @@ -134,6 +135,24 @@ chart.SetMode(pulse.ModeBraille) Pulse Braille Sub-Pixel Rendering

+## ⚖️ Bidirectional & RX/TX Traffic + +Monitor inverted network streams, disk I/O, or profit/loss with a central zero baseline ruling (`├`), smooth bidirectional area fill, and auto-symmetric scaling: + +```go +chart := pulse.New(80, 15, + pulse.WithRange(0, 100*MB), + pulse.WithSymmetric(true), + pulse.WithZeroBaseline(true), + pulse.WithSeriesInverted("tx", true), + pulse.WithLabelFormatter(scale.BytesRateFormatter(true)), +) +``` + +

+ Pulse Bidirectional RX/TX Traffic +

+ ## 📚 Documentation Detailed guides and API references are available in the [docs](docs/) directory: @@ -143,6 +162,7 @@ Detailed guides and API references are available in the [docs](docs/) directory: | [**Getting Started**](docs/getting-started.md) | Constructor options, sliding window buffers, multi-series, and runtime controls | | [**Rendering Modes**](docs/rendering-modes.md) | Box-drawing curves (rounded vs bold), Braille sub-pixel matrix, and area fill | | [**Axes & Grid**](docs/axes-and-grid.md) | Adaptive NiceTicks, custom tick quarter-steps, and Grafana grid alignment | +| [**Zero-Crossing & RX/TX**](docs/zero-crossing-fill.md) | Grafana-style bidirectional charts, zero baseline rulings, and series inversion | | [**Timeline Events**](docs/timeline-events.md) | Pin deployments, alerts, and markers (`▼`, `🚀`, `⚡`) with guidelines and sliding cards | | [**Bubble Tea Integration**](docs/bubbletea.md) | Embedding in Bubble Tea models, telemetry ticks, and interactive hotkeys | @@ -151,6 +171,9 @@ Detailed guides and API references are available in the [docs](docs/) directory: Try the included examples directly from your terminal: ```bash +# Grafana-style live network traffic (RX / TX) with zero baseline & mode toggles +go run ./examples/rxtxdemo + # Live multi-series stream (CPU, MEM, NET) with hotkeys: t (thickness), m (braille), q (quit) go run ./examples/multidemo diff --git a/assets/events.png b/assets/events.png new file mode 100644 index 0000000..8a4b10b Binary files /dev/null and b/assets/events.png differ diff --git a/assets/fill.png b/assets/fill.png new file mode 100644 index 0000000..c1c26fa Binary files /dev/null and b/assets/fill.png differ diff --git a/assets/rxtx.png b/assets/rxtx.png new file mode 100644 index 0000000..43fd6eb Binary files /dev/null and b/assets/rxtx.png differ diff --git a/assets/zerocross.png b/assets/zerocross.png new file mode 100644 index 0000000..62a791c Binary files /dev/null and b/assets/zerocross.png differ diff --git a/cmd/gen-shots/main.go b/cmd/gen-shots/main.go index 3166ef1..c353b98 100644 --- a/cmd/gen-shots/main.go +++ b/cmd/gen-shots/main.go @@ -8,11 +8,12 @@ import ( "charm.land/lipgloss/v2" "github.com/ingvarch/pulse" + "github.com/ingvarch/pulse/scale" "github.com/ingvarch/pulse/theme" ) func main() { - mode := flag.String("mode", "hero", "hero | quickstart | styled | braille") + mode := flag.String("mode", "hero", "hero | quickstart | styled | braille | rxtx | events | fill | zerocross") flag.Parse() switch *mode { @@ -24,6 +25,14 @@ func main() { renderStyled() case "braille": renderBraille() + case "rxtx": + renderRXTX() + case "events": + renderEvents() + case "fill": + renderFill() + case "zerocross": + renderZeroCross() } } @@ -146,3 +155,188 @@ func renderBraille() { fmt.Println(lc.View()) fmt.Println(lc.LegendBox()) } + +func renderRXTX() { + preset := theme.TokyoNight() + chartW, chartH := 80, 15 + const MB = 1024 * 1024 + + lc := pulse.New(chartW, chartH, + pulse.WithLineWidth(1), + pulse.WithRange(0, 100*MB), + pulse.WithSymmetric(true), + pulse.WithZeroBaseline(true), + pulse.WithTicks(-100*MB, -50*MB, 0, 50*MB, 100*MB), + pulse.WithLabelFormatter(scale.BytesRateFormatter(true)), + pulse.WithSeriesStyle("rx", lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a"))), + pulse.WithSeriesStyle("tx", lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))), + pulse.WithSeriesInverted("tx", true), + pulse.WithAxisStyle(preset.Axis), + pulse.WithFill(true), + pulse.WithTintedFill(true), + pulse.WithGrid(true), + ) + + rng := rand.New(rand.NewSource(123)) + for i := 1; i <= chartW; i++ { + rx := (48 + 26*math.Sin(float64(i)*0.13) + 8*math.Cos(float64(i)*0.07) + (rng.Float64()-0.5)*4) * MB + tx := (32 + 20*math.Cos(float64(i)*0.11) + 6*math.Sin(float64(i)*0.09) + (rng.Float64()-0.5)*3) * MB + lc.PushSeries("rx", rx) + lc.PushSeries("tx", tx) + } + + rxVal, _ := lc.Last("rx") + txVal, _ := lc.Last("tx") + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#c0caf5")). + Render(fmt.Sprintf("NETWORK THROUGHPUT ▲ RX %s | ▼ TX %s", scale.FormatBytesRate(rxVal), scale.FormatBytesRate(txVal))) + + content := fmt.Sprintf("%s\n\n%s\n%s", header, lc.View(), lc.LegendBox()) + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#3b4261")). + Padding(1, 2). + Render(content) + + fmt.Println(box) +} + +func renderEvents() { + preset := theme.TokyoNight() + chartW, chartH := 80, 13 + + lc := pulse.New(chartW, chartH, + pulse.WithRange(0, 100), + pulse.WithTicks(0, 25, 50, 75, 100), + pulse.WithFill(true), + pulse.WithTintedFill(true), + pulse.WithGrid(true), + pulse.WithAxisStyle(preset.Axis), + pulse.WithLineStyle(preset.LineFor(60)), + ) + + for i := 0; i < chartW; i++ { + val := 42.0 + 32.0*math.Sin(float64(i)*0.14) + 6.0*math.Cos(float64(i)*0.06) + lc.Push(val) + } + + lc.AddEventAt(52, pulse.Event{ + Label: "Deploy v1.4.2", + Glyph: "🚀", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")).Bold(true), + }) + + lc.AddEventAt(26, pulse.Event{ + Label: "Traffic Surge", + Glyph: "⚡", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#e0af68")).Bold(true), + }) + + lc.AddEventAt(10, pulse.Event{ + Label: "Cache Eviction", + Glyph: "⚠️", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#f7768e")).Bold(true), + }) + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#c0caf5")). + Render("PRODUCTION SERVICE LATENCY & TIMELINE EVENTS") + + content := fmt.Sprintf("%s\n\n%s\n%s", header, lc.View(), lc.EventsBox()) + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#3b4261")). + Padding(1, 2). + Render(content) + + fmt.Println(box) +} + +func renderFill() { + preset := theme.TokyoNight() + chartW, chartH := 80, 13 + + lc := pulse.New(chartW, chartH, + pulse.WithRange(0, 100), + pulse.WithTicks(0, 25, 50, 75, 100), + pulse.WithLineWidth(1), + pulse.WithFill(true), + pulse.WithTintedFill(true), + pulse.WithGrid(true), + pulse.WithAxisStyle(preset.Axis), + pulse.WithLineStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7aa2f7"))), + ) + + for i := 0; i < chartW; i++ { + val := 45 + 35*math.Sin(float64(i)*0.12) + 10*math.Cos(float64(i)*0.05) + lc.Push(val) + } + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#c0caf5")). + Render("SHADED & TINTED AREA FILL (GRID ABSORPTION)") + + content := fmt.Sprintf("%s\n\n%s", header, lc.View()) + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#3b4261")). + Padding(1, 2). + Render(content) + + fmt.Println(box) +} + +func renderZeroCross() { + preset := theme.TokyoNight() + chartW, chartH := 80, 13 + + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#f7768e")) + + lc := pulse.New(chartW, chartH, + pulse.WithLineWidth(1), + pulse.WithRange(-40, 40), + pulse.WithSymmetric(true), + pulse.WithZeroBaseline(true), + pulse.WithTicks(-40, -20, 0, 20, 40), + pulse.WithLineStyle(green), + pulse.WithNegativeStyle(red), + pulse.WithFill(true), + pulse.WithTintedFill(true), + pulse.WithGrid(true), + pulse.WithAxisStyle(preset.Axis), + pulse.WithLabelFormatter(func(v float64) string { + if v > 0 { + return fmt.Sprintf("+%.0f ms", v) + } + return fmt.Sprintf("%.0f ms", v) + }), + ) + + for i := 0; i < chartW; i++ { + delta := 28.0*math.Sin(float64(i)*0.16) + 6.0*math.Cos(float64(i)*0.08) + lc.Push(delta) + } + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#c0caf5")). + Render("BIDIRECTIONAL ZERO-CROSSING (LATENCY DRIFT)") + + content := fmt.Sprintf("%s\n\n%s", header, lc.View()) + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#3b4261")). + Padding(1, 2). + Render(content) + + fmt.Println(box) +} diff --git a/docs/axes-and-grid.md b/docs/axes-and-grid.md index 477c1e5..29661cc 100644 --- a/docs/axes-and-grid.md +++ b/docs/axes-and-grid.md @@ -38,3 +38,47 @@ chart := pulse.New(60, 12, pulse.WithAxisStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#444b6a"))), ) ``` + +

+ Grafana-Style Coordinate Grid +

+ +## Symmetric Y-Axis (`WithSymmetric`) + +When plotting bidirectional metrics (such as network RX/TX, disk read/write, or profit/loss), you often need zero to stay directly in the vertical center of the chart with equal positive and negative scaling. + +`pulse.WithSymmetric(true)` normalizes bounds to $[-\text{limit}, +\text{limit}]$ where $\text{limit} = \max(|min|, |max|)$: + +```go +chart := pulse.New(60, 13, + pulse.WithRange(0, 100), // Automatically normalized to [-100, +100] + pulse.WithSymmetric(true), + pulse.WithZeroBaseline(true), +) +``` + +

+ Symmetric Y-Axis Centered Baseline +

+ +You can also dynamically toggle symmetry at runtime via `chart.SetSymmetric(bool)` or inspect it with `chart.Symmetric() bool`. + +## Label Formatters & Network Rates (`scale`) + +Custom tick labels can be formatted using `pulse.WithLabelFormatter(...)`. The `scale` package provides built-in formatters for human-readable bytes and throughput rates: + +```go +import "github.com/ingvarch/pulse/scale" + +// 1. Throughput rates with sign (e.g. "+50 MB/s", "-25 MB/s") +pulse.WithLabelFormatter(scale.FormatBytesRate) + +// 2. Mirrored absolute throughput rates without minus sign (e.g. "50 MB/s" for both RX and TX) +pulse.WithLabelFormatter(scale.FormatBytesRateAbs) + +// 3. Or use the configurable helper: +pulse.WithLabelFormatter(scale.BytesRateFormatter(true)) // true for absolute, false for signed +``` + +For static data size metrics (RAM, disk space), `scale.FormatBytes` formats values as `"512 B"`, `"1.5 KB"`, `"10 MB"`, `"2 GB"`, etc. + diff --git a/docs/getting-started.md b/docs/getting-started.md index 4f73770..6d4669e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,6 +41,10 @@ func main() { } ``` +

+ Pulse Basic Usage Output +

+ ## Sliding Window `pulse.Model` maintains an internal sliding buffer equal to the chart's width (`w`). When you call `Push(val)` or `PushSeries(name, val)` beyond the width, older points automatically roll off, creating a smooth real-time stream. diff --git a/docs/rendering-modes.md b/docs/rendering-modes.md index 78e1c63..4382bb4 100644 --- a/docs/rendering-modes.md +++ b/docs/rendering-modes.md @@ -55,6 +55,10 @@ pulse.WithTintColor(color) // optional custom tint color; defaults to Tokyo Nigh - **Custom Tint (`WithTintColor`)**: Overrides the default Tokyo Night slate (`#1f2335`) with any custom color or palette surface. - Grid lines (`┼`, `│`) that fall inside filled regions are cleanly absorbed so the shaded fill stays crisp and uniform without visual glitches. +

+ Shaded and Tinted Area Fill +

+ --- ## 2. Braille Matrix (`ModeBraille`) diff --git a/docs/timeline-events.md b/docs/timeline-events.md index 6ce5e46..d6cc627 100644 --- a/docs/timeline-events.md +++ b/docs/timeline-events.md @@ -16,12 +16,10 @@ In modern operations and incident response, correlating metrics with discrete en Pulse enables you to pin events directly onto the time-series grid with vertical guidelines (`┆`), custom badges or emojis (`▼`, `🚀`, `⚡`, `⚠️`), and dynamic aging cards. -```text - 100│─────────────▼─────── ╭──────────╮ [Deploy v1.4.2 (-24)] - │─────────────┆─────╭──╯░░░░░░░░░░╰── - 50│─────────────┆───╭─╯░░░░░░░░░░░░░░░░ - 0│─────────────┆───╯░░░░░░░░░░░░░░░░░░ -``` +

+ Timeline Annotations & Event Markers +

+ --- diff --git a/docs/zero-crossing-fill.md b/docs/zero-crossing-fill.md new file mode 100644 index 0000000..42104f8 --- /dev/null +++ b/docs/zero-crossing-fill.md @@ -0,0 +1,276 @@ +# Bidirectional Zero-Crossing Fill & RX/TX Traffic + +Pulse features first-class support for **Grafana-style bidirectional charts**, allowing metrics with both positive and negative values to be visually anchored around a central **Zero Baseline** ($Y = 0$). + +This capability is essential for modern terminal dashboards monitoring: +* **Network Throughput (RX / TX)**: Incoming traffic above the baseline, outgoing traffic mirrored below the baseline. +* **Latency Deltas & Drift**: Positive spikes indicate degradation; negative values indicate performance improvements. +* **Financial & Trading Metrics (P&L / Cash Flow)**: Gains rendered in green above zero, losses rendered in red below zero. +* **Queue / Buffer Dynamics**: Queue accumulation rate ($> 0$) vs drain rate ($< 0$). + +

+ Pulse Bidirectional Zero-Crossing Fill +

+ +--- + +## 🎯 The Problem with Bottom-Anchored Fill + +In traditional terminal charting libraries, area fill (`░`) is unconditionally anchored to the bottom edge of the plot. When visualizing metrics that cross zero: +1. Negative values are filled down to the minimum Y-bound, making large negative spikes look visually inverted. +2. The zero threshold ($0.0$) disappears into the background grid without any clear boundary. +3. Both positive and negative values share the same line color, obscuring critical phase changes. + +Pulse resolves this by introducing **Bidirectional Zero-Crossing Fill**, where positive values fill downward to $Y=0$ (`┴`), negative values fill upward to $Y=0$ (`┬`), and the zero boundary itself acts as a prominent baseline ruling (`├`). + +--- + +## 📐 Anatomy of the Zero Baseline + +When `pulse.WithZeroBaseline(true)` is enabled: + +

+ Pulse Zero Baseline Anatomy +

+ +* **Y-Axis Divider (`├`)**: Replaces the regular axis bar `│` at row $Y = 0$, clearly anchoring the zero point. +* **Baseline Ruling (`─`)**: Empty cells at $Y = 0$ render a solid baseline line, colored with `axisStyle`. +* **Positive Fill Connectors (`┴`)**: Connect positive curves and area fill seamlessly to the baseline, inheriting the positive series color. +* **Negative Fill Connectors (`┬`)**: Connect negative curves and area fill seamlessly to the baseline, inheriting the negative series color. +* **Bi-directional Intersections (`┼`)**: When both positive and negative series occupy the same column at the baseline. + +--- + +## 📦 API & Configuration Options + +### Functional Options + +| Option | Description | +| :--- | :--- | +| `pulse.WithZeroBaseline(on bool)` | Enables or disables explicit zero baseline rendering (`├` on Y-axis and baseline ruling at $Y=0$). | +| `pulse.WithSymmetric(on bool)` | Normalizes the Y range to be symmetric around zero ($[-\text{limit}, +\text{limit}]$), centering the zero baseline vertically. | +| `pulse.WithNegativeStyle(s lipgloss.Style)` | Sets the style for negative values ($< 0$) of the default series. | +| `pulse.WithSeriesNegativeStyle(name string, s lipgloss.Style)` | Sets the style for negative values ($< 0$) of a named series. | +| `pulse.WithSeriesInverted(name string, inverted bool)` | Automatically inverts values when plotted ($v \to -v$). Raw positive values are preserved in `Last()` and data storage. | +| `pulse.WithInverted(inverted bool)` | Inverts the default series when plotted ($v \to -v$). | + +### Runtime Model Methods + +| Method | Description | +| :--- | :--- | +| `chart.SetZeroBaseline(on bool)` | Dynamically toggles zero baseline rendering. | +| `chart.ZeroBaseline() bool` | Returns whether zero baseline rendering is enabled. | +| `chart.SetSymmetric(on bool)` | Dynamically toggles symmetric range normalization around zero. | +| `chart.Symmetric() bool` | Returns whether symmetric range normalization is enabled. | +| `chart.SetNegativeStyle(s lipgloss.Style)` | Dynamically sets negative line and fill style for default series. | +| `chart.NegativeStyle() lipgloss.Style` | Returns negative style for default series. | +| `chart.SetSeriesNegativeStyle(name string, s lipgloss.Style)` | Dynamically sets negative style for a named series. | +| `chart.SeriesNegativeStyle(name string) lipgloss.Style` | Returns negative style for a named series. | +| `chart.SetSeriesInverted(name string, inverted bool)` | Dynamically toggles value inversion for a named series. | +| `chart.SeriesInverted(name string) bool` | Returns whether value inversion is active for a named series. | + +--- + +## ⚖️ Auto-Symmetric Range & Centered Zero Baseline + +In mirror charts like RX/TX network traffic, Read/Write disk IO, or Long/Short positions, you typically want the zero baseline to be anchored **directly in the center** of the plot area, with equal vertical headroom for both directions. + +Enabling `pulse.WithSymmetric(true)` (or dynamically via `chart.SetSymmetric(true)`) automatically normalizes the Y range bounds to: +$$[-\text{limit}, +\text{limit}] \quad \text{where} \quad \text{limit} = \max(|min|, |max|)$$ + +For example, an asymmetric input like `WithRange(-20, 80)` is automatically normalized to `[-80, 80]`. On a chart of height 9, row index 4 ($(9-1)/2 = 4$) becomes the exact vertical center for the zero baseline ruling (`├`), ensuring identical headroom and scaling for both ingress and egress spikes. + +Whenever you push data or adjust limits with `chart.SetRange(min, max)`, Pulse ensures the bounds remain balanced around zero without manual recalculations. + +--- + +## 📊 Adaptive Byte & Throughput Rate Formatting (`scale`) + +Pulse includes high-performance formatters for byte counts and transfer rates in the `github.com/ingvarch/pulse/scale` package: + +| Function | Output Example | Typical Use Case | +| :--- | :--- | :--- | +| `scale.FormatBytes(v float64)` | `"512 B"`, `"1.5 KB"`, `"10 MB"`, `"2 GB"` | Memory, Disk storage | +| `scale.FormatBytesRate(v float64)` | `"1 KB/s"`, `"50 MB/s"`, `"-50 MB/s"` | Signed network & IO throughput | +| `scale.FormatBytesRateAbs(v float64)` | `"1 KB/s"`, `"50 MB/s"`, `"50 MB/s"` | Mirrored Y-axis labels without negative sign | +| `scale.BytesRateFormatter(abs bool)` | Returns `func(float64) string` | Direct drop-in for `pulse.WithLabelFormatter(...)` | + +### Mirrored Y-Axis Labels +When plotting inverted TX traffic below the baseline, negative values like `-50 MB/s` are conceptually positive egress bandwidth. By passing `scale.BytesRateFormatter(true)`, both upper (RX) and lower (TX) ticks render as positive rates: + +```go +pulse.WithLabelFormatter(scale.BytesRateFormatter(true)) +// Renders: +// +100 MB/s │ +// +50 MB/s │ +// 0 B/s ├ +// +50 MB/s │ +// +100 MB/s │ +``` + +--- + +## ✨ Rounded Corners & Smooth Curves + +Pulse supports smooth continuous box-drawing curves across both positive and negative coordinates: + +* **Thin Lines (`pulse.WithLineWidth(1)`)**: + * Positive peaks form smooth top arcs: `╭───╮` + * Negative troughs form smooth bottom arcs: `╰───╯` + * Zero crossings form smooth S-curves: `╮` $\to$ `╰` or `╭` $\to$ `╯` +* **Bold Lines (`pulse.WithLineWidth(2)`)**: + * Heavy box-drawing characters (`┏`, `┓`, `┗`, `┛`, `┃`, `━`). +* **Sharp Lines (`pulse.WithSmooth(false)`)**: + * Crisp 90° angular corners (`┌`, `┐`, `└`, `┘`). + +> [!TIP] +> To display thin rounded corners (`╭`, `╮`, `╯`, `╰`), specify `pulse.WithLineWidth(1)`. Standard Unicode defines rounded box characters only for thin line weights. + +--- + +## 💡 Practical Examples + +### 1. Network RX / TX Traffic (Grafana Pattern) + +In this pattern, both RX and TX data are received as raw bytes per second (e.g. `50 * 1024 * 1024`). By configuring `WithSeriesInverted("tx", true)` and `WithSymmetric(true)`, TX is automatically mirrored below the baseline while retaining its positive magnitude for stats and headers: + +```go +package main + +import ( + "fmt" + "math" + + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" + "github.com/ingvarch/pulse/scale" + "github.com/ingvarch/pulse/theme" +) + +const MB = 1024 * 1024 + +func main() { + preset := theme.TokyoNight() + + chart := pulse.New(60, 13, + pulse.WithLineWidth(1), // Thin rounded curves: ╭ ╮ ╯ ╰ + pulse.WithRange(0, 100*MB), + pulse.WithSymmetric(true), // Automatically centers zero: [-100 MB, +100 MB] + pulse.WithZeroBaseline(true), + pulse.WithTicks(-100*MB, -50*MB, 0, 50*MB, 100*MB), + pulse.WithLabelFormatter(scale.BytesRateFormatter(true)), // Mirrored absolute labels: 50 MB/s + pulse.WithSeriesStyle("rx", lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a"))), // Green (RX) + pulse.WithSeriesStyle("tx", lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))), // Purple (TX) + pulse.WithSeriesInverted("tx", true), // Auto-mirror TX below zero + pulse.WithAxisStyle(preset.Axis), + pulse.WithFill(true), + pulse.WithTintedFill(true), + ) + + // Stream sample traffic points in bytes/second + for i := 0; i < 60; i++ { + rx := (45.0 + 35.0*math.Sin(float64(i)*0.15)) * MB + tx := (30.0 + 25.0*math.Cos(float64(i)*0.12)) * MB + chart.PushSeries("rx", rx) + chart.PushSeries("tx", tx) + } + + rxVal, _ := chart.Last("rx") + txVal, _ := chart.Last("tx") + + fmt.Printf("Current: ▲ RX %s | ▼ TX %s\n\n", scale.FormatBytesRate(rxVal), scale.FormatBytesRate(txVal)) + fmt.Println(chart.View()) + fmt.Println(chart.LegendBox()) +} +``` + +

+ Network RX/TX Traffic Output +

+ +--- + +### 2. Single-Series Zero-Crossing (P&L / Latency Delta) + +For a single metric that fluctuates above and below zero, use `WithNegativeStyle` to automatically paint positive values green and negative values red: + +```go +package main + +import ( + "fmt" + "math" + + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" +) + +func main() { + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#f7768e")) + + chart := pulse.New(50, 11, + pulse.WithLineWidth(1), + pulse.WithRange(-40, 40), + pulse.WithZeroBaseline(true), + pulse.WithLineStyle(green), // Values >= 0: Green + pulse.WithNegativeStyle(red), // Values < 0: Red + pulse.WithFill(true), + pulse.WithTintedFill(true), + pulse.WithLabelFormatter(func(v float64) string { + if v > 0 { + return fmt.Sprintf("+%.0f ms", v) + } + return fmt.Sprintf("%.0f ms", v) + }), + ) + + for i := 0; i < 50; i++ { + delta := 25.0 * math.Sin(float64(i)*0.2) + chart.Push(delta) + } + + fmt.Println(chart.View()) +} +``` + +

+ Single-Series Zero-Crossing Output +

+ +--- + +### 3. Braille Sub-Pixel Bidirectional Mode + +Bidirectional fill works identically in `pulse.ModeBraille`. Dots above $Y=0$ fill downwards to the zero sub-pixel line; dots below $Y=0$ fill upwards to the zero sub-pixel line: + +```go +chart := pulse.New(60, 10, + pulse.WithMode(pulse.ModeBraille), + pulse.WithRange(-50, 50), + pulse.WithZeroBaseline(true), + pulse.WithFill(true), + pulse.WithLineStyle(green), + pulse.WithNegativeStyle(red), +) +``` + +--- + +## 🎮 Interactive Demo + +An interactive Bubble Tea demo featuring live network traffic with runtime mode toggles is included in [`examples/rxtxdemo`](../examples/rxtxdemo): + +```bash +go run ./examples/rxtxdemo +``` + +**Hotkeys available in the demo:** +* `w` — Toggle line thickness (1: thin rounded `╭╯` $\leftrightarrow$ 2: bold `┏┛`). +* `s` — Toggle line smoothing (rounded corners `╭╮` $\leftrightarrow$ sharp corners `┌┐`). +* `m` — Toggle rendering mode (`ModeLines` $\leftrightarrow$ `ModeBraille`). +* `f` — Toggle area fill on/off. +* `t` — Toggle tinted fill background. +* `z` — Toggle zero baseline ruling on/off. +* `y` — Toggle symmetric zero-centered range on/off. +* `q` — Quit. diff --git a/examples/rxtxdemo/main.go b/examples/rxtxdemo/main.go new file mode 100644 index 0000000..75ee408 --- /dev/null +++ b/examples/rxtxdemo/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "fmt" + "math" + "math/rand" + "os" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/ingvarch/pulse" + "github.com/ingvarch/pulse/scale" + "github.com/ingvarch/pulse/theme" +) + +const MB = 1024.0 * 1024.0 + +type tickMsg time.Time + +type model struct { + chart *pulse.Model + step int +} + +func tick() tea.Msg { + time.Sleep(150 * time.Millisecond) + return tickMsg{} +} + +func rxSignal(step int) float64 { + base := 45 + 30*math.Sin(float64(step)*0.12) + 15*math.Cos(float64(step)*0.05) + if step%23 < 5 { + base += 25 + 15*rand.Float64() + } + return math.Max(5, math.Min(95, base)) * MB +} + +func txSignal(step int) float64 { + base := 35 + 25*math.Cos(float64(step)*0.09) + 10*math.Sin(float64(step)*0.04) + if (step+10)%27 < 4 { + base += 30 + 10*rand.Float64() + } + return math.Max(5, math.Min(95, base)) * MB +} + +func (m model) Init() tea.Cmd { return tick } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + s := msg.String() + if s == "q" || s == "ctrl+c" || s == "esc" { + return m, tea.Quit + } + if s == "m" { + m.chart.ToggleRenderMode() + } + if s == "w" { + if m.chart.LineWidth() == 1 { + m.chart.SetLineWidth(2) + } else { + m.chart.SetLineWidth(1) + } + } + if s == "s" { + m.chart.SetSmooth(!m.chart.Smooth()) + } + if s == "y" { + m.chart.SetSymmetric(!m.chart.Symmetric()) + } + if s == "f" { + m.chart.SetFill(!m.chart.Fill()) + } + if s == "t" { + m.chart.SetTintedFill(!m.chart.TintedFill()) + } + if s == "z" { + m.chart.SetZeroBaseline(!m.chart.ZeroBaseline()) + } + case tickMsg: + m.step++ + rx := rxSignal(m.step) + tx := txSignal(m.step) + m.chart.PushSeries("rx", rx) + m.chart.PushSeries("tx", tx) + return m, tick + } + return m, nil +} + +func (m model) View() tea.View { + rx, _ := m.chart.Last("rx") + tx, _ := m.chart.Last("tx") + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7dcfff")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#565f89")) + rxStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a")).Bold(true) + txStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7")).Bold(true) + + modeStr := "Lines" + if m.chart.Mode() == pulse.ModeBraille { + modeStr = "Braille" + } + cornerStr := "rounded ╭╯" + if !m.chart.Smooth() { + cornerStr = "sharp ┌┘" + } + if m.chart.LineWidth() > 1 { + cornerStr = "bold ┏┛" + } + + fillStr := "off" + if m.chart.Fill() { + if m.chart.TintedFill() { + fillStr = "tinted" + } else { + fillStr = "on" + } + } + + symStr := "asym" + if m.chart.Symmetric() { + symStr = "sym" + } + + header := lipgloss.JoinHorizontal( + lipgloss.Center, + titleStyle.Render("⚡ Network Interface (eth0) Traffic"), + dimStyle.Render(fmt.Sprintf(" [%s (w/s) | %s (m) | %s (y) | fill: %s (f/t) | zero: %v (z) | q: quit]", + cornerStr, modeStr, symStr, fillStr, m.chart.ZeroBaseline())), + ) + + stats := lipgloss.JoinHorizontal( + lipgloss.Center, + rxStyle.Render(fmt.Sprintf(" ▲ RX: %s", scale.FormatBytesRate(rx))), + " ", + txStyle.Render(fmt.Sprintf(" ▼ TX: %s", scale.FormatBytesRate(tx))), + " ", + m.chart.LegendBox(), + ) + + out := lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + m.chart.View(), + "", + stats, + ) + "\n" + + return tea.NewView(out) +} + +func main() { + preset := theme.TokyoNight() + + chart := pulse.New(65, 15, + pulse.WithLineWidth(1), + pulse.WithRange(-100*MB, 100*MB), + pulse.WithSymmetric(true), + pulse.WithZeroBaseline(true), + pulse.WithTicks(-100*MB, -50*MB, 0, 50*MB, 100*MB), + pulse.WithLabelFormatter(scale.FormatBytesRateAbs), + pulse.WithSeriesStyle("rx", lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a"))), + pulse.WithSeriesStyle("tx", lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))), + pulse.WithSeriesInverted("tx", true), + pulse.WithAxisStyle(preset.Axis), + pulse.WithFill(true), + pulse.WithTintedFill(true), + ) + + // Pre-seed historical window + for i := 0; i < 65; i++ { + chart.PushSeries("rx", rxSignal(i)) + chart.PushSeries("tx", txSignal(i)) + } + + p := tea.NewProgram(model{chart: chart, step: 65}) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/options.go b/options.go index 8d95b8a..224f008 100644 --- a/options.go +++ b/options.go @@ -96,3 +96,34 @@ func WithLabelWidth(w int) Option { func WithRenderer(r Renderer) Option { return func(m *Model) { m.SetRenderer(r) } } + +// WithZeroBaseline enables or disables explicit zero baseline rendering (├ on Y-axis and baseline ruling at Y=0). +func WithZeroBaseline(on bool) Option { + return func(m *Model) { m.SetZeroBaseline(on) } +} + +// WithSymmetric normalizes the Y range to be symmetric around zero ([-max, +max]), +// ensuring the zero baseline remains anchored directly in the center of the chart. +func WithSymmetric(on bool) Option { + return func(m *Model) { m.SetSymmetric(on) } +} + +// WithNegativeStyle sets the style for negative values (< 0) of the default series. +func WithNegativeStyle(s lipgloss.Style) Option { + return func(m *Model) { m.SetNegativeStyle(s) } +} + +// WithSeriesNegativeStyle sets the style for negative values (< 0) of a named series. +func WithSeriesNegativeStyle(name string, s lipgloss.Style) Option { + return func(m *Model) { m.SetSeriesNegativeStyle(name, s) } +} + +// WithSeriesInverted configures a named series to invert its values when plotted (v -> -v). +func WithSeriesInverted(name string, inverted bool) Option { + return func(m *Model) { m.SetSeriesInverted(name, inverted) } +} + +// WithInverted configures the default series to invert its values when plotted (v -> -v). +func WithInverted(inverted bool) Option { + return func(m *Model) { m.SetInverted(inverted) } +} diff --git a/pulse.go b/pulse.go index f8b584d..1d25885 100644 --- a/pulse.go +++ b/pulse.go @@ -2,6 +2,7 @@ package pulse import ( "image/color" + "math" "slices" "charm.land/lipgloss/v2" @@ -36,12 +37,21 @@ type Model struct { step int events []eventRecord + + zeroBaseline bool + symmetric bool + negativeStyle lipgloss.Style + hasNegStyle bool + inverted bool } type series struct { - data []float64 - style lipgloss.Style - styled bool + data []float64 + style lipgloss.Style + styled bool + negativeStyle lipgloss.Style + hasNegStyle bool + inverted bool } // New creates a chart for a w x h cells plot area. @@ -70,6 +80,9 @@ func New(w, h int, opts ...Option) *Model { for _, o := range opts { o(m) } + if m.symmetric { + m.SetRange(m.min, m.max) + } if m.min > m.max { m.min, m.max = m.max, m.min } @@ -83,6 +96,10 @@ func (m *Model) getOrCreate(name string) *series { s, ok := m.series[name] if !ok { s = &series{} + if name == "" && m.hasNegStyle { + s.negativeStyle = m.negativeStyle + s.hasNegStyle = true + } m.series[name] = s if name != "" { m.order = append(m.order, name) @@ -128,6 +145,13 @@ func (m *Model) SetRange(min, max float64) { if min == max { max = min + 1 } + if m.symmetric { + limit := math.Max(math.Abs(min), math.Abs(max)) + if limit == 0 { + limit = 1 + } + min, max = -limit, limit + } m.min, m.max = min, max } @@ -316,3 +340,103 @@ func (m *Model) Last(name string) (float64, bool) { } return s.data[len(s.data)-1], true } + +// SetZeroBaseline enables or disables explicit zero baseline rendering. +func (m *Model) SetZeroBaseline(on bool) { m.zeroBaseline = on } + +// ZeroBaseline returns whether explicit zero baseline rendering is enabled. +func (m *Model) ZeroBaseline() bool { return m.zeroBaseline } + +// SetSymmetric enables or disables symmetric Y range normalization around zero ([-max, +max]). +func (m *Model) SetSymmetric(on bool) { + m.symmetric = on + if on { + m.SetRange(m.min, m.max) + } +} + +// Symmetric returns whether symmetric Y range normalization is enabled. +func (m *Model) Symmetric() bool { return m.symmetric } + +// SetNegativeStyle dynamically sets the negative line style for the default series. +func (m *Model) SetNegativeStyle(s lipgloss.Style) { + m.negativeStyle = s + m.hasNegStyle = true + if ms, ok := m.series[""]; ok { + ms.negativeStyle = s + ms.hasNegStyle = true + } +} + +// NegativeStyle returns the negative line style for the default series, or default line style. +func (m *Model) NegativeStyle() lipgloss.Style { + if s, ok := m.series[""]; ok && s.hasNegStyle { + return s.negativeStyle + } + if m.hasNegStyle { + return m.negativeStyle + } + return m.lineStyle +} + +// SetSeriesNegativeStyle dynamically sets the negative style for a named series. +func (m *Model) SetSeriesNegativeStyle(name string, s lipgloss.Style) { + ms := m.getOrCreate(name) + ms.negativeStyle = s + ms.hasNegStyle = true + if name == "" { + m.negativeStyle = s + m.hasNegStyle = true + } +} + +// SeriesNegativeStyle returns the negative style for a named series. +func (m *Model) SeriesNegativeStyle(name string) lipgloss.Style { + if name == "" { + return m.NegativeStyle() + } + if s, ok := m.series[name]; ok && s.hasNegStyle { + return s.negativeStyle + } + return m.SeriesStyle(name) +} + +func (m *Model) hasSeriesNegativeStyle(name string) bool { + if name == "" { + if s, ok := m.series[""]; ok && s.hasNegStyle { + return true + } + return m.hasNegStyle + } + if s, ok := m.series[name]; ok { + return s.hasNegStyle + } + return false +} + +// SetSeriesInverted enables or disables value inversion for a named series. +func (m *Model) SetSeriesInverted(name string, inverted bool) { + ms := m.getOrCreate(name) + ms.inverted = inverted +} + +// SeriesInverted returns whether value inversion is enabled for a named series. +func (m *Model) SeriesInverted(name string) bool { + if s, ok := m.series[name]; ok { + return s.inverted + } + if name == "" { + return m.inverted + } + return false +} + +// SetInverted enables or disables value inversion for the default series. +func (m *Model) SetInverted(inverted bool) { + m.inverted = inverted +} + +// Inverted returns whether value inversion is enabled for the default series. +func (m *Model) Inverted() bool { + return m.inverted +} diff --git a/renderer.go b/renderer.go index a1e7a06..2368ccc 100644 --- a/renderer.go +++ b/renderer.go @@ -43,19 +43,32 @@ type Chart interface { SeriesNames() []string SeriesData(name string) []float64 VisibleEvents() []VisibleEvent + ZeroBaseline() bool + Symmetric() bool + NegativeStyle() lipgloss.Style + SeriesNegativeStyle(name string) lipgloss.Style + SeriesInverted(name string) bool } // RenderContext contains prepared axis, tick, label, and style metadata passed to a Renderer. type RenderContext struct { - LabelWidth int - LabelStyle lipgloss.Style - RowLabel map[int]string - HGrid []bool - VGrid []bool - Names []string - Styles []lipgloss.Style - SeriesData [][]float64 - GridStyle lipgloss.Style + LabelWidth int + LabelStyle lipgloss.Style + RowLabel map[int]string + HGrid []bool + VGrid []bool + Names []string + Styles []lipgloss.Style + NegativeStyles []lipgloss.Style + HasNegStyles []bool + SeriesData [][]float64 + GridStyle lipgloss.Style + ZeroRow int +} + +// isNegativeRow reports whether terminal row r represents negative values. +func (ctx RenderContext) isNegativeRow(r int, maxVal float64) bool { + return (ctx.ZeroRow >= 0 && r > ctx.ZeroRow) || (ctx.ZeroRow < 0 && maxVal < 0) } // Renderer defines how chart series data and axes are rendered into a string. @@ -90,11 +103,17 @@ func tickRowOf(h int, min, max, v float64) int { func renderGrid(c Chart, ctx RenderContext, cellAt func(r, c int) (string, bool)) string { w, h := c.Width(), c.Height() - axis := c.AxisStyle().Render("│") + axisNormal := c.AxisStyle().Render("│") + axisZero := c.AxisStyle().Render("├") + isZeroRow := c.ZeroBaseline() && ctx.ZeroRow >= 0 var sb strings.Builder for r := 0; r < h; r++ { sb.WriteString(ctx.LabelStyle.Render(ctx.RowLabel[r])) - sb.WriteString(axis) + if isZeroRow && r == ctx.ZeroRow { + sb.WriteString(axisZero) + } else { + sb.WriteString(axisNormal) + } for col := 0; col < w; col++ { if s, ok := cellAt(r, col); ok { sb.WriteString(s) @@ -103,6 +122,14 @@ func renderGrid(c Chart, ctx RenderContext, cellAt func(r, c int) (string, bool) } continue } + if isZeroRow && r == ctx.ZeroRow { + if ctx.VGrid[col] { + sb.WriteString(c.AxisStyle().Render("┼")) + } else { + sb.WriteString(c.AxisStyle().Render("─")) + } + continue + } if ctx.HGrid[r] || ctx.VGrid[col] { sb.WriteString(ctx.GridStyle.Render(gridCell(ctx.HGrid[r], ctx.VGrid[col]))) continue @@ -116,15 +143,18 @@ func renderGrid(c Chart, ctx RenderContext, cellAt func(r, c int) (string, bool) return sb.String() } -func styleFor(owner int, styles []lipgloss.Style, fallback lipgloss.Style) lipgloss.Style { - if owner >= 0 && owner < len(styles) { - return styles[owner] +func styleFor(owner int, isNeg bool, ctx RenderContext, fallback lipgloss.Style) lipgloss.Style { + if owner >= 0 && owner < len(ctx.Styles) { + if isNeg && owner < len(ctx.HasNegStyles) && ctx.HasNegStyles[owner] { + return ctx.NegativeStyles[owner] + } + return ctx.Styles[owner] } return fallback } -func faintStyleFor(owner int, styles []lipgloss.Style, fallback lipgloss.Style) lipgloss.Style { - return styleFor(owner, styles, fallback).Faint(true) +func faintStyleFor(owner int, isNeg bool, ctx RenderContext, fallback lipgloss.Style) lipgloss.Style { + return styleFor(owner, isNeg, ctx, fallback).Faint(true) } func gridCell(h, v bool) string { diff --git a/renderer_braille.go b/renderer_braille.go index 1fb3988..337bfe1 100644 --- a/renderer_braille.go +++ b/renderer_braille.go @@ -28,7 +28,7 @@ func dotCellOf(w, h, xd, yd int) (row, col int, bit uint8, ok bool) { return h - 1 - rowFromBottom, cx, brailleBits[dy][dx], true } -func plotDots(grid [][]uint8, owner [][]int, idx, lineWidth, w, h, xd, yd int) { +func plotDots(grid [][]uint8, owner [][]int, isNegGrid [][]bool, isNeg bool, idx, lineWidth, w, h, xd, yd int) { pts := [][2]int{{xd, yd}} if lineWidth > 1 { pts = append(pts, [2]int{xd + 1, yd}, [2]int{xd, yd + 1}, [2]int{xd + 1, yd + 1}) @@ -40,6 +40,9 @@ func plotDots(grid [][]uint8, owner [][]int, idx, lineWidth, w, h, xd, yd int) { } grid[row][col] |= bit owner[row][col] = idx + if isNeg { + isNegGrid[row][col] = true + } } } @@ -138,16 +141,18 @@ func sampleLineOf(w, h int, smooth bool, xs, ys []int) []dot { return out } -func plotSeriesBraille(c Chart, grid [][]uint8, owner [][]int, idx int, data []float64) []int { +func plotSeriesBraille(c Chart, grid [][]uint8, owner [][]int, isNegGrid [][]bool, idx int, data []float64) ([]int, []int) { w, h := c.Width(), c.Height() min, max := c.Min(), c.Max() - colTop := make([]int, w*2) - for i := range colTop { - colTop[i] = -1 + colMaxY := make([]int, w*2) + colMinY := make([]int, w*2) + for i := range colMaxY { + colMaxY[i] = -1 + colMinY[i] = math.MaxInt } n := len(data) if n == 0 { - return colTop + return colMaxY, colMinY } window := w if window < 1 { @@ -167,16 +172,23 @@ func plotSeriesBraille(c Chart, grid [][]uint8, owner [][]int, idx int, data []f xd := (window - n + j) * dw / den xs[j], ys[j] = xd, dotYOf(h, min, max, v) } - top := func(xd, yd int) { - if xd >= 0 && xd < len(colTop) && yd > colTop[xd] { - colTop[xd] = yd + base := dotYOf(h, min, max, 0) + record := func(xd, yd int) { + if xd >= 0 && xd < len(colMaxY) { + if yd > colMaxY[xd] { + colMaxY[xd] = yd + } + if yd < colMinY[xd] { + colMinY[xd] = yd + } } } for _, d := range sampleLineOf(w, h, c.Smooth(), xs, ys) { - plotDots(grid, owner, idx, c.LineWidth(), w, h, d.x, d.y) - top(d.x, d.y) + isNeg := d.y < base || (c.Max() < 0) + plotDots(grid, owner, isNegGrid, isNeg, idx, c.LineWidth(), w, h, d.x, d.y) + record(d.x, d.y) } - return colTop + return colMaxY, colMinY } // Render implements Renderer for BrailleRenderer. @@ -184,8 +196,10 @@ func (BrailleRenderer) Render(c Chart, ctx RenderContext) string { w, h := c.Width(), c.Height() grid := makeCells(h, w, uint8(0)) owner := makeCells(h, w, -1) + ownerIsNeg := makeCells(h, w, false) fillMask := makeCells(h, w, uint8(0)) fillOwner := makeCells(h, w, -1) + fillIsNeg := makeCells(h, w, false) base := dotYOf(h, c.Min(), c.Max(), 0) for idx, name := range ctx.Names { @@ -195,21 +209,40 @@ func (BrailleRenderer) Render(c Chart, ctx RenderContext) string { } else { data = c.SeriesData(name) } - colTop := plotSeriesBraille(c, grid, owner, idx, data) + colMaxY, colMinY := plotSeriesBraille(c, grid, owner, ownerIsNeg, idx, data) if !c.Fill() { continue } - for xd, top := range colTop { - for yd := base; yd < top; yd++ { - if xd%2 != 0 || yd%2 != 0 { - continue + for xd := 0; xd < w*2; xd++ { + maxY := colMaxY[xd] + minY := colMinY[xd] + if maxY >= base { + for yd := base; yd < maxY; yd++ { + if xd%2 != 0 || yd%2 != 0 { + continue + } + row, col, bit, ok := dotCellOf(w, h, xd, yd) + if !ok { + continue + } + fillMask[row][col] |= bit + fillOwner[row][col] = idx + fillIsNeg[row][col] = false } - row, col, bit, ok := dotCellOf(w, h, xd, yd) - if !ok { - continue + } + if minY < base { + for yd := minY; yd <= base; yd++ { + if xd%2 != 0 || yd%2 != 0 { + continue + } + row, col, bit, ok := dotCellOf(w, h, xd, yd) + if !ok { + continue + } + fillMask[row][col] |= bit + fillOwner[row][col] = idx + fillIsNeg[row][col] = true } - fillMask[row][col] |= bit - fillOwner[row][col] = idx } } } @@ -228,16 +261,19 @@ func (BrailleRenderer) Render(c Chart, ctx RenderContext) string { dots := lineDots | fillDots if dots != 0 { if lineDots != 0 { - st := styleFor(owner[r][col], ctx.Styles, fallback) + ownerIdx := owner[r][col] + isNeg := ownerIsNeg[r][col] + st := styleFor(ownerIdx, isNeg, ctx, fallback) if tinted { st = st.Background(tintColorFor(c, st)) } return st.Render(string(rune(0x2800 + int(dots)))), true } ownerIdx := fillOwner[r][col] - st := faintStyleFor(ownerIdx, ctx.Styles, fallback) + isNeg := fillIsNeg[r][col] + st := faintStyleFor(ownerIdx, isNeg, ctx, fallback) if tinted { - baseStyle := styleFor(ownerIdx, ctx.Styles, fallback) + baseStyle := styleFor(ownerIdx, isNeg, ctx, fallback) st = st.Background(tintColorFor(c, baseStyle)) } return st.Render(string(rune(0x2800 + int(dots)))), true diff --git a/renderer_lines.go b/renderer_lines.go index b58bb3c..47ac57d 100644 --- a/renderer_lines.go +++ b/renderer_lines.go @@ -96,7 +96,17 @@ func runeForMask(lineWidth int, smooth bool, mask uint8) rune { } // plotSeriesLines renders a smooth continuous box-drawing line and shaded area fill ░. -func plotSeriesLines(c Chart, lineMask [][]uint8, lineOwner [][]int, fillMask [][]bool, fillOwner [][]int, idx int, data []float64) { +func plotSeriesLines( + c Chart, + lineMask [][]uint8, + lineOwner [][]int, + fillMask [][]bool, + fillOwner [][]int, + fillAbove, fillBelow []bool, + fillAboveOwner, fillBelowOwner []int, + idx int, + data []float64, +) { w, h := c.Width(), c.Height() min, max := c.Min(), c.Max() n := len(data) @@ -159,28 +169,64 @@ func plotSeriesLines(c Chart, lineMask [][]uint8, lineOwner [][]int, fillMask [] // Baseline for area fill (0 by default) baseRow := tickRowOf(h, min, max, 0) + hasZeroBaseline := c.ZeroBaseline() && min <= 0 && max >= 0 // Shaded area fill ░ below the line (strictly beneath the line's lower boundary in each column) if c.Fill() { for col := startCol; col < window; col++ { bottomY := -1 + topY := -1 for r := h - 1; r >= 0; r-- { if lineOwner[r][col] == idx { bottomY = r break } } + for r := 0; r < h; r++ { + if lineOwner[r][col] == idx { + topY = r + break + } + } if bottomY < 0 { bottomY = ys[col] } - lo, hi := bottomY+1, baseRow - if lo > hi { - lo, hi = baseRow, bottomY-1 + if topY < 0 { + topY = ys[col] } - for y := lo; y <= hi && y < h; y++ { - if y >= 0 { - fillMask[y][col] = true - fillOwner[y][col] = idx + + if hasZeroBaseline { + if bottomY < baseRow { + // Strictly positive: fill from bottomY + 1 down to baseRow - 1 + for y := bottomY + 1; y < baseRow && y < h; y++ { + if y >= 0 { + fillMask[y][col] = true + fillOwner[y][col] = idx + } + } + fillAbove[col] = true + fillAboveOwner[col] = idx + } else if topY > baseRow { + // Strictly negative: fill from baseRow + 1 down to topY - 1 + for y := baseRow + 1; y < topY && y < h; y++ { + if y >= 0 { + fillMask[y][col] = true + fillOwner[y][col] = idx + } + } + fillBelow[col] = true + fillBelowOwner[col] = idx + } + } else { + lo, hi := bottomY+1, baseRow + if lo > hi { + lo, hi = baseRow, bottomY-1 + } + for y := lo; y <= hi && y < h; y++ { + if y >= 0 { + fillMask[y][col] = true + fillOwner[y][col] = idx + } } } } @@ -195,6 +241,15 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string { fillMask := makeCells(h, w, false) fillOwner := makeCells(h, w, -1) + fillAbove := make([]bool, w) + fillBelow := make([]bool, w) + fillAboveOwner := make([]int, w) + fillBelowOwner := make([]int, w) + for i := range fillAboveOwner { + fillAboveOwner[i] = -1 + fillBelowOwner[i] = -1 + } + for idx, name := range ctx.Names { var data []float64 if idx < len(ctx.SeriesData) && ctx.SeriesData[idx] != nil { @@ -202,7 +257,7 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string { } else { data = c.SeriesData(name) } - plotSeriesLines(c, lineMask, lineOwner, fillMask, fillOwner, idx, data) + plotSeriesLines(c, lineMask, lineOwner, fillMask, fillOwner, fillAbove, fillBelow, fillAboveOwner, fillBelowOwner, idx, data) } eventMap := eventsByColumn(c.VisibleEvents()) @@ -210,13 +265,16 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string { fallback, glyphWidth, glyphSmooth := c.LineStyle(), c.LineWidth(), c.Smooth() tinted := c.TintedFill() solid := c.SolidFill() + isZero := c.ZeroBaseline() && ctx.ZeroRow >= 0 + return renderGrid(c, ctx, func(r, col int) (string, bool) { ve, hasEvent := eventMap[col] if hasEvent && r == 0 { return renderEventPin(ve), true } if lineMask[r][col] != 0 { - st := styleFor(lineOwner[r][col], ctx.Styles, fallback) + isNeg := ctx.isNegativeRow(r, c.Max()) + st := styleFor(lineOwner[r][col], isNeg, ctx, fallback) if tinted { st = st.Background(tintColorFor(c, st)) } @@ -226,18 +284,49 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string { if hasEvent { var bg color.Color if fillMask[r][col] && tinted { - baseStyle := styleFor(fillOwner[r][col], ctx.Styles, fallback) + isNeg := ctx.isNegativeRow(r, c.Max()) + baseStyle := styleFor(fillOwner[r][col], isNeg, ctx, fallback) bg = tintColorFor(c, baseStyle) } if s, ok := renderEventGuideline(ve, bg); ok { return s, true } } + if isZero && r == ctx.ZeroRow { + hasAbove := fillAbove[col] + hasBelow := fillBelow[col] + if hasAbove && hasBelow { + st := ctx.GridStyle + if tinted { + baseStyle := styleFor(fillAboveOwner[col], false, ctx, fallback) + st = st.Background(tintColorFor(c, baseStyle)) + } + return st.Render("┼"), true + } + if hasAbove { + owner := fillAboveOwner[col] + st := styleFor(owner, false, ctx, fallback) + if tinted { + st = st.Background(tintColorFor(c, st)) + } + return st.Render("┴"), true + } + if hasBelow { + owner := fillBelowOwner[col] + st := styleFor(owner, true, ctx, fallback) + if tinted { + st = st.Background(tintColorFor(c, st)) + } + return st.Render("┬"), true + } + return "", false + } if fillMask[r][col] { owner := fillOwner[r][col] - st := faintStyleFor(owner, ctx.Styles, fallback) + isNeg := ctx.isNegativeRow(r, c.Max()) + st := faintStyleFor(owner, isNeg, ctx, fallback) if tinted { - baseStyle := styleFor(owner, ctx.Styles, fallback) + baseStyle := styleFor(owner, isNeg, ctx, fallback) bg := tintColorFor(c, baseStyle) if solid { return lipgloss.NewStyle().Background(bg).Render(" "), true diff --git a/scale/scale.go b/scale/scale.go index 7a57a28..2650d26 100644 --- a/scale/scale.go +++ b/scale/scale.go @@ -1,6 +1,7 @@ package scale import ( + "fmt" "math" "strconv" ) @@ -52,3 +53,65 @@ func NiceTicks(min, max float64, maxTicks int) []float64 { func FormatTick(v float64) string { return strconv.FormatFloat(v, 'g', 4, 64) } + +func formatBytesWithSuffix(v float64, suffix string, abs bool) string { + if math.IsNaN(v) || math.IsInf(v, 0) { + return "0 B" + suffix + } + neg := v < 0 + if abs { + neg = false + } + val := math.Abs(v) + if val == 0 { + return "0 B" + suffix + } + + units := []string{"B", "KB", "MB", "GB", "TB", "PB"} + unitIdx := 0 + for val >= 1024 && unitIdx < len(units)-1 { + val /= 1024 + unitIdx++ + } + + var formatted string + if unitIdx == 0 { + formatted = fmt.Sprintf("%.0f %s%s", val, units[unitIdx], suffix) + } else { + rounded := math.Round(val*10) / 10 + if rounded == math.Floor(rounded) { + formatted = fmt.Sprintf("%.0f %s%s", rounded, units[unitIdx], suffix) + } else { + formatted = fmt.Sprintf("%.1f %s%s", rounded, units[unitIdx], suffix) + } + } + + if neg { + return "-" + formatted + } + return formatted +} + +// FormatBytes formats a byte count into a human-readable string (e.g. "1 KB", "1.5 MB"). +func FormatBytes(v float64) string { + return formatBytesWithSuffix(v, "", false) +} + +// FormatBytesRate formats a throughput value in bytes per second (e.g. "1 KB/s", "100 MB/s"). +func FormatBytesRate(v float64) string { + return formatBytesWithSuffix(v, "/s", false) +} + +// FormatBytesRateAbs formats a throughput value in bytes per second using its absolute value (e.g. "50 MB/s"). +// Ideal for mirror/symmetric Y-axes (RX/TX, Read/Write). +func FormatBytesRateAbs(v float64) string { + return formatBytesWithSuffix(v, "/s", true) +} + +// BytesRateFormatter returns a Y-axis label formatter function for bytes per second. +// If abs is true, negative values are formatted without a minus sign (ideal for TX / Write). +func BytesRateFormatter(abs bool) func(float64) string { + return func(v float64) string { + return formatBytesWithSuffix(v, "/s", abs) + } +} diff --git a/scale/scale_test.go b/scale/scale_test.go index 37a27c0..000c024 100644 --- a/scale/scale_test.go +++ b/scale/scale_test.go @@ -36,3 +36,70 @@ func TestFormatTick_RoundsLongFloat(t *testing.T) { t.Fatalf("FormatTick(88.666..) = %q, want %q", got, "88.67") } } + +func TestFormatBytes(t *testing.T) { + tests := []struct { + input float64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1 KB"}, + {1536, "1.5 KB"}, + {1048576, "1 MB"}, + {1572864, "1.5 MB"}, + {1073741824, "1 GB"}, + {-1048576, "-1 MB"}, + {-1536, "-1.5 KB"}, + } + for _, tc := range tests { + if got := scale.FormatBytes(tc.input); got != tc.want { + t.Errorf("FormatBytes(%v) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestFormatBytesRate(t *testing.T) { + tests := []struct { + input float64 + want string + }{ + {0, "0 B/s"}, + {1024, "1 KB/s"}, + {104857600, "100 MB/s"}, + {-104857600, "-100 MB/s"}, + } + for _, tc := range tests { + if got := scale.FormatBytesRate(tc.input); got != tc.want { + t.Errorf("FormatBytesRate(%v) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestFormatBytesRateAbs(t *testing.T) { + tests := []struct { + input float64 + want string + }{ + {0, "0 B/s"}, + {52428800, "50 MB/s"}, + {-52428800, "50 MB/s"}, + } + for _, tc := range tests { + if got := scale.FormatBytesRateAbs(tc.input); got != tc.want { + t.Errorf("FormatBytesRateAbs(%v) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestBytesRateFormatter(t *testing.T) { + fmtAbs := scale.BytesRateFormatter(true) + if got := fmtAbs(-1048576); got != "1 MB/s" { + t.Errorf("BytesRateFormatter(true)(-1MB) = %q, want %q", got, "1 MB/s") + } + + fmtSigned := scale.BytesRateFormatter(false) + if got := fmtSigned(-1048576); got != "-1 MB/s" { + t.Errorf("BytesRateFormatter(false)(-1MB) = %q, want %q", got, "-1 MB/s") + } +} diff --git a/view.go b/view.go index bb9a44f..070fd46 100644 --- a/view.go +++ b/view.go @@ -1,6 +1,8 @@ package pulse import ( + "slices" + "charm.land/lipgloss/v2" "github.com/ingvarch/pulse/scale" @@ -41,6 +43,20 @@ func (m *Model) formatValue(v float64) string { // View renders the chart: Y-axis labels + axis border + plot area. func (m *Model) View() string { ticks := m.gridTicks() + if m.zeroBaseline && m.min <= 0 && m.max >= 0 { + hasZero := false + for _, t := range ticks { + if t == 0 { + hasZero = true + break + } + } + if !hasZero { + ticks = append(ticks, 0) + slices.Sort(ticks) + } + } + rowLabel := map[int]string{} for i := len(ticks) - 1; i >= 0; i-- { t := ticks[i] @@ -64,6 +80,11 @@ func (m *Model) View() string { } labelStyle := m.axisStyle.Width(labelWidth).Align(lipgloss.Right) + zeroRow := -1 + if m.min <= 0 && m.max >= 0 { + zeroRow = m.tickRow(0) + } + hGrid := make([]bool, m.h) vGrid := make([]bool, m.w) if m.grid { @@ -81,25 +102,41 @@ func (m *Model) View() string { names = append(names, "") names = append(names, m.order...) styles := make([]lipgloss.Style, len(names)) + negativeStyles := make([]lipgloss.Style, len(names)) + hasNegStyles := make([]bool, len(names)) seriesData := make([][]float64, len(names)) for idx, name := range names { styles[idx] = m.SeriesStyle(name) + negativeStyles[idx] = m.SeriesNegativeStyle(name) + hasNegStyles[idx] = m.hasSeriesNegativeStyle(name) if s, ok := m.series[name]; ok { - seriesData[idx] = s.data + isInverted := s.inverted || (name == "" && m.inverted) + if isInverted { + inv := make([]float64, len(s.data)) + for i, val := range s.data { + inv[i] = -val + } + seriesData[idx] = inv + } else { + seriesData[idx] = s.data + } } } gridStyle := m.axisStyle.Faint(true) ctx := RenderContext{ - LabelWidth: labelWidth, - LabelStyle: labelStyle, - RowLabel: rowLabel, - HGrid: hGrid, - VGrid: vGrid, - Names: names, - Styles: styles, - SeriesData: seriesData, - GridStyle: gridStyle, + LabelWidth: labelWidth, + LabelStyle: labelStyle, + RowLabel: rowLabel, + HGrid: hGrid, + VGrid: vGrid, + Names: names, + Styles: styles, + NegativeStyles: negativeStyles, + HasNegStyles: hasNegStyles, + SeriesData: seriesData, + GridStyle: gridStyle, + ZeroRow: zeroRow, } r := m.renderer diff --git a/zerocross_test.go b/zerocross_test.go new file mode 100644 index 0000000..fbc64a6 --- /dev/null +++ b/zerocross_test.go @@ -0,0 +1,380 @@ +package pulse_test + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" +) + +func TestZeroBaseline_AxisAndRulings(t *testing.T) { + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) + m := pulse.New(20, 9, + pulse.WithRange(-50, 50), + pulse.WithZeroBaseline(true), + pulse.WithLineStyle(green), + pulse.WithFill(true), + ) + + if !m.ZeroBaseline() { + t.Fatal("expected ZeroBaseline() to be true") + } + + // 1. Initial empty view: should contain ├ at the zero row + view := m.View() + if !strings.Contains(view, "├") { + t.Fatalf("expected view to contain '├' on axis at zero baseline, got:\n%s", view) + } + + // 2. Push positive values: area fill must produce ┴ on zero baseline row + for i := 0; i < 5; i++ { + m.Push(35) + } + posView := m.View() + if !strings.Contains(posView, "┴") { + t.Fatalf("expected view with positive fill to contain '┴' on zero baseline, got:\n%s", posView) + } + + // 3. Clear and push negative values: area fill must produce ┬ on zero baseline row + m2 := pulse.New(20, 9, + pulse.WithRange(-50, 50), + pulse.WithZeroBaseline(true), + pulse.WithLineStyle(green), + pulse.WithFill(true), + ) + for i := 0; i < 5; i++ { + m2.Push(-35) + } + negView := m2.View() + if !strings.Contains(negView, "┬") { + t.Fatalf("expected view with negative fill to contain '┬' on zero baseline, got:\n%s", negView) + } +} + +func TestZeroBaseline_OutsideRangeDoesNotRender(t *testing.T) { + // Range strictly above zero: zero baseline should not activate + mPos := pulse.New(20, 8, + pulse.WithRange(20, 100), + pulse.WithZeroBaseline(true), + ) + if strings.Contains(mPos.View(), "├") { + t.Fatalf("expected no '├' when 0 is outside positive range [20, 100], got:\n%s", mPos.View()) + } + + // Range strictly below zero: zero baseline should not activate + mNeg := pulse.New(20, 8, + pulse.WithRange(-100, -20), + pulse.WithZeroBaseline(true), + ) + if strings.Contains(mNeg.View(), "├") { + t.Fatalf("expected no '├' when 0 is outside negative range [-100, -20], got:\n%s", mNeg.View()) + } +} + +func TestNegativeStyle_LinesRenderer(t *testing.T) { + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + + m := pulse.New(15, 9, + pulse.WithLineWidth(1), + pulse.WithRange(-50, 50), + pulse.WithLineStyle(green), + pulse.WithNegativeStyle(red), + pulse.WithZeroBaseline(true), + pulse.WithFill(true), + ) + + if m.NegativeStyle().GetForeground() != red.GetForeground() { + t.Fatal("NegativeStyle() did not retain style") + } + + // Push sequence: positive then negative + m.Push(30) + m.Push(30) + m.Push(-30) + m.Push(-30) + + view := m.View() + + // Both green (positive) and red (negative) must be present in the output + if !strings.Contains(view, green.Render("─")) && !strings.Contains(view, green.Render("╭")) { + t.Fatalf("expected positive line styled in green, got:\n%s", view) + } + if !strings.Contains(view, red.Render("─")) && !strings.Contains(view, red.Render("╰")) { + t.Fatalf("expected negative line styled in red, got:\n%s", view) + } +} + +func TestSeriesNegativeStyle(t *testing.T) { + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + + m := pulse.New(15, 9, + pulse.WithLineWidth(1), + pulse.WithRange(-50, 50), + pulse.WithSeriesStyle("pnl", green), + pulse.WithSeriesNegativeStyle("pnl", red), + pulse.WithFill(true), + ) + + if m.SeriesNegativeStyle("pnl").GetForeground() != red.GetForeground() { + t.Fatal("SeriesNegativeStyle(\"pnl\") did not retain style") + } + + m.PushSeries("pnl", 25) + m.PushSeries("pnl", 25) + m.PushSeries("pnl", -25) + m.PushSeries("pnl", -25) + + view := m.View() + if !strings.Contains(view, red.Render("─")) && !strings.Contains(view, red.Render("╰")) { + t.Fatalf("expected negative line in series 'pnl' styled in red, got:\n%s", view) + } +} + +func TestSeriesInverted_RXTX(t *testing.T) { + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a")) + purple := lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7")) + + m := pulse.New(20, 9, + pulse.WithRange(-100, 100), + pulse.WithZeroBaseline(true), + pulse.WithSeriesStyle("rx", green), + pulse.WithSeriesStyle("tx", purple), + pulse.WithSeriesInverted("tx", true), + pulse.WithFill(true), + ) + + if !m.SeriesInverted("tx") { + t.Fatal("expected SeriesInverted(\"tx\") to be true") + } + if m.SeriesInverted("rx") { + t.Fatal("expected SeriesInverted(\"rx\") to be false") + } + + // Push raw positive values for both + m.PushSeries("rx", 60) + m.PushSeries("tx", 40) + + // Last must preserve raw pushed value (40, not -40) + if lastTX, ok := m.Last("tx"); !ok || lastTX != 40 { + t.Fatalf("m.Last(\"tx\") = %v, want 40", lastTX) + } + if lastRX, ok := m.Last("rx"); !ok || lastRX != 60 { + t.Fatalf("m.Last(\"rx\") = %v, want 60", lastRX) + } + + view := m.View() + // Both colors must be present: green above, purple below + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + // Row 0..3 are positive rows (rx), row 4 is zero, row 5..8 are negative rows (tx) + posHalf := strings.Join(lines[:4], "\n") + negHalf := strings.Join(lines[5:], "\n") + + if !strings.Contains(posHalf, "\x1b[38;2;158;206;106m") && !strings.Contains(posHalf, "9ece6a") { + // check if green is in positive half + t.Fatalf("positive half must contain rx green style, got:\n%s", posHalf) + } + if !strings.Contains(negHalf, "\x1b[38;2;187;154;247m") && !strings.Contains(negHalf, "bb9af7") { + // check if purple is in negative half + t.Fatalf("negative half must contain tx purple style, got:\n%s", negHalf) + } +} + +func TestBrailleRenderer_BidirectionalFill(t *testing.T) { + cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) + + withFill := pulse.New(20, 8, + pulse.WithMode(pulse.ModeBraille), + pulse.WithRange(-50, 50), + pulse.WithLineStyle(cyan), + pulse.WithFill(true), + ) + noFill := pulse.New(20, 8, + pulse.WithMode(pulse.ModeBraille), + pulse.WithRange(-50, 50), + pulse.WithLineStyle(cyan), + pulse.WithFill(false), + ) + + for i := 0; i < 5; i++ { + withFill.Push(-30) + noFill.Push(-30) + } + + viewWith := withFill.View() + viewNo := noFill.View() + + // When fill is enabled for negative values in Braille, viewWith must differ from viewNo + if viewWith == viewNo { + t.Fatalf("expected Braille view with negative fill to differ from no-fill view, but they are identical:\n%s", viewWith) + } +} + +func TestBrailleRenderer_NegativeStyle(t *testing.T) { + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + + m := pulse.New(15, 8, + pulse.WithMode(pulse.ModeBraille), + pulse.WithRange(-50, 50), + pulse.WithLineStyle(green), + pulse.WithNegativeStyle(red), + ) + + m.Push(30) + m.Push(-30) + + view := m.View() + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + negHalf := strings.Join(lines[len(lines)/2:], "\n") + + if !strings.Contains(negHalf, "\x1b[38;2;255;0;0m") && !strings.Contains(negHalf, "ff0000") { + t.Fatalf("expected red style in negative braille rows, got:\n%s", negHalf) + } +} + +func TestZeroCrossing_RoundedCorners(t *testing.T) { + // A chart with thin lines (lineWidth = 1) and smooth = true (default) + m := pulse.New(15, 9, + pulse.WithLineWidth(1), + pulse.WithRange(-50, 50), + pulse.WithZeroBaseline(true), + pulse.WithFill(false), + ) + + // Stream positive crest: 0 -> 30 -> 30 -> 0 => rounded corners '╭' and '╮' + m.Push(0) + m.Push(30) + m.Push(30) + m.Push(0) + + viewPos := m.View() + if !strings.Contains(viewPos, "╭") { + t.Fatalf("expected positive crest to contain rounded corner '╭', got:\n%s", viewPos) + } + if !strings.Contains(viewPos, "╮") { + t.Fatalf("expected positive crest to contain rounded corner '╮', got:\n%s", viewPos) + } + + // Stream negative trough: 0 -> -30 -> -30 -> 0 => rounded corners '╰' and '╯' + m2 := pulse.New(15, 9, + pulse.WithLineWidth(1), + pulse.WithRange(-50, 50), + pulse.WithZeroBaseline(true), + pulse.WithFill(false), + ) + m2.Push(0) + m2.Push(-30) + m2.Push(-30) + m2.Push(0) + + viewNeg := m2.View() + if !strings.Contains(viewNeg, "╰") { + t.Fatalf("expected negative trough to contain rounded corner '╰', got:\n%s", viewNeg) + } + if !strings.Contains(viewNeg, "╯") { + t.Fatalf("expected negative trough to contain rounded corner '╯', got:\n%s", viewNeg) + } + + // Sharp mode: WithSmooth(false) => '┌', '┐', '└', '┘' instead of '╭', '╮', '╰', '╯' + mSharp := pulse.New(15, 9, + pulse.WithLineWidth(1), + pulse.WithSmooth(false), + pulse.WithRange(-50, 50), + pulse.WithZeroBaseline(true), + pulse.WithFill(false), + ) + mSharp.Push(0) + mSharp.Push(30) + mSharp.Push(30) + mSharp.Push(-30) + mSharp.Push(-30) + mSharp.Push(0) + + viewSharp := mSharp.View() + if strings.Contains(viewSharp, "╭") || strings.Contains(viewSharp, "╰") { + t.Fatalf("sharp mode must not contain rounded corners, got:\n%s", viewSharp) + } + if !strings.Contains(viewSharp, "┌") || !strings.Contains(viewSharp, "┘") { + t.Fatalf("sharp mode must contain sharp corners '┌' and '┘', got:\n%s", viewSharp) + } +} + +func TestSeriesInverted_RXTX_RoundedCorners(t *testing.T) { + m := pulse.New(15, 9, + pulse.WithLineWidth(1), + pulse.WithRange(-50, 50), + pulse.WithZeroBaseline(true), + pulse.WithSeriesInverted("tx", true), + ) + + // Stream RX peak (positive crest) + m.PushSeries("rx", 10) + m.PushSeries("rx", 35) + m.PushSeries("rx", 35) + m.PushSeries("rx", 10) + + // Stream TX peak (raw positive 10 -> 35 -> 35 -> 10, inverted to negative trough) + m.PushSeries("tx", 10) + m.PushSeries("tx", 35) + m.PushSeries("tx", 35) + m.PushSeries("tx", 10) + + view := m.View() + t.Logf("RX/TX View:\n%s", view) + if !strings.Contains(view, "╭") || !strings.Contains(view, "╮") { + t.Fatalf("expected RX to produce rounded top corners '╭' and '╮', got:\n%s", view) + } + if !strings.Contains(view, "╰") || !strings.Contains(view, "╯") { + t.Fatalf("expected TX to produce rounded bottom corners '╰' and '╯', got:\n%s", view) + } +} + +func TestSymmetricRange(t *testing.T) { + // 1. Initial configuration with WithSymmetric(true) + m := pulse.New(20, 9, + pulse.WithRange(0, 100), + pulse.WithSymmetric(true), + pulse.WithZeroBaseline(true), + ) + + if !m.Symmetric() { + t.Fatal("expected Symmetric() to be true") + } + if m.Min() != -100 || m.Max() != 100 { + t.Fatalf("expected WithSymmetric to expand [0, 100] to [-100, 100], got [%v, %v]", m.Min(), m.Max()) + } + + // 2. SetRange with asymmetric positive-dominant range: [-20, 80] -> [-80, 80] + m.SetRange(-20, 80) + if m.Min() != -80 || m.Max() != 80 { + t.Fatalf("expected SetRange(-20, 80) with symmetric to become [-80, 80], got [%v, %v]", m.Min(), m.Max()) + } + + // 3. SetRange with asymmetric negative-dominant range: [-90, 15] -> [-90, 90] + m.SetRange(-90, 15) + if m.Min() != -90 || m.Max() != 90 { + t.Fatalf("expected SetRange(-90, 15) with symmetric to become [-90, 90], got [%v, %v]", m.Min(), m.Max()) + } + + // 4. Zero row must be centered on height 9: (9-1)/2 = row 4 + lines := strings.Split(strings.TrimRight(m.View(), "\n"), "\n") + if len(lines) != 9 { + t.Fatalf("expected 9 lines, got %d", len(lines)) + } + if !strings.Contains(lines[4], "├") { + t.Fatalf("expected line 4 (middle row) to be the zero baseline, got:\n%s", lines[4]) + } + + // 5. Disable symmetric + m.SetSymmetric(false) + if m.Symmetric() { + t.Fatal("expected Symmetric() to be false after SetSymmetric(false)") + } + m.SetRange(-20, 80) + if m.Min() != -20 || m.Max() != 80 { + t.Fatalf("expected SetRange(-20, 80) to be [-20, 80] when symmetric is false, got [%v, %v]", m.Min(), m.Max()) + } +}