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)
+## ⚖️ 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)),
+)
+```
+
+
+
+
+
## 📚 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"))),
)
```
+
+
+
+
+
+## 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),
+)
+```
+
+
+
+
+
+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() {
}
```
+
+
+
+
## 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.
+
+
+
+
---
## 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│─────────────┆───╯░░░░░░░░░░░░░░░░░░
-```
+
+
+
+
---
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$).
+
+
+
+
+
+---
+
+## 🎯 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:
+
+
+
+
+
+* **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())
+}
+```
+
+
+
+
+
+---
+
+### 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())
+}
+```
+
+
+
+
+
+---
+
+### 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())
+ }
+}