Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
This commit is contained in:
parent
8715a7b464
commit
34e4fed1f0
4 changed files with 242 additions and 77 deletions
|
|
@ -51,12 +51,20 @@ results.addEventListener("click", (e) => {
|
|||
location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${row.dataset.date}`;
|
||||
});
|
||||
|
||||
let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date
|
||||
|
||||
async function runGrade() {
|
||||
if (!selected) return;
|
||||
updateHash();
|
||||
placeholder.hidden = true;
|
||||
results.hidden = false;
|
||||
results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`;
|
||||
const seq = ++gradeSeq;
|
||||
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
|
||||
// panel immediately would just flash. Only a genuinely slow (network) load
|
||||
// ever shows it.
|
||||
const spin = setTimeout(() => {
|
||||
if (seq === gradeSeq) results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`;
|
||||
}, 150);
|
||||
|
||||
// Omit the date when it's today, so the URL (and cache key) matches the prefetch
|
||||
// fired from the other views — a same-tab navigation then reuses the response.
|
||||
|
|
@ -64,11 +72,19 @@ async function runGrade() {
|
|||
const url = dateInput.value === todayISO() ? `api/grade?${q}` : `api/grade?${q}&date=${dateInput.value}`;
|
||||
let data;
|
||||
try {
|
||||
data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade);
|
||||
// Stale-while-revalidate: a stale cached copy renders immediately and the
|
||||
// update callback repaints if the background revalidation finds new data.
|
||||
data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade, false, (upd) => {
|
||||
if (seq === gradeSeq) render(upd);
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(spin);
|
||||
if (seq !== gradeSeq) return;
|
||||
results.innerHTML = `<p class="error">${err.message}</p>`;
|
||||
return;
|
||||
}
|
||||
clearTimeout(spin);
|
||||
if (seq !== gradeSeq) return;
|
||||
render(data);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -450,10 +450,16 @@ async function fetchCalendar() {
|
|||
placeholder.hidden = true;
|
||||
refreshBtn.hidden = true;
|
||||
calHead.hidden = false;
|
||||
calHead.innerHTML = `<p class="spinner">Grading daily weather across the range…</p>`;
|
||||
calEl.innerHTML = "";
|
||||
keyEl.innerHTML = "";
|
||||
data = null;
|
||||
// Delay the spinner: cache-served chunks render in a few ms, so clearing the
|
||||
// grid immediately would just flash-blank a warm load. A genuinely slow
|
||||
// (network) load still gets the spinner and a cleared grid.
|
||||
const spin = setTimeout(() => {
|
||||
if (token !== fetchToken || data) return;
|
||||
calHead.innerHTML = `<p class="spinner">Grading daily weather across the range…</p>`;
|
||||
calEl.innerHTML = "";
|
||||
keyEl.innerHTML = "";
|
||||
}, 150);
|
||||
|
||||
const q = `lat=${selected.lat}&lon=${selected.lon}`;
|
||||
// A custom span is split into ≤2-year requests; the default (no range) is one
|
||||
|
|
@ -498,7 +504,15 @@ async function fetchCalendar() {
|
|||
? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}`
|
||||
: `api/v2/calendar?${q}&months=24`;
|
||||
try {
|
||||
const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true);
|
||||
// Stale-while-revalidate: a stale cached chunk renders immediately; if the
|
||||
// background revalidation finds changed data, re-merge that chunk's days
|
||||
// and repaint (the cell/place metadata is identical across versions).
|
||||
const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true, (upd) => {
|
||||
if (token !== fetchToken) return;
|
||||
results[i] = upd;
|
||||
for (const x of upd.days) dayMap.set(x.date, x);
|
||||
advance();
|
||||
});
|
||||
if (token !== fetchToken) return;
|
||||
results[i] = d;
|
||||
for (const x of d.days) dayMap.set(x.date, x);
|
||||
|
|
@ -526,6 +540,7 @@ async function fetchCalendar() {
|
|||
};
|
||||
pump();
|
||||
});
|
||||
clearTimeout(spin);
|
||||
if (token !== fetchToken) return; // superseded while loading
|
||||
|
||||
if (!data) { // every chunk failed
|
||||
|
|
|
|||
|
|
@ -93,21 +93,42 @@ function stepDay(delta) {
|
|||
}
|
||||
|
||||
// ---- fetch + render ----
|
||||
let daySeq = 0; // supersedes an in-flight load when the user picks a new spot/date
|
||||
|
||||
async function fetchDay() {
|
||||
if (!selected) return;
|
||||
const dateQ = curDate ? `&date=${curDate}` : "";
|
||||
history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}${curDate ? `&date=${curDate}` : ""}`);
|
||||
placeholder.hidden = true;
|
||||
dayHead.hidden = false;
|
||||
dayHead.innerHTML = `<p class="spinner">Building the percentile breakdown…</p>`;
|
||||
dayBody.innerHTML = "";
|
||||
const seq = ++daySeq;
|
||||
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
|
||||
// page immediately would just flash. Only a slow (network) load shows it.
|
||||
const spin = setTimeout(() => {
|
||||
if (seq !== daySeq) return;
|
||||
dayHead.innerHTML = `<p class="spinner">Building the percentile breakdown…</p>`;
|
||||
dayBody.innerHTML = "";
|
||||
}, 150);
|
||||
let data;
|
||||
try {
|
||||
data = await window.Thermograph.getJSON(`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day);
|
||||
// Stale-while-revalidate: a stale cached copy renders immediately; the update
|
||||
// callback repaints if the background revalidation finds new data.
|
||||
data = await window.Thermograph.getJSON(
|
||||
`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day,
|
||||
false, (upd) => { if (seq === daySeq) finishDay(upd); });
|
||||
} catch (err) {
|
||||
clearTimeout(spin);
|
||||
if (seq !== daySeq) return;
|
||||
dayHead.innerHTML = `<p class="error">${err.message}</p>`;
|
||||
dayBody.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
clearTimeout(spin);
|
||||
if (seq !== daySeq) return;
|
||||
finishDay(data);
|
||||
}
|
||||
|
||||
function finishDay(data) {
|
||||
latest = data.latest;
|
||||
curDate = data.detail.date;
|
||||
dateInput.value = curDate;
|
||||
|
|
|
|||
249
static/nav.js
249
static/nav.js
|
|
@ -57,112 +57,225 @@ function refreshNav(lat, lon, date) {
|
|||
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.
|
||||
// ---- 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
|
||||
|
||||
// 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]);
|
||||
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;
|
||||
}
|
||||
|
||||
function putCache(store, key, s) {
|
||||
try { store.setItem(key, s); }
|
||||
catch (e) { evictOldestCache(store, 8); try { store.setItem(key, s); } catch (e2) {} }
|
||||
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.
|
||||
// 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")}`;
|
||||
}
|
||||
|
||||
// `persist` picks localStorage (survives tab close / navigating back to a prior
|
||||
// spot); otherwise sessionStorage (per-tab). A cache hit needs BOTH freshness
|
||||
// checks: within its TTL, and stored on today's local date.
|
||||
async function getJSON(url, ttlMs = 600000, persist = false) {
|
||||
const store = persist ? localStorage : sessionStorage;
|
||||
const key = "tg:" + url;
|
||||
try {
|
||||
const raw = store.getItem(key);
|
||||
if (raw) {
|
||||
const o = JSON.parse(raw);
|
||||
if (o.day === localDay() && Date.now() - o.t < ttlMs) return o.d;
|
||||
}
|
||||
} catch (e) {}
|
||||
const res = await fetch(url);
|
||||
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; }
|
||||
try {
|
||||
const s = JSON.stringify({ t: Date.now(), day: localDay(), d });
|
||||
if (s.length < 1500000) putCache(store, key, s); // skip caching very large payloads
|
||||
} catch (e) {}
|
||||
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.
|
||||
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; }
|
||||
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) {
|
||||
function viewFetches(q, today) {
|
||||
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],
|
||||
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. Each view with no fresh same-day cache is fetched once in the
|
||||
// background (already-cached views are left alone). Deduped per spot.
|
||||
// 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)},${skip}`;
|
||||
const tag = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
if (_prefetched === tag) return;
|
||||
_prefetched = tag;
|
||||
const q = `lat=${lat}&lon=${lon}`;
|
||||
const fetches = viewFetches(q);
|
||||
const own = new Set(VIEW_OWN[skip] || [skip]);
|
||||
const warm = () => {
|
||||
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(() => {});
|
||||
}
|
||||
};
|
||||
// Yield first so the landing view finishes rendering before we fetch the rest.
|
||||
setTimeout(warm, 350);
|
||||
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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue