363 lines
12 KiB
Go
363 lines
12 KiB
Go
|
|
// 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),
|
|||
|
|
}
|
|||
|
|
}
|