The server-rendered SEO pages shared base.html.j2's nav but carried no JS, so
they lacked the °F/°C toggle and account/bell, and their temperatures were baked
as dual-unit strings that couldn't respond to a toggle.
- Load units.js + account.js + climate.js on base.html.j2, so every SEO page gets
the same header as the interactive pages (nav + °F/°C toggle + account + bell).
- Emit each temperature as <span class="temp" data-temp-f> (default °F text, real
for crawlers/no-JS); climate.js rewrites them to the active unit on load and on
toggle. Tint classes stay pinned to absolute °F. Drops the inline "(NN°C)".
- Make account.js base-aware: derive the app base from import.meta.url so its
api/v2 fetches and the alerts links resolve from deep /climate/{slug} URLs
instead of 404ing. Equivalent on the depth-1 interactive pages.
- Default the unit from the browser locale for first-time visitors (no stored
pref): en-US → °F, everyone else → °C. A stored choice always wins.
90 lines
4 KiB
JavaScript
90 lines
4 KiB
JavaScript
// 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.
|
|
|
|
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
|
|
}
|
|
const _stored = localStorage.getItem(UNIT_KEY);
|
|
let _unit = (_stored === "C" || _stored === "F") ? _stored : 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 : ""}`;
|
|
}
|
|
|
|
export function onUnitChange(cb) { _unitCbs.push(cb); }
|
|
|
|
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);
|
|
_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");
|
|
wrap.setAttribute("aria-label", "Temperature 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);
|
|
})();
|