diff --git a/README.md b/README.md index 722d8d5..a2633b7 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ - **🏷️ 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. +- **βŒ‡ Gaps for Missing Points**: Push `math.NaN()` for a missing sample. `NaN` and `Β±Inf` draw as gaps, with no line, fill, or connecting stroke. +- **β””β”΄ Optional Time Axis**: `WithXAxis` adds an axis row and a label row under the plot, with labels from your own function of the point age. - **🫧 Charm Native**: Fully customizable with Lip Gloss styles and built for Bubble Tea event loops. ## πŸš€ Quick Start diff --git a/assets/braille.png b/assets/braille.png index 23de2b4..1302b5f 100644 Binary files a/assets/braille.png and b/assets/braille.png differ diff --git a/assets/events.png b/assets/events.png index 8a4b10b..9c6b77f 100644 Binary files a/assets/events.png and b/assets/events.png differ diff --git a/assets/fill.png b/assets/fill.png index c1c26fa..78212ed 100644 Binary files a/assets/fill.png and b/assets/fill.png differ diff --git a/assets/hero.png b/assets/hero.png index abba79e..0717665 100644 Binary files a/assets/hero.png and b/assets/hero.png differ diff --git a/assets/quickstart.png b/assets/quickstart.png index 94b7ab9..882dd58 100644 Binary files a/assets/quickstart.png and b/assets/quickstart.png differ diff --git a/assets/rxtx.png b/assets/rxtx.png index 43fd6eb..d0962a1 100644 Binary files a/assets/rxtx.png and b/assets/rxtx.png differ diff --git a/assets/styled.png b/assets/styled.png index 7022c70..ac6c8f9 100644 Binary files a/assets/styled.png and b/assets/styled.png differ diff --git a/assets/xaxis.png b/assets/xaxis.png new file mode 100644 index 0000000..dc251a7 Binary files /dev/null and b/assets/xaxis.png differ diff --git a/assets/zerocross.png b/assets/zerocross.png index 62a791c..6a6564b 100644 Binary files a/assets/zerocross.png and b/assets/zerocross.png differ diff --git a/cmd/gen-shots/main.go b/cmd/gen-shots/main.go index c353b98..22821b7 100644 --- a/cmd/gen-shots/main.go +++ b/cmd/gen-shots/main.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "math/rand" + "time" "charm.land/lipgloss/v2" "github.com/ingvarch/pulse" @@ -13,7 +14,7 @@ import ( ) func main() { - mode := flag.String("mode", "hero", "hero | quickstart | styled | braille | rxtx | events | fill | zerocross") + mode := flag.String("mode", "hero", "hero | quickstart | styled | braille | rxtx | events | fill | zerocross | xaxis") flag.Parse() switch *mode { @@ -33,6 +34,8 @@ func main() { renderFill() case "zerocross": renderZeroCross() + case "xaxis": + renderXAxis() } } @@ -340,3 +343,49 @@ func renderZeroCross() { fmt.Println(box) } + +func renderXAxis() { + preset := theme.TokyoNight() + chartW, chartH := 80, 13 + newest := time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC) + step := 7 * 24 * time.Hour / time.Duration(chartW) + + 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"))), + pulse.WithXAxis(func(age int) string { + return newest.Add(-time.Duration(age) * step).Format("Jan 2") + }), + ) + + rng := rand.New(rand.NewSource(7)) + for i := 0; i < chartW; i++ { + if i >= 52 && i < 58 { + lc.Push(math.NaN()) + continue + } + v := 38 + 24*math.Sin(float64(i)*0.2) + 8*math.Cos(float64(i)*0.07) + (rng.Float64()-0.5)*6 + lc.Push(math.Max(0, math.Min(100, v))) + } + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#c0caf5")). + Render("CPU, LAST 7 DAYS (GAP = NO DATA)") + + 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 29661cc..f26e624 100644 --- a/docs/axes-and-grid.md +++ b/docs/axes-and-grid.md @@ -43,6 +43,38 @@ chart := pulse.New(60, 12, Grafana-Style Coordinate Grid

+## Time Axis (`WithXAxis`) + +`pulse.WithXAxis(label)` draws an axis row and a label row under the plot. `label(age)` returns the text for the point `age` steps before the newest one, so age 0 is the newest point. The function runs on every `View()` and gets only the age. To keep the labels with the data, compute them from the time of the newest point and update that time on each push. + +```go +var newest time.Time // time of the newest point +step := 7 * 24 * time.Hour / 60 // 60 points cover 7 days + +chart := pulse.New(60, 10, + pulse.WithRange(0, 100), + pulse.WithXAxis(func(age int) string { + return newest.Add(-time.Duration(age) * step).Format("Jan 2") + }), +) + +// For each sample: +newest = sample.Time +chart.Push(sample.Value) +``` + +Labels sit at the left edge, the right edge, and at the quarter columns (`w / 4`, `2w / 4`, `3w / 4`), the same columns as the vertical grid. The left label starts at column 0, the right label ends at the last column, and the middle labels are centered on their column with a `β”΄` mark on the axis row. + +- An empty string means no label and no mark at that column. +- A label that does not fit inside the plot width is dropped. +- Labels keep at least one space between them. The right edge is placed first, then the left edge, then the quarters. A label that would touch one already placed is dropped, so narrow charts show only the edge labels. + +`Height()` stays the plot height. With the axis on, `View()` returns `Height()+2` lines: the plot rows, the axis row, and the label row. Leave room for the two extra lines when you lay the chart out. The axis rows do not depend on the grid, the render mode, or events. `chart.SetXAxis(nil)` turns the axis off. + +

+ Time axis under a 7-day CPU chart with a gap +

+ ## 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. diff --git a/docs/getting-started.md b/docs/getting-started.md index 6d4669e..9ec5392 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -55,6 +55,18 @@ totalPoints := chart.Len() // Default series cpuPoints := chart.LenSeries("cpu") // Named series ``` +## Missing Points + +Push `math.NaN()` when a sample is missing. `NaN`, `+Inf` and `-Inf` are drawn as gaps: the series draws nothing in that column, and its line stops before the gap and starts again after it. Grid lines and event markers still show in a gap. + +```go +chart.Push(42) +chart.Push(math.NaN()) // gap +chart.Push(45) +``` + +`Last(name)` skips trailing gaps and returns the last finite value. It returns `(0, false)` if the series has none, and `LegendBox()` then shows no value for it. `SeriesData(name)` returns the raw values, including `NaN` and `Inf`. + ## Multi-Series Plotting You can plot multiple concurrent time-series on the same coordinate space: @@ -99,6 +111,7 @@ if val, ok := chart.Last("cpu"); ok { All styling and rendering options can be modified dynamically at runtime: ```go chart.SetGrid(true) // toggle grid +chart.SetXAxis(label) // time labels under the plot (View adds 2 lines) chart.SetFill(false) // toggle shaded area fill chart.SetLineWidth(2) // switch between thin (1) and bold (2) chart.SetMode(pulse.ModeBraille)// switch between ModeLines and ModeBraille diff --git a/gap_test.go b/gap_test.go new file mode 100644 index 0000000..7960b6c --- /dev/null +++ b/gap_test.go @@ -0,0 +1,308 @@ +package pulse_test + +import ( + "fmt" + "math" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/ingvarch/pulse" +) + +const gapW, gapH = 40, 5 + +// plotRows returns the plot area of the chart as plain-text rows, without labels and axis. +func plotRows(c *pulse.Model) [][]rune { + lines := strings.Split(ansi.Strip(c.View()), "\n") + rows := make([][]rune, len(lines)) + for i, l := range lines { + r := []rune(l) + rows[i] = r[len(r)-c.Width():] + } + return rows +} + +func colRunes(rows [][]rune, col int) string { + var sb strings.Builder + for _, r := range rows { + sb.WriteRune(r[col]) + } + return sb.String() +} + +func isBlankCol(rows [][]rune, col int) bool { + return strings.TrimSpace(colRunes(rows, col)) == "" +} + +func assertBlankCols(t *testing.T, rows [][]rune, from, to int) { + t.Helper() + for col := from; col < to; col++ { + if !isBlankCol(rows, col) { + t.Fatalf("column %d must be blank, got %q", col, colRunes(rows, col)) + } + } +} + +func assertDrawnCols(t *testing.T, rows [][]rune, from, to int) { + t.Helper() + for col := from; col < to; col++ { + if isBlankCol(rows, col) { + t.Fatalf("column %d must have a line", col) + } + } +} + +func gapValue(i, from, to int) float64 { + if i >= from && i < to { + return math.NaN() + } + return 40 + 30*math.Sin(float64(i)*0.3) +} + +func newGapChart(mode pulse.RenderMode, fill bool, opts ...pulse.Option) *pulse.Model { + base := []pulse.Option{ + pulse.WithRange(0, 100), pulse.WithTicks(0, 50, 100), + pulse.WithGrid(false), pulse.WithFill(fill), pulse.WithLineWidth(1), + pulse.WithMode(mode), + } + return pulse.New(gapW, gapH, append(base, opts...)...) +} + +var gapModes = []struct { + name string + mode pulse.RenderMode +}{ + {"lines", pulse.ModeLines}, + {"braille", pulse.ModeBraille}, +} + +func TestGap_MiddleStartEnd(t *testing.T) { + cases := []struct { + name string + from, to int + }{ + {"middle", 15, 22}, + {"start", 0, 8}, + {"end", 30, 40}, + } + for _, m := range gapModes { + for _, fill := range []bool{false, true} { + for _, tc := range cases { + name := m.name + "/" + tc.name + if fill { + name += "/fill" + } + t.Run(name, func(t *testing.T) { + c := newGapChart(m.mode, fill) + for i := 0; i < gapW; i++ { + c.Push(gapValue(i, tc.from, tc.to)) + } + rows := plotRows(c) + assertBlankCols(t, rows, tc.from, tc.to) + assertDrawnCols(t, rows, 0, tc.from) + assertDrawnCols(t, rows, tc.to, gapW) + }) + } + } + } +} + +func TestGap_LonePoint(t *testing.T) { + t.Run("lines", func(t *testing.T) { + c := newGapChart(pulse.ModeLines, false) + for i := 0; i < 10; i++ { + switch i { + case 5: + c.Push(50) + default: + c.Push(math.NaN()) + } + } + rows := plotRows(c) + // 10 points occupy the last 10 columns, the lone point is at column gapW-5. + col := gapW - 10 + 5 + if got := strings.TrimSpace(colRunes(rows, col)); got != "─" { + t.Fatalf("lone point must be a single ─, got %q", got) + } + for i := 0; i < gapW; i++ { + if i != col { + assertBlankCols(t, rows, i, i+1) + } + } + }) + t.Run("braille", func(t *testing.T) { + c := newGapChart(pulse.ModeBraille, false) + for i := 0; i < 10; i++ { + switch i { + case 5: + c.Push(50) + default: + c.Push(math.NaN()) + } + } + rows := plotRows(c) + nonEmpty := 0 + for _, row := range rows { + for _, ch := range row { + if ch != ' ' { + nonEmpty++ + } + } + } + if nonEmpty != 1 { + t.Fatalf("lone point must be one braille cell, got %d", nonEmpty) + } + }) +} + +func TestGap_AllGapsDrawNothing(t *testing.T) { + for _, m := range gapModes { + for _, fill := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/fill=%v", m.name, fill), func(t *testing.T) { + c := newGapChart(m.mode, fill) + for i := 0; i < gapW; i++ { + c.Push(math.NaN()) + } + assertBlankCols(t, plotRows(c), 0, gapW) + }) + } + } +} + +func TestGap_InfIsGap(t *testing.T) { + for _, m := range gapModes { + for _, inf := range []float64{math.Inf(1), math.Inf(-1)} { + t.Run(fmt.Sprintf("%s/%v", m.name, inf), func(t *testing.T) { + c := newGapChart(m.mode, true) + for i := 0; i < gapW; i++ { + v := gapValue(i, 15, 22) + if math.IsNaN(v) { + v = inf + } + c.Push(v) + } + rows := plotRows(c) + assertBlankCols(t, rows, 15, 22) + assertDrawnCols(t, rows, 0, 15) + assertDrawnCols(t, rows, 22, gapW) + }) + } + } +} + +func TestGap_OtherSeriesStillDrawn(t *testing.T) { + for _, m := range gapModes { + t.Run(m.name, func(t *testing.T) { + c := newGapChart(m.mode, false) + for i := 0; i < gapW; i++ { + c.PushSeries("a", gapValue(i, 15, 22)) + c.PushSeries("b", 80) + } + rows := plotRows(c) + assertDrawnCols(t, rows, 15, 22) + solo := newGapChart(m.mode, false) + for i := 0; i < gapW; i++ { + solo.PushSeries("b", 80) + } + want := plotRows(solo) + for col := 15; col < 22; col++ { + if colRunes(rows, col) != colRunes(want, col) { + t.Fatalf("column %d: got %q, want only series b %q", col, colRunes(rows, col), colRunes(want, col)) + } + } + }) + } +} + +func TestGap_GridAndEventStillShow(t *testing.T) { + for _, m := range gapModes { + t.Run(m.name, func(t *testing.T) { + c := newGapChart(m.mode, false, pulse.WithGrid(true)) + for i := 0; i < gapW; i++ { + c.Push(gapValue(i, 15, 22)) + } + c.AddEventAt(gapW-1-18, pulse.Event{Glyph: "!"}) + empty := newGapChart(m.mode, false, pulse.WithGrid(true)) + empty.AddEventAt(gapW-1-18, pulse.Event{Glyph: "!"}) + rows, want := plotRows(c), plotRows(empty) + for col := 15; col < 22; col++ { + if colRunes(rows, col) != colRunes(want, col) { + t.Fatalf("column %d: got %q, want the empty chart's %q", col, colRunes(rows, col), colRunes(want, col)) + } + } + }) + } +} + +func TestGap_ZeroBaselineNegativeStyle(t *testing.T) { + neg := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + for _, m := range gapModes { + t.Run(m.name, func(t *testing.T) { + c := pulse.New(gapW, 9, pulse.WithRange(-50, 50), pulse.WithZeroBaseline(true), + pulse.WithNegativeStyle(neg), pulse.WithFill(true), pulse.WithGrid(false), + pulse.WithLineWidth(1), pulse.WithMode(m.mode)) + for i := 0; i < gapW; i++ { + v := 40 * math.Sin(float64(i)*0.3) + if i >= 15 && i < 22 { + v = math.NaN() + } + c.Push(v) + } + rows := plotRows(c) + for col := 15; col < 22; col++ { + for r, row := range rows { + want := ' ' + if r == 4 { + want = '─' + } + if row[col] != want { + t.Fatalf("row %d col %d: got %q, want %q", r, col, row[col], want) + } + } + } + assertDrawnCols(t, rows, 0, 15) + assertDrawnCols(t, rows, 22, gapW) + }) + } +} + +func TestGap_LastSkipsTrailingGaps(t *testing.T) { + c := pulse.New(10, 5, pulse.WithRange(0, 100)) + c.PushSeries("cpu", 12.5) + c.PushSeries("cpu", math.NaN()) + c.PushSeries("cpu", math.Inf(1)) + if v, ok := c.Last("cpu"); !ok || v != 12.5 { + t.Fatalf("Last() = %v, %v; want 12.5, true", v, ok) + } + if box := c.LegendBox(); !strings.Contains(box, "12.5") { + t.Fatalf("LegendBox() must show the last finite value, got:\n%s", box) + } +} + +func TestGap_LastOfAllGapsIsFalse(t *testing.T) { + c := pulse.New(10, 5, pulse.WithRange(0, 100)) + c.PushSeries("cpu", math.NaN()) + c.PushSeries("cpu", math.Inf(-1)) + if v, ok := c.Last("cpu"); ok || v != 0 { + t.Fatalf("Last() = %v, %v; want 0, false", v, ok) + } + box := ansi.Strip(c.LegendBox()) + for _, bad := range []string{"NaN", "Inf"} { + if strings.Contains(box, bad) { + t.Fatalf("LegendBox() must show no value, got:\n%s", box) + } + } +} + +func TestGap_SeriesDataKeepsRawValues(t *testing.T) { + c := pulse.New(10, 5) + c.Push(1) + c.Push(math.NaN()) + c.Push(math.Inf(1)) + got := c.SeriesData("") + if len(got) != 3 || !math.IsNaN(got[1]) || !math.IsInf(got[2], 1) { + t.Fatalf("SeriesData() = %v, want raw values with NaN and +Inf", got) + } +} diff --git a/go.mod b/go.mod index c89163a..c8e271f 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.26.0 require ( charm.land/bubbletea/v2 v2.0.10 charm.land/lipgloss/v2 v2.0.6 + github.com/charmbracelet/x/ansi v0.11.8 github.com/shirou/gopsutil/v4 v4.26.8 ) require ( github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/options.go b/options.go index 224f008..1ff378d 100644 --- a/options.go +++ b/options.go @@ -127,3 +127,10 @@ func WithSeriesInverted(name string, inverted bool) Option { func WithInverted(inverted bool) Option { return func(m *Model) { m.SetInverted(inverted) } } + +// WithXAxis draws an axis row and a label row under the plot. +// label returns the text for the point age steps before the newest (age 0 is the newest point). +// With the axis on, View returns Height()+2 lines. +func WithXAxis(label func(age int) string) Option { + return func(m *Model) { m.SetXAxis(label) } +} diff --git a/pulse.go b/pulse.go index 1d25885..aa7e20e 100644 --- a/pulse.go +++ b/pulse.go @@ -34,6 +34,7 @@ type Model struct { labelWidth int labelFormatter func(float64) string + xAxisLabel func(age int) string step int events []eventRecord @@ -109,11 +110,13 @@ func (m *Model) getOrCreate(name string) *series { } // Push adds a data point to the default series. Maintains a sliding window of width w. +// NaN, +Inf and -Inf are kept as given and drawn as gaps. func (m *Model) Push(v float64) { m.PushSeries("", v) } // PushSeries adds a data point to a named series. +// NaN, +Inf and -Inf are kept as given and drawn as gaps. func (m *Model) PushSeries(name string, v float64) { s := m.getOrCreate(name) s.data = append(s.data, v) @@ -270,10 +273,15 @@ func (m *Model) Renderer() Renderer { return m.renderer } +// SetXAxis sets the X axis label function; nil turns the axis off. +// See WithXAxis. +func (m *Model) SetXAxis(label func(age int) string) { m.xAxisLabel = label } + // Width returns the chart plot width in terminal cells. func (m *Model) Width() int { return m.w } // Height returns the chart plot height in terminal cells. +// With an X axis, View returns Height()+2 lines. func (m *Model) Height() int { return m.h } // Min returns the chart minimum Y value. @@ -314,6 +322,7 @@ func (m *Model) SeriesNames() []string { } // SeriesData returns a copy of data points for a series. +// Missing points are returned raw (NaN, +Inf, -Inf), so custom renderers see them. func (m *Model) SeriesData(name string) []float64 { s, ok := m.series[name] if !ok { @@ -332,13 +341,19 @@ func (m *Model) SeriesStyle(name string) lipgloss.Style { return m.lineStyle } -// Last returns the most recent value of a named series. +// Last returns the most recent finite value of a named series. +// Trailing gaps (NaN, +Inf, -Inf) are skipped. func (m *Model) Last(name string) (float64, bool) { s, ok := m.series[name] - if !ok || len(s.data) == 0 { + if !ok { return 0, false } - return s.data[len(s.data)-1], true + for i := len(s.data) - 1; i >= 0; i-- { + if !isGap(s.data[i]) { + return s.data[i], true + } + } + return 0, false } // SetZeroBaseline enables or disables explicit zero baseline rendering. diff --git a/renderer.go b/renderer.go index 2368ccc..67351e5 100644 --- a/renderer.go +++ b/renderer.go @@ -41,6 +41,7 @@ type Chart interface { LineStyle() lipgloss.Style SeriesStyle(name string) lipgloss.Style SeriesNames() []string + // SeriesData returns raw values, including NaN and Inf for missing points. SeriesData(name string) []float64 VisibleEvents() []VisibleEvent ZeroBaseline() bool @@ -85,6 +86,11 @@ func spanOf(min, max float64) float64 { return max - min } +// isGap reports whether v is a missing point (NaN or Β±Inf) that is drawn as a gap. +func isGap(v float64) bool { + return math.IsNaN(v) || math.IsInf(v, 0) +} + func normOf(min, max, v float64) float64 { norm := (v - min) / spanOf(min, max) if norm < 0 { diff --git a/renderer_braille.go b/renderer_braille.go index 337bfe1..4dfb8c3 100644 --- a/renderer_braille.go +++ b/renderer_braille.go @@ -166,12 +166,6 @@ func plotSeriesBraille(c Chart, grid [][]uint8, owner [][]int, isNegGrid [][]boo if den < 1 { den = 1 } - xs := make([]int, n) - ys := make([]int, n) - for j, v := range data { - xd := (window - n + j) * dw / den - xs[j], ys[j] = xd, dotYOf(h, min, max, v) - } base := dotYOf(h, min, max, 0) record := func(xd, yd int) { if xd >= 0 && xd < len(colMaxY) { @@ -183,10 +177,27 @@ func plotSeriesBraille(c Chart, grid [][]uint8, owner [][]int, isNegGrid [][]boo } } } - for _, d := range sampleLineOf(w, h, c.Smooth(), xs, ys) { - 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) + plotRun := func(xs, ys []int) { + for _, d := range sampleLineOf(w, h, c.Smooth(), xs, ys) { + 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) + } + } + var xs, ys []int + for j, v := range data { + if isGap(v) { + if len(xs) > 0 { + plotRun(xs, ys) + xs, ys = xs[:0], ys[:0] + } + continue + } + xs = append(xs, (window-n+j)*dw/den) + ys = append(ys, dotYOf(h, min, max, v)) + } + if len(xs) > 0 { + plotRun(xs, ys) } return colMaxY, colMinY } diff --git a/renderer_lines.go b/renderer_lines.go index 47ac57d..ce324bc 100644 --- a/renderer_lines.go +++ b/renderer_lines.go @@ -118,16 +118,31 @@ func plotSeriesLines( if startCol < 0 { startCol = 0 data = data[n-window:] - n = window } ys := make([]int, window) for j, v := range data { col := startCol + j - ys[col] = tickRowOf(h, min, max, v) + if !isGap(v) { + ys[col] = tickRowOf(h, min, max, v) + } } + finite := func(col int) bool { return col >= startCol && col < window && !isGap(data[col-startCol]) } - for col := startCol + 1; col < window; col++ { + for col := startCol; col < window; col++ { + if !finite(col) { + continue + } + if !finite(col - 1) { + // Segment start; a lone point is a short horizontal stroke. + mask := uint8(armLeft) + if !finite(col + 1) { + mask |= armRight + } + lineMask[ys[col]][col] |= mask + lineOwner[ys[col]][col] = idx + continue + } y0 := ys[col-1] y1 := ys[col] @@ -158,15 +173,6 @@ func plotSeriesLines( } } - // Terminate endpoints - if n == 1 { - lineMask[ys[startCol]][startCol] |= (armLeft | armRight) - lineOwner[ys[startCol]][startCol] = idx - } else if ys[startCol] >= 0 && ys[startCol] < h { - lineMask[ys[startCol]][startCol] |= armLeft - lineOwner[ys[startCol]][startCol] = idx - } - // Baseline for area fill (0 by default) baseRow := tickRowOf(h, min, max, 0) hasZeroBaseline := c.ZeroBaseline() && min <= 0 && max >= 0 @@ -174,6 +180,9 @@ func plotSeriesLines( // 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++ { + if !finite(col) { + continue + } bottomY := -1 topY := -1 for r := h - 1; r >= 0; r-- { diff --git a/renderer_test.go b/renderer_test.go index 8ea4ca0..4717699 100644 --- a/renderer_test.go +++ b/renderer_test.go @@ -1,10 +1,12 @@ package pulse_test import ( + "math" "strings" "testing" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/ingvarch/pulse" ) @@ -110,35 +112,41 @@ func TestLineChart_PlainRenderKeepsDimensions(t *testing.T) { } func TestLineChart_GridLinesExactAndEven(t *testing.T) { - lc := pulse.New(20, 8, pulse.WithRange(0, 100)) - rows := strings.Split(lc.View(), "\n") - plot := func(r int) string { - runes := []rune(rows[r]) - i := -1 - for j, ru := range runes { - if ru == 'β”‚' { - i = j - break + const w, h = 20, 7 + tickRows := map[int]bool{0: true, 3: true, 6: true} + for _, m := range gapModes { + t.Run(m.name, func(t *testing.T) { + lc := pulse.New(w, h, pulse.WithRange(0, 100), pulse.WithTicks(0, 50, 100), pulse.WithMode(m.mode)) + rows := strings.Split(ansi.Strip(lc.View()), "\n") + if len(rows) != h { + t.Fatalf("expected %d rows, got %d", h, len(rows)) } - } - if i < 0 { - t.Fatalf("row %d has no axis", r) - } - return string(runes[i+1:]) - } - // Solid ruling: every row in the plot has no spaces. - for r := range rows { - for _, ru := range plot(r) { - if ru == ' ' { - t.Fatalf("grid row %d must be solid, got %q", r, plot(r)) + for r, ln := range rows { + _, plot, ok := strings.Cut(ln, "β”‚") + if !ok { + t.Fatalf("row %d has no axis", r) + } + cells := []rune(plot) + if len(cells) != w { + t.Fatalf("row %d has %d plot cells, want %d", r, len(cells), w) + } + for c, got := range cells { + quarter := c == 5 || c == 10 || c == 15 + want := ' ' + switch { + case tickRows[r] && quarter: + want = 'β”Ό' + case tickRows[r]: + want = '─' + case quarter: + want = 'β”‚' + } + if got != want { + t.Fatalf("row %d col %d = %q, want %q (row %q)", r, c, got, want, plot) + } + } } - } - } - // Solid grid made of box characters. - for _, want := range []string{"─", "β”‚", "β”Ό"} { - if !strings.Contains(lc.View(), want) { - t.Fatalf("View() with grid must contain %q", want) - } + }) } plain := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithGrid(false), pulse.WithFill(false)) seedCPU(plain) @@ -272,3 +280,15 @@ func TestLineChart_LabelFormatterAndWidth(t *testing.T) { t.Fatalf("view must contain formatted labels: 4 GHz, 2 GHz, 0 GHz:\n%s", view) } } + +func TestLineChart_NonFiniteTickIsSkipped(t *testing.T) { + for _, m := range gapModes { + t.Run(m.name, func(t *testing.T) { + lc := pulse.New(24, 5, pulse.WithRange(0, 100), pulse.WithTicks(math.NaN(), 50), pulse.WithMode(m.mode)) + rows := strings.Split(ansi.Strip(lc.View()), "\n") + if strings.Contains(rows[0], "NaN") || strings.Contains(rows[0], "─") { + t.Fatalf("row 0 must have no label and no grid line, got %q", rows[0]) + } + }) + } +} diff --git a/view.go b/view.go index 070fd46..227ffb9 100644 --- a/view.go +++ b/view.go @@ -40,7 +40,13 @@ func (m *Model) formatValue(v float64) string { return scale.FormatTick(v) } -// View renders the chart: Y-axis labels + axis border + plot area. +// quarterCols returns the columns at 1/4, 2/4 and 3/4 of width w. +func quarterCols(w int) [3]int { + return [3]int{w / 4, 2 * w / 4, 3 * w / 4} +} + +// View renders the chart: Y-axis labels + axis border + plot area, +// then the X axis and label rows when WithXAxis is set. func (m *Model) View() string { ticks := m.gridTicks() if m.zeroBaseline && m.min <= 0 && m.max >= 0 { @@ -60,7 +66,7 @@ func (m *Model) View() string { rowLabel := map[int]string{} for i := len(ticks) - 1; i >= 0; i-- { t := ticks[i] - if t < m.min || t > m.max { + if isGap(t) || t < m.min || t > m.max { continue } row := m.tickRow(t) @@ -88,13 +94,11 @@ func (m *Model) View() string { hGrid := make([]bool, m.h) vGrid := make([]bool, m.w) if m.grid { - for r := range hGrid { + for r := range rowLabel { hGrid[r] = true } - for k := 1; k < 4; k++ { - if col := k * m.w / 4; col < m.w { - vGrid[col] = true - } + for _, col := range quarterCols(m.w) { + vGrid[col] = true } } @@ -143,7 +147,12 @@ func (m *Model) View() string { if r == nil { r = LinesRenderer{} } - return r.Render(m, ctx) + out := r.Render(m, ctx) + if m.xAxisLabel == nil { + return out + } + axis, labels := m.xAxisRows(labelWidth) + return out + "\n" + axis + "\n" + labels } // String implements fmt.Stringer, returning the rendered chart View(). diff --git a/xaxis.go b/xaxis.go new file mode 100644 index 0000000..07412b7 --- /dev/null +++ b/xaxis.go @@ -0,0 +1,86 @@ +package pulse + +import ( + "slices" + "strings" + + "charm.land/lipgloss/v2" +) + +type placedLabel struct { + col, start, width int + text string + middle bool +} + +func (l placedLabel) end() int { return l.start + l.width } + +// xAxisLabels places the labels for the candidate columns: right edge, left edge, then the quarters. +// A label that does not fit in the plot or touches a placed one is dropped. +func (m *Model) xAxisLabels() []placedLabel { + cols := []int{m.w - 1, 0} + for _, col := range quarterCols(m.w) { + if col > 0 && col < m.w-1 { + cols = append(cols, col) + } + } + + var placed []placedLabel + for i, col := range cols { + text := m.xAxisLabel(m.w - 1 - col) + width := lipgloss.Width(text) + if width == 0 { + continue + } + l := placedLabel{col: col, width: width, text: text, middle: i >= 2} + switch i { + case 0: + l.start = m.w - width + case 1: + l.start = 0 + default: + l.start = col - (width-1)/2 + } + if l.start < 0 || l.end() > m.w { + continue + } + if !slices.ContainsFunc(placed, func(p placedLabel) bool { + return l.start <= p.end() && p.start <= l.end() + }) { + placed = append(placed, l) + } + } + slices.SortFunc(placed, func(a, b placedLabel) int { return a.start - b.start }) + return placed +} + +// xAxisRows renders the axis row and the label row under the plot. +func (m *Model) xAxisRows(labelWidth int) (axis, labels string) { + placed := m.xAxisLabels() + ticks := make([]bool, m.w) + for _, l := range placed { + ticks[l.col] = l.middle + } + + var ax strings.Builder + ax.WriteString("β””") + for _, t := range ticks { + if t { + ax.WriteString("β”΄") + } else { + ax.WriteString("─") + } + } + + var lb strings.Builder + lb.WriteString(strings.Repeat(" ", labelWidth+1)) + pos := 0 + for _, l := range placed { + lb.WriteString(strings.Repeat(" ", l.start-pos)) + lb.WriteString(m.axisStyle.Render(l.text)) + pos = l.end() + } + lb.WriteString(strings.Repeat(" ", m.w-pos)) + + return strings.Repeat(" ", labelWidth) + m.axisStyle.Render(ax.String()), lb.String() +} diff --git a/xaxis_test.go b/xaxis_test.go new file mode 100644 index 0000000..b8c1c6e --- /dev/null +++ b/xaxis_test.go @@ -0,0 +1,276 @@ +package pulse_test + +import ( + "fmt" + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/ingvarch/pulse" +) + +// clockLabels labels each age with the time `age` minutes before newest. +func clockLabels(newest time.Time) func(int) string { + return func(age int) string { + return newest.Add(-time.Duration(age) * time.Minute).Format("15:04") + } +} + +func clock(h, m int) time.Time { return time.Date(2026, 10, 15, h, m, 0, 0, time.UTC) } + +func newXAxisChart(w int, opts ...pulse.Option) *pulse.Model { + return pulse.New(w, 5, append([]pulse.Option{pulse.WithRange(0, 100)}, opts...)...) +} + +func axisRows(c *pulse.Model) (axis, labels string) { + lines := strings.Split(ansi.Strip(c.View()), "\n") + n := len(lines) + return lines[n-2], lines[n-1] +} + +func assertAxisRows(t *testing.T, c *pulse.Model, wantAxis, wantLabels string) { + t.Helper() + axis, labels := axisRows(c) + if axis != wantAxis { + t.Fatalf("axis row:\n got %q\nwant %q", axis, wantAxis) + } + if labels != wantLabels { + t.Fatalf("label row:\n got %q\nwant %q", labels, wantLabels) + } +} + +func TestXAxis_Layouts(t *testing.T) { + tests := []struct { + name string + w int + newest time.Time + axis string + labels string + }{ + { + "w40", 40, clock(14, 39), + " └──────────┴─────────┴─────────┴─────────", + " 14:00 14:10 14:20 14:30 14:39", + }, + { + "w61", 61, clock(15, 0), + " └───────────────┴──────────────┴──────────────┴───────────────", + " 14:00 14:15 14:30 14:45 15:00", + }, + { + "w12 only edges fit", 12, clock(15, 0), + " └────────────", + " 14:49 15:00", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := newXAxisChart(tc.w, pulse.WithXAxis(clockLabels(tc.newest))) + assertAxisRows(t, c, tc.axis, tc.labels) + }) + } +} + +func TestXAxis_HeightAndPlotUnchanged(t *testing.T) { + label := clockLabels(clock(14, 39)) + with := newXAxisChart(40, pulse.WithXAxis(label)) + without := newXAxisChart(40) + for i := 0; i < 40; i++ { + with.Push(float64(i * 2)) + without.Push(float64(i * 2)) + } + if with.Height() != 5 { + t.Fatalf("Height() = %d, want 5", with.Height()) + } + got := strings.Split(with.View(), "\n") + want := strings.Split(without.View(), "\n") + if len(got) != with.Height()+2 { + t.Fatalf("View has %d lines, want %d", len(got), with.Height()+2) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("plot row %d differs:\n got %q\nwant %q", i, got[i], want[i]) + } + } +} + +func TestXAxis_EmptyLabels(t *testing.T) { + t.Run("only newest", func(t *testing.T) { + c := newXAxisChart(40, pulse.WithXAxis(func(age int) string { + if age == 0 { + return "now" + } + return "" + })) + assertAxisRows(t, c, + " β””"+strings.Repeat("─", 40), + strings.Repeat(" ", 42)+"now") + }) + t.Run("all empty", func(t *testing.T) { + c := newXAxisChart(40, pulse.WithXAxis(func(int) string { return "" })) + assertAxisRows(t, c, + " β””"+strings.Repeat("─", 40), + strings.Repeat(" ", 45)) + }) +} + +func TestXAxis_NarrowWidths(t *testing.T) { + tests := []struct { + name string + w int + labels string + }{ + {"w1 nothing fits", 1, strings.Repeat(" ", 6)}, + {"w4 nothing fits", 4, strings.Repeat(" ", 9)}, + {"w5 right edge only", 5, " 15:00"}, + {"w8 right edge wins over left", 8, " 15:00"}, + {"w11 both edges", 11, " 14:50 15:00"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := newXAxisChart(tc.w, pulse.WithXAxis(clockLabels(clock(15, 0)))) + assertAxisRows(t, c, " β””"+strings.Repeat("─", tc.w), tc.labels) + }) + } +} + +func TestXAxis_TooWideLabelDropped(t *testing.T) { + c := newXAxisChart(10, pulse.WithXAxis(func(age int) string { + if age == 9 { + return "far too long label" + } + return "" + })) + assertAxisRows(t, c, + " β””"+strings.Repeat("─", 10), + strings.Repeat(" ", 15)) +} + +func TestXAxis_Spacing(t *testing.T) { + tests := []struct { + name string + labels map[int]string + axis string + row string + }{ + { + "one space between labels is kept", + map[int]string{39: "1234567", 29: "abcde"}, + " β””" + strings.Repeat("─", 10) + "β”΄" + strings.Repeat("─", 29), + " 1234567 abcde" + strings.Repeat(" ", 27), + }, + { + "touching label is dropped", + map[int]string{39: "12345678", 29: "abcde"}, + " β””" + strings.Repeat("─", 40), + " 12345678" + strings.Repeat(" ", 32), + }, + { + "even width centers left of the column", + map[int]string{19: "ab"}, + " β””" + strings.Repeat("─", 20) + "β”΄" + strings.Repeat("─", 19), + strings.Repeat(" ", 25) + "ab" + strings.Repeat(" ", 18), + }, + { + "w/4 is placed before 2w/4", + map[int]string{29: "abcdefghijk", 19: "abcdefghijk"}, + " β””" + strings.Repeat("─", 10) + "β”΄" + strings.Repeat("─", 29), + strings.Repeat(" ", 10) + "abcdefghijk" + strings.Repeat(" ", 24), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := newXAxisChart(40, pulse.WithXAxis(func(age int) string { return tc.labels[age] })) + assertAxisRows(t, c, tc.axis, tc.row) + }) + } +} + +func TestXAxis_AxisStyle(t *testing.T) { + st := lipgloss.NewStyle().Foreground(lipgloss.Color("#7aa2f7")) + c := newXAxisChart(12, pulse.WithAxisStyle(st), pulse.WithXAxis(clockLabels(clock(15, 0)))) + lines := strings.Split(c.View(), "\n") + n := len(lines) + if want := " " + st.Render("└────────────"); lines[n-2] != want { + t.Fatalf("axis row:\n got %q\nwant %q", lines[n-2], want) + } + if want := " " + st.Render("14:49") + " " + st.Render("15:00"); lines[n-1] != want { + t.Fatalf("label row:\n got %q\nwant %q", lines[n-1], want) + } +} + +func TestXAxis_IndependentOfGridModeEvents(t *testing.T) { + label := clockLabels(clock(14, 39)) + wantAxis := " └──────────┴─────────┴─────────┴─────────" + wantLabels := " 14:00 14:10 14:20 14:30 14:39" + for _, mode := range gapModes { + for _, grid := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/grid=%v", mode.name, grid), func(t *testing.T) { + c := newXAxisChart(40, pulse.WithXAxis(label), + pulse.WithMode(mode.mode), pulse.WithGrid(grid), + pulse.WithEvent(10, pulse.Event{Label: "deploy", Glyph: "*"})) + for i := 0; i < 40; i++ { + c.Push(float64(i)) + } + assertAxisRows(t, c, wantAxis, wantLabels) + }) + } + } +} + +func TestXAxis_SetXAxisNilTurnsOff(t *testing.T) { + c := newXAxisChart(40) + c.SetXAxis(clockLabels(clock(14, 39))) + if n := len(strings.Split(c.View(), "\n")); n != 7 { + t.Fatalf("axis on: %d lines, want 7", n) + } + c.SetXAxis(nil) + if got, want := c.View(), newXAxisChart(40).View(); got != want { + t.Fatalf("axis off differs from a chart that never had it:\n%s\n%s", got, want) + } +} + +func TestXAxis_LabelsFollowPushes(t *testing.T) { + newest := clock(14, 39) + c := newXAxisChart(40, pulse.WithXAxis(func(age int) string { + return clockLabels(newest)(age) + })) + _, first := axisRows(c) + newest = newest.Add(time.Minute) + c.Push(1) + _, second := axisRows(c) + if first == second { + t.Fatalf("label row did not change after a push: %q", first) + } + if !strings.HasSuffix(second, "14:40") { + t.Fatalf("label row %q must end with the new time", second) + } +} + +func TestXAxis_EqualLineWidths(t *testing.T) { + labels := map[string]func(int) string{ + "ascii": clockLabels(clock(14, 39)), + "cjk": func(age int) string { return fmt.Sprintf("%dεˆ†ε‰", age) }, + } + for name, label := range labels { + t.Run(name, func(t *testing.T) { + c := newXAxisChart(40, pulse.WithXAxis(label), + pulse.WithAxisStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#444b6a")))) + for _, line := range strings.Split(c.View(), "\n") { + if w := lipgloss.Width(line); w != 4+1+40 { + t.Fatalf("line %q has width %d, want %d", ansi.Strip(line), w, 45) + } + } + }) + } +} + +func TestXAxis_CustomRendererGetsAxisRows(t *testing.T) { + c := newXAxisChart(12, pulse.WithRenderer(staticRenderer{}), pulse.WithXAxis(clockLabels(clock(15, 0)))) + want := "STATIC_CHART\n └────────────\n 14:49 15:00" + if got := ansi.Strip(c.View()); got != want { + t.Fatalf("got %q, want %q", got, want) + } +}