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, `
`)
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, ``)
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
}