// 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
". 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(`%d°%s`, *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(`%d°`, *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(`%s`, *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(`%s`, *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), } }