317 lines
7.8 KiB
Go
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])
|
|
}
|