web/sparkline: two-axis trend chart with hover dots
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"html/template"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RenderSparkline returns an inline SVG <svg> element of the
|
||||
@@ -99,3 +100,217 @@ func RenderSparkline(points []float64, width, height int) template.HTML {
|
||||
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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user