Files
restic-manager/internal/web/sparkline/sparkline.go
T

317 lines
7.8 KiB
Go

// 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 <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())
}
// 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 <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.
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 = 56, 56, 16, 28
w, h := opts.Width, opts.Height
innerW := w - padL - padR
innerH := h - padT - padB
var b strings.Builder
fmt.Fprintf(&b,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time">`,
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,
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/>`,
padL, h/2, w-padR, h/2)
fmt.Fprintf(&b,
`<text x="%d" y="%d" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">%s</text>`,
w/2, h/2+4, opts.EmptyLabel)
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
for i := 0; i <= opts.GridBands; i++ {
y := padT + innerH*i/opts.GridBands
fmt.Fprintf(&b,
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.08"/>`,
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,
`<polyline fill="none" stroke="%s" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
s.Stroke, strings.TrimSpace(seg.String()))
seg.Reset()
}
for i, v := range s.Points {
if math.IsNaN(v) {
flush()
continue
}
x := float64(padL) + stepX*float64(i)
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,
`<circle cx="%.2f" cy="%.2f" r="2.5" fill="%s"><title>%s · %s: %s</title></circle>`,
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")
}
if axArr[AxisRight].has {
writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start")
}
xLabels := []int{0, len(days) / 2, len(days) - 1}
anchors := []string{"start", "middle", "end"}
for i, idx := range xLabels {
x := float64(padL) + stepX*float64(idx)
fmt.Fprintf(&b,
`<text x="%.2f" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
x, h-padB+16, anchors[i], days[idx].Format("Jan 2"))
}
b.WriteString(`</svg>`)
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,
`<text x="%d" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
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])
}