diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-24 08:34:00 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-24 08:34:00 +0200 |
| commit | 89ba8287d490a0fef15a80a34ea4efe0c5ecf2e8 (patch) | |
| tree | d164744464c7997a23be60200eebf710015d8fed | |
| parent | 9c400937d9e32f3ce85c668d9ca52c351f8b5d13 (diff) | |
tui: add dashboard sparkline renderer
| -rw-r--r-- | internal/tui/dashboard/sparkline.go | 74 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline_test.go | 43 |
2 files changed, 117 insertions, 0 deletions
diff --git a/internal/tui/dashboard/sparkline.go b/internal/tui/dashboard/sparkline.go new file mode 100644 index 0000000..1531ca6 --- /dev/null +++ b/internal/tui/dashboard/sparkline.go @@ -0,0 +1,74 @@ +package dashboard + +import "math" + +var sparkChars = []rune("▁▂▃▄▅▆▇█") + +func renderSparkline(data []float64, width int) string { + if len(data) == 0 || width <= 0 { + return "" + } + + samples := sampleForWidth(data, width) + min, max := minMax(samples) + if min == max { + return repeatRune('▄', len(samples)) + } + + out := make([]rune, len(samples)) + scale := float64(len(sparkChars) - 1) + denom := max - min + for i, value := range samples { + idx := int(math.Round((value - min) / denom * scale)) + if idx < 0 { + idx = 0 + } + if idx >= len(sparkChars) { + idx = len(sparkChars) - 1 + } + out[i] = sparkChars[idx] + } + return string(out) +} + +func sampleForWidth(data []float64, width int) []float64 { + if width >= len(data) { + return append([]float64(nil), data...) + } + if width == 1 { + return []float64{data[len(data)-1]} + } + + last := len(data) - 1 + samples := make([]float64, width) + for i := 0; i < width; i++ { + idx := int(math.Round(float64(i) * float64(last) / float64(width-1))) + samples[i] = data[idx] + } + return samples +} + +func minMax(values []float64) (float64, float64) { + min := values[0] + max := values[0] + for _, v := range values[1:] { + if v < min { + min = v + } + if v > max { + max = v + } + } + return min, max +} + +func repeatRune(r rune, count int) string { + if count <= 0 { + return "" + } + out := make([]rune, count) + for i := range out { + out[i] = r + } + return string(out) +} diff --git a/internal/tui/dashboard/sparkline_test.go b/internal/tui/dashboard/sparkline_test.go new file mode 100644 index 0000000..d39d145 --- /dev/null +++ b/internal/tui/dashboard/sparkline_test.go @@ -0,0 +1,43 @@ +package dashboard + +import ( + "strings" + "testing" +) + +func TestRenderSparklineEmptyOrInvalidWidth(t *testing.T) { + if got := renderSparkline(nil, 5); got != "" { + t.Fatalf("expected empty sparkline for nil data, got %q", got) + } + if got := renderSparkline([]float64{1, 2, 3}, 0); got != "" { + t.Fatalf("expected empty sparkline for width 0, got %q", got) + } +} + +func TestRenderSparklineSingleValue(t *testing.T) { + got := renderSparkline([]float64{10}, 8) + if got != "▄" { + t.Fatalf("expected single mid-bar rune, got %q", got) + } +} + +func TestRenderSparklineAllEqualValues(t *testing.T) { + got := renderSparkline([]float64{5, 5, 5, 5}, 4) + if got != "▄▄▄▄" { + t.Fatalf("expected flat sparkline, got %q", got) + } +} + +func TestRenderSparklineRespectsWidthTruncation(t *testing.T) { + got := renderSparkline([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 4) + if len([]rune(got)) != 4 { + t.Fatalf("expected 4 runes, got %q", got) + } +} + +func TestRenderSparklineSpansLowToHigh(t *testing.T) { + got := renderSparkline([]float64{0, 10}, 2) + if !strings.Contains(got, "▁") || !strings.Contains(got, "█") { + t.Fatalf("expected low/high bars, got %q", got) + } +} |
