web/sparkline: two-axis trend chart with hover dots
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderSparkline returns an inline SVG <svg> element of the
|
// 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>`)
|
b.WriteString(`</svg>`)
|
||||||
return template.HTML(b.String())
|
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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadGolden(t *testing.T, name string) string {
|
func loadGolden(t *testing.T, name string) string {
|
||||||
@@ -39,3 +40,35 @@ func TestSparkline_ThreePoints(t *testing.T) {
|
|||||||
t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
|
t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChart_Empty(t *testing.T) {
|
||||||
|
got := strings.TrimRight(string(RenderChart(nil, nil, ChartOpts{Width: 600, Height: 220})), "\n")
|
||||||
|
want := loadGolden(t, "chart_empty.svg")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("empty chart mismatch:\nwant:\n%s\ngot:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChart_TwoSeries(t *testing.T) {
|
||||||
|
days := []time.Time{
|
||||||
|
time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
series := []Series{
|
||||||
|
{
|
||||||
|
Name: "size", Stroke: "#3b82f6", Axis: AxisLeft, Format: FormatBytes,
|
||||||
|
Points: []float64{1024, 2048, 4096, 8192},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapshots", Stroke: "#f59e0b", Axis: AxisRight, Format: FormatCount,
|
||||||
|
Points: []float64{1, 2, 3, 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := strings.TrimRight(string(RenderChart(series, days, ChartOpts{Width: 600, Height: 220})), "\n")
|
||||||
|
want := loadGolden(t, "chart_two_series.svg")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("two_series chart mismatch:\nwant:\n%s\ngot:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time"><line x1="56" y1="110" x2="544" y2="110" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/><text x="300" y="114" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">no data yet</text></svg>
|
||||||
|
After Width: | Height: | Size: 381 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time"><line x1="56" y1="16" x2="544" y2="16" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="60" x2="544" y2="60" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="104" x2="544" y2="104" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="148" x2="544" y2="148" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="192" x2="544" y2="192" stroke="currentColor" stroke-opacity="0.08"/><circle cx="56.00" cy="192.00" r="2.5" fill="#3b82f6"><title>2026-05-01 · size: 1.0 KiB</title></circle><circle cx="218.67" cy="166.86" r="2.5" fill="#3b82f6"><title>2026-05-02 · size: 2.0 KiB</title></circle><circle cx="381.33" cy="116.57" r="2.5" fill="#3b82f6"><title>2026-05-03 · size: 4.0 KiB</title></circle><circle cx="544.00" cy="16.00" r="2.5" fill="#3b82f6"><title>2026-05-04 · size: 8.0 KiB</title></circle><polyline fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="56.00,192.00 218.67,166.86 381.33,116.57 544.00,16.00"/><circle cx="56.00" cy="192.00" r="2.5" fill="#f59e0b"><title>2026-05-01 · snapshots: 1</title></circle><circle cx="218.67" cy="133.33" r="2.5" fill="#f59e0b"><title>2026-05-02 · snapshots: 2</title></circle><circle cx="381.33" cy="74.67" r="2.5" fill="#f59e0b"><title>2026-05-03 · snapshots: 3</title></circle><circle cx="544.00" cy="16.00" r="2.5" fill="#f59e0b"><title>2026-05-04 · snapshots: 4</title></circle><polyline fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="56.00,192.00 218.67,133.33 381.33,74.67 544.00,16.00"/><text x="50" y="19" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">8.0 KiB</text><text x="50" y="63" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">6.2 KiB</text><text x="50" y="107" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">4.5 KiB</text><text x="50" y="151" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">2.8 KiB</text><text x="50" y="195" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">1.0 KiB</text><text x="550" y="19" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">4</text><text x="550" y="63" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">3</text><text x="550" y="107" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">2</text><text x="550" y="151" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">2</text><text x="550" y="195" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">1</text><text x="56.00" y="208" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">May 1</text><text x="381.33" y="208" text-anchor="middle" font-size="10" fill="currentColor" fill-opacity="0.55">May 3</text><text x="544.00" y="208" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">May 4</text></svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
Reference in New Issue
Block a user