Files
llmgw/internal/web/charts2.go
T
Ilker ManapandClaude Opus 5 f8bb78103f
CI / test (push) Successful in 1m32s
Stop the ranked charts from spilling over the tables below them
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>
2026-09-17 19:06:53 +02:00

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
}