summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 08:34:00 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 08:34:00 +0200
commit89ba8287d490a0fef15a80a34ea4efe0c5ecf2e8 (patch)
treed164744464c7997a23be60200eebf710015d8fed
parent9c400937d9e32f3ce85c668d9ca52c351f8b5d13 (diff)
tui: add dashboard sparkline renderer
-rw-r--r--internal/tui/dashboard/sparkline.go74
-rw-r--r--internal/tui/dashboard/sparkline_test.go43
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)
+ }
+}