web/sparkline: inline-SVG sparkline renderer (empty / single / multi)
This commit is contained in:
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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 |
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user