CI / test (push) Successful in 1m32s
The bars were wrapped in <div class="chart">, and .chart was already the fixed-height box the time-series charts are drawn in: 120px, with a border and a background. A chart that grows a row at a time does not fit in a cage, so the SVG overflowed it and landed on the table underneath. Renamed to .ranked, which is a wrapper rather than the mark itself, and capped at the design width so a wide window scales the box instead of inflating the 12px labels. The test reads the class back out of the markup and checks the stylesheet does not cap it, because the collision was invisible in both files on their own — each rule was reasonable, and only the shared name was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
4.4 KiB
Go
155 lines
4.4 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"strings"
|
|
)
|
|
|
|
// Row is one labelled value in a ranked chart.
|
|
type Row struct {
|
|
Label string
|
|
Value float64
|
|
// Note is shown beside the value: a second figure worth carrying, such as
|
|
// how many requests made up a token total.
|
|
Note string
|
|
}
|
|
|
|
// HBarChart ranks labelled values as horizontal bars.
|
|
//
|
|
// Horizontal because the labels are email addresses and model names. Rotated or
|
|
// truncated labels on a column chart are a way of saying the chart is more
|
|
// important than being able to read it, and here the label is the point: the
|
|
// question is which person or which model, not what shape the distribution is.
|
|
//
|
|
// One series, so no legend — the heading names it. The value is written at the
|
|
// end of each bar rather than on an axis, because a reader wants the number for
|
|
// the row they are looking at, not a scale to estimate it against.
|
|
func HBarChart(rows []Row, colour, unit string) template.HTML {
|
|
if colour == "" {
|
|
colour = "--viz-1"
|
|
}
|
|
if len(rows) == 0 {
|
|
return template.HTML(`<p class="empty">Nothing recorded yet.</p>`) //nolint:gosec // constant
|
|
}
|
|
|
|
const (
|
|
rowH = 26.0 // the band
|
|
barH = 14.0 // the mark, capped well under the band so the rest is air
|
|
labelW = 190.0
|
|
valueW = 96.0
|
|
width = 720.0
|
|
)
|
|
height := float64(len(rows))*rowH + 8
|
|
|
|
maxV := 0.0
|
|
for _, r := range rows {
|
|
if r.Value > maxV {
|
|
maxV = r.Value
|
|
}
|
|
}
|
|
if maxV <= 0 {
|
|
maxV = 1
|
|
}
|
|
plotW := width - labelW - valueW
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<div class="ranked"><svg viewBox="0 0 %.0f %.0f" role="img" `+
|
|
`preserveAspectRatio="xMinYMin meet" aria-label="ranked bars">`, width, height)
|
|
|
|
for i, r := range rows {
|
|
y := float64(i)*rowH + 4
|
|
w := r.Value / maxV * plotW
|
|
if w < 2 && r.Value > 0 {
|
|
// A value that rounds to nothing still happened, and a bar of zero
|
|
// width reads as no data rather than as a small number.
|
|
w = 2
|
|
}
|
|
|
|
fmt.Fprintf(&b, `<text x="0" y="%.1f" font-size="12" fill="var(%s)">%s</text>`,
|
|
y+barH-2, cssVar("--fg"), escapeText(trimLabel(r.Label, 30)))
|
|
|
|
// Square at the baseline, rounded at the data end: the shape says which
|
|
// end is the measurement and which is the origin.
|
|
fmt.Fprintf(&b,
|
|
`<path d="M%.1f %.1f h%.1f a4 4 0 0 1 4 4 v%.1f a4 4 0 0 1 -4 4 h-%.1f z" fill="var(%s)">`+
|
|
`<title>%s: %s%s</title></path>`,
|
|
labelW, y, maxFloat(w-4, 0), barH-8, maxFloat(w-4, 0),
|
|
cssVar(colour),
|
|
escapeText(r.Label), escapeText(compact(r.Value)), escapeText(unitSuffix(unit)))
|
|
|
|
value := compact(r.Value) + unitSuffix(unit)
|
|
if r.Note != "" {
|
|
value += " " + r.Note
|
|
}
|
|
fmt.Fprintf(&b, `<text x="%.1f" y="%.1f" font-size="12" fill="var(%s)">%s</text>`,
|
|
labelW+plotW+8, y+barH-2, cssVar("--muted"), escapeText(value))
|
|
}
|
|
b.WriteString(`</svg></div>`)
|
|
return template.HTML(b.String()) //nolint:gosec // every interpolation is escaped above
|
|
}
|
|
|
|
// Sparkline draws a value's recent shape beside its number.
|
|
//
|
|
// No axes, no labels, no gridlines. It answers "rising, falling or flat" and
|
|
// nothing else; a reader who wants a number reads the number it sits under.
|
|
func Sparkline(values []float64, colour string) template.HTML {
|
|
if colour == "" {
|
|
colour = "--viz-1"
|
|
}
|
|
if len(values) < 2 {
|
|
return ""
|
|
}
|
|
const w, h = 120.0, 24.0
|
|
|
|
maxV := 0.0
|
|
for _, v := range values {
|
|
if v > maxV {
|
|
maxV = v
|
|
}
|
|
}
|
|
if maxV <= 0 {
|
|
// Flat at zero is a real answer — no traffic — and drawing it along the
|
|
// bottom says so, where an empty box would read as a broken chart.
|
|
maxV = 1
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<svg viewBox="0 0 %.0f %.0f" width="%.0f" height="%.0f" `+
|
|
`role="img" aria-label="recent trend">`, w, h, w, h)
|
|
b.WriteString(`<polyline fill="none" stroke="var(` + cssVar(colour) + `)` +
|
|
`" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" points="`)
|
|
step := w / float64(len(values)-1)
|
|
for i, v := range values {
|
|
fmt.Fprintf(&b, "%.1f,%.1f ", float64(i)*step, h-(v/maxV*(h-4))-2)
|
|
}
|
|
b.WriteString(`"/></svg>`)
|
|
return template.HTML(b.String()) //nolint:gosec // numbers and a CSS var only
|
|
}
|
|
|
|
// trimLabel keeps a long label readable without letting it push the plot off
|
|
// the side. Long email addresses are the common case.
|
|
func trimLabel(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
if n <= 1 {
|
|
return s[:n]
|
|
}
|
|
return s[:n-1] + "…"
|
|
}
|
|
|
|
func unitSuffix(unit string) string {
|
|
if unit == "" {
|
|
return ""
|
|
}
|
|
return " " + unit
|
|
}
|
|
|
|
func maxFloat(a, b float64) float64 {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|