thermograph/static/nav.js
Emi Griffith a251b0df23 Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).

- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
  from style.css's :root custom properties at load — the CSS is now the
  single source of truth; the JS map exists only because inline-SVG work
  (chart + PNG export) needs literal values, with hex fallbacks for a
  missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
  todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
  and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
  the shared classic-script scope, and a leaked locHash collided with page
  destructuring (caught by the browser smoke, not by node --check).
  locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
  each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
  no longer drift from what the app shows.

Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00

358 lines
15 KiB
JavaScript

"use strict";
// Shared cross-view navigation + last-location memory. Loaded on all pages
// before that page's own script. It remembers the most recently selected spot
// in localStorage and keeps the header's view links (Map · Calendar · Day)
// pointing at that spot — so switching views, or coming back to the map, lands
// you where you were instead of an empty world map.
//
// Wrapped in an IIFE so nothing here becomes a global — every classic script
// shares one global scope, and a leaked `function locHash` collides with a
// page's `const { locHash } = window.Thermograph`. The namespace object at
// the bottom is the only export.
(function () {
const LOC_KEY = "thermograph:loc";
function readStored() {
try {
const o = JSON.parse(localStorage.getItem(LOC_KEY));
if (o && typeof o.lat === "number" && typeof o.lon === "number") return o;
} catch (e) {}
return null;
}
// Where should this page start? A hash (a shared/opened link) always wins;
// otherwise fall back to the last place the user looked at.
function loadLastLocation() {
const p = new URLSearchParams(location.hash.slice(1));
const lat = parseFloat(p.get("lat")), lon = parseFloat(p.get("lon"));
if (!isNaN(lat) && !isNaN(lon)) return { lat, lon, date: p.get("date") || null };
return readStored();
}
// Remember a spot. Passing no `date` preserves whatever date was stored before,
// so hopping through the calendar (which has no date) doesn't wipe the day you
// were on elsewhere.
function saveLastLocation(lat, lon, date) {
let d = date;
if (d === undefined) { const prev = readStored(); d = prev ? prev.date : null; }
try { localStorage.setItem(LOC_KEY, JSON.stringify({ lat, lon, date: d || null })); } catch (e) {}
refreshNav(lat, lon, d);
}
function locHash(lat, lon, date) {
let h = `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`;
if (date) h += `&date=${date}`;
return h;
}
// Point every header view-link at the current spot so a tap keeps your place.
function refreshNav(lat, lon, date) {
if (lat == null || lon == null) return;
const h = locHash(lat, lon, date);
document.querySelectorAll(".view-nav a[data-view]").forEach((a) => {
a.href = (a.dataset.base || "") + h;
});
}
// ---- temperature units (shared °C / °F toggle) ----
// The API always speaks Fahrenheit; the unit is a pure display concern. Pages
// format temps through `fmtTempU`/`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";
let _unit = (localStorage.getItem(UNIT_KEY) === "C") ? "C" : "F";
const _unitCbs = [];
function getUnit() { return _unit; }
// A Fahrenheit value as a number in the active unit.
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).
function fmtTempU(vF, withLetter) {
if (vF == null || isNaN(vF)) return "—";
return `${Math.round(toUnit(vF))}°${withLetter ? _unit : ""}`;
}
function onUnitChange(cb) { _unitCbs.push(cb); }
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. Skipped on Compare, whose comfort slider is inherently °F-based.
function buildUnitToggle() {
const brand = document.querySelector(".brand");
if (!brand || brand.querySelector(".unit-toggle")) return;
const active = document.querySelector(".view-nav a.active");
if (active && active.dataset.view === "compare") 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);
});
const nav = brand.querySelector(".view-nav");
brand.insertBefore(wrap, nav); // sits just left of the view tabs (top-right)
syncUnitToggle(wrap);
}
(function initNav() {
document.querySelectorAll(".view-nav a[data-view]").forEach((a) => {
a.dataset.base = a.getAttribute("href");
});
buildUnitToggle();
const loc = loadLastLocation();
if (loc) refreshNav(loc.lat, loc.lon, loc.date);
})();
// ---- shared response cache (IndexedDB) + cross-view prefetch ----
// Cached API JSON lets the views (a full page load each) reuse data instead of
// refetching. Entries live in IndexedDB — no localStorage ~5 MB quota, so even
// multi-year calendar payloads persist across tabs and restarts — with a small
// in-memory map in front for same-page rereads.
//
// Freshness is layered:
// * fresh (within TTL, stored today) → served as-is, no request.
// * stale + caller passed onUpdate → served instantly, then revalidated in
// the background with If-None-Match (stale-while-revalidate). A 304 just
// re-stamps the entry; a changed payload triggers onUpdate for a repaint.
// * stale without onUpdate / no entry → network (still conditional when an
// etag is stored, so an unchanged payload costs an empty 304).
// * network failure with any entry cached → the stale copy, rather than an error.
const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000 }; // calendar: 6h
const IDB_NAME = "thermograph-cache", IDB_STORE = "resp";
const IDB_MAX_AGE = 14 * 86400000; // prune entries untouched for 2 weeks
let _dbp = null;
function idb() {
if (_dbp) return _dbp;
_dbp = new Promise((resolve) => {
let req;
try { req = indexedDB.open(IDB_NAME, 1); } catch (e) { return resolve(null); }
req.onupgradeneeded = () => { req.result.createObjectStore(IDB_STORE, { keyPath: "url" }); };
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null); // blocked/private mode — cache disabled
req.onblocked = () => resolve(null);
});
return _dbp;
}
const MEM = new Map(); // page-lifetime L1 over IndexedDB
async function cacheGet(url) {
if (MEM.has(url)) return MEM.get(url);
const db = await idb();
if (!db) return null;
return new Promise((resolve) => {
try {
const req = db.transaction(IDB_STORE, "readonly").objectStore(IDB_STORE).get(url);
req.onsuccess = () => { const r = req.result || null; if (r) MEM.set(url, r); resolve(r); };
req.onerror = () => resolve(null);
} catch (e) { resolve(null); }
});
}
async function cachePut(rec) {
MEM.set(rec.url, rec);
const db = await idb();
if (!db) return;
try { db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).put(rec); } catch (e) {}
}
// One-time hygiene per page load: drop cache entries untouched for weeks, and
// clear the legacy localStorage/sessionStorage entries this cache replaced.
(async function cleanCache() {
try {
for (const st of [localStorage, sessionStorage]) {
const ks = [];
for (let i = 0; i < st.length; i++) { const k = st.key(i); if (k && k.startsWith("tg:")) ks.push(k); }
ks.forEach((k) => st.removeItem(k));
}
} catch (e) {}
const db = await idb();
if (!db) return;
try {
const os = db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE);
os.openCursor().onsuccess = (e) => {
const cur = e.target.result;
if (!cur) return;
if (!cur.value.t || Date.now() - cur.value.t > IDB_MAX_AGE) cur.delete();
cur.continue();
};
} catch (e) {}
})();
// The viewer's own calendar day ("YYYY-MM-DD", local clock). Every cached response
// is tagged with the day it was stored, so a cache made yesterday is treated as
// stale today — the newest available day (and the day counts derived from it) can't
// be hidden behind an otherwise-fresh cache across a midnight rollover. (The
// revalidation that follows is conditional: unchanged data costs an empty 304.)
function localDay() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const isFresh = (rec, ttlMs) => !!(rec && rec.day === localDay() && Date.now() - rec.t < ttlMs);
// GET + store, conditional when a cached etag exists. A 304 re-stamps the cached
// entry (fresh again, no payload moved); a 200 stores payload + etag.
async function fetchStore(url, rec) {
const headers = rec && rec.etag ? { "If-None-Match": rec.etag } : {};
const res = await fetch(url, { headers });
if (res.status === 304 && rec) {
cachePut({ ...rec, t: Date.now(), day: localDay() });
return rec.d;
}
if (!res.ok) {
// Error bodies aren't always JSON (a proxy 502 serves an HTML page) — fall
// back to the status line rather than surfacing a JSON parse error.
let detail = null;
try { detail = (await res.json()).detail; } catch (e) {}
const err = new Error(detail || `Request failed (${res.status} ${res.statusText})`.trim());
err.status = res.status;
throw err;
}
const d = await res.json();
cachePut({ url, t: Date.now(), day: localDay(), etag: res.headers.get("ETag") || null, d });
return d;
}
// `persist` is legacy (everything persists in IndexedDB now); kept so call sites
// don't churn. `onUpdate` opts into stale-while-revalidate: a stale entry is
// returned immediately and onUpdate(fresh) fires only if revalidation finds
// actually-changed data.
async function getJSON(url, ttlMs = 600000, persist = false, onUpdate = null) {
const rec = await cacheGet(url);
if (isFresh(rec, ttlMs)) return rec.d;
if (rec && onUpdate) {
fetchStore(url, rec).then((d) => {
if (d !== rec.d && JSON.stringify(d) !== JSON.stringify(rec.d)) onUpdate(d);
}).catch(() => {}); // background refresh is best-effort; the stale render stands
return rec.d;
}
try { return await fetchStore(url, rec); }
catch (e) { if (rec) return rec.d; throw e; } // offline/unreachable → stale beats an error
}
// Is there already a usable (fresh, same-day) cache entry for this URL? Lets the
// prefetch skip views that are already warm and only reach out for the ones missing.
async function hasFreshCache(url, ttlMs) {
return isFresh(await cacheGet(url), ttlMs);
}
// Seed the cache for a URL from data that arrived by other means (a bundle
// slice), with the etag its own endpoint would have sent — so the entry is
// later revalidated per-view exactly as if it had been fetched directly.
function seedCache(url, data, etag) {
cachePut({ url, t: Date.now(), day: localDay(), etag: etag || null, d: data });
}
// The primary API each view loads first. Weekly (the map page) owns two panels —
// its grade and its forecast — so leaving that page prefetches neither.
function viewFetches(q, today) {
return {
grade: [`api/grade?${q}`, TTL.grade],
forecast: [`api/v2/forecast?${q}`, TTL.forecast],
calendar: [`api/v2/calendar?${q}&months=24`, TTL.calendar],
day: [`api/v2/day?${q}&date=${today}`, TTL.day],
};
}
// The fetch(es) that belong to the page prefetch is called from, so they're skipped
// (that data is already loading in the foreground).
const VIEW_OWN = { grade: ["grade", "forecast"], calendar: ["calendar"], day: ["day"] };
// After the landing page's own data is up, warm the OTHER views so switching to
// them is instant — with ONE /api/v2/cell bundle request instead of one per view.
// Its slices are the exact per-view payloads (each carrying the etag its endpoint
// would emit), seeded under the per-view URLs the views actually request. The
// bundle call itself is conditional via a remembered etag, so a still-valid spot
// costs an empty 304. Deduped per spot; afterwards the 8 surrounding grid cells
// are warmed server-side (see prefetchNeighbors).
let _prefetched = "";
function prefetchViews(lat, lon, skip) {
const tag = `${lat.toFixed(4)},${lon.toFixed(4)}`;
if (_prefetched === tag) return;
_prefetched = tag;
const q = `lat=${lat}&lon=${lon}`;
// Yield first so the landing view finishes rendering before we fetch the rest.
setTimeout(async () => {
const fetches = viewFetches(q, localDay());
const own = new Set(VIEW_OWN[skip] || [skip]);
let missing = false;
for (const name of Object.keys(fetches)) {
if (own.has(name)) continue;
if (!(await hasFreshCache(...fetches[name]))) { missing = true; break; }
}
if (missing) {
try {
const ek = "tg-cell-etag:" + q;
let prev = null;
try { prev = sessionStorage.getItem(ek); } catch (e) {}
const res = await fetch(`api/v2/cell?${q}`, prev ? { headers: { "If-None-Match": prev } } : {});
if (res.ok && res.status !== 304) {
const bundle = await res.json();
try { sessionStorage.setItem(ek, res.headers.get("ETag") || ""); } catch (e) {}
for (const [name, slice] of Object.entries(bundle.slices || {})) {
if (!slice || !slice.data) continue;
const url = name === "day"
? `api/v2/day?${q}&date=${bundle.today}` // the day slice targets today
: (fetches[name] || [])[0];
if (url) seedCache(url, slice.data, slice.etag);
}
}
} catch (e) {} // prefetch is a bonus; every view still fetches for itself
}
prefetchNeighbors(lat, lon);
}, 350);
}
// Warm the server for the 8 grid cells around the selected one, so tapping a
// nearby spot lands on already-graded data. prefetch=1 is warm-only: the server
// never spends weather-API quota for it (a cold cell answers 204), and at most
// one reverse-geocode lookup happens per brand-new cell — hence the ≥1.1 s
// stagger, respecting Nominatim's 1-request/second policy. Responses aren't
// stored client-side (cache keys are coordinate-based); the value is server-side
// warmth. Mirrors backend/grid.py's snapping math.
const _warmedCells = new Set();
function prefetchNeighbors(lat, lon) {
const latStep = 1 / 34.5;
const i = Math.floor(lat / latStep);
const centerLat = (i + 0.5) * latStep;
const lonStep = latStep / Math.max(Math.cos((centerLat * Math.PI) / 180), 0.05);
const j = Math.floor(lon / lonStep);
const centerLon = (j + 0.5) * lonStep;
let k = 0;
for (const di of [-1, 0, 1]) {
for (const dj of [-1, 0, 1]) {
if (!di && !dj) continue;
const nLat = centerLat + di * latStep;
if (Math.abs(nLat) > 90) continue; // no rows past the poles
const nLon = ((centerLon + dj * lonStep + 540) % 360) - 180; // wrap across the antimeridian
const cellTag = `${i + di}_${j + dj}`;
if (_warmedCells.has(cellTag)) continue;
_warmedCells.add(cellTag);
const nq = `lat=${nLat.toFixed(5)}&lon=${nLon.toFixed(5)}`;
setTimeout(() => { fetch(`api/v2/cell?${nq}&prefetch=1`).catch(() => {}); }, 1500 + 1100 * k++);
}
}
}
window.Thermograph = { loadLastLocation, saveLastLocation, locHash, getJSON, prefetchViews,
hasFreshCache, TTL, getUnit, toUnit, fmtTemp: fmtTempU, onUnitChange, setUnit };
})();