diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go index 6d6f0f8..8c5f433 100644 --- a/internal/web/sparkline/sparkline.go +++ b/internal/web/sparkline/sparkline.go @@ -9,6 +9,7 @@ import ( "html/template" "math" "strings" + "time" ) // RenderSparkline returns an inline SVG element of the @@ -99,3 +100,217 @@ func RenderSparkline(points []float64, width, height int) template.HTML { b.WriteString(``) return template.HTML(b.String()) } + +// Axis selects which y-axis a Series is normalised against. +type Axis int + +const ( + // AxisLeft maps the series to the left y-axis. + AxisLeft Axis = iota + // AxisRight maps the series to the right y-axis. + AxisRight +) + +// Format selects how a Series' values appear in hover tooltips. +type Format int + +const ( + // FormatBytes formats a value as a human-readable byte size. + FormatBytes Format = iota + // FormatCount formats a value as an integer count. + FormatCount +) + +// Series is one labelled trace on a chart. +type Series struct { + Name string + Points []float64 // NaN breaks the polyline + Stroke string // hex colour + Axis Axis + Format Format +} + +// ChartOpts controls rendering of the full trend chart. +type ChartOpts struct { + Width int + Height int + GridBands int // default 4 + EmptyLabel string // default "no data yet" +} + +// RenderChart returns an inline SVG with up to two y-axes, +// one polyline per series, hover-dot per data point, and X-axis +// labels at start / midpoint / end. With no series or empty +// series, renders a faint baseline + EmptyLabel centred. +func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTML { + if opts.Width <= 0 { + opts.Width = 600 + } + if opts.Height <= 0 { + opts.Height = 220 + } + if opts.GridBands <= 0 { + opts.GridBands = 4 + } + if opts.EmptyLabel == "" { + opts.EmptyLabel = "no data yet" + } + const padL, padR, padT, padB = 56, 56, 16, 28 + w, h := opts.Width, opts.Height + innerW := w - padL - padR + innerH := h - padT - padB + + var b strings.Builder + fmt.Fprintf(&b, + ``, + w, h) + + hasData := false + for _, s := range series { + for _, p := range s.Points { + if !math.IsNaN(p) { + hasData = true + break + } + } + if hasData { + break + } + } + if !hasData || len(days) == 0 { + fmt.Fprintf(&b, + ``, + padL, h/2, w-padR, h/2) + fmt.Fprintf(&b, + `%s`, + w/2, h/2+4, opts.EmptyLabel) + b.WriteString(``) + return template.HTML(b.String()) + } + + for i := 0; i <= opts.GridBands; i++ { + y := padT + innerH*i/opts.GridBands + fmt.Fprintf(&b, + ``, + padL, y, w-padR, y) + } + + type axBounds struct { + min, max float64 + has bool + } + // Use fixed-order array to avoid map iteration non-determinism. + var axArr [2]axBounds + for _, s := range series { + a := &axArr[s.Axis] + for _, p := range s.Points { + if math.IsNaN(p) { + continue + } + if !a.has { + a.min, a.max, a.has = p, p, true + continue + } + if p < a.min { + a.min = p + } + if p > a.max { + a.max = p + } + } + } + for i := range axArr { + if axArr[i].has && axArr[i].max == axArr[i].min { + axArr[i].max = axArr[i].min + 1 + } + } + + stepX := 0.0 + if len(days) > 1 { + stepX = float64(innerW) / float64(len(days)-1) + } + + for _, s := range series { + a := &axArr[s.Axis] + if !a.has { + continue + } + var seg strings.Builder + flush := func() { + if seg.Len() == 0 { + return + } + fmt.Fprintf(&b, + ``, + s.Stroke, strings.TrimSpace(seg.String())) + seg.Reset() + } + for i, v := range s.Points { + if math.IsNaN(v) { + flush() + continue + } + x := float64(padL) + stepX*float64(i) + y := float64(padT) + float64(innerH) - (v-a.min)/(a.max-a.min)*float64(innerH) + fmt.Fprintf(&seg, "%.2f,%.2f ", x, y) + d := days[i] + fmt.Fprintf(&b, + `%s · %s: %s`, + x, y, s.Stroke, d.Format("2006-01-02"), s.Name, formatValue(v, s.Format)) + } + flush() + } + + if axArr[AxisLeft].has { + writeAxisLabels(&b, padL-6, padT, innerH, axArr[AxisLeft].min, axArr[AxisLeft].max, FormatBytes, "end") + } + if axArr[AxisRight].has { + writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start") + } + + xLabels := []int{0, len(days) / 2, len(days) - 1} + anchors := []string{"start", "middle", "end"} + for i, idx := range xLabels { + x := float64(padL) + stepX*float64(idx) + fmt.Fprintf(&b, + `%s`, + x, h-padB+16, anchors[i], days[idx].Format("Jan 2")) + } + + b.WriteString(``) + return template.HTML(b.String()) +} + +func writeAxisLabels(b *strings.Builder, x, padT, innerH int, min, max float64, f Format, anchor string) { + const bands = 4 + for i := 0; i <= bands; i++ { + y := padT + innerH*i/bands + v := max - (max-min)*float64(i)/float64(bands) + fmt.Fprintf(b, + `%s`, + x, y+3, anchor, formatValue(v, f)) + } +} + +func formatValue(v float64, f Format) string { + switch f { + case FormatBytes: + return humanBytes(v) + default: + return fmt.Sprintf("%.0f", v) + } +} + +func humanBytes(v float64) string { + const k = 1024.0 + units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} + i := 0 + for v >= k && i < len(units)-1 { + v /= k + i++ + } + if v >= 100 { + return fmt.Sprintf("%.0f %s", v, units[i]) + } + return fmt.Sprintf("%.1f %s", v, units[i]) +} diff --git a/internal/web/sparkline/sparkline_test.go b/internal/web/sparkline/sparkline_test.go index f0af51b..9320d1f 100644 --- a/internal/web/sparkline/sparkline_test.go +++ b/internal/web/sparkline/sparkline_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func loadGolden(t *testing.T, name string) string { @@ -39,3 +40,35 @@ func TestSparkline_ThreePoints(t *testing.T) { t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got) } } + +func TestChart_Empty(t *testing.T) { + got := strings.TrimRight(string(RenderChart(nil, nil, ChartOpts{Width: 600, Height: 220})), "\n") + want := loadGolden(t, "chart_empty.svg") + if got != want { + t.Errorf("empty chart mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestChart_TwoSeries(t *testing.T) { + days := []time.Time{ + time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), + } + series := []Series{ + { + Name: "size", Stroke: "#3b82f6", Axis: AxisLeft, Format: FormatBytes, + Points: []float64{1024, 2048, 4096, 8192}, + }, + { + Name: "snapshots", Stroke: "#f59e0b", Axis: AxisRight, Format: FormatCount, + Points: []float64{1, 2, 3, 4}, + }, + } + got := strings.TrimRight(string(RenderChart(series, days, ChartOpts{Width: 600, Height: 220})), "\n") + want := loadGolden(t, "chart_two_series.svg") + if got != want { + t.Errorf("two_series chart mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} diff --git a/internal/web/sparkline/testdata/chart_empty.svg b/internal/web/sparkline/testdata/chart_empty.svg new file mode 100644 index 0000000..52f8d64 --- /dev/null +++ b/internal/web/sparkline/testdata/chart_empty.svg @@ -0,0 +1 @@ +no data yet diff --git a/internal/web/sparkline/testdata/chart_two_series.svg b/internal/web/sparkline/testdata/chart_two_series.svg new file mode 100644 index 0000000..f84855c --- /dev/null +++ b/internal/web/sparkline/testdata/chart_two_series.svg @@ -0,0 +1 @@ +2026-05-01 · size: 1.0 KiB2026-05-02 · size: 2.0 KiB2026-05-03 · size: 4.0 KiB2026-05-04 · size: 8.0 KiB2026-05-01 · snapshots: 12026-05-02 · snapshots: 22026-05-03 · snapshots: 32026-05-04 · snapshots: 48.0 KiB6.2 KiB4.5 KiB2.8 KiB1.0 KiB43221May 1May 3May 4