Files
Ilker ManapandClaude Opus 5.5 0f70bdc99d Place chart points by their time, so a window's samples fill the chart's width
Five minutes at five-second steps is sixty samples on a chart a hundred and
thirty dots wide, and resampling by count drew them on its right half.
Points are now put in the column of their time, held across the columns
between samples, a series that began late starting where it began.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 08:55:19 +02:00

289 lines
7.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
// Charts drawn in a terminal the way btop draws them: Braille characters,
// each cell two dots across and four down, so a chart forty cells wide holds
// eighty points and five rows twenty levels. Filled below the line, and
// coloured by height — green low, yellow, red near the top — so how hard
// something is working reads before any number does.
// Braille dot bits by column (0 left, 1 right) and row (0 top .. 3 bottom).
var brailleBit = [2][4]rune{{0x01, 0x02, 0x04, 0x40}, {0x08, 0x10, 0x20, 0x80}}
// gradient is the colour at a fraction of a chart's height, 0 bottom, 1 top.
func gradient(f float64) lipgloss.Color {
stops := [][3]float64{{80, 200, 120}, {230, 200, 60}, {230, 80, 70}}
f = math.Max(0, math.Min(1, f))
seg := f * float64(len(stops)-1)
i := int(seg)
if i >= len(stops)-1 {
i, seg = len(stops)-2, float64(len(stops)-1)
}
t := seg - float64(i)
c := [3]float64{}
for k := range c {
c[k] = stops[i][k] + (stops[i+1][k]-stops[i][k])*t
}
return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", int(c[0]), int(c[1]), int(c[2])))
}
// lineColors are the colours of the lines of a chart with several.
var lineColors = []lipgloss.Color{"#61afef", "#e5c07b", "#c678dd", "#56b6c2", "#e06c75", "#98c379", "#d19a66", "#abb2bf"}
// chartSeries is one line: its values, oldest first, and a name for the
// legend. NaN is no sample.
type chartSeries struct {
name string
values []float64
// at is when each value was sampled, and from and to the window drawn:
// with them points are placed by time, across the whole width. Without,
// the values are spread evenly over it.
at []time.Time
from, to time.Time
}
// fit lays a series out in n columns.
func (s chartSeries) fit(n int) []float64 {
if len(s.at) == len(s.values) && len(s.at) > 0 && s.to.After(s.from) {
return byTime(s.values, s.at, s.from, s.to, n)
}
return resample(s.values, n)
}
// byTime puts each sample in the column of its time; a column with none
// carries the last value on, unless the last is further back than a gap in
// the samples, which is drawn as one. Before the first sample is nothing.
func byTime(vals []float64, at []time.Time, from, to time.Time, n int) []float64 {
out := make([]float64, n)
width := to.Sub(from) / time.Duration(n)
// The spacing the samples were taken at; twice it is a gap.
gap := width
if len(at) > 1 {
gap = max(gap, 2*at[len(at)-1].Sub(at[0])/time.Duration(len(at)-1))
}
j := 0
last, lastAt := math.NaN(), time.Time{}
for c := range out {
end := from.Add(time.Duration(c+1) * width)
sum, k := 0.0, 0
for ; j < len(vals) && !at[j].After(end); j++ {
if !math.IsNaN(vals[j]) {
sum += vals[j]
k++
last, lastAt = vals[j], at[j]
}
}
switch {
case k > 0:
out[c] = sum / float64(k)
case !lastAt.IsZero() && end.Sub(lastAt) <= gap:
out[c] = last
default:
out[c] = math.NaN()
}
}
return out
}
// brailleChart draws series into width×height cells. top is the value at the
// top of the chart; 0 takes the largest value. One series is filled and
// coloured by height; several are lines, each its own colour.
func brailleChart(series []chartSeries, width, height int, top float64) []string {
width, height = max(width, 4), max(height, 1)
if top <= 0 {
for _, s := range series {
for _, v := range s.values {
if !math.IsNaN(v) && v > top {
top = v
}
}
}
}
if top <= 0 {
top = 1
}
cols, rows := width*2, height*4
dots := make([][]rune, height)
owner := make([][]int, height)
for r := range dots {
dots[r] = make([]rune, width)
owner[r] = make([]int, width)
for c := range owner[r] {
owner[r][c] = -1
}
}
set := func(x, y, who int) { // y from the bottom
if x < 0 || x >= cols || y < 0 || y >= rows {
return
}
r := height - 1 - y/4
dots[r][x/2] |= brailleBit[x%2][3-y%4]
owner[r][x/2] = who
}
area := len(series) == 1
for si, s := range series {
vals := s.fit(cols)
prev := -1
for x, v := range vals {
if math.IsNaN(v) {
prev = -1
continue
}
h := int(math.Round(v / top * float64(rows-1)))
h = max(0, min(rows-1, h))
if area {
for y := 0; y <= h; y++ {
set(x, y, si)
}
continue
}
// A line: this point, and the rise or fall from the last.
lo, hi := h, h
if prev >= 0 {
lo, hi = min(prev, h), max(prev, h)
}
for y := lo; y <= hi; y++ {
set(x, y, si)
}
prev = h
}
}
out := make([]string, height)
for r := range dots {
var b strings.Builder
for c, d := range dots[r] {
if owner[r][c] < 0 {
b.WriteString(" ")
continue
}
color := lineColors[owner[r][c]%len(lineColors)]
if area {
color = gradient(float64(height-1-r) / float64(max(1, height-1)))
}
b.WriteString(lipgloss.NewStyle().Foreground(color).Render(string(0x2800 + d)))
}
out[r] = b.String()
}
return out
}
// resample fits values into n points: the most recent n, each the average of
// what falls in it, the left padded with no sample when there are fewer.
func resample(vals []float64, n int) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = math.NaN()
}
if len(vals) == 0 {
return out
}
if len(vals) <= n {
// Fewer values than columns: each held across its share of them,
// so the series spans the chart rather than huddling at its right.
for i := range out {
out[i] = vals[i*len(vals)/n]
}
return out
}
per := float64(len(vals)) / float64(n)
for i := range out {
from, to := int(float64(i)*per), int(float64(i+1)*per)
sum, k := 0.0, 0
for _, v := range vals[from:max(to, from+1)] {
if !math.IsNaN(v) {
sum += v
k++
}
}
if k > 0 {
out[i] = sum / float64(k)
}
}
return out
}
var sparkRunes = []rune("▁▂▃▄▅▆▇█")
// sparkline is values in one row of block characters, coloured by height.
func sparkline(vals []float64, width int, top float64) string {
return sparkSeries(chartSeries{values: vals}, width, top)
}
// sparkSeries is sparkline for a series that knows its times.
func sparkSeries(s chartSeries, width int, top float64) string {
vals := s.fit(width)
if top <= 0 {
for _, v := range vals {
if !math.IsNaN(v) && v > top {
top = v
}
}
}
if top <= 0 {
top = 1
}
var b strings.Builder
for _, v := range vals {
if math.IsNaN(v) {
b.WriteString(" ")
continue
}
f := math.Max(0, math.Min(1, v/top))
r := sparkRunes[int(math.Round(f*float64(len(sparkRunes)-1)))]
b.WriteString(lipgloss.NewStyle().Foreground(gradient(f)).Render(string(r)))
}
return b.String()
}
// meter is a fraction as a bar of width cells, btop's ■, coloured by level.
func meter(f float64, width int) string {
f = math.Max(0, math.Min(1, f))
n := int(math.Round(f * float64(width)))
var b strings.Builder
for i := 0; i < width; i++ {
if i < n {
b.WriteString(lipgloss.NewStyle().Foreground(gradient(float64(i) / float64(max(1, width-1)))).Render("■"))
} else {
b.WriteString(sDim.Render("■"))
}
}
return b.String()
}
// chartBox is a chart with its title and current value above it, and the
// window's span below: what a panel of the monitor shows.
func chartBox(title, now string, series []chartSeries, width, height int, top float64, span string) string {
var b strings.Builder
head := mdBold.Render(title)
if now != "" {
head += " " + now
}
if len(series) > 1 {
var leg []string
for i, s := range series {
if i == 6 {
leg = append(leg, sDim.Render(fmt.Sprintf("+%d", len(series)-6)))
break
}
leg = append(leg, lipgloss.NewStyle().Foreground(lineColors[i%len(lineColors)]).Render("━ ")+truncate(s.name, 18))
}
head += " " + strings.Join(leg, " ")
}
b.WriteString(truncateWidth(head, width) + "\n")
for _, l := range brailleChart(series, width, height, top) {
b.WriteString(l + "\n")
}
left, right := "-"+span, "now"
gap := max(1, width-len(left)-len(right))
b.WriteString(sDim.Render(left + strings.Repeat("─", gap) + right))
return b.String()
}