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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Pulse is a Go library that draws live line charts in the terminal for Lip Gloss
- [CONTRIBUTING.md](CONTRIBUTING.md): read before starting a change, a commit or a pull request. It has the branch names, the doc comment rule for exported names, the commit style with the `pulse` scope and `Closes #N`.
- [docs/getting-started.md](docs/getting-started.md): read before changing `New`, `Push`, series, gaps, styles, legends or the runtime setters in `pulse.go`, `options.go` and `legend.go`.
- [docs/rendering-modes.md](docs/rendering-modes.md): read before changing `renderer*.go`: line glyphs, line width, smoothing, braille and the fill options.
- [docs/axes-and-grid.md](docs/axes-and-grid.md): read before changing Y ticks, the grid, the time axis, the symmetric range, the log scale or label formatters in `view.go`, `xaxis.go`, `logscale.go` and `scale/`.
- [docs/axes-and-grid.md](docs/axes-and-grid.md): read before changing Y ticks, the grid, the time axis, the symmetric range, the log scale, the right axis or label formatters in `view.go`, `xaxis.go`, `logscale.go`, `rightaxis.go` and `scale/`.
- [docs/zero-crossing-fill.md](docs/zero-crossing-fill.md): read before changing the zero baseline, inverted series, negative styles or the byte-rate formatters.
- [docs/timeline-events.md](docs/timeline-events.md): read before changing `event.go`: event placement, guidelines, pruning and `EventsBox`.
- [docs/thresholds.md](docs/thresholds.md): read before changing `threshold.go`: threshold lines, labels and colors, and how `view.go` and `renderer*.go` use them.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Pulse is a Go library that draws live line charts in the terminal. It is for dev
- Area fill under the curves, stippled, tinted or solid, over a grid aligned to the Y ticks.
- A Y range that fits the visible data, for metrics without a fixed scale.
- Charts around zero: a zero baseline, mirrored series such as network RX and TX, a symmetric range and byte-rate labels.
- A second Y axis on the right, with its own range and labels, for two metrics on different scales.
- Logarithmic Y scale for values that span orders of magnitude.
- Thresholds: dashed limit lines with labels, and series colored where they cross a limit.
- Events such as deploys and alerts, pinned to points on the timeline with a marker and a vertical line.
Expand Down Expand Up @@ -55,7 +56,7 @@ go -C examples run ./livedemo

- [Getting Started](docs/getting-started.md): install, the sliding window, history and resizing, missing points, several series, styling and themes, legends, runtime settings
- [Rendering Modes](docs/rendering-modes.md): box-drawing curves, braille dots, area fill
- [Axes and Grid](docs/axes-and-grid.md): Y ticks, the grid, the time axis, automatic range, logarithmic scale, symmetric range, label formatters
- [Axes and Grid](docs/axes-and-grid.md): Y ticks, the grid, the time axis, automatic range, logarithmic scale, right axis, symmetric range, label formatters
- [Zero-Crossing and RX/TX](docs/zero-crossing-fill.md): zero baseline, mirrored series, negative styles, byte-rate labels
- [Thresholds](docs/thresholds.md): limit lines, labels, colors by threshold
- [Timeline Events](docs/timeline-events.md): deploys and alerts on the chart, the events card
Expand Down
Binary file added assets/rightaxis.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 49 additions & 1 deletion cmd/gen-shots/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
)

func main() {
mode := flag.String("mode", "hero", "hero | quickstart | styled | braille | rxtx | events | fill | zerocross | xaxis | xaxisat | autorange | thresholds | logscale")
mode := flag.String("mode", "hero", "hero | quickstart | styled | braille | rxtx | events | fill | zerocross | xaxis | xaxisat | autorange | thresholds | logscale | rightaxis")
flag.Parse()

switch *mode {
Expand Down Expand Up @@ -44,6 +44,8 @@ func main() {
renderThresholds()
case "logscale":
renderLogScale()
case "rightaxis":
renderRightAxis()
}
}

Expand Down Expand Up @@ -541,3 +543,49 @@ func renderLogScale() {

fmt.Println(box)
}

func renderRightAxis() {
preset := theme.TokyoNight()

lc := pulse.New(80, 13,
pulse.WithRange(0, 100),
pulse.WithTicks(0, 25, 50, 75, 100),
pulse.WithRightAxis(pulse.Axis{
Min: 0, Max: 5000,
Ticks: []float64{0, 1250, 2500, 3750, 5000},
Formatter: func(v float64) string { return fmt.Sprintf("%.0f/s", v) },
}),
pulse.WithLineWidth(1),
pulse.WithFill(false),
pulse.WithAxisStyle(preset.Axis),
pulse.WithSeriesStyle("cpu", lipgloss.NewStyle().Foreground(lipgloss.Color(theme.SeriesColor(1)))),
pulse.WithSeriesStyle("requests", lipgloss.NewStyle().Foreground(lipgloss.Color(theme.SeriesColor(4)))),
)

rng := rand.New(rand.NewSource(31))
for i := 0; i < 80; i++ {
rps := 2400 + 1200*math.Sin(float64(i)*0.12) + (rng.Float64()-0.5)*300
if i >= 50 && i < 62 {
rps += 1100
}
cpu := 14 + rps/5000*60 + (rng.Float64()-0.5)*6
lc.PushSeries("cpu", math.Max(0, math.Min(100, cpu)))
lc.PushSeries("requests", math.Max(0, math.Min(5000, rps)))
}
lc.SetSeriesRightAxis("requests", true)

header := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#c0caf5")).
Render("CPU (LEFT) AND REQUESTS PER SECOND (RIGHT)")

content := fmt.Sprintf("%s\n\n%s\n%s", header, lc.View(), lc.Legend())

box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#3b4261")).
Padding(1, 2).
Render(content)

fmt.Println(box)
}
39 changes: 37 additions & 2 deletions docs/axes-and-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ fmt.Println(chart.Min(), chart.Max()) // 0 800
<img src="../assets/autorange.png" alt="Throughput chart whose range follows a spike" width="100%" />
</p>

- `View` computes the range from the finite values in the visible window (the last `Width()` points) of all series, the default series included. A spike leaves the range when it scrolls out of the window.
- `View` computes the range from the finite values in the visible window (the last `Width()` points) of all series except those on the [right axis](#right-axis), the default series included. A spike leaves the range when it scrolls out of the window.
- Gaps (NaN, +Inf, -Inf) are ignored.
- 0 always stays inside the range. A chart of positive values starts at 0. (On a [log scale](#logarithmic-scale) the range is in whole decades instead.)
- The bounds are rounded out with `scale.NiceRange`, so the top and the bottom are tick values and both get a label.
Expand Down Expand Up @@ -67,10 +67,45 @@ chart.PushSeries("p99", 300)
- `WithTicks` works as on the linear scale: a tick at or below zero, or outside the range, gets no label. The label formatter gets the real value (10, 100, 1000), not the logarithm.
- [Thresholds](thresholds.md) use the same mapping. A threshold with `Value <= 0` draws no line and colors nothing.
- The fill goes down to the bottom row.
- The zero baseline, the symmetric range (a fixed range stays as set, for example `1..1000`), inverted series, negative styles and zero-crossing fill are ignored on a log scale. An inverted series is drawn at its real positive values.
- The zero baseline, the symmetric range (a fixed range stays as set, for example `1..1000`), inverted series, negative styles and zero-crossing fill are ignored on a log scale. An inverted series is drawn at its real positive values, except on the [right axis](#right-axis).
- Events, the time axis, gaps and legends work as on the linear scale.
- A custom renderer gets a `Chart` whose `Min()` and `Max()` are `0` and the number of decades, and whose series values are `log10(v / lo)`, where `lo` is the bottom of the range.

## Right Axis

`WithRightAxis` adds a second Y axis on the right, with its own range and labels. Use it for two metrics on different scales, such as CPU in percent and requests per second.

```go
chart := pulse.New(80, 13,
pulse.WithRange(0, 100),
pulse.WithRightAxis(pulse.Axis{
Min: 0, Max: 5000,
Formatter: func(v float64) string { return fmt.Sprintf("%.0f/s", v) },
}),
)
chart.PushSeries("cpu", 42)
chart.PushSeries("requests", 2100)
chart.SetSeriesRightAxis("requests", true)
```

<p align="center">
<img src="../assets/rightaxis.png" alt="CPU on the left axis and requests per second on the right axis" width="100%" />
</p>

- `Axis` has `Min`, `Max`, optional `Ticks` and an optional `Formatter`. Without ticks, `scale.NiceTicks` picks them. Without a formatter, `scale.FormatTick` formats the labels.
- `WithSeriesRightAxis(name)` or `SetSeriesRightAxis(name, on)` plots a series against the right range. `SeriesRightAxis(name)` and `RightAxis()` read the settings back. A series flagged for the right axis stays on the left until an axis is set.
- `SetRightAxis` ignores a call with a NaN or infinite bound. A `Min` above `Max` is swapped, and equal bounds widen to `Min..Min+1`. The right range is fixed and linear, also on a log scale.
- The plot stays `w` wide. Every line of `View` gets one cell for the axis line plus a label column as wide as the widest right label, so all lines have the same width. The height does not change. The time axis rows keep their alignment with the plot and are padded to the same width.
- A right label sits on the row of its own tick. A tick outside the right range gets no label, and when two ticks share a row the higher one wins, as on the left.
- The horizontal grid follows the left ticks only, so a right label can sit on a row without a grid line.
- `Legend` and `LegendBox` add ` →` after the name of a right-axis series.
- Values outside the right range are clamped to the top or bottom row. Gaps stay gaps. An inverted series is negated before it is mapped, also on a log scale.
- [Auto range](#automatic-range), the zero baseline and the symmetric range use the left axis only. [Thresholds](thresholds.md) and threshold colors do not color a right-axis series, and neither do negative styles: its cells below the zero row keep the series style.
- The fill of a right-axis series goes down to the same baseline row as the fill of a left series.
- A custom renderer gets right-axis values mapped into the left range in `ctx.SeriesData` and `Chart.SeriesData`. `SeriesInverted` is false for them, and `ctx.RightAxis` marks them.
- When the time axis is on, its axis row ends with `┘` under the right axis line.
- Both render modes, events, the time axis, gaps, history and `SetSize` work as without a right axis.

## Custom Ticks

For fixed ranges (e.g. CPU or RAM percent `0%..100%`), you can specify explicit quarter-step ticks:
Expand Down
2 changes: 1 addition & 1 deletion docs/bubbletea.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func (m model) View() tea.View {

## Resizing

Terminal size changes arrive as `tea.WindowSizeMsg`. Call `SetSize` with the plot size: the window width minus the label column and the axis, and the height minus the lines you draw around the chart. The data stays in the chart, so you do not create a new one.
Terminal size changes arrive as `tea.WindowSizeMsg`. Call `SetSize` with the plot size: the window width minus the label column and the axis (and the right axis line and its label column, with `WithRightAxis`), and the height minus the lines you draw around the chart. The data stays in the chart, so you do not create a new one.

Create the chart with `pulse.WithHistory(n)` to keep points that a narrow window hides. When the terminal gets wider, the chart shows them again. Without it, the new columns on the left stay empty until new points arrive. See [History and Resizing](getting-started.md#history-and-resizing).

Expand Down
3 changes: 2 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The PNG files in `assets/` come from `cmd/gen-shots`, which prints one chart per
go run ./cmd/gen-shots -mode xaxis | env PATH=/usr/bin:/bin "$(command -v freeze)" -o assets/xaxis.png
```

- The modes are `hero`, `quickstart`, `styled`, `braille`, `rxtx`, `events`, `fill`, `zerocross`, `xaxis`, `xaxisat`, `autorange`, `thresholds` and `logscale`. Each one writes `assets/<mode>.png`, except `xaxisat`, which writes `assets/xaxis-at.png`.
- The modes are `hero`, `quickstart`, `styled`, `braille`, `rxtx`, `events`, `fill`, `zerocross`, `xaxis`, `xaxisat`, `autorange`, `thresholds`, `logscale` and `rightaxis`. Each one writes `assets/<mode>.png`, except `xaxisat`, which writes `assets/xaxis-at.png`.
- The data is generated with fixed random seeds, so a mode draws the same chart on every run.
- `env PATH=/usr/bin:/bin` hides `rsvg-convert` from freeze. When librsvg is installed, freeze converts the image with `rsvg-convert`, which ignores freeze's embedded monospace font and draws the chart in a proportional font.
- `freeze --execute` hangs in a non-interactive shell; pipe the output in instead.
Expand Down Expand Up @@ -77,6 +77,7 @@ git push origin v0.8.0
| `renderer_lines.go` | `ModeLines`: box-drawing curves |
| `renderer_braille.go` | `ModeBraille`: braille dots with Catmull-Rom splines |
| `logscale.go` | `SetLogScale`, the log range fix-ups and the plot-space `Chart` for renderers |
| `rightaxis.go` | `Axis`, `WithRightAxis`, `SetSeriesRightAxis`, and the mapping of right-axis series into the plot |
| `threshold.go` | `Threshold`, `SetThresholds`, threshold lines and row styles |
| `event.go` | Timeline events, `EventsBox` |
| `legend.go` | `Legend`, `LegendBox` |
Expand Down
4 changes: 4 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ if val, ok := chart.Last("cpu"); ok {
}
```

A series on the [right axis](axes-and-grid.md#right-axis) gets ` →` after its name in both legends.

## Dynamic Settings

All styling and rendering options can be modified dynamically at runtime:
Expand All @@ -179,6 +181,8 @@ chart.SetAutoRange(true) // fit the Y range to the visible data
chart.SetLogScale(true) // map values to rows by log10, see axes-and-grid.md
chart.SetThresholds(t...) // limit lines and labels, see thresholds.md
chart.SetThresholdColors(true) // color series by the thresholds
chart.SetRightAxis(a) // second Y axis on the right, see axes-and-grid.md
chart.SetSeriesRightAxis(n, true) // plot a series against the right axis
chart.SetXAxis(label) // time labels under the plot (View adds 2 lines)
chart.SetXAxisAt(label) // time labels at the columns you choose
chart.SetFill(false) // toggle shaded area fill
Expand Down
1 change: 1 addition & 0 deletions docs/thresholds.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ chart := pulse.New(80, 13,
- A threshold above `Max()` colors nothing. A threshold below `Min()` colors every row.
- The color is chosen per terminal row, in both modes, so a point in the same row as the limit takes its color.
- An inverted series is colored where it is drawn.
- A series on the [right axis](axes-and-grid.md#right-axis) is not colored by thresholds.

## Range

Expand Down
4 changes: 3 additions & 1 deletion docs/zero-crossing-fill.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ This capability is essential for modern terminal dashboards monitoring:

---

The zero baseline, the symmetric range, inverted series and negative styles are ignored on a [log scale](axes-and-grid.md#logarithmic-scale).
The zero baseline, the symmetric range, inverted series and negative styles are ignored on a [log scale](axes-and-grid.md#logarithmic-scale). An inverted series on the [right axis](axes-and-grid.md#right-axis) is still negated.

A series on the [right axis](axes-and-grid.md#right-axis) gets no negative style. Its fill still goes to the zero baseline.

## The Problem with Bottom-Anchored Fill

Expand Down
4 changes: 2 additions & 2 deletions legend.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ func (m *Model) Legend() string {
parts := make([]string, 0, len(m.order))
for _, name := range m.order {
swatch := m.SeriesStyle(name).Render("■")
parts = append(parts, swatch+" "+name)
parts = append(parts, swatch+" "+m.legendName(name))
}
return strings.Join(parts, " ")
}
Expand All @@ -28,7 +28,7 @@ func (m *Model) LegendBox() string {
if v, ok := m.Last(name); ok {
value = m.formatValue(v)
}
lines = append(lines, swatch+" "+m.axisStyle.Render(name+" "+value))
lines = append(lines, swatch+" "+m.axisStyle.Render(m.legendName(name)+" "+value))
}
return lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
Expand Down
17 changes: 12 additions & 5 deletions pulse.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ type Model struct {
hasNegStyle bool
inverted bool

rightAxis Axis
hasRightAxis bool

thresholds []Threshold
thresholdColors bool
}
Expand All @@ -62,6 +65,7 @@ type series struct {
negativeStyle lipgloss.Style
hasNegStyle bool
inverted bool
rightAxis bool
}

// New creates a chart for a w x h cells plot area.
Expand Down Expand Up @@ -274,14 +278,17 @@ func (m *Model) LineWidth() int {
// SetTicks dynamically sets explicit tick values for the Y axis.
// Ticks are copied, sorted ascending, and deduplicated.
func (m *Model) SetTicks(ticks ...float64) {
m.customTicks = sortedTicks(ticks)
}

// sortedTicks returns a sorted, deduplicated copy of ticks, or nil when it is empty.
func sortedTicks(ticks []float64) []float64 {
if len(ticks) == 0 {
m.customTicks = nil
return
return nil
}
cp := make([]float64, len(ticks))
copy(cp, ticks)
cp := slices.Clone(ticks)
slices.Sort(cp)
m.customTicks = slices.Compact(cp)
return slices.Compact(cp)
}

// SetLabelFormatter dynamically updates the Y-axis label formatter.
Expand Down
12 changes: 10 additions & 2 deletions renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ var DefaultTintColor color.Color = lipgloss.Color("#1f2335")
//
// On a log scale, Render gets a Chart in plot space: Min 0, Max log10(hi/lo) for the drawn range lo..hi,
// series values log10(v/lo) with non-positive values as NaN, and ZeroBaseline, Symmetric and SeriesInverted false.
//
// A series on the right axis comes with its values mapped linearly into the range Min..Max this Chart reports
// (the plot space on a log scale), negated when inverted, with SeriesInverted false. RenderContext.RightAxis marks it.
type Chart interface {
Width() int
Height() int
Expand Down Expand Up @@ -68,6 +71,9 @@ type RenderContext struct {
SeriesData [][]float64
GridStyle lipgloss.Style
ZeroRow int
// RightAxis marks the series drawn against the right axis, in Names order.
// Their values are already mapped into the left range, and threshold and negative styles skip them.
RightAxis []bool
// ThresholdStyles maps a row to the threshold style of series cells in it. It is empty when threshold colors are off.
ThresholdStyles map[int]lipgloss.Style
// ThresholdLines maps a row to its threshold line: one rendered cell per plot column.
Expand Down Expand Up @@ -181,12 +187,14 @@ func renderGrid(c Chart, ctx RenderContext, cellAt func(r, c int) (string, bool)

// styleFor picks the style of a series cell in row r: the negative style of its series,
// else the threshold style of the row, else the series style.
// A right-axis series always gets its own style.
func styleFor(owner, r int, isNeg bool, ctx RenderContext, fallback lipgloss.Style) lipgloss.Style {
known := owner >= 0 && owner < len(ctx.Styles)
if known && isNeg && owner < len(ctx.HasNegStyles) && ctx.HasNegStyles[owner] {
right := owner >= 0 && owner < len(ctx.RightAxis) && ctx.RightAxis[owner]
if known && !right && isNeg && owner < len(ctx.HasNegStyles) && ctx.HasNegStyles[owner] {
return ctx.NegativeStyles[owner]
}
if st, ok := ctx.ThresholdStyles[r]; ok {
if st, ok := ctx.ThresholdStyles[r]; ok && !right {
return st
}
if known {
Expand Down
Loading
Loading