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

130 lines
4.2 KiB
Go

package web
import (
"fmt"
"html/template"
"strings"
"time"
)
// Inline SVG, generated here.
//
// A chart is a shape, and a column of numbers makes the reader compute the
// shape themselves. Drawing it costs about a hundred lines and no dependency:
// a charting library would mean a JavaScript toolchain in CI, a build step, and
// a megabyte of bundle for what is fundamentally a list of rectangles.
//
// The output is marked template.HTML, so everything interpolated into it must
// be a number this file produced. No caller-supplied string is ever written
// into the markup except through escapeText.
// Series is one set of bars.
type Series struct {
Values []float64
Labels []string
// Colour is a CSS custom property name, so the chart follows the page's
// light and dark palettes instead of pinning its own.
Colour string
}
const (
chartW = 720.0
chartH = 120.0
chartPad = 18.0
)
// BarChart renders a series as bars with a baseline and a peak label.
//
// Empty and all-zero series render as an empty frame rather than nothing: a
// missing chart reads as a broken page, and a flat one reads as no traffic,
// which is the truth.
//
// unit is written after the peak as it is given, so a caller passes it in
// the page's language.
func BarChart(s Series, unit string) template.HTML { return english.BarChart(s, unit) }
// BarChart is the chart in this language: its peak, and the words it says
// when there is none.
func (l *Lang) BarChart(s Series, unit string) template.HTML {
if s.Colour == "" {
s.Colour = "--accent"
}
var b strings.Builder
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" preserveAspectRatio="none" role="img">`,
chartW, chartH)
maxV := 0.0
for _, v := range s.Values {
if v > maxV {
maxV = v
}
}
// Baseline first so bars sit on it.
fmt.Fprintf(&b, `<line x1="0" y1="%.1f" x2="%.0f" y2="%.1f" class="axis"/>`,
chartH-chartPad, chartW, chartH-chartPad)
if n := len(s.Values); n > 0 && maxV > 0 {
slot := chartW / float64(n)
width := slot * 0.72
for i, v := range s.Values {
h := (v / maxV) * (chartH - chartPad*2)
if h < 1 && v > 0 {
// A bar for a real value must be visible, or an hour with
// traffic looks identical to one without.
h = 1
}
x := float64(i)*slot + (slot-width)/2
y := chartH - chartPad - h
title := ""
if i < len(s.Labels) {
title = fmt.Sprintf("<title>%s</title>", escapeText(s.Labels[i]))
}
fmt.Fprintf(&b,
`<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" style="fill:var(%s)">%s</rect>`,
x, y, width, h, cssVar(s.Colour), title)
}
}
b.WriteString(`</svg>`)
peak := l.T("chart.peak_unit", l.Compact(maxV), unit)
if maxV == 0 {
peak = l.T("chart.nothing_recorded")
}
return template.HTML(`<div class="chart-wrap">` + b.String() + //nolint:gosec // built above
`<span class="chart-peak">` + string(escapeText(peak)) + `</span></div>`)
}
// cssVar restricts what can reach a style attribute to names this package
// defines, so a colour can never become a way to inject CSS.
func cssVar(name string) string {
switch name {
case "--accent", "--ok", "--warn", "--bad",
// The chart palette, and the text tokens charts label themselves with.
// An allowlist rather than a check, because this value is interpolated
// into a style attribute and the set of things that belong there is
// small, known and worth writing down.
"--viz-1", "--viz-2", "--viz-3", "--viz-4", "--viz-grid",
"--fg", "--muted":
return name
default:
return "--accent"
}
}
// escapeText is the only path by which non-numeric content enters the markup.
func escapeText(s string) template.HTML {
return template.HTML(template.HTMLEscapeString(s)) //nolint:gosec // escaped on the line above
}
// compact renders a number the way a person reads one at a glance.
func compact(v float64) string { return english.Compact(v) }
// hourLabel is what a bar's tooltip says.
func hourLabel(t time.Time, v float64, unit string) string { return english.hourLabel(t, v, unit) }
// hourLabel names the day the way this language does: "Mon 15:04", "Pzt
// 15:04". unit is the caller's, in this language already.
func (l *Lang) hourLabel(t time.Time, v float64, unit string) string {
return l.T("chart.hour_label", l.Format(t.Local(), "Mon 15:04"), l.Compact(v), unit)
}