From a619923799459ff16d5d8b8758a5d9fb724c76bf Mon Sep 17 00:00:00 2001
From: Igor Churmeev
Date: Thu, 1 Oct 2026 16:17:43 +0000
Subject: [PATCH 1/4] feat(pulse): add bidirectional zero-crossing fill and
RX/TX traffic support
---
examples/rxtxdemo/main.go | 178 ++++++++++++++++++++
options.go | 26 +++
pulse.go | 108 +++++++++++-
renderer.go | 56 +++++--
renderer_braille.go | 86 +++++++---
renderer_lines.go | 115 +++++++++++--
view.go | 57 +++++--
zerocross_test.go | 334 ++++++++++++++++++++++++++++++++++++++
8 files changed, 893 insertions(+), 67 deletions(-)
create mode 100644 examples/rxtxdemo/main.go
create mode 100644 zerocross_test.go
diff --git a/examples/rxtxdemo/main.go b/examples/rxtxdemo/main.go
new file mode 100644
index 0000000..22bfe44
--- /dev/null
+++ b/examples/rxtxdemo/main.go
@@ -0,0 +1,178 @@
+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/theme"
+)
+
+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))
+}
+
+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))
+}
+
+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 == "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"
+ }
+ }
+
+ header := lipgloss.JoinHorizontal(
+ lipgloss.Center,
+ titleStyle.Render("⚡ Network Interface (eth0) Traffic"),
+ dimStyle.Render(fmt.Sprintf(" [%s (w/s) | %s (m) | fill: %s (f/t) | zero: %v (z) | q: quit]",
+ cornerStr, modeStr, fillStr, m.chart.ZeroBaseline())),
+ )
+
+ stats := lipgloss.JoinHorizontal(
+ lipgloss.Center,
+ rxStyle.Render(fmt.Sprintf(" ▲ RX: %5.1f MB/s", rx)),
+ " ",
+ txStyle.Render(fmt.Sprintf(" ▼ TX: %5.1f MB/s", 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, 100),
+ pulse.WithZeroBaseline(true),
+ pulse.WithTicks(-100, -50, 0, 50, 100),
+ pulse.WithLabelFormatter(func(v float64) string {
+ if v == 0 {
+ return "0 MB/s"
+ }
+ return fmt.Sprintf("%.0f MB/s", math.Abs(v))
+ }),
+ 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..1b94357 100644
--- a/options.go
+++ b/options.go
@@ -96,3 +96,29 @@ 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) }
+}
+
+// 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..4084990 100644
--- a/pulse.go
+++ b/pulse.go
@@ -36,12 +36,20 @@ type Model struct {
step int
events []eventRecord
+
+ zeroBaseline 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.
@@ -83,6 +91,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)
@@ -316,3 +328,93 @@ 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 }
+
+// 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..52260cf 100644
--- a/renderer.go
+++ b/renderer.go
@@ -43,19 +43,26 @@ type Chart interface {
SeriesNames() []string
SeriesData(name string) []float64
VisibleEvents() []VisibleEvent
+ ZeroBaseline() 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
}
// Renderer defines how chart series data and axes are rendered into a string.
@@ -90,11 +97,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 +116,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 +137,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..55368c0 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.ZeroRow >= 0 && r > ctx.ZeroRow) || (ctx.ZeroRow < 0 && c.Max() < 0)
+ 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.ZeroRow >= 0 && r > ctx.ZeroRow) || (ctx.ZeroRow < 0 && c.Max() < 0)
+ 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.ZeroRow >= 0 && r > ctx.ZeroRow) || (ctx.ZeroRow < 0 && c.Max() < 0)
+ 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/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..c64747b
--- /dev/null
+++ b/zerocross_test.go
@@ -0,0 +1,334 @@
+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)
+ }
+}
+
From b4d82e67499e6132a19b20fbb5fa62aac9d6cbde Mon Sep 17 00:00:00 2001
From: Igor Churmeev
Date: Thu, 1 Oct 2026 16:28:05 +0000
Subject: [PATCH 2/4] docs(pulse): add zero-crossing fill and rx/tx traffic
guide
---
README.md | 4 +
docs/zero-crossing-fill.md | 240 +++++++++++++++++++++++++++++++++++++
2 files changed, 244 insertions(+)
create mode 100644 docs/zero-crossing-fill.md
diff --git a/README.md b/README.md
index ef0aed1..0129004 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,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 +152,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/docs/zero-crossing-fill.md b/docs/zero-crossing-fill.md
new file mode 100644
index 0000000..0bb0410
--- /dev/null
+++ b/docs/zero-crossing-fill.md
@@ -0,0 +1,240 @@
+# 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.
+
+```text
+Traditional Bottom Fill (Awkward & Misleading):
+ +50│ ╭───╮
+ │░░░░░░░░│░░░│░░░░░░░░░░░░░░░░░░░░ <- Fill extends all the way down
+ 0│░░░░░░░░│░░░│░░░░░░░░░░░░░░░░░░░░
+ │░░░░░░░░│░░░│░░░░░░░░╰───╯░░░░░░░
+ -50│░░░░░░░░┴───┴────────────────────
+
+Pulse Bidirectional Fill (Grafana Style):
+ +50│ ╭───╮
+ │ │░░░│ <- Positive fill (down to 0)
+ 0├────────┴───┴───────┬───┬──────── <- Prominent zero baseline (├)
+ │ │░░░│ <- Negative fill (up to 0)
+ -50│ ╰───╯
+```
+
+---
+
+## 📐 Anatomy of the Zero Baseline
+
+When `pulse.WithZeroBaseline(true)` is enabled:
+
+```text
+ +100│─────────────╭───╮──────────
+ │─────────────│░░░│────────── <- RX positive fill (down to 0)
+ 0├─────────────┴───┴─────┬───┬── <- Zero baseline (├ on axis)
+ │───────────────────────│░░░│ <- TX negative fill (up to 0)
+ -100│───────────────────────╰───╯
+```
+
+* **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.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.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. |
+
+---
+
+## ✨ 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 positive bandwidth numbers (e.g. `52.4 MB/s`). By configuring `WithSeriesInverted("tx", 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/theme"
+)
+
+func main() {
+ preset := theme.TokyoNight()
+
+ chart := pulse.New(60, 13,
+ pulse.WithLineWidth(1), // Thin rounded curves: ╭ ╮ ╯ ╰
+ pulse.WithRange(-100, 100),
+ pulse.WithZeroBaseline(true),
+ pulse.WithTicks(-100, -50, 0, 50, 100),
+ pulse.WithLabelFormatter(func(v float64) string {
+ if v == 0 {
+ return "0 MB/s"
+ }
+ return fmt.Sprintf("%.0f MB/s", math.Abs(v))
+ }),
+ 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
+ for i := 0; i < 60; i++ {
+ rx := 45 + 35*math.Sin(float64(i)*0.15)
+ tx := 30 + 25*math.Cos(float64(i)*0.12)
+ chart.PushSeries("rx", rx)
+ chart.PushSeries("tx", tx)
+ }
+
+ rxVal, _ := chart.Last("rx")
+ txVal, _ := chart.Last("tx")
+
+ fmt.Printf("Current: ▲ RX %.1f MB/s | ▼ TX %.1f MB/s\n\n", rxVal, 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.
+* `q` — Quit.
From 6d0854ce74bc4def4febf544f1e452c2969da1da Mon Sep 17 00:00:00 2001
From: Igor Churmeev
Date: Thu, 1 Oct 2026 16:41:49 +0000
Subject: [PATCH 3/4] feat(pulse): add symmetric range and adaptive byte rate
formatters
---
README.md | 1 +
docs/axes-and-grid.md | 36 ++++++++++++++++++
docs/zero-crossing-fill.md | 75 +++++++++++++++++++++++++++++++-------
examples/rxtxdemo/main.go | 36 +++++++++++-------
options.go | 7 ++++
pulse.go | 24 ++++++++++++
renderer.go | 6 +++
renderer_lines.go | 6 +--
scale/scale.go | 63 ++++++++++++++++++++++++++++++++
scale/scale_test.go | 68 ++++++++++++++++++++++++++++++++++
zerocross_test.go | 48 ++++++++++++++++++++++++
11 files changed, 340 insertions(+), 30 deletions(-)
diff --git a/README.md b/README.md
index 0129004..552b74c 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
diff --git a/docs/axes-and-grid.md b/docs/axes-and-grid.md
index 477c1e5..9c4bd24 100644
--- a/docs/axes-and-grid.md
+++ b/docs/axes-and-grid.md
@@ -38,3 +38,39 @@ 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/zero-crossing-fill.md b/docs/zero-crossing-fill.md
index 0bb0410..55f0e0c 100644
--- a/docs/zero-crossing-fill.md
+++ b/docs/zero-crossing-fill.md
@@ -62,6 +62,7 @@ When `pulse.WithZeroBaseline(true)` is enabled:
| 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. |
@@ -73,6 +74,8 @@ When `pulse.WithZeroBaseline(true)` is enabled:
| :--- | :--- |
| `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. |
@@ -82,6 +85,52 @@ When `pulse.WithZeroBaseline(true)` is enabled:
---
+## ⚖️ 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|)$$
+
+```text
+Asymmetric Input: WithRange(-20, 80) -> Normalized to: [-80, 80]
+ +80│ ╭───╮
+ +40│ │░░░│ <- +80 max
+ 0├────────┴───┴───────┬───┬──────── <- Exactly at center row (h=9 -> row 4)
+ -40│ │░░░│ <- -80 min
+ -80│ ╰───╯
+```
+
+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:
@@ -104,7 +153,7 @@ Pulse supports smooth continuous box-drawing curves across both positive and neg
### 1. Network RX / TX Traffic (Grafana Pattern)
-In this pattern, both RX and TX data are received as positive bandwidth numbers (e.g. `52.4 MB/s`). By configuring `WithSeriesInverted("tx", true)`, TX is automatically mirrored below the baseline while retaining its positive magnitude for stats and headers:
+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
@@ -115,23 +164,22 @@ import (
"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(-100, 100),
+ pulse.WithRange(0, 100*MB),
+ pulse.WithSymmetric(true), // Automatically centers zero: [-100 MB, +100 MB]
pulse.WithZeroBaseline(true),
- pulse.WithTicks(-100, -50, 0, 50, 100),
- pulse.WithLabelFormatter(func(v float64) string {
- if v == 0 {
- return "0 MB/s"
- }
- return fmt.Sprintf("%.0f MB/s", math.Abs(v))
- }),
+ 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
@@ -140,10 +188,10 @@ func main() {
pulse.WithTintedFill(true),
)
- // Stream sample traffic points
+ // Stream sample traffic points in bytes/second
for i := 0; i < 60; i++ {
- rx := 45 + 35*math.Sin(float64(i)*0.15)
- tx := 30 + 25*math.Cos(float64(i)*0.12)
+ 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)
}
@@ -151,7 +199,7 @@ func main() {
rxVal, _ := chart.Last("rx")
txVal, _ := chart.Last("tx")
- fmt.Printf("Current: ▲ RX %.1f MB/s | ▼ TX %.1f MB/s\n\n", rxVal, txVal)
+ fmt.Printf("Current: ▲ RX %s | ▼ TX %s\n\n", scale.FormatBytesRate(rxVal), scale.FormatBytesRate(txVal))
fmt.Println(chart.View())
fmt.Println(chart.LegendBox())
}
@@ -237,4 +285,5 @@ go run ./examples/rxtxdemo
* `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
index 22bfe44..f743502 100644
--- a/examples/rxtxdemo/main.go
+++ b/examples/rxtxdemo/main.go
@@ -11,9 +11,12 @@ import (
"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 {
@@ -31,7 +34,7 @@ func rxSignal(step int) float64 {
if step%23 < 5 {
base += 25 + 15*rand.Float64()
}
- return math.Max(5, math.Min(95, base))
+ return math.Max(5, math.Min(95, base)) * MB
}
func txSignal(step int) float64 {
@@ -39,7 +42,7 @@ func txSignal(step int) float64 {
if (step+10)%27 < 4 {
base += 30 + 10*rand.Float64()
}
- return math.Max(5, math.Min(95, base))
+ return math.Max(5, math.Min(95, base)) * MB
}
func (m model) Init() tea.Cmd { return tick }
@@ -64,6 +67,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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())
}
@@ -114,18 +120,23 @@ func (m model) View() tea.View {
}
}
+ 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) | fill: %s (f/t) | zero: %v (z) | q: quit]",
- cornerStr, modeStr, fillStr, m.chart.ZeroBaseline())),
+ 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: %5.1f MB/s", rx)),
+ rxStyle.Render(fmt.Sprintf(" ▲ RX: %s", scale.FormatBytesRate(rx))),
" ",
- txStyle.Render(fmt.Sprintf(" ▼ TX: %5.1f MB/s", tx)),
+ txStyle.Render(fmt.Sprintf(" ▼ TX: %s", scale.FormatBytesRate(tx))),
" ",
m.chart.LegendBox(),
)
@@ -147,15 +158,11 @@ func main() {
chart := pulse.New(65, 15,
pulse.WithLineWidth(1),
- pulse.WithRange(-100, 100),
+ pulse.WithRange(-100*MB, 100*MB),
+ pulse.WithSymmetric(true),
pulse.WithZeroBaseline(true),
- pulse.WithTicks(-100, -50, 0, 50, 100),
- pulse.WithLabelFormatter(func(v float64) string {
- if v == 0 {
- return "0 MB/s"
- }
- return fmt.Sprintf("%.0f MB/s", math.Abs(v))
- }),
+ 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),
@@ -176,3 +183,4 @@ func main() {
os.Exit(1)
}
}
+
diff --git a/options.go b/options.go
index 1b94357..b87698b 100644
--- a/options.go
+++ b/options.go
@@ -102,6 +102,13 @@ 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) }
diff --git a/pulse.go b/pulse.go
index 4084990..c282a0a 100644
--- a/pulse.go
+++ b/pulse.go
@@ -2,6 +2,7 @@ package pulse
import (
"image/color"
+ "math"
"slices"
"charm.land/lipgloss/v2"
@@ -38,6 +39,7 @@ type Model struct {
events []eventRecord
zeroBaseline bool
+ symmetric bool
negativeStyle lipgloss.Style
hasNegStyle bool
inverted bool
@@ -78,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
}
@@ -140,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
}
@@ -335,6 +347,18 @@ 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
diff --git a/renderer.go b/renderer.go
index 52260cf..2368ccc 100644
--- a/renderer.go
+++ b/renderer.go
@@ -44,6 +44,7 @@ type Chart interface {
SeriesData(name string) []float64
VisibleEvents() []VisibleEvent
ZeroBaseline() bool
+ Symmetric() bool
NegativeStyle() lipgloss.Style
SeriesNegativeStyle(name string) lipgloss.Style
SeriesInverted(name string) bool
@@ -65,6 +66,11 @@ type RenderContext struct {
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.
// Custom renderers only need the Chart contract: build one with New and Push
// in tests, no mocks required.
diff --git a/renderer_lines.go b/renderer_lines.go
index 55368c0..47ac57d 100644
--- a/renderer_lines.go
+++ b/renderer_lines.go
@@ -273,7 +273,7 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string {
return renderEventPin(ve), true
}
if lineMask[r][col] != 0 {
- isNeg := (ctx.ZeroRow >= 0 && r > ctx.ZeroRow) || (ctx.ZeroRow < 0 && c.Max() < 0)
+ isNeg := ctx.isNegativeRow(r, c.Max())
st := styleFor(lineOwner[r][col], isNeg, ctx, fallback)
if tinted {
st = st.Background(tintColorFor(c, st))
@@ -284,7 +284,7 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string {
if hasEvent {
var bg color.Color
if fillMask[r][col] && tinted {
- isNeg := (ctx.ZeroRow >= 0 && r > ctx.ZeroRow) || (ctx.ZeroRow < 0 && c.Max() < 0)
+ isNeg := ctx.isNegativeRow(r, c.Max())
baseStyle := styleFor(fillOwner[r][col], isNeg, ctx, fallback)
bg = tintColorFor(c, baseStyle)
}
@@ -323,7 +323,7 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string {
}
if fillMask[r][col] {
owner := fillOwner[r][col]
- isNeg := (ctx.ZeroRow >= 0 && r > ctx.ZeroRow) || (ctx.ZeroRow < 0 && c.Max() < 0)
+ isNeg := ctx.isNegativeRow(r, c.Max())
st := faintStyleFor(owner, isNeg, ctx, fallback)
if tinted {
baseStyle := styleFor(owner, isNeg, ctx, fallback)
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..9d00563 100644
--- a/scale/scale_test.go
+++ b/scale/scale_test.go
@@ -36,3 +36,71 @@ 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/zerocross_test.go b/zerocross_test.go
index c64747b..6a73c6a 100644
--- a/zerocross_test.go
+++ b/zerocross_test.go
@@ -332,3 +332,51 @@ func TestSeriesInverted_RXTX_RoundedCorners(t *testing.T) {
}
}
+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())
+ }
+}
+
+
From ffe8a654dd9306c5a6f09a2f5a87827f6f310a4f Mon Sep 17 00:00:00 2001
From: Igor Churmeev
Date: Thu, 1 Oct 2026 17:07:13 +0000
Subject: [PATCH 4/4] docs(pulse): replace text charts with terminal
screenshots across documentation
---
README.md | 18 ++++
assets/events.png | Bin 0 -> 505753 bytes
assets/fill.png | Bin 0 -> 527816 bytes
assets/rxtx.png | Bin 0 -> 402665 bytes
assets/zerocross.png | Bin 0 -> 302764 bytes
cmd/gen-shots/main.go | 196 ++++++++++++++++++++++++++++++++++++-
docs/axes-and-grid.md | 8 ++
docs/getting-started.md | 4 +
docs/rendering-modes.md | 4 +
docs/timeline-events.md | 10 +-
docs/zero-crossing-fill.md | 47 ++++-----
examples/rxtxdemo/main.go | 1 -
options.go | 2 -
pulse.go | 2 -
scale/scale_test.go | 1 -
zerocross_test.go | 2 -
16 files changed, 250 insertions(+), 45 deletions(-)
create mode 100644 assets/events.png
create mode 100644 assets/fill.png
create mode 100644 assets/rxtx.png
create mode 100644 assets/zerocross.png
diff --git a/README.md b/README.md
index 552b74c..722d8d5 100644
--- a/README.md
+++ b/README.md
@@ -135,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:
diff --git a/assets/events.png b/assets/events.png
new file mode 100644
index 0000000000000000000000000000000000000000..8a4b10ba9a7e314da1bb6600151cdf834b279daa
GIT binary patch
literal 505753
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