diff --git a/AGENTS.md b/AGENTS.md
index 28b8c9f..f3282ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/README.md b/README.md
index 8a56513..7fae5fe 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
diff --git a/assets/rightaxis.png b/assets/rightaxis.png
new file mode 100644
index 0000000..7aae537
Binary files /dev/null and b/assets/rightaxis.png differ
diff --git a/cmd/gen-shots/main.go b/cmd/gen-shots/main.go
index 4407def..18715b8 100644
--- a/cmd/gen-shots/main.go
+++ b/cmd/gen-shots/main.go
@@ -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 {
@@ -44,6 +44,8 @@ func main() {
renderThresholds()
case "logscale":
renderLogScale()
+ case "rightaxis":
+ renderRightAxis()
}
}
@@ -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)
+}
diff --git a/docs/axes-and-grid.md b/docs/axes-and-grid.md
index 45376d3..7896388 100644
--- a/docs/axes-and-grid.md
+++ b/docs/axes-and-grid.md
@@ -28,7 +28,7 @@ fmt.Println(chart.Min(), chart.Max()) // 0 800
-- `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.
@@ -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)
+```
+
+
+
+
+
+- `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:
diff --git a/docs/bubbletea.md b/docs/bubbletea.md
index cd36ecb..b9b69dd 100644
--- a/docs/bubbletea.md
+++ b/docs/bubbletea.md
@@ -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).
diff --git a/docs/development.md b/docs/development.md
index 55a9e39..3d0023a 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -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/.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/.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.
@@ -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` |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 4e08057..524e8c8 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -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:
@@ -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
diff --git a/docs/thresholds.md b/docs/thresholds.md
index 2edeee7..f4cf9d1 100644
--- a/docs/thresholds.md
+++ b/docs/thresholds.md
@@ -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
diff --git a/docs/zero-crossing-fill.md b/docs/zero-crossing-fill.md
index 0713e28..4c5f511 100644
--- a/docs/zero-crossing-fill.md
+++ b/docs/zero-crossing-fill.md
@@ -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
diff --git a/legend.go b/legend.go
index 40ddcc2..4b0a512 100644
--- a/legend.go
+++ b/legend.go
@@ -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, " ")
}
@@ -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()).
diff --git a/pulse.go b/pulse.go
index c8905c5..59b19b0 100644
--- a/pulse.go
+++ b/pulse.go
@@ -51,6 +51,9 @@ type Model struct {
hasNegStyle bool
inverted bool
+ rightAxis Axis
+ hasRightAxis bool
+
thresholds []Threshold
thresholdColors bool
}
@@ -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.
@@ -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.
diff --git a/renderer.go b/renderer.go
index 41230ca..5ed692c 100644
--- a/renderer.go
+++ b/renderer.go
@@ -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
@@ -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.
@@ -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 {
diff --git a/rightaxis.go b/rightaxis.go
new file mode 100644
index 0000000..e8376a5
--- /dev/null
+++ b/rightaxis.go
@@ -0,0 +1,174 @@
+package pulse
+
+import (
+ "slices"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+
+ "github.com/ingvarch/pulse/scale"
+)
+
+// Axis is a Y axis with its own range and labels, such as the right axis of WithRightAxis.
+type Axis struct {
+ // Min and Max are the range of the axis.
+ Min, Max float64
+ // Ticks are the labeled values. When empty, scale.NiceTicks picks them.
+ Ticks []float64
+ // Formatter formats a tick label. When nil, scale.FormatTick is used.
+ Formatter func(float64) string
+}
+
+// WithRightAxis adds a Y axis on the right with its own range and labels, as SetRightAxis does.
+func WithRightAxis(a Axis) Option {
+ return func(m *Model) { m.SetRightAxis(a) }
+}
+
+// WithSeriesRightAxis plots the named series against the right axis.
+func WithSeriesRightAxis(name string) Option {
+ return func(m *Model) { m.SetSeriesRightAxis(name, true) }
+}
+
+// SetRightAxis adds or replaces the right Y axis.
+// A call with a NaN or infinite bound is ignored. Min above Max is swapped, and equal bounds widen to Min..Min+1.
+// Ticks are copied, sorted and deduplicated.
+// Series on the right axis are drawn against its range; the horizontal grid follows the left ticks.
+func (m *Model) SetRightAxis(a Axis) {
+ if isGap(a.Min) || isGap(a.Max) {
+ return
+ }
+ if a.Min > a.Max {
+ a.Min, a.Max = a.Max, a.Min
+ }
+ if a.Min == a.Max {
+ a.Max = a.Min + 1
+ }
+ a.Ticks = sortedTicks(a.Ticks)
+ m.rightAxis, m.hasRightAxis = a, true
+}
+
+// RightAxis returns the right axis and whether one is set.
+func (m *Model) RightAxis() (Axis, bool) {
+ a := m.rightAxis
+ a.Ticks = slices.Clone(a.Ticks)
+ return a, m.hasRightAxis
+}
+
+// SetSeriesRightAxis plots the named series against the right axis, or back against the left one.
+// It has no effect until a right axis is set.
+func (m *Model) SetSeriesRightAxis(name string, on bool) {
+ m.getOrCreate(name).rightAxis = on
+}
+
+// SeriesRightAxis returns whether the named series is set to the right axis.
+func (m *Model) SeriesRightAxis(name string) bool {
+ s, ok := m.series[name]
+ return ok && s.rightAxis
+}
+
+// onRight reports whether the named series is drawn against the right axis.
+func (m *Model) onRight(name string) bool {
+ return m.hasRightAxis && m.SeriesRightAxis(name)
+}
+
+// legendName returns the series name for a legend, marked when the series is on the right axis.
+func (m *Model) legendName(name string) string {
+ if m.onRight(name) {
+ return name + " →"
+ }
+ return name
+}
+
+// rightRowLabels returns the right axis labels by row, and the width of the widest one.
+func (m *Model) rightRowLabels() (map[int]string, int) {
+ a := m.rightAxis
+ ticks := a.Ticks
+ if len(ticks) == 0 {
+ ticks = scale.NiceTicks(a.Min, a.Max, m.maxGridTicks())
+ }
+ format := a.Formatter
+ if format == nil {
+ format = scale.FormatTick
+ }
+ labels := rowLabels(ticks, a.Min, a.Max, func(v float64) int { return tickRowOf(m.h, a.Min, a.Max, v) }, format)
+ width := 0
+ for _, lbl := range labels {
+ width = max(width, lipgloss.Width(lbl))
+ }
+ return labels, width
+}
+
+// appendRightAxis adds the right axis line and the label column to every plot line of out.
+func (m *Model) appendRightAxis(out string, labels map[int]string, width, zeroRow int) string {
+ style := m.axisStyle.Width(width).Align(lipgloss.Left)
+ lines := strings.Split(out, "\n")
+ for r := range lines {
+ axis := "│"
+ if m.zeroBaseline && r == zeroRow {
+ axis = "┤"
+ }
+ lines[r] += m.axisStyle.Render(axis) + style.Render(labels[r])
+ }
+ return strings.Join(lines, "\n")
+}
+
+// withoutRight returns the series data with the right-axis series left out as nil.
+func withoutRight(seriesData [][]float64, onRight []bool) [][]float64 {
+ if !slices.Contains(onRight, true) {
+ return seriesData
+ }
+ out := slices.Clone(seriesData)
+ for i, right := range onRight {
+ if right {
+ out[i] = nil
+ }
+ }
+ return out
+}
+
+// rightChart is the Chart a renderer sees when series are on the right axis: their values are already in the left drawing space.
+type rightChart struct {
+ Chart
+ data map[string][]float64
+}
+
+// SeriesData returns the mapped values of a right-axis series.
+func (c rightChart) SeriesData(name string) []float64 {
+ if d, ok := c.data[name]; ok {
+ return d
+ }
+ return c.Chart.SeriesData(name)
+}
+
+// SeriesInverted is false for a right-axis series: its values are negated before the mapping.
+func (c rightChart) SeriesInverted(name string) bool {
+ if _, ok := c.data[name]; ok {
+ return false
+ }
+ return c.Chart.SeriesInverted(name)
+}
+
+// toRightPlot maps the right-axis series into the range chart sees and stores them in plot. Gaps stay gaps.
+// It returns the Chart the renderers get.
+func (m *Model) toRightPlot(names []string, onRight []bool, seriesData, plot [][]float64, chart Chart) Chart {
+ lo, hi := chart.Min(), chart.Max()
+ a := m.rightAxis
+ byName := map[string][]float64{}
+ for i, right := range onRight {
+ if !right || seriesData[i] == nil {
+ continue
+ }
+ d := make([]float64, len(seriesData[i]))
+ for j, v := range seriesData[i] {
+ d[j] = v
+ if !isGap(v) {
+ d[j] = lo + (v-a.Min)/(a.Max-a.Min)*(hi-lo)
+ }
+ }
+ plot[i], byName[names[i]] = d, d
+ }
+ if len(byName) == 0 {
+ return chart
+ }
+ return rightChart{Chart: chart, data: byName}
+}
diff --git a/rightaxis_test.go b/rightaxis_test.go
new file mode 100644
index 0000000..d10a9ef
--- /dev/null
+++ b/rightaxis_test.go
@@ -0,0 +1,399 @@
+package pulse_test
+
+import (
+ "fmt"
+ "math"
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/ingvarch/pulse"
+ "github.com/ingvarch/pulse/scale"
+)
+
+const raW, raH = 20, 11
+
+func raOpts(mode pulse.RenderMode, opts ...pulse.Option) []pulse.Option {
+ return append([]pulse.Option{
+ pulse.WithMode(mode),
+ pulse.WithRange(0, 100),
+ pulse.WithGrid(false),
+ pulse.WithFill(false),
+ pulse.WithLineWidth(1),
+ }, opts...)
+}
+
+func raUnits(v float64) string { return fmt.Sprintf("%.0fu", v) }
+
+// raPlotRows returns the plot cells of every View line, with the left label column and the right axis cut off.
+func raPlotRows(m *pulse.Model, labelWidth int) []string {
+ var rows []string
+ for _, line := range strings.Split(ansi.Strip(m.View()), "\n") {
+ rows = append(rows, string([]rune(line)[labelWidth+1:labelWidth+1+m.Width()]))
+ }
+ return rows[:m.Height()]
+}
+
+func pushConst(m *pulse.Model, name string, v float64) {
+ for range raW {
+ m.PushSeries(name, v)
+ }
+}
+
+func TestRightAxis_SeriesUsesRightRange(t *testing.T) {
+ forModes(t, func(t *testing.T, mode pulse.RenderMode) {
+ right := pulse.New(raW, raH, raOpts(mode,
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 250}),
+ pulse.WithSeriesRightAxis("s"))...)
+ onLeft := pulse.New(raW, raH, raOpts(mode)...)
+ same := pulse.New(raW, raH, raOpts(mode)...)
+ pushConst(right, "s", 50)
+ pushConst(onLeft, "s", 50)
+ pushConst(same, "s", 20)
+
+ if got, want := raPlotRows(right, 4), raPlotRows(same, 4); strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("right series 50 on 0..250 must draw like 20 on 0..100:\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+ if got, left := raPlotRows(right, 4), raPlotRows(onLeft, 4); strings.Join(got, "\n") == strings.Join(left, "\n") {
+ t.Fatal("the same value must land on different rows on the left and right axis")
+ }
+ })
+}
+
+func TestRightAxis_GapsStayGaps(t *testing.T) {
+ forModes(t, func(t *testing.T, mode pulse.RenderMode) {
+ m := pulse.New(raW, raH, raOpts(mode,
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 250}),
+ pulse.WithSeriesRightAxis("s"))...)
+ ref := pulse.New(raW, raH, raOpts(mode)...)
+ for i := range raW {
+ switch i % 4 {
+ case 1:
+ m.PushSeries("s", math.NaN())
+ ref.PushSeries("s", math.NaN())
+ case 2:
+ m.PushSeries("s", math.Inf(1))
+ ref.PushSeries("s", math.Inf(1))
+ case 3:
+ m.PushSeries("s", math.Inf(-1))
+ ref.PushSeries("s", math.Inf(-1))
+ default:
+ m.PushSeries("s", 125)
+ ref.PushSeries("s", 50)
+ }
+ }
+ got, want := strings.Join(raPlotRows(m, 4), "\n"), strings.Join(raPlotRows(ref, 4), "\n")
+ if strings.TrimSpace(want) == "" || got != want {
+ t.Fatalf("gaps must stay gaps and points must be drawn:\n%s\nwant\n%s", got, want)
+ }
+ })
+}
+
+func TestRightAxis_LinesHaveEqualWidth(t *testing.T) {
+ forModes(t, func(t *testing.T, mode pulse.RenderMode) {
+ for _, xaxis := range []string{"", "at", "every"} {
+ opts := raOpts(mode, pulse.WithGrid(true))
+ switch xaxis {
+ case "every":
+ opts = append(opts, pulse.WithXAxis(func(age int) string { return fmt.Sprint(age) }))
+ case "at":
+ opts = append(opts, pulse.WithXAxisAt(func(age int) string {
+ if age%5 == 0 {
+ return fmt.Sprint(age)
+ }
+ return ""
+ }))
+ }
+ plain := pulse.New(raW, raH, opts...)
+ m := pulse.New(raW, raH, append(opts, pulse.WithRightAxis(pulse.Axis{
+ Min: 0, Max: 5000, Ticks: []float64{0, 2500, 5000}, Formatter: raUnits,
+ }))...)
+ for _, c := range []*pulse.Model{plain, m} {
+ pushConst(c, "s", 50)
+ }
+ base := lipgloss.Width(strings.SplitN(plain.View(), "\n", 2)[0])
+ lines := strings.Split(m.View(), "\n")
+ if want := len(strings.Split(plain.View(), "\n")); len(lines) != want {
+ t.Fatalf("xaxis=%q: %d lines, want %d", xaxis, len(lines), want)
+ }
+ for i, line := range lines {
+ if got, want := lipgloss.Width(line), base+1+len("5000u"); got != want {
+ t.Fatalf("xaxis=%q: line %d width = %d, want %d: %q", xaxis, i, got, want, ansi.Strip(line))
+ }
+ }
+ }
+ })
+}
+
+func TestRightAxis_LabelsOnTickRowsGridOnLeftTicks(t *testing.T) {
+ forModes(t, func(t *testing.T, mode pulse.RenderMode) {
+ m := pulse.New(raW, raH, raOpts(mode,
+ pulse.WithGrid(true),
+ pulse.WithTicks(0, 25, 75, 100),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 5000, Ticks: []float64{0, 2500, 5000, 9000}, Formatter: raUnits}))...)
+ lines := strings.Split(ansi.Strip(m.View()), "\n")
+ wantLabel := map[int]string{0: "5000u", 5: "2500u", 10: "0u"}
+ for r, line := range lines {
+ right := strings.TrimSpace(string([]rune(line)[4+1+raW+1:]))
+ if right != wantLabel[r] {
+ t.Fatalf("row %d: right label = %q, want %q", r, right, wantLabel[r])
+ }
+ }
+ if strings.Contains(lines[5], "─") {
+ t.Fatalf("row 5 is a right tick only and must have no grid line: %q", lines[5])
+ }
+ for _, r := range []int{0, 3, 8, 10} {
+ if !strings.Contains(lines[r], "─") {
+ t.Fatalf("row %d is a left tick and must have a grid line: %q", r, lines[r])
+ }
+ }
+ })
+}
+
+func TestRightAxis_DefaultTicksAndFormatter(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithRange(0, 100), pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 6000}))
+ lines := strings.Split(ansi.Strip(m.View()), "\n")
+ if got, want := strings.TrimSpace(string([]rune(lines[0])[4+1+raW+1:])), scale.FormatTick(6000); got != want {
+ t.Fatalf("top right label = %q, want %q", got, want)
+ }
+}
+
+func TestRightAxis_AxisLineMirrorsZeroBaseline(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithRange(-100, 100), pulse.WithZeroBaseline(true),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 10}))
+ for r, line := range strings.Split(ansi.Strip(m.View()), "\n") {
+ want := '│'
+ if r == 5 {
+ want = '┤'
+ }
+ if got := []rune(line)[4+1+raW]; got != want {
+ t.Fatalf("row %d: right axis glyph = %q, want %q", r, got, want)
+ }
+ }
+}
+
+func TestRightAxis_XAxisRowClosesWithCorner(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithXAxis(func(age int) string { return fmt.Sprint(age) }),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 5000, Ticks: []float64{5000}, Formatter: raUnits}))
+ lines := strings.Split(ansi.Strip(m.View()), "\n")
+ axis := []rune(lines[raH])
+ if got := axis[4+1+raW]; got != '┘' {
+ t.Fatalf("X axis row corner = %q, want ┘: %q", got, lines[raH])
+ }
+ if rest := strings.TrimSpace(string(axis[4+1+raW+1:])); rest != "" {
+ t.Fatalf("X axis row must be blank after the corner, got %q", rest)
+ }
+}
+
+func TestRightAxis_HigherTickWinsOnSharedRow(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithRange(0, 100),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 5000, Ticks: []float64{100, 5000, 0}, Formatter: raUnits}))
+ lines := strings.Split(ansi.Strip(m.View()), "\n")
+ if got := strings.TrimSpace(string([]rune(lines[raH-1])[4+1+raW+1:])); got != "100u" {
+ t.Fatalf("bottom right label = %q, want the higher tick 100u", got)
+ }
+}
+
+func TestRightAxis_LegendMarksRightSeries(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 5000}))
+ m.PushSeries("cpu", 10)
+ m.PushSeries("rps", 20)
+ m.SetSeriesRightAxis("rps", true)
+ if got := ansi.Strip(m.Legend()); got != "■ cpu ■ rps →" {
+ t.Fatalf("Legend() = %q", got)
+ }
+ box := ansi.Strip(m.LegendBox())
+ if !strings.Contains(box, "rps → ") || strings.Contains(box, "cpu →") {
+ t.Fatalf("LegendBox() must mark rps only:\n%s", box)
+ }
+}
+
+func TestRightAxis_AutoRangeIgnoresRightSeries(t *testing.T) {
+ forModes(t, func(t *testing.T, mode pulse.RenderMode) {
+ m := pulse.New(raW, raH, pulse.WithMode(mode), pulse.WithAutoRange(true),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 5000}),
+ pulse.WithSeriesRightAxis("rps"))
+ m.PushSeries("cpu", 10)
+ m.PushSeries("rps", 4000)
+ m.View()
+ if m.Max() > 20 {
+ t.Fatalf("Max = %v, want a range that fits the left series only", m.Max())
+ }
+ })
+}
+
+func TestRightAxis_ThresholdColorsSkipRightSeries(t *testing.T) {
+ forModes(t, func(t *testing.T, mode pulse.RenderMode) {
+ build := func(right bool) *pulse.Model {
+ opts := raOpts(mode,
+ pulse.WithSeriesStyle("s", thBase),
+ pulse.WithThresholds(pulse.Threshold{Value: 10, Style: thCrit}),
+ pulse.WithThresholdColors(true),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 100}))
+ if right {
+ opts = append(opts, pulse.WithSeriesRightAxis("s"))
+ }
+ m := pulse.New(raW, raH, opts...)
+ pushConst(m, "s", 50)
+ return m
+ }
+ row := rowOfGlyph(t, build(true), 10)
+ if c := cellAt(build(false), row, 10); !styledAs(c, thCrit) {
+ t.Fatalf("control: left series cell = %q, want the threshold style", c)
+ }
+ if c := cellAt(build(true), row, 10); !styledAs(c, thBase) {
+ t.Fatalf("right series cell = %q, want the series style", c)
+ }
+ })
+}
+
+func TestRightAxis_InvertedSeriesIsNegatedBeforeMapping(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithRange(-100, 100), pulse.WithGrid(false), pulse.WithFill(false),
+ pulse.WithRightAxis(pulse.Axis{Min: -250, Max: 250}),
+ pulse.WithSeriesRightAxis("s"), pulse.WithSeriesInverted("s", true))
+ ref := pulse.New(raW, raH, pulse.WithRange(-100, 100), pulse.WithGrid(false), pulse.WithFill(false))
+ pushConst(m, "s", 125)
+ pushConst(ref, "s", -50)
+ if got, want := raPlotRows(m, 4), raPlotRows(ref, 4); strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("inverted right series 125 must draw like -50:\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+func TestRightAxis_LogScaleKeepsRightAxisLinear(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithRange(1, 100), pulse.WithLogScale(true), pulse.WithGrid(false), pulse.WithFill(false),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 250}), pulse.WithSeriesRightAxis("s"))
+ ref := pulse.New(raW, raH, pulse.WithRange(1, 100), pulse.WithLogScale(true), pulse.WithGrid(false), pulse.WithFill(false))
+ pushConst(m, "s", 125)
+ pushConst(ref, "s", 10) // the middle of a 1..100 log range
+ if got, want := raPlotRows(m, 4), raPlotRows(ref, 4); strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("right series at the middle of 0..250 must sit at the middle row:\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+func TestSetRightAxis_Validation(t *testing.T) {
+ m := pulse.New(raW, raH)
+ m.SetRightAxis(pulse.Axis{Min: math.NaN(), Max: 10})
+ if _, ok := m.RightAxis(); ok {
+ t.Fatal("a NaN bound must not create the axis")
+ }
+ m.SetRightAxis(pulse.Axis{Min: 0, Max: math.Inf(1)})
+ if _, ok := m.RightAxis(); ok {
+ t.Fatal("an infinite bound must not create the axis")
+ }
+ m.SetRightAxis(pulse.Axis{Min: 20, Max: 10})
+ if a, ok := m.RightAxis(); !ok || a.Min != 10 || a.Max != 20 {
+ t.Fatalf("RightAxis() = %+v, %v, want swapped 10..20", a, ok)
+ }
+ m.SetRightAxis(pulse.Axis{Min: math.NaN(), Max: 99})
+ if a, _ := m.RightAxis(); a.Min != 10 || a.Max != 20 {
+ t.Fatalf("a NaN bound must keep the range, got %+v", a)
+ }
+ m.SetRightAxis(pulse.Axis{Min: 5, Max: 5})
+ if a, _ := m.RightAxis(); a.Min != 5 || a.Max != 6 {
+ t.Fatalf("equal bounds must widen to 5..6, got %+v", a)
+ }
+}
+
+func TestSetSeriesRightAxis_Toggle(t *testing.T) {
+ m := pulse.New(raW, raH)
+ if m.SeriesRightAxis("s") {
+ t.Fatal("a new series is on the left axis")
+ }
+ m.SetSeriesRightAxis("s", true)
+ if !m.SeriesRightAxis("s") {
+ t.Fatal("SeriesRightAxis must report true")
+ }
+ m.SetSeriesRightAxis("s", false)
+ if m.SeriesRightAxis("s") {
+ t.Fatal("SeriesRightAxis must report false")
+ }
+}
+
+func TestRightAxis_SeriesWithoutAxisStaysOnLeft(t *testing.T) {
+ m := pulse.New(raW, raH, raOpts(pulse.ModeLines, pulse.WithSeriesRightAxis("s"))...)
+ ref := pulse.New(raW, raH, raOpts(pulse.ModeLines)...)
+ pushConst(m, "s", 50)
+ pushConst(ref, "s", 50)
+ if m.View() != ref.View() {
+ t.Fatal("without a right axis the flag must change nothing")
+ }
+ if strings.Contains(m.Legend(), "→") {
+ t.Fatal("without a right axis the legend must not mark the series")
+ }
+}
+
+func TestRightAxis_NegativeStyleSkipsRightSeries(t *testing.T) {
+ forModes(t, func(t *testing.T, mode pulse.RenderMode) {
+ build := func(right bool, v float64) *pulse.Model {
+ opts := []pulse.Option{
+ pulse.WithMode(mode), pulse.WithRange(-100, 100), pulse.WithZeroBaseline(true),
+ pulse.WithGrid(false), pulse.WithFill(false), pulse.WithLineWidth(1),
+ pulse.WithSeriesStyle("s", thBase), pulse.WithSeriesNegativeStyle("s", thNeg),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 100}),
+ }
+ if right {
+ opts = append(opts, pulse.WithSeriesRightAxis("s"))
+ }
+ m := pulse.New(raW, raH, opts...)
+ pushConst(m, "s", v)
+ return m
+ }
+ left := build(false, -80)
+ if c := cellAt(left, 9, 10); !styledAs(c, thNeg) {
+ t.Fatalf("control: left cell = %q, want the negative style", c)
+ }
+ right := build(true, 10) // maps to -80 on the left range
+ if c := cellAt(right, 9, 10); !styledAs(c, thBase) {
+ t.Fatalf("right cell = %q, want the series style", c)
+ }
+ })
+}
+
+type raRecorder struct {
+ data []float64
+ inverted bool
+ right []bool
+ min, max float64
+}
+
+func (r *raRecorder) Render(c pulse.Chart, ctx pulse.RenderContext) string {
+ r.data, r.inverted, r.right = c.SeriesData("r"), c.SeriesInverted("r"), ctx.RightAxis
+ r.min, r.max = c.Min(), c.Max()
+ return ""
+}
+
+func TestRightAxis_CustomRendererGetsMappedValues(t *testing.T) {
+ rec := &raRecorder{}
+ m := pulse.New(4, raH, pulse.WithRenderer(rec), pulse.WithRange(0, 100),
+ pulse.WithRightAxis(pulse.Axis{Min: 0, Max: 250}),
+ pulse.WithSeriesRightAxis("r"), pulse.WithSeriesInverted("r", true))
+ m.PushSeries("r", 125)
+ m.PushSeries("r", math.NaN())
+ _ = m.View()
+ if len(rec.data) != 2 || rec.data[0] != -50 {
+ t.Fatalf("Chart.SeriesData = %v, want the negated 125 mapped to -50", rec.data)
+ }
+ if !math.IsNaN(rec.data[1]) {
+ t.Fatalf("a gap must stay a gap, got %v", rec.data[1])
+ }
+ if rec.inverted {
+ t.Fatal("SeriesInverted must be false for a right-axis series")
+ }
+ if len(rec.right) != 2 || rec.right[0] || !rec.right[1] {
+ t.Fatalf("ctx.RightAxis = %v, want [false true]", rec.right)
+ }
+}
+
+func TestRightAxis_InvertedSeriesOnLogScale(t *testing.T) {
+ m := pulse.New(raW, raH, pulse.WithRange(1, 100), pulse.WithLogScale(true), pulse.WithGrid(false), pulse.WithFill(false),
+ pulse.WithRightAxis(pulse.Axis{Min: -250, Max: 250}),
+ pulse.WithSeriesRightAxis("s"), pulse.WithSeriesInverted("s", true))
+ ref := pulse.New(raW, raH, pulse.WithRange(1, 100), pulse.WithLogScale(true), pulse.WithGrid(false), pulse.WithFill(false),
+ pulse.WithRightAxis(pulse.Axis{Min: -250, Max: 250}), pulse.WithSeriesRightAxis("s"))
+ pushConst(m, "s", -125)
+ pushConst(ref, "s", 125)
+ if got, want := raPlotRows(m, 4), raPlotRows(ref, 4); strings.Join(got, "\n") != strings.Join(want, "\n") || strings.TrimSpace(strings.Join(want, "")) == "" {
+ t.Fatalf("inverted right series -125 must draw like 125:\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
diff --git a/view.go b/view.go
index 905703f..98b5a84 100644
--- a/view.go
+++ b/view.go
@@ -3,6 +3,7 @@ package pulse
import (
"math"
"slices"
+ "strings"
"charm.land/lipgloss/v2"
@@ -49,32 +50,52 @@ func (m *Model) formatValue(v float64) string {
return scale.FormatTick(v)
}
+// rowLabels labels the ticks inside lo..hi by their row. When two ticks share a row, the later one in ticks wins.
+func rowLabels(ticks []float64, lo, hi float64, rowOf func(float64) int, format func(float64) string) map[int]string {
+ labels := map[int]string{}
+ for i := len(ticks) - 1; i >= 0; i-- {
+ t := ticks[i]
+ if isGap(t) || t < lo || t > hi {
+ continue
+ }
+ row := rowOf(t)
+ if _, taken := labels[row]; !taken {
+ labels[row] = format(t)
+ }
+ }
+ return labels
+}
+
// 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 right axis line and its label column when WithRightAxis is set,
// then the X axis and label rows when WithXAxis is set.
func (m *Model) View() string {
names := make([]string, 0, len(m.order)+1)
names = append(names, "")
names = append(names, m.order...)
seriesData := make([][]float64, len(names))
+ onRight := make([]bool, len(names))
for idx, name := range names {
+ onRight[idx] = m.onRight(name)
if s, ok := m.series[name]; ok {
data := m.window(s)
- if !m.logScale && (s.inverted || (name == "" && m.inverted)) {
+ if (!m.logScale || onRight[idx]) && (s.inverted || (name == "" && m.inverted)) {
data = negated(data)
}
seriesData[idx] = data
}
}
+ leftData := withoutRight(seriesData, onRight)
if m.autoRange {
if m.logScale {
- m.min, m.max = fitLogRange(seriesData)
+ m.min, m.max = fitLogRange(leftData)
} else {
- m.min, m.max = m.fitRange(seriesData)
+ m.min, m.max = m.fitRange(leftData)
}
m.rangeMin, m.rangeMax = m.min, m.max
}
@@ -95,17 +116,7 @@ func (m *Model) View() string {
}
}
- rowLabel := map[int]string{}
- for i := len(ticks) - 1; i >= 0; i-- {
- t := ticks[i]
- if isGap(t) || t < lo || t > hi {
- continue
- }
- row := m.tickRow(t)
- if _, taken := rowLabel[row]; !taken {
- rowLabel[row] = m.formatValue(t)
- }
- }
+ rowLabel := rowLabels(ticks, lo, hi, m.tickRow, m.formatValue)
labelWidth := m.labelWidth
if labelWidth <= 0 {
@@ -140,9 +151,11 @@ func (m *Model) View() string {
}
var chart Chart = m
+ plot := leftData
if m.logScale {
- seriesData, chart = m.toLogPlot(names, seriesData)
+ plot, chart = m.toLogPlot(names, leftData)
}
+ chart = m.toRightPlot(names, onRight, seriesData, plot, chart)
styles := make([]lipgloss.Style, len(names))
negativeStyles := make([]lipgloss.Style, len(names))
@@ -164,7 +177,8 @@ func (m *Model) View() string {
Styles: styles,
NegativeStyles: negativeStyles,
HasNegStyles: hasNegStyles,
- SeriesData: seriesData,
+ SeriesData: plot,
+ RightAxis: onRight,
GridStyle: gridStyle,
ZeroRow: zeroRow,
}
@@ -176,11 +190,18 @@ func (m *Model) View() string {
r = LinesRenderer{}
}
out := r.Render(chart, ctx)
+ corner, pad := "", ""
+ if m.hasRightAxis {
+ labels, width := m.rightRowLabels()
+ out = m.appendRightAxis(out, labels, width, zeroRow)
+ corner = m.axisStyle.Render("┘") + strings.Repeat(" ", width)
+ pad = strings.Repeat(" ", 1+width)
+ }
if m.xAxisLabel == nil {
return out
}
axis, labels := m.xAxisRows(labelWidth, placed)
- return out + "\n" + axis + "\n" + labels
+ return out + "\n" + axis + corner + "\n" + labels + pad
}
// String implements fmt.Stringer, returning the rendered chart View().