// 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 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, ``, w, h) if len(real) < 2 { fmt.Fprintf(&b, ``, pad, h/2, w-pad, h/2) fmt.Fprintf(&b, ``, w/2, h/2+4, h-6) b.WriteString(``) 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, ``, 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, `current %.0f, %s%.0f over window`, cur, sign, delta) 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. 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]) }