2026-07-11 00:29:47 +00:00
|
|
|
"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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(function initNav() {
|
|
|
|
|
document.querySelectorAll(".view-nav a[data-view]").forEach((a) => {
|
|
|
|
|
a.dataset.base = a.getAttribute("href");
|
|
|
|
|
});
|
|
|
|
|
const loc = loadLastLocation();
|
|
|
|
|
if (loc) refreshNav(loc.lat, loc.lon, loc.date);
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
// ---- shared response cache + cross-view prefetch ----
|
|
|
|
|
// Cached API JSON lets the three views (a full page load each) reuse data instead
|
|
|
|
|
// of refetching, and the server caches upstream so a client miss is still cheap.
|
|
|
|
|
// The calendar cache is *persistent* (localStorage) so a page refresh — or coming
|
|
|
|
|
// back to a location viewed earlier — repaints instantly instead of regrading.
|
|
|
|
|
const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000 }; // calendar: 6h
|
|
|
|
|
|
|
|
|
|
// Evict the oldest N "tg:" entries from a Storage (ordered by stored timestamp) to
|
|
|
|
|
// make room when it hits quota.
|
|
|
|
|
function evictOldestCache(store, n) {
|
|
|
|
|
const items = [];
|
|
|
|
|
for (let i = 0; i < store.length; i++) {
|
|
|
|
|
const k = store.key(i);
|
|
|
|
|
if (!k || !k.startsWith("tg:")) continue;
|
|
|
|
|
let t = 0;
|
|
|
|
|
try { t = JSON.parse(store.getItem(k)).t || 0; } catch (e) {}
|
|
|
|
|
items.push([k, t]);
|
|
|
|
|
}
|
|
|
|
|
items.sort((a, b) => a[1] - b[1]);
|
|
|
|
|
for (let i = 0; i < Math.min(n, items.length); i++) store.removeItem(items[i][0]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function putCache(store, key, s) {
|
|
|
|
|
try { store.setItem(key, s); }
|
|
|
|
|
catch (e) { evictOldestCache(store, 8); try { store.setItem(key, s); } catch (e2) {} }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 02:28:34 +00:00
|
|
|
// 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.
|
|
|
|
|
function localDay() {
|
|
|
|
|
const d = new Date();
|
|
|
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
// `persist` picks localStorage (survives tab close / navigating back to a prior
|
2026-07-11 02:28:34 +00:00
|
|
|
// spot); otherwise sessionStorage (per-tab). A cache hit needs BOTH freshness
|
|
|
|
|
// checks: within its TTL, and stored on today's local date.
|
2026-07-11 00:29:47 +00:00
|
|
|
async function getJSON(url, ttlMs = 600000, persist = false) {
|
|
|
|
|
const store = persist ? localStorage : sessionStorage;
|
|
|
|
|
const key = "tg:" + url;
|
|
|
|
|
try {
|
|
|
|
|
const raw = store.getItem(key);
|
2026-07-11 02:28:34 +00:00
|
|
|
if (raw) {
|
|
|
|
|
const o = JSON.parse(raw);
|
|
|
|
|
if (o.day === localDay() && Date.now() - o.t < ttlMs) return o.d;
|
|
|
|
|
}
|
2026-07-11 00:29:47 +00:00
|
|
|
} catch (e) {}
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const d = await res.json();
|
|
|
|
|
if (!res.ok) { const err = new Error(d.detail || "request failed"); err.status = res.status; throw err; }
|
|
|
|
|
try {
|
2026-07-11 02:28:34 +00:00
|
|
|
const s = JSON.stringify({ t: Date.now(), day: localDay(), d });
|
2026-07-11 00:29:47 +00:00
|
|
|
if (s.length < 1500000) putCache(store, key, s); // skip caching very large payloads
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
return d;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 02:28:34 +00:00
|
|
|
// 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.
|
|
|
|
|
function hasFreshCache(url, ttlMs, persist) {
|
|
|
|
|
const store = persist ? localStorage : sessionStorage;
|
|
|
|
|
try {
|
|
|
|
|
const raw = store.getItem("tg:" + url);
|
|
|
|
|
if (!raw) return false;
|
|
|
|
|
const o = JSON.parse(raw);
|
|
|
|
|
return o.day === localDay() && Date.now() - o.t < ttlMs;
|
|
|
|
|
} catch (e) { return false; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
return {
|
|
|
|
|
grade: [`api/grade?${q}`, TTL.grade, false],
|
|
|
|
|
forecast: [`api/v2/forecast?${q}`, TTL.forecast, false],
|
|
|
|
|
calendar: [`api/v2/calendar?${q}&months=24`, TTL.calendar, true],
|
|
|
|
|
day: [`api/v2/day?${q}`, TTL.day, false],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
// 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. Each view with no fresh same-day cache is fetched once in the
|
|
|
|
|
// background (already-cached views are left alone). Deduped per spot.
|
2026-07-11 00:29:47 +00:00
|
|
|
let _prefetched = "";
|
|
|
|
|
function prefetchViews(lat, lon, skip) {
|
|
|
|
|
const tag = `${lat.toFixed(4)},${lon.toFixed(4)},${skip}`;
|
|
|
|
|
if (_prefetched === tag) return;
|
|
|
|
|
_prefetched = tag;
|
|
|
|
|
const q = `lat=${lat}&lon=${lon}`;
|
2026-07-11 02:28:34 +00:00
|
|
|
const fetches = viewFetches(q);
|
|
|
|
|
const own = new Set(VIEW_OWN[skip] || [skip]);
|
2026-07-11 00:29:47 +00:00
|
|
|
const warm = () => {
|
2026-07-11 02:28:34 +00:00
|
|
|
for (const name of Object.keys(fetches)) {
|
|
|
|
|
if (own.has(name)) continue; // the current page's own data
|
|
|
|
|
const [url, ttl, persist] = fetches[name];
|
|
|
|
|
if (hasFreshCache(url, ttl, persist)) continue; // already warm — no refetch
|
|
|
|
|
getJSON(url, ttl, persist).catch(() => {});
|
|
|
|
|
}
|
2026-07-11 00:29:47 +00:00
|
|
|
};
|
|
|
|
|
// Yield first so the landing view finishes rendering before we fetch the rest.
|
|
|
|
|
setTimeout(warm, 350);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 02:28:34 +00:00
|
|
|
window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL };
|