web/sparkline: inline-SVG sparkline renderer (empty / single / multi)

This commit is contained in:
2026-05-07 18:50:23 +01:00
parent bb2a88be24
commit db88c5a7d1
5 changed files with 145 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
// Package sparkline renders inline SVG sparklines and trend
// charts for the dashboard and host repo page. All output is
// pure server-rendered SVG with no JavaScript, no external
// stylesheet, and no client library.
package sparkline
import (
"fmt"
"html/template"
"math"
"strings"
)
// RenderSparkline returns an inline SVG <svg> element of the
// given size containing a single polyline normalised across the
// full y-range of points. NaN entries break the polyline. With
// fewer than two real points the SVG still renders but contains
// only a faint baseline + an em-dash placeholder.
func RenderSparkline(points []float64, width, height int) template.HTML {
const pad = 2
w := width
h := height
innerW := w - 2*pad
innerH := h - 2*pad
var real []float64
for _, p := range points {
if !math.IsNaN(p) {
real = append(real, p)
}
}
var b strings.Builder
fmt.Fprintf(&b,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-sparkline" role="img" aria-label="repo size trend">`,
w, h)
if len(real) < 2 {
fmt.Fprintf(&b,
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/>`,
pad, h/2, w-pad, h/2)
fmt.Fprintf(&b,
`<text x="%d" y="%d" text-anchor="middle" font-size="%d" fill="currentColor" fill-opacity="0.4">—</text>`,
w/2, h/2+4, h-6)
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
min, max := real[0], real[0]
for _, v := range real {
if v < min {
min = v
}
if v > max {
max = v
}
}
span := max - min
if span == 0 {
span = 1
}
stepX := 0.0
if len(points) > 1 {
stepX = float64(innerW) / float64(len(points)-1)
}
var seg strings.Builder
flush := func() {
if seg.Len() == 0 {
return
}
fmt.Fprintf(&b,
`<polyline fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
strings.TrimSpace(seg.String()))
seg.Reset()
}
for i, v := range points {
if math.IsNaN(v) {
flush()
continue
}
x := float64(pad) + stepX*float64(i)
y := float64(pad) + float64(innerH) - (v-min)/span*float64(innerH)
fmt.Fprintf(&seg, "%.2f,%.2f ", x, y)
}
flush()
cur := real[len(real)-1]
first := real[0]
delta := cur - first
sign := "+"
if delta < 0 {
sign = "-"
delta = -delta
}
fmt.Fprintf(&b, `<title>current %.0f, %s%.0f over window</title>`, cur, sign, delta)
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
+41
View File
@@ -0,0 +1,41 @@
package sparkline
import (
"os"
"path/filepath"
"strings"
"testing"
)
func loadGolden(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read golden %s: %v", name, err)
}
return strings.TrimRight(string(b), "\n")
}
func TestSparkline_Empty(t *testing.T) {
got := strings.TrimRight(string(RenderSparkline(nil, 80, 20)), "\n")
want := loadGolden(t, "empty.svg")
if got != want {
t.Errorf("empty sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
}
}
func TestSparkline_SinglePoint(t *testing.T) {
got := strings.TrimRight(string(RenderSparkline([]float64{100}, 80, 20)), "\n")
want := loadGolden(t, "single_point.svg")
if got != want {
t.Errorf("single_point sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
}
}
func TestSparkline_ThreePoints(t *testing.T) {
got := strings.TrimRight(string(RenderSparkline([]float64{10, 30, 20}, 80, 20)), "\n")
want := loadGolden(t, "three_points.svg")
if got != want {
t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20" class="repo-sparkline" role="img" aria-label="repo size trend"><line x1="2" y1="10" x2="78" y2="10" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/><text x="40" y="14" text-anchor="middle" font-size="14" fill="currentColor" fill-opacity="0.4"></text></svg>

After

Width:  |  Height:  |  Size: 340 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20" class="repo-sparkline" role="img" aria-label="repo size trend"><line x1="2" y1="10" x2="78" y2="10" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/><text x="40" y="14" text-anchor="middle" font-size="14" fill="currentColor" fill-opacity="0.4"></text></svg>

After

Width:  |  Height:  |  Size: 340 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20" class="repo-sparkline" role="img" aria-label="repo size trend"><polyline fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" points="2.00,18.00 40.00,2.00 78.00,10.00"/><title>current 20, +10 over window</title></svg>

After

Width:  |  Height:  |  Size: 327 B