"use strict"; // Shared cross-view navigation + last-location memory. Loaded on all three pages // (map, calendar, day) 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 US-wide map. 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 = '' + ''; 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; } const d = await res.json(); if (!res.ok) { const err = new Error(d.detail || "request failed"); err.status = res.status; throw err; } 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 cellTag = `${i + di}_${j + dj}`; if (_warmedCells.has(cellTag)) continue; _warmedCells.add(cellTag); const nq = `lat=${(centerLat + di * latStep).toFixed(5)}&lon=${(centerLon + dj * lonStep).toFixed(5)}`; setTimeout(() => { fetch(`api/v2/cell?${nq}&prefetch=1`).catch(() => {}); }, 1500 + 1100 * k++); } } } window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL, getUnit, toUnit, fmtTemp: fmtTempU, onUnitChange, setUnit };