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() }