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 <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, 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
176 lines
7.6 KiB
JavaScript
176 lines
7.6 KiB
JavaScript
// 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";
|
|
|
|
// First-time default: pick °F only for the countries that actually use it (the US
|
|
// plus a handful of territories/nations), otherwise °C — read off the browser
|
|
// locale's region subtag (en-US → F, de-DE → C). A stored choice always wins, so
|
|
// this only affects visitors who've never toggled. Not persisted, so an unstored
|
|
// visitor keeps re-deriving the same locale default until they pick one.
|
|
const F_REGIONS = new Set(["US", "PR", "GU", "VI", "AS", "MP", "UM", "BS", "BZ", "KY", "PW", "FM", "MH", "LR"]);
|
|
function defaultUnit() {
|
|
const langs = (navigator.languages && navigator.languages.length)
|
|
? navigator.languages : [navigator.language];
|
|
for (const l of langs) {
|
|
const m = /-([A-Za-z]{2})\b/.exec(l || "");
|
|
if (m) return F_REGIONS.has(m[1].toUpperCase()) ? "F" : "C";
|
|
}
|
|
return "F"; // no region subtag to go on → keep the historical default
|
|
}
|
|
// A climate page knows which city it is about, so it publishes that country's
|
|
// convention on <body data-unit-default>. It sits *between* the stored choice and
|
|
// the locale guess: better than the locale (a UK visitor reading about Phoenix
|
|
// wants the °F the page already rendered, and repainting it to °C would just be a
|
|
// flash), but never better than an explicit toggle. Pages with no single city —
|
|
// the homepage, the interactive views — emit no attribute and fall through.
|
|
function pageDefault() {
|
|
const v = document.body && document.body.dataset.unitDefault;
|
|
return (v === "C" || v === "F") ? v : null;
|
|
}
|
|
const _stored = localStorage.getItem(UNIT_KEY);
|
|
let _unit = (_stored === "C" || _stored === "F")
|
|
? _stored
|
|
: (pageDefault() || defaultUnit());
|
|
const _unitCbs = [];
|
|
|
|
export function getUnit() { return _unit; }
|
|
|
|
// A Fahrenheit value as a number in the active unit.
|
|
export function toUnit(vF) { return _unit === "C" ? (vF - 32) * 5 / 9 : vF; }
|
|
|
|
// A Fahrenheit value formatted as a rounded temperature in the active unit.
|
|
// `withLetter` appends the C/F letter (off by default — the nav toggle shows it).
|
|
export function fmtTemp(vF, withLetter) {
|
|
if (vF == null || isNaN(vF)) return "—";
|
|
return `${Math.round(toUnit(vF))}°${withLetter ? _unit : ""}`;
|
|
}
|
|
|
|
// A Fahrenheit-degree *difference* (a span or tolerance, not an absolute
|
|
// reading) formatted in the active unit. Differences scale by 5/9 with no 32°
|
|
// offset, so they can't go through toUnit/fmtTemp.
|
|
export function fmtDelta(dF, withLetter) {
|
|
if (dF == null || isNaN(dF)) return "—";
|
|
const d = _unit === "C" ? dF * 5 / 9 : dF;
|
|
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
|
|
// <span data-unit-label="wind">mph</span>. 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) {} });
|
|
}
|
|
|
|
function syncUnitToggle(wrap) {
|
|
wrap.querySelectorAll("button[data-unit]").forEach((b) => {
|
|
const on = b.dataset.unit === _unit;
|
|
b.classList.toggle("active", on);
|
|
b.setAttribute("aria-pressed", on ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
// Drop a segmented °F/°C control into the header's top-right, ahead of the view
|
|
// switcher. A page opts out with data-no-unit-toggle on <body>.
|
|
(function buildUnitToggle() {
|
|
const brand = document.querySelector(".brand");
|
|
if (!brand || brand.querySelector(".unit-toggle")) return;
|
|
if (document.body.dataset.noUnitToggle != null) return;
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "unit-toggle";
|
|
wrap.setAttribute("role", "group");
|
|
// 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 = '<button type="button" data-unit="F">°F</button>'
|
|
+ '<button type="button" data-unit="C">°C</button>';
|
|
wrap.addEventListener("click", (e) => {
|
|
const b = e.target.closest("button[data-unit]");
|
|
if (b) setUnit(b.dataset.unit);
|
|
});
|
|
// Lives inside the nav menu panel: display:contents keeps it inline top-right
|
|
// on desktop; on phones it stacks inside the hamburger dropdown. Fall back to a
|
|
// brand sibling if the panel isn't present.
|
|
const panel = brand.querySelector(".nav-panel");
|
|
if (panel) panel.appendChild(wrap);
|
|
else brand.insertBefore(wrap, brand.querySelector(".view-nav"));
|
|
syncUnitToggle(wrap);
|
|
})();
|
|
|
|
paintUnitLabels();
|