// 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
. 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 are whole numbers — that is how rainfall is reported, and a tenth // of a millimetre is below what the source resolves. Inches keep two decimals, // since a whole inch of rain is a lot to round to. // 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" ? `${Math.round(vIn * MM_PER_IN)}${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) {} }); } 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 . (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 = '' + ''; 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();