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 `
`;
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();