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 `

` + escapeText(l.T("chart.nothing_yet")) + `

` } 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(`
`) 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, `
`+ `%s`+ ``+ `%s
`, escapeText(title), escapeText(r.Label), pct, cssVar(colour), escapeText(value)) } b.WriteString(`
`) 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, ``, w, h, w, h, escapeText(l.T("chart.trend"))) b.WriteString(``) 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 }