thermograph/frontend/server/internal/format/format.go

363 lines
12 KiB
Go
Raw Normal View History

// Package format is unit-aware display formatting for the SSR templates —
// the Go port of format.py (itself ported from backend/web/content.py,
// unchanged in behavior: still markup-wrapped spans for the templates,
// operating on the raw floats the content API returns).
//
// One deliberate structural change from the Python: format.py scoped the
// active display unit in a ContextVar (`unit_scope`) so template globals
// could read it implicitly. html/template FuncMaps are engine-global and
// parsed once, so an ambient mutable unit would be a data race across
// requests — instead every unit-dependent function here takes the Unit as an
// explicit first argument, and the page handlers thread it through the render
// context (the reasoning the Python gave for the ContextVar — "a silently
// wrong unit at the one site someone forgets" — is answered in Go by the
// compiler: forgetting the argument fails the build, not the user).
package format
import (
"encoding/json"
"fmt"
"html/template"
"math"
"strconv"
"strings"
)
// Unit is the active display unit: "C", "F", or "" (unset — the Python's
// ContextVar default None). Anything other than "C" formats as Fahrenheit,
// exactly like the Python's `_UNIT.get() == "C"` checks; "" additionally
// means "no data-unit-default attribute on <body>".
type Unit string
// FCountries is the set of countries that actually use Fahrenheit. Mirrors
// backend/api/content_payloads.py's F_COUNTRIES / frontend static/units.js's
// F_REGIONS — a test asserts the backend copy stays identical (the backend
// has its own test asserting the same across its surfaces).
var FCountries = map[string]bool{
"US": true, "PR": true, "GU": true, "VI": true, "AS": true, "MP": true,
"UM": true, "BS": true, "BZ": true, "KY": true, "PW": true, "FM": true,
"MH": true, "LR": true,
}
// Unit-conversion factors (format.py's MM_PER_IN / KMH_PER_MPH).
const (
MMPerIn = 25.4
KmhPerMph = 1.609344
)
// tempTiers are the absolute-temperature colour tiers (°F upper bounds), the
// same 9 tiers the interactive grader uses (format.py's _TEMP_TIERS). Note
// these are ABSOLUTE °F cut points for painting cells — distinct from the
// percentile TEMP_BANDS in bands.go, which grade a day against its own
// history.
var tempTiers = []struct {
upper float64
class string
}{
{20, "rec-cold"}, {32, "very-cold"}, {45, "cold"}, {58, "cool"},
{70, "normal"}, {80, "warm"}, {90, "hot"}, {100, "very-hot"},
}
// Shared axis for the month range bars (format.py's _AXIS_LO/_AXIS_HI).
const (
axisLo = -10.0
axisHi = 115.0
)
// UnitForCountry maps an ISO country code to its display unit: "F" for the
// Fahrenheit-using set, "C" everywhere else (empty/unknown codes are "C").
func UnitForCountry(code string) Unit {
if FCountries[strings.ToUpper(code)] {
return "F"
}
return "C"
}
// RoundHalfUp rounds like JS's Math.round — floor(x + 0.5) — not Go's
// math.Round (which rounds halves away from zero) nor Python's round()
// (halves to even), so the server and climate.js's repaint never visibly
// disagree. Negative halves round UP (-0.5 -> 0), matching Math.round.
func RoundHalfUp(x float64) int {
return int(math.Floor(x + 0.5))
}
// cFromF converts °F to whole °C, rounding half-up (format.py's _c).
func cFromF(f float64) int {
return RoundHalfUp((f - 32) * 5 / 9)
}
// shown is the integer temperature to display in the active unit.
func shown(u Unit, f float64) int {
if u == "C" {
return cFromF(f)
}
return RoundHalfUp(f)
}
// letter is the displayed unit letter — "C" only when the active unit is
// Celsius, "F" otherwise (including the unset unit, like the Python).
func letter(u Unit) string {
if u == "C" {
return "C"
}
return "F"
}
// Temp renders a temperature span: the data-temp-f attribute always carries
// the raw °F value (one decimal) for the client-side converter, while the
// visible text is in the active unit. nil renders the em-dash placeholder.
func Temp(u Unit, f *float64) template.HTML {
if f == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f">%d°%s</span>`,
*f, shown(u, *f), letter(u)))
}
// TempBare is Temp without the unit letter (and with data-bare for the
// converter): the range strip prints "58°71°".
func TempBare(u Unit, f *float64) template.HTML {
if f == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f" data-bare>%d°</span>`,
*f, shown(u, *f)))
}
// TempText is the plain-text temperature ("71°F"), no span.
func TempText(u Unit, f *float64) string {
if f == nil {
return "—"
}
return fmt.Sprintf("%d°%s", shown(u, *f), letter(u))
}
// Precip renders a precipitation span; data-precip-in carries the raw inches
// (three decimals) for the converter.
func Precip(u Unit, v *float64) template.HTML {
if v == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="precip" data-precip-in="%.3f">%s</span>`,
*v, PrecipText(u, v)))
}
// PrecipText is the plain-text precipitation: whole millimetres for Celsius
// locales, hundredths of an inch otherwise.
func PrecipText(u Unit, v *float64) string {
if v == nil {
return "—"
}
if u == "C" {
return fmt.Sprintf("%d mm", RoundHalfUp(*v*MMPerIn))
}
return fmt.Sprintf("%.2f in", *v)
}
// Wind renders a wind-speed span; data-wind-mph carries the raw mph.
func Wind(u Unit, v *float64) template.HTML {
if v == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="wind" data-wind-mph="%.1f">%s</span>`,
*v, WindText(u, v)))
}
// WindText is the plain-text wind speed: km/h for Celsius locales, mph
// otherwise — whole numbers both ways.
func WindText(u Unit, v *float64) string {
if v == nil {
return "—"
}
if u == "C" {
return fmt.Sprintf("%d km/h", RoundHalfUp(*v*KmhPerMph))
}
return fmt.Sprintf("%d mph", RoundHalfUp(*v))
}
// tempMetrics are the metric keys formatted as temperatures (format.py's
// _TEMP_METRICS).
var tempMetrics = map[string]bool{"tmax": true, "tmin": true, "feels": true}
// Fmt dispatches a metric value to its formatter (format.py's fmt):
// temperatures and precip get their converter spans, humidity is a plain
// absolute figure, and everything else (wind, gust) formats as wind speed.
func Fmt(u Unit, metric string, v *float64) template.HTML {
if v == nil {
return "—"
}
if tempMetrics[metric] {
return Temp(u, v)
}
switch metric {
case "precip":
return Precip(u, v)
case "humid":
return template.HTML(fmt.Sprintf("%.1f g/m³", *v))
}
return Wind(u, v) // wind, gust
}
// TempClass is the diverging-palette tier name for an absolute Fahrenheit
// temperature (or "none" for a missing value). Each tier's bound is an
// EXCLUSIVE upper edge (f < upper), so exactly 20°F is "very-cold", not
// "rec-cold"; at or above the last bound is "rec-hot".
func TempClass(f *float64) string {
if f == nil {
return "none"
}
for _, t := range tempTiers {
if *f < t.upper {
return t.class
}
}
return "rec-hot"
}
// PctOrdinal renders a percentile as a display ordinal: 66.4 -> "66th",
// 99.6 -> "99th". Floored into 1..99 — an empirical percentile is a rank
// against the sample, so rounding can land on 100 (or 0), and "100th
// percentile" reads as a measurement error rather than "as extreme as it has
// ever been"; the band label ("Near Record") carries the how-extreme part.
//
// Mirrors backend/data/grading.py's pct_ordinal, which stays canonical for
// the in-process surfaces (and static/shared.js's pctOrd client-side); this
// is the SSR-side copy, same formula. floor(x + 0.5), NOT round-half-even:
// Python's round() gives 16 for 16.5 while JavaScript's Math.round gives 17 —
// the same reading would then read "16th" server-side and "17th" client-side.
//
// A non-numeric or missing value renders the em-dash placeholder, like the
// Python's caught TypeError/ValueError (NaN included: math.floor raised
// ValueError on it). Infinities clamp into the 1..99 range (the Python would
// have crashed on OverflowError; they cannot occur in a percentile).
func PctOrdinal(pct any) string {
f, ok := toFloat(pct)
if !ok || math.IsNaN(f) {
return "—"
}
var n int
switch {
case math.IsInf(f, 1):
n = 99
case math.IsInf(f, -1):
n = 1
default:
n = RoundHalfUp(f)
}
n = min(99, max(1, n))
suffix := "th"
if !(10 <= n%100 && n%100 <= 20) {
switch n % 10 {
case 1:
suffix = "st"
case 2:
suffix = "nd"
case 3:
suffix = "rd"
}
}
return strconv.Itoa(n) + suffix
}
// toFloat coerces the loosely-typed values templates hand the ordinal filter
// (payload float64s, nullable *float64s, json.Number, template int literals,
// strings — everything Python's float(pct) accepted).
func toFloat(v any) (float64, bool) {
switch x := v.(type) {
case nil:
return 0, false
case float64:
return x, true
case float32:
return float64(x), true
case *float64:
if x == nil {
return 0, false
}
return *x, true
case int:
return float64(x), true
case int64:
return float64(x), true
case json.Number:
f, err := x.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f, err == nil
}
return 0, false
}
// AsFloatPtr coerces the loosely-typed values templates hand the formatter:
// nullable *float64 payload fields, plain float64s, and int/float literals
// (the template port writes {{.Fmt.Temp -10.0}} where Jinja had temp(-10)).
// nil (and a nil *float64) mean "missing" — rendered as the em-dash.
func AsFloatPtr(v any) *float64 {
switch x := v.(type) {
case nil:
return nil
case *float64:
return x
case float64:
return &x
case int:
f := float64(x)
return &f
}
return nil
}
// Formatter is the request-scoped, unit-bound formatter the render data
// carries as .Fmt — the Go stand-in for format.py's ContextVar unit scope.
// html/template FuncMaps are engine-global, so the per-request unit rides on
// the data instead, and templates call {{.Fmt.Temp .HighF}} where Jinja had
// {{ temp(m.high_f) }}.
type Formatter struct {
Unit Unit
}
// NewFormatter binds a formatter to the page's active unit ("" behaves as
// Fahrenheit, like the Python's unset ContextVar).
func NewFormatter(u Unit) *Formatter { return &Formatter{Unit: u} }
// Temp is the template-facing Temp (accepts *float64 fields and literals).
func (f *Formatter) Temp(v any) template.HTML { return Temp(f.Unit, AsFloatPtr(v)) }
// TempBare is the template-facing TempBare.
func (f *Formatter) TempBare(v any) template.HTML { return TempBare(f.Unit, AsFloatPtr(v)) }
// TempClass is the template-facing TempClass (unit-independent — absolute °F
// tiers — but lives on the formatter so templates have one formatting seam).
func (f *Formatter) TempClass(v any) string { return TempClass(AsFloatPtr(v)) }
// RangeBar is the geometry for one month's low->high bar on the shared
// -10..115°F axis: left offset + width as percentages, and the tier colour at
// each end. Left/Width are pre-formatted strings (Python rendered
// round(x, 1) floats, which always print with one decimal — "42.0", not
// "42" — so formatting here keeps the rendered bytes identical).
type RangeBar struct {
Left string // e.g. "48.8"
Width string // e.g. "12.4"; never below "2.0" (a sliver stays visible)
C1 string // tier colour at the low end (from the UNCLAMPED low)
C2 string // tier colour at the high end
}
// MonthRangeBar computes the bar, or nil when either end is missing
// (format.py's range_bar). The endpoints are clamped into the axis for
// geometry, but the colours come from the raw values.
func MonthRangeBar(lowF, highF *float64) *RangeBar {
if lowF == nil || highF == nil {
return nil
}
lo := math.Max(axisLo, math.Min(axisHi, *lowF))
hi := math.Max(axisLo, math.Min(axisHi, *highF))
const span = axisHi - axisLo
return &RangeBar{
Left: strconv.FormatFloat((lo-axisLo)/span*100, 'f', 1, 64),
Width: strconv.FormatFloat(math.Max(2.0, (hi-lo)/span*100), 'f', 1, 64),
C1: TempClass(lowF),
C2: TempClass(highF),
}
}