Files
llmgw/internal/web/charts2.go
T
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

148 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 {
return english.HBarChart(rows, colour, unit)
}
// HBarChart is the ranked chart with its figures, and what it says when it
// has none, in this language.
func (l *Lang) HBarChart(rows []Row, colour, unit string) template.HTML {
if colour == "" {
colour = "--viz-1"
}
if len(rows) == 0 {
return `<p class="empty">` + escapeText(l.T("chart.nothing_yet")) + `</p>`
}
maxV := 0.0
for _, r := range rows {
if r.Value > maxV {
maxV = r.Value
}
}
if maxV <= 0 {
maxV = 1
}
// HTML rather than an SVG of fixed width. The SVG gave the value 96 units
// and "34.2k tok 114 req" did not fit, so the one figure a reader came for
// was the one cut off. A grid lets the label and the value take the room
// they need and gives the bar whatever is left.
var b strings.Builder
b.WriteString(`<div class="ranked" role="list">`)
for _, r := range rows {
pct := r.Value / maxV * 100
if pct < 0.5 && 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.
pct = 0.5
}
value := l.Compact(r.Value) + unitSuffix(unit)
title := r.Label + ": " + value
if r.Note != "" {
value += " · " + r.Note
}
fmt.Fprintf(&b, `<div class="rk" role="listitem" title="%s">`+
`<span class="rk-label">%s</span>`+
`<span class="rk-track"><i class="rk-bar" style="width:%.1f%%;background:var(%s)"></i></span>`+
`<span class="rk-value">%s</span></div>`,
escapeText(title), escapeText(r.Label), pct, cssVar(colour), escapeText(value))
}
b.WriteString(`</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 {
return english.Sparkline(values, colour)
}
// Sparkline is the trend line, named for a screen reader in this language.
func (l *Lang) 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="%s">`, w, h, w, h, escapeText(l.T("chart.trend")))
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
}