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.
func BarChart(s Series, unit string) template.HTML {
if s.Colour == "" {
s.Colour = "--accent"
}
var b strings.Builder
fmt.Fprintf(&b, ``)
peak := fmt.Sprintf("peak %s %s", compact(maxV), unit)
if maxV == 0 {
peak = "nothing recorded"
}
return template.HTML(`
`)
}
// 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 {
switch {
case v >= 1e9:
return fmt.Sprintf("%.1fB", v/1e9)
case v >= 1e6:
return fmt.Sprintf("%.1fM", v/1e6)
case v >= 1e3:
return fmt.Sprintf("%.1fk", v/1e3)
case v == float64(int64(v)):
return fmt.Sprintf("%d", int64(v))
default:
return fmt.Sprintf("%.1f", v)
}
}
// hourLabel is what a bar's tooltip says.
func hourLabel(t time.Time, v float64, unit string) string {
return fmt.Sprintf("%s — %s %s", t.Local().Format("Mon 15:04"), compact(v), unit)
}