Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,6 +135,24 @@ chart.SetMode(pulse.ModeBraille)
<img src="assets/braille.png" alt="Pulse Braille Sub-Pixel Rendering" width="100%" />
</p>

## ⚖️ 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)),
)
```

<p align="center">
<img src="assets/rxtx.png" alt="Pulse Bidirectional RX/TX Traffic" width="100%" />
</p>

## 📚 Documentation

Detailed guides and API references are available in the [docs](docs/) directory:
Expand All @@ -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 |

Expand All @@ -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

Expand Down
Binary file added assets/events.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/fill.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/rxtx.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/zerocross.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
196 changes: 195 additions & 1 deletion cmd/gen-shots/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -24,6 +25,14 @@ func main() {
renderStyled()
case "braille":
renderBraille()
case "rxtx":
renderRXTX()
case "events":
renderEvents()
case "fill":
renderFill()
case "zerocross":
renderZeroCross()
}
}

Expand Down Expand Up @@ -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)
}
44 changes: 44 additions & 0 deletions docs/axes-and-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,47 @@ chart := pulse.New(60, 12,
pulse.WithAxisStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#444b6a"))),
)
```

<p align="center">
<img src="../assets/fill.png" alt="Grafana-Style Coordinate Grid" width="100%" />
</p>

## 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),
)
```

<p align="center">
<img src="../assets/rxtx.png" alt="Symmetric Y-Axis Centered Baseline" width="100%" />
</p>

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.

4 changes: 4 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ func main() {
}
```

<p align="center">
<img src="../assets/quickstart.png" alt="Pulse Basic Usage Output" width="100%" />
</p>

## 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.
Expand Down
4 changes: 4 additions & 0 deletions docs/rendering-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="../assets/fill.png" alt="Shaded and Tinted Area Fill" width="100%" />
</p>

---

## 2. Braille Matrix (`ModeBraille`)
Expand Down
10 changes: 4 additions & 6 deletions docs/timeline-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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│─────────────┆───╯░░░░░░░░░░░░░░░░░░
```
<p align="center">
<img src="../assets/events.png" alt="Timeline Annotations & Event Markers" width="100%" />
</p>


---

Expand Down
Loading
Loading