Files
llmgw/internal/web/timeseries.go
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

282 lines
8.9 KiB
Go

package web
import (
"fmt"
"html/template"
"math"
"sort"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
)
// vizColours is the categorical palette, in the order it is handed out.
//
// Fixed order, never cycled. A fifth line does not get a generated hue — it is
// folded into "other" by foldSeries below, because a chart whose colours repeat
// is one where two lines mean the same thing and neither can be read.
var vizColours = []string{"--viz-1", "--viz-2", "--viz-3", "--viz-4"}
// otherColour is the muted grey the tail is drawn in, so it reads as a total
// rather than as a fifth thing with a name.
const otherColour = "--muted"
const (
tsW = 720.0
tsH = 150.0
tsPadL = 44.0
tsPadB = 18.0
tsPadT = 8.0
tsGridRows = 3
)
// TimeSeries is a chart of several lines over one clock.
type TimeSeries struct {
SVG template.HTML
Legend []LegendEntry
// Empty is true when there was nothing to draw, so the page can say why
// rather than showing a frame with no lines in it.
Empty bool
Peak string
}
// LegendEntry names one line and the colour it was drawn in.
//
// A legend is always present for two lines or more: colour alone is not an
// encoding anybody can rely on, and a reader who cannot tell two lines apart
// has a picture rather than a chart.
type LegendEntry struct {
Name string
Colour string
Last string
}
// LineChart draws several series against one axis.
//
// One axis, always. Two measures of different scale belong in two charts: a
// second y-axis lets any two lines be made to cross wherever the author likes,
// and the reader has no way to know it happened.
//
// Ranked by peak, which is what a gauge asks: the model that hit the ceiling is
// the one worth a colour. For a counter, see LineChartTotals.
func LineChart(series []promq.Series, unit string, maxSeries int) TimeSeries {
return english.LineChart(series, unit, maxSeries)
}
// LineChart is the chart with its axis, peak and legend figures written the
// way this language writes numbers.
func (l *Lang) LineChart(series []promq.Series, unit string, maxSeries int) TimeSeries {
return l.lineChart(series, unit, maxSeries, promq.Series.Max, promq.Series.Last)
}
// LineChartTotals is LineChart ranked by the sum over the window.
//
// Which is the right question of a counter. "Who used the most" is answered by
// the total, and ranking by peak would give a colour to the team that made one
// large burst over the team that ran steadily all week — the opposite of what a
// usage report is read for.
//
// Its legend gives each line's total too. The latest slice, which is what a
// gauge's legend shows, is usually a quiet one for a counter: a legend reading
// "0 · 0 · 0" under a table of hundreds said nothing true.
func LineChartTotals(series []promq.Series, unit string, maxSeries int) TimeSeries {
return english.LineChartTotals(series, unit, maxSeries)
}
// LineChartTotals is LineChartTotals in this language.
func (l *Lang) LineChartTotals(series []promq.Series, unit string, maxSeries int) TimeSeries {
return l.lineChart(series, unit, maxSeries, promq.Series.Sum, promq.Series.Sum)
}
func (l *Lang) lineChart(series []promq.Series, unit string, maxSeries int,
weight, legend func(promq.Series) float64,
) TimeSeries {
if maxSeries <= 0 || maxSeries > len(vizColours) {
maxSeries = len(vizColours)
}
series = foldSeries(series, maxSeries, weight)
if len(series) == 0 {
return TimeSeries{Empty: true}
}
minT, maxT, maxV := bounds(series)
if maxT.Equal(minT) {
// One instant is not a line. Reported as empty rather than drawn as a
// dot in the corner, which reads as a broken chart.
return TimeSeries{Empty: true}
}
// A flat zero is a real answer — nothing is happening — and drawing it
// along the bottom says so, where an autoscaled empty frame would not.
if maxV <= 0 {
maxV = 1
}
maxV = niceCeiling(maxV)
var b strings.Builder
fmt.Fprintf(&b, `<svg class="chart chart-lines" viewBox="0 0 %.0f %.0f" role="img">`, tsW, tsH)
l.writeGrid(&b, maxV, unit)
out := TimeSeries{Peak: l.formatValue(maxV, unit)}
span := maxT.Sub(minT).Seconds()
for i, s := range series {
colour, name := otherColour, l.T("chart.other")
if s.Name != otherLabel {
colour, name = vizColours[i%len(vizColours)], s.Name
}
writeLine(&b, s, minT, span, maxV, colour)
out.Legend = append(out.Legend, LegendEntry{
Name: name, Colour: colour, Last: l.formatValue(legend(s), unit),
})
}
b.WriteString(`</svg>`)
out.SVG = template.HTML(b.String()) //nolint:gosec // numbers and CSS vars only
return out
}
const otherLabel = "other"
// foldSeries keeps the busiest lines and sums the rest into one.
//
// Rather than dropping the tail, which would make a chart that does not add up,
// or colouring it, which needs hues the palette does not have. The fold is
// labelled so nobody reads it as a single model.
//
// What "busiest" means is the caller's: a gauge is ranked by its peak and a
// counter by its total, and the two disagree exactly where it matters.
func foldSeries(in []promq.Series, keep int, weight func(promq.Series) float64) []promq.Series {
clean := make([]promq.Series, 0, len(in))
for _, s := range in {
if len(s.Points) > 0 {
clean = append(clean, s)
}
}
if len(clean) <= keep {
return clean
}
sort.SliceStable(clean, func(i, j int) bool { return weight(clean[i]) > weight(clean[j]) })
rest := clean[keep-1:]
folded := promq.Series{Name: otherLabel}
// Summed by timestamp, because the tail's lines do not necessarily share
// sample times and adding them by index would shift points sideways.
at := map[int64]float64{}
for _, s := range rest {
for _, p := range s.Points {
at[p.At.Unix()] += p.Value
}
}
keys := make([]int64, 0, len(at))
for k := range at {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
for _, k := range keys {
folded.Points = append(folded.Points, promq.Point{At: time.Unix(k, 0), Value: at[k]})
}
return append(append([]promq.Series{}, clean[:keep-1]...), folded)
}
func bounds(series []promq.Series) (minT, maxT time.Time, maxV float64) {
for _, s := range series {
for _, p := range s.Points {
if minT.IsZero() || p.At.Before(minT) {
minT = p.At
}
if p.At.After(maxT) {
maxT = p.At
}
if p.Value > maxV {
maxV = p.Value
}
}
}
return minT, maxT, maxV
}
// writeGrid draws the horizontal rules and their labels.
//
// Recessive on purpose: the grid is there to be read off, not looked at, and a
// grid as dark as the data competes with it.
func (l *Lang) writeGrid(b *strings.Builder, maxV float64, unit string) {
plotH := tsH - tsPadB - tsPadT
for i := 0; i <= tsGridRows; i++ {
y := tsPadT + plotH*float64(i)/tsGridRows
v := maxV * float64(tsGridRows-i) / tsGridRows
fmt.Fprintf(b, `<line x1="%.0f" y1="%.1f" x2="%.0f" y2="%.1f" class="axis"/>`,
tsPadL, y, tsW, y)
fmt.Fprintf(b, `<text x="%.0f" y="%.1f" class="tick">%s</text>`,
tsPadL-6, y+3, escapeText(l.formatValue(v, unit)))
}
}
func writeLine(b *strings.Builder, s promq.Series, minT time.Time,
span, maxV float64, colour string,
) {
plotW := tsW - tsPadL
plotH := tsH - tsPadB - tsPadT
fmt.Fprintf(b, `<polyline class="line" fill="none" stroke="var(%s)" points="`, cssVar(colour))
for _, p := range s.Points {
x := tsPadL + plotW*(p.At.Sub(minT).Seconds()/span)
y := tsPadT + plotH*(1-clamp01(p.Value/maxV))
fmt.Fprintf(b, "%.1f,%.1f ", x, y)
}
b.WriteString(`"/>`)
}
func clamp01(v float64) float64 {
if math.IsNaN(v) || v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// niceCeiling rounds an axis maximum up to something a person would choose.
//
// An axis topping out at 0.8347 makes every gridline a number nobody can hold
// in their head, and the shape of the data is what the chart is for.
func niceCeiling(v float64) float64 {
if v <= 0 {
return 1
}
mag := math.Pow(10, math.Floor(math.Log10(v)))
for _, step := range []float64{1, 2, 2.5, 5, 10} {
if v <= step*mag {
return step * mag
}
}
return 10 * mag
}
func formatValue(v float64, unit string) string { return english.formatValue(v, unit) }
// formatValue is a figure on an axis or in a legend, in this language's
// separators. The percent sign stays after the number, as it is on every
// other page of the portal, so a figure reads the same wherever it is.
func (l *Lang) formatValue(v float64, unit string) string {
switch unit {
case "%":
// A decimal below ten, where whole percents would print a quiet
// machine's axis as 0%, 0%, 0%.
if v != 0 && v*100 < 10 {
return l.Num(v*100, 1) + "%"
}
return l.Num(v*100, 0) + "%"
case "s":
if v < 1 {
return l.T("chart.axis_ms", l.Num(v*1000, 0))
}
return l.T("chart.axis_s", l.Num(v, 1))
case "bytes":
// Binary, like every other size on these pages. compact's own
// suffixes are for counts, and on bytes read as "20.0BB".
return strings.Replace(l.Bytes(int64(v)), " ", "", 1)
default:
return l.Compact(v)
}
}