// 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"
"time"
)
// RenderSparkline returns an inline SVG `)
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. Points
// beyond len(days) are ignored.
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 = 72, 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 i >= len(days) {
break
}
if math.IsNaN(v) {
flush()
continue
}
x := float64(padL) + stepX*float64(i)
if len(days) == 1 {
// Single-day: pin the lone dot to the chart centre so it
// sits under the centred date label.
x = float64(padL) + float64(innerW)/2
}
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")
// Rotated axis title in the left margin. Position inset from
// the viewBox edge by ≈ font-size so the rotated glyph extents
// don't clip against the SVG boundary.
cy := padT + innerH/2
fmt.Fprintf(&b,
`Size`,
cy, cy)
}
if axArr[AxisRight].has {
writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start")
cy := padT + innerH/2
fmt.Fprintf(&b,
`Snapshots`,
w-14, cy, w-14, cy)
}
// X-axis labels at start / mid / end. With 1-2 days the indices
// collapse onto each other — dedupe so we don't stack overlapping
// "Jan 2" labels at the same x coordinate.
type xLabel struct {
idx int
anchor string
}
var xLabels []xLabel
switch {
case len(days) == 1:
xLabels = []xLabel{{0, "middle"}}
case len(days) == 2:
xLabels = []xLabel{{0, "start"}, {1, "end"}}
default:
xLabels = []xLabel{{0, "start"}, {len(days) / 2, "middle"}, {len(days) - 1, "end"}}
}
for _, l := range xLabels {
x := float64(padL) + stepX*float64(l.idx)
// With a single point, anchor "middle" centres on padL — push to
// the chart's centre line so the lone label sits over the dot.
if len(days) == 1 {
x = float64(padL) + float64(innerW)/2
}
fmt.Fprintf(&b,
`%s`,
x, h-padB+16, l.anchor, days[l.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])
}