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