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>
130 lines
4.2 KiB
Go
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)
|
|
}
|