From b1c6b9476504e83ab72cb0dc81e7cbe40afb38ca Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 12:03:41 -0700 Subject: [PATCH] Follow the unit system for precipitation and wind too (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as / , the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8 --- static/app.js | 22 ++++++++---- static/chart.js | 57 ++++++++++++++++++------------- static/climate.js | 37 ++++++++++++++------ static/legend.html | 8 ++--- static/shared.js | 23 +++++++------ static/style.css | 9 ++++- static/units.js | 84 +++++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 178 insertions(+), 62 deletions(-) diff --git a/static/app.js b/static/app.js index f2f1857..9d6704d 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,6 @@ // Weekly (map) page. Imports replace the old script-tag ordering contract. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; -import { fmtTemp, onUnitChange } from "./units.js"; +import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.js"; import "./account.js"; // header sign-in entry + notification bell import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; @@ -186,9 +186,11 @@ async function runGrade() { // column headers, so values stay short). Dry days label the running dry streak // (see precipCell) rather than the rain amount. const cTemp = fmtTemp; // active unit, bare degree -const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); -const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`); -const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·"); +const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); // relative %, unit-invariant +const cWind = (v) => (v == null ? "—" : `${Math.round(toWind(v))}`); +// fmtPrecip carries the precision: two decimals for inches, one for mm (0.04" and +// 1.0mm are the same amount, and "1.02 mm" would imply precision we don't have). +const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·"); // Monochrome inline icons (currentColor) so the UI chrome matches the dark theme // instead of using colorful emoji. Feather-style line paths. @@ -302,9 +304,15 @@ function precipCell(d, isFc, isToday) { // Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first). // Wide runs (e.g. 2 weeks) scroll horizontally inside their own container; the // metric-label column stays pinned. Tapping any cell/day opens that day's detail. -const RD_METRICS = [ +// Wind and rain cells are bare numbers to keep the grid narrow, so their row +// label carries the unit — and has to be built per render, since the unit can +// change under us (onUnitChange re-renders). +const RD_METRICS = () => [ ["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp], - ["humid", "Hum", cHum], ["wind", "Wind", cWind], ["gust", "Gust", cWind], ["precip", "Rain", cRain], + ["humid", "Hum", cHum], + ["wind", `Wind ${windUnit()}`, cWind], + ["gust", `Gust ${windUnit()}`, cWind], + ["precip", `Rain ${precipUnit()}`, cRain], ]; function daysTable(rows, targetDate) { if (!rows.length) return ""; @@ -319,7 +327,7 @@ function daysTable(rows, targetDate) { const cls = "rd-colh" + (isTarget(d) ? " rd-today" : isFc(d) ? " rd-fc" : ""); return `
${dow}${md}
`; }).join(""); - const body = RD_METRICS.map(([key, label, fmt]) => + const body = RD_METRICS().map(([key, label, fmt]) => `
${label}
` + rows.map((d) => key === "precip" ? precipCell(d, isFc(d), isTarget(d)) diff --git a/static/chart.js b/static/chart.js index 13939a9..ace1159 100644 --- a/static/chart.js +++ b/static/chart.js @@ -2,7 +2,8 @@ // value trace + labeled points) with per-metric wrappers, the site-matched // palette, and the pointer-driven hover. Extracted from app.js so any page can // plot a metric series; app.js keeps only its page orchestration (buildChart). -import { fmtTemp, toUnit } from "./units.js"; +import { fmtTemp, toUnit, toWind, fmtPrecip, fmtWind, fmtHumid, + windUnit, humidUnit } from "./units.js"; import { TIER_COLORS, drynessColor, pctOrd } from "./shared.js"; // #rrggbb -> rgba() with alpha, so band tints derive from the same site colors. @@ -74,13 +75,15 @@ const pget = (o, k) => { // Per-series display meta for the diverging-scale metrics. `sfx` is the unit // appended to axis + point labels ("°"); wind carries its unit in the subtitle // instead of on every point to keep the plot uncluttered. +// `kind` picks the formatter and says whether the value converts; the labels that +// name a unit are built per render so they follow the active unit system. const SERIES = { - tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }), - tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }), - feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }), - humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }), - wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }), - gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }), + tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°", kind: "temp" }), + tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°", kind: "temp" }), + feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°", kind: "temp" }), + humid: (C) => ({ color: C.humid, label: `Absolute humidity (${humidUnit()})`, sfx: "", kind: "humid" }), + wind: (C) => ({ color: C.wind, label: `Wind speed (${windUnit()})`, sfx: "", kind: "wind" }), + gust: (C) => ({ color: C.gust, label: `Wind gust (${windUnit()})`, sfx: "", kind: "wind" }), }; // Percentile "fan" tiers — bands between successive percentile marks, colored to @@ -179,10 +182,11 @@ function lineChart(cfg) { // One temperature-scale series (High/Low/Feels/Wind/Gust). export function tempChart(key, days, n, xFor, C) { const s = SERIES[key](C); - // Temperature series (° suffix) show in the active unit; wind/gust stay in mph. - // The plot keeps its °F domain — only the labels convert — so positions hold. - const isTemp = s.sfx === "°"; - const fmtV = (v) => `${Math.round(isTemp ? toUnit(v) : v)}${s.sfx}`; + // Labels show the active unit; the plot keeps its imperial domain — only the + // labels convert — so point positions and the fan geometry hold. + const fmtV = (v) => (s.kind === "temp" + ? `${Math.round(toUnit(v))}${s.sfx}` + : s.kind === "wind" ? `${Math.round(toWind(v))}` : `${Math.round(v)}`); return lineChart({ days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false, getVal: (d) => (d[key] ? d[key].value : null), @@ -209,8 +213,8 @@ export function precipChart(days, n, xFor, C) { // Rain days take their intensity-tier color; no-rain days warm with the dry // streak (tan→red) so a dry spell reads as dry, matching the Dry chart. dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)), - fmtAxis: (v) => `${v.toFixed(2)}"`, - fmtLabel: (v) => `${v.toFixed(2)}"`, + fmtAxis: (v) => fmtPrecip(v, false), + fmtLabel: (v) => fmtPrecip(v, false), labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline legend: `Precipitation (dashed = median)` + @@ -245,13 +249,18 @@ export function attachChartHover(wrap, days) { const tip = wrap.querySelector("#chart-tip"); const cross = wrap.querySelector("#crosshair"); const C = chartPalette(); - const pctLine = (label, g, unit, color) => { + // Every measurable line converts to the active unit system; `kind` picks how. + const TIP_FMT = { + temp: (v) => `${Math.round(toUnit(v))}°`, + humid: (v) => fmtHumid(v), + wind: (v) => fmtWind(v), + precip: (v) => fmtPrecip(v), + }; + const pctLine = (label, g, kind, color) => { if (!g) return `
${label}
`; const pct = g.percentile == null ? "" : ` · ${pctOrd(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; - // "°" marks a temperature line — show it in the active unit; others are as-is. - const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value; - return `
${label} ${val}${unit}${pct} ${g.grade}
`; + return `
${label} ${TIP_FMT[kind](g.value)}${pct} ${g.grade}
`; }; // Pointer events cover mouse, touch and pen with one path, so tapping/dragging @@ -263,13 +272,13 @@ export function attachChartHover(wrap, days) { const dt = new Date(d.date + "T00:00:00"); const nt = d.normals.tmax, ni = d.normals.tmin; tip.innerHTML = `
${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
- ${pctLine("High", d.tmax, "°", C.hi)} - ${pctLine("Low", d.tmin, "°", C.lo)} - ${pctLine("Feels", d.feels, "°", C.feels)} - ${pctLine("Humidity", d.humid, " g/m³", C.humid)} - ${pctLine("Wind", d.wind, " mph", C.wind)} - ${pctLine("Gust", d.gust, " mph", C.gust)} - ${pctLine("Precip", d.precip, '"', C.precip)} + ${pctLine("High", d.tmax, "temp", C.hi)} + ${pctLine("Low", d.tmin, "temp", C.lo)} + ${pctLine("Feels", d.feels, "temp", C.feels)} + ${pctLine("Humidity", d.humid, "humid", C.humid)} + ${pctLine("Wind", d.wind, "wind", C.wind)} + ${pctLine("Gust", d.gust, "wind", C.gust)} + ${pctLine("Precip", d.precip, "precip", C.precip)}
Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}
`; tip.hidden = false; diff --git a/static/climate.js b/static/climate.js index d30f9b6..c252824 100644 --- a/static/climate.js +++ b/static/climate.js @@ -1,16 +1,31 @@ -// Client-side temperature converter for the server-rendered climate/city/record -// pages. Those pages render every temperature as `72°F` (see content.py `_temp`/`_temp_bare`) so the -// value is real in the HTML for crawlers and no-JS visitors. Here we rewrite each -// span to the active unit on load and whenever the °F/°C toggle changes — reusing -// the same unit machinery as the interactive pages. Importing units.js also boots -// its toggle-injection IIFE, so this one script gives the pages a working toggle. -import { fmtTemp, onUnitChange } from "./units.js"; +// Client-side unit converter for the server-rendered climate/city/record pages. +// Those pages render every measurement as a span carrying the imperial value — +// `23°C`, `.precip[data-precip-in]`, +// `.wind[data-wind-mph]` (see content.py `_temp`/`_precip`/`_wind`) — so the number +// is real in the HTML for crawlers and no-JS visitors, already in the city's own +// convention. Here we rewrite each span to the active unit on load and whenever the +// toggle changes, reusing the same unit machinery as the interactive pages. +// Importing units.js also boots its toggle-injection IIFE, so this one script gives +// the pages a working toggle. +// +// Absolute humidity is g/m³ in both systems, so it is rendered as plain text with +// no span and nothing to repaint here. +import { fmtTemp, fmtPrecip, fmtWind, onUnitChange } from "./units.js"; + +// [selector, data attribute, formatter] — the attribute is always the imperial +// value, which is what makes repainting idempotent. +const PAINTERS = [ + [".temp[data-temp-f]", "tempF", (v, el) => fmtTemp(v, !el.hasAttribute("data-bare"))], + [".precip[data-precip-in]", "precipIn", (v) => fmtPrecip(v)], + [".wind[data-wind-mph]", "windMph", (v) => fmtWind(v)], +]; function paint() { - for (const el of document.querySelectorAll(".temp[data-temp-f]")) { - const f = parseFloat(el.dataset.tempF); - if (!Number.isNaN(f)) el.textContent = fmtTemp(f, !el.hasAttribute("data-bare")); + for (const [sel, attr, fmt] of PAINTERS) { + for (const el of document.querySelectorAll(sel)) { + const v = parseFloat(el.dataset[attr]); + if (!Number.isNaN(v)) el.textContent = fmt(v, el); + } } } diff --git a/static/legend.html b/static/legend.html index 8ac9db2..786ac88 100644 --- a/static/legend.html +++ b/static/legend.html @@ -79,14 +79,14 @@

The metrics

-
High / Low
The day's highest and lowest air temperature (°F).
+
High / Low
The day's highest and lowest air temperature (°F).
Feels
Apparent temperature — what the air actually felt like once humidity and wind are factored in, taking whichever apparent extreme (heat-index high or wind-chill low) sits further from a temperate baseline, graded against its own history.
Humid
Absolute humidity: grams of water vapor per cubic meter of air (g/m³).
-
Wind
Average sustained wind speed (mph).
-
Gust
Peak wind gust for the day (mph).
-
Precip
Total precipitation (inches), graded by intensity among rain days.
+
Wind
Average sustained wind speed (mph).
+
Gust
Peak wind gust for the day (mph).
+
Precip
Total precipitation (inches), graded by intensity among rain days.
Dry streak
Consecutive days since the last measurable rain — the color deepens the longer it's been dry, and resets to blue on a rain day.
diff --git a/static/shared.js b/static/shared.js index 91bad7c..0c221f5 100644 --- a/static/shared.js +++ b/static/shared.js @@ -2,7 +2,7 @@ // constant and helper here previously existed as a copy in app.js, calendar.js, // day.js, compare.js and/or legend.html; pages import what they need so a tier // color, scale label or formatter has exactly one home. -import { fmtTemp } from "./units.js"; +import { fmtTemp, fmtPrecip, fmtWind, fmtHumid, getUnit } from "./units.js"; // ---- tier colors ---- // style.css's :root custom properties are the source of truth (they're not // re-themed, so reading them once at load is safe). The JS map exists because @@ -113,14 +113,16 @@ export function metricBuckets(days, metric) { }); } -// Format a single metric value in the metric's own unit — temps follow the active -// °C/°F unit; humidity, wind, precip and dry-streak are fixed-unit. +// Format a single metric value in the metric's own unit. Everything measurable +// follows the active unit system via units.js; a dry-streak is a day count and +// has no unit to follow. function fmtMetricVal(unit, v) { if (v == null || isNaN(v)) return ""; if (unit === "temp") return fmtTemp(v); - if (unit === "humid") return `${Math.round(v)} g/m³`; - if (unit === "wind") return `${Math.round(v)} mph`; - if (unit === "precip") return `${v.toFixed(2)}″`; + if (unit === "humid") return fmtHumid(v); + if (unit === "wind") return fmtWind(v); + // The tier bar is tight on width, so precip keeps the ″ glyph over " mm"/" in". + if (unit === "precip") return `${fmtPrecip(v, false)}${getUnit() === "C" ? "mm" : "″"}`; if (unit === "dsr") return `${Math.round(v)}d`; return `${Math.round(v)}`; } @@ -172,10 +174,11 @@ export function distStrip(buckets, showCount) { } // ---- formatters & tiny utils ---- -// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.) -export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`); -export const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`); -export const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`); +// Every measure follows the active unit system; units.js owns the conversions and +// these are re-exported so the pages that already import them here keep working. +// (Re-exporting the imported bindings, not `export … from` — this module already +// imports them above, and declaring both is a duplicate-binding SyntaxError.) +export { fmtPrecip, fmtWind, fmtHumid }; export const ord = (n) => { const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100; return r + (s[(v - 20) % 10] || s[v] || s[0]); diff --git a/static/style.css b/static/style.css index e462cae..e119f15 100644 --- a/static/style.css +++ b/static/style.css @@ -798,10 +798,17 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } } .rd-mlabel { position: sticky; left: 0; z-index: 2; background: var(--surface); - display: flex; align-items: center; padding-right: 6px; + display: flex; align-items: center; gap: 4px; padding-right: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); } +/* Wind/rain cells are bare numbers to keep the grid narrow, so the row label + carries the unit — quieter than the label itself, and lowercase since "km/h" + and "mm" are unit symbols, not words. */ +.rd-unit { + font-weight: 500; text-transform: none; letter-spacing: 0; + font-size: 10px; opacity: .75; +} .rd-colh { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 0; padding-bottom: 3px; font-size: 10px; color: var(--muted); diff --git a/static/units.js b/static/units.js index 14f12ba..49ee291 100644 --- a/static/units.js +++ b/static/units.js @@ -1,7 +1,16 @@ -// Temperature units — the shared °C/°F toggle and unit-aware formatting. -// The API always speaks Fahrenheit; the unit is a pure display concern. Pages -// format temps through `fmtTemp`/`toUnit` and re-render on `onUnitChange`, so the -// stored/plotted values stay in °F and only the numbers shown flip. +// Units — the shared toggle and unit-aware formatting for every measure we show. +// The API always speaks imperial (°F, inches, mph); the unit is a pure display +// concern. Pages format through fmtTemp/fmtPrecip/fmtWind and re-render on +// `onUnitChange`, so stored and plotted values stay imperial and only the numbers +// shown flip. +// +// One flag drives all of them: picking °C also gives mm and km/h. A reader who +// wants Celsius wants the rest metric too, and a per-measure control would be +// three toggles in a header that has room for one. The button still shows °F/°C +// because temperature is what people look for. +// +// Absolute humidity (g/m³) is metric in both systems — there is no imperial unit +// for it anyone would recognise — so it is formatted here but never converted. const UNIT_KEY = "thermograph:unit"; @@ -57,14 +66,75 @@ export function fmtDelta(dF, withLetter) { return `${Math.round(d)}°${withLetter ? _unit : ""}`; } +// --- precipitation (stored in inches) ---------------------------------------- +const MM_PER_IN = 25.4; + +// Inches as a number in the active unit. +export function toPrecip(vIn) { return _unit === "C" ? vIn * MM_PER_IN : vIn; } + +// Millimetres get one decimal and inches two: 0.04 in and 1.0 mm are the same +// amount, and rendering "1.02 mm" would imply a precision the source lacks. +// The suffix is " in"/" mm" to match what content.py renders server-side, so a +// climate page's repaint reproduces the text it shipped with rather than swapping +// the glyph. Compact surfaces (the tier bar) pass withUnit=false and add their own. +export function fmtPrecip(vIn, withUnit = true) { + if (vIn == null || isNaN(vIn)) return "—"; + return _unit === "C" + ? `${(vIn * MM_PER_IN).toFixed(1)}${withUnit ? " mm" : ""}` + : `${vIn.toFixed(2)}${withUnit ? " in" : ""}`; +} + +export const precipUnit = () => (_unit === "C" ? "mm" : "in"); + +// --- wind (stored in mph) ---------------------------------------------------- +const KMH_PER_MPH = 1.609344; + +export function toWind(vMph) { return _unit === "C" ? vMph * KMH_PER_MPH : vMph; } + +export function fmtWind(vMph, withUnit = true) { + if (vMph == null || isNaN(vMph)) return "—"; + const v = Math.round(toWind(vMph)); + return `${v}${withUnit ? (_unit === "C" ? " km/h" : " mph") : ""}`; +} + +export const windUnit = () => (_unit === "C" ? "km/h" : "mph"); + +// --- absolute humidity (g/m³ in both systems) -------------------------------- +export function fmtHumid(v, withUnit = true) { + if (v == null || isNaN(v)) return "—"; + return `${v.toFixed(1)}${withUnit ? " g/m³" : ""}`; +} + +export const humidUnit = () => "g/m³"; + export function onUnitChange(cb) { _unitCbs.push(cb); } +// --- unit symbols in prose --------------------------------------------------- +// Static copy that names a unit ("wind speed (mph)") marks it up as +// mph. The HTML ships the imperial symbol so +// there's something sensible with no JS; this keeps it honest once a unit is +// known. Runs on load and on every toggle, wherever units.js is imported. +const UNIT_LABEL = { + temp: () => `°${_unit}`, + precip: () => (_unit === "C" ? "mm" : "inches"), + wind: () => windUnit(), + humid: () => "g/m³", +}; + +function paintUnitLabels() { + for (const el of document.querySelectorAll("[data-unit-label]")) { + const f = UNIT_LABEL[el.dataset.unitLabel]; + if (f) el.textContent = f(); + } +} + export function setUnit(u) { const nu = (u === "C") ? "C" : "F"; if (nu === _unit) return; _unit = nu; try { localStorage.setItem(UNIT_KEY, _unit); } catch (e) {} document.querySelectorAll(".unit-toggle").forEach(syncUnitToggle); + paintUnitLabels(); _unitCbs.forEach((cb) => { try { cb(_unit); } catch (e) {} }); } @@ -85,7 +155,9 @@ function syncUnitToggle(wrap) { const wrap = document.createElement("div"); wrap.className = "unit-toggle"; wrap.setAttribute("role", "group"); - wrap.setAttribute("aria-label", "Temperature units"); + // Labelled for what it actually switches now — temperature, precipitation and + // wind — even though the buttons read °F/°C. + wrap.setAttribute("aria-label", "Units"); wrap.innerHTML = '' + ''; wrap.addEventListener("click", (e) => { @@ -100,3 +172,5 @@ function syncUnitToggle(wrap) { else brand.insertBefore(wrap, brand.querySelector(".view-nav")); syncUnitToggle(wrap); })(); + +paintUnitLabels();