From 4d3a5a10536d05bd5906ae28b52e2974482e6602 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 13:28:33 -0700 Subject: [PATCH] Convert the frontend to ES modules; split nav.js by concern (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend was classic scripts sharing one global scope, with a load-order contract enforced only by comments (leaflet -> nav -> shared -> mappicker -> page) and hand-rolled window.Thermograph / window.LocationPicker namespaces. Page scripts now import what they use; the dependency graph replaces the ordering contract, and no app globals remain (Leaflet stays a classic script / global L, loaded first). nav.js had grown four concerns; it's now three single-purpose modules: - nav.js: last-location memory + header view-links + locHash. - units.js: the °F/°C toggle and unit-aware formatting. The compare special case is gone — pages that want the toggle import units.js; compare simply doesn't. - cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews) now takes the calling page's own view names instead of a page-identity map (VIEW_OWN) — adding a page no longer means editing this module. The slice->URL map stays here as bundle-contract knowledge. frontend/package.json ({"type": "module"}) makes CI's node --check parse the files as modules. Verified: node --check on all files as modules; 108 backend tests; headless-Chromium smoke across all five pages against live data — zero console/page errors, all render assertions pass (cards, chart, calendar grid + metric switch, ladders, compare ranking, legend scales). --- static/app.js | 34 ++-- static/cache.js | 234 ++++++++++++++++++++++++ static/calendar.html | 5 +- static/calendar.js | 35 ++-- static/compare.html | 5 +- static/compare.js | 23 ++- static/day.html | 5 +- static/day.js | 28 +-- static/index.html | 5 +- static/legend.html | 8 +- static/mappicker.js | 411 +++++++++++++++++++++---------------------- static/nav.js | 319 +-------------------------------- static/package.json | 3 + static/shared.js | 317 ++++++++++++++++----------------- static/units.js | 61 +++++++ 15 files changed, 730 insertions(+), 763 deletions(-) create mode 100644 static/cache.js create mode 100644 static/package.json create mode 100644 static/units.js diff --git a/static/app.js b/static/app.js index 0dac510..f7953f9 100644 --- a/static/app.js +++ b/static/app.js @@ -1,7 +1,10 @@ -"use strict"; -// Shared tier colors, scales, formatters and helpers (single home: shared.js). -const { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel, - fmtPrecip, fmtWind, fmtHumid, locHash } = window.Thermograph; +// Weekly (map) page. Imports replace the old script-tag ordering contract. +import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; +import { fmtTemp, toUnit, onUnitChange } from "./units.js"; +import { getJSON, TTL, prefetchViews } from "./cache.js"; +import { initFindButton, setFindLabel } from "./mappicker.js"; +import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel, + fmtPrecip, fmtWind, fmtHumid } from "./shared.js"; let selected = null; // {lat, lon} @@ -38,7 +41,7 @@ function selectLocation(lat, lon) { // The Find button opens the shared modal map picker; choosing a spot loads it. const findBtn = document.getElementById("find-btn"); const locLabel = document.getElementById("loc-label"); -window.LocationPicker.initFindButton(findBtn, "Find a location", +initFindButton(findBtn, "Find a location", () => selected, (lat, lon) => selectLocation(lat, lon)); dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); }); @@ -78,7 +81,7 @@ async function runGrade() { try { // 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) => { + data = await getJSON(url, TTL.grade, false, (upd) => { if (seq === gradeSeq) render(upd); }); } catch (err) { @@ -92,13 +95,10 @@ async function runGrade() { render(data); } -// Temps flow through the shared unit formatter (°F from the API, shown in the -// active unit); the others are unit-agnostic. -const fmtTemp = (v) => window.Thermograph.fmtTemp(v); // Compact cell formatters for the dense recent/forecast table (units live in the // column headers, so values stay short). Dry days label the running dry streak // (see precipCell) rather than the rain amount. -const cTemp = (v) => window.Thermograph.fmtTemp(v); // active unit, bare degree +const cTemp = fmtTemp; // active unit, bare degree const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`); const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·"); @@ -119,7 +119,7 @@ function render(data) { // results have rendered (otherwise the name would show twice). locLabel.hidden = true; locLabel.textContent = ""; - window.LocationPicker.setFindLabel(findBtn, "Change location"); + setFindLabel(findBtn, "Change location"); const c = data.climatology; const cell = data.cell; const yrs = c.year_range ? `${c.year_range[0]}–${c.year_range[1]}` : "—"; @@ -180,7 +180,7 @@ function render(data) { `; renderSeries(); // fills #series (chart + graded-days timeline) from the window - window.Thermograph.prefetchViews(selected.lat, selected.lon, "grade"); // warm calendar/forecast + prefetchViews(selected.lat, selected.lon, ["grade", "forecast"]); // warm calendar/day document.getElementById("btn-link").onclick = copyShareLink; document.getElementById("btn-png").onclick = downloadChartPng; } @@ -249,7 +249,7 @@ function daysTable(rows, targetDate) { let recentData = null; // /grade response — the whole window, newest-first // Repaint everything in the newly-picked unit. -window.Thermograph.onUnitChange(() => { +onUnitChange(() => { if (recentData) render(recentData); }); @@ -561,7 +561,7 @@ function tempChart(key, days, n, xFor, C) { // Temperature series (° suffix) show in the active unit; wind/gust stay in mph. // The plot keeps its °F domain — only the labels convert — so positions hold. const isTemp = s.sfx === "°"; - const fmtV = (v) => `${Math.round(isTemp ? window.Thermograph.toUnit(v) : v)}${s.sfx}`; + const fmtV = (v) => `${Math.round(isTemp ? toUnit(v) : v)}${s.sfx}`; return lineChart({ days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false, getVal: (d) => (d[key] ? d[key].value : null), @@ -629,7 +629,7 @@ function attachChartHover(wrap) { const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; // "°" marks a temperature line — show it in the active unit; others are as-is. - const val = unit === "°" ? Math.round(window.Thermograph.toUnit(g.value)) : g.value; + const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value; return `
${label} ${val}${unit}${pct} ${g.grade}
`; }; @@ -763,13 +763,13 @@ function flashBtn(id, text) { function updateHash() { if (!selected) return; history.replaceState(null, "", locHash(selected.lat, selected.lon, dateInput.value)); - window.Thermograph.saveLastLocation(selected.lat, selected.lon, dateInput.value); + saveLastLocation(selected.lat, selected.lon, dateInput.value); } // Start where you left off: an explicit link (hash) wins, otherwise the last // place you looked at (remembered across views), otherwise the default world map. function restoreFromHash() { - const loc = window.Thermograph.loadLastLocation(); + const loc = loadLastLocation(); if (!loc) return; if (loc.date) dateInput.value = loc.date; syncTodayBtn(); diff --git a/static/cache.js b/static/cache.js new file mode 100644 index 0000000..e329e24 --- /dev/null +++ b/static/cache.js @@ -0,0 +1,234 @@ +// 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. + +export 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. +export 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. +export 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 per-view primary URLs the /cell bundle's slices map onto. This is the +// bundle contract (the server builds exactly these default requests), so it +// lives here next to the bundle fetch — not knowledge of any one page. +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], + }; +} + +// 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). +// +// `ownViews` lists the view names the calling page already loads in the +// foreground (so they're skipped) — the page declares what it owns instead of +// this module knowing every page. +let _prefetched = ""; +export function prefetchViews(lat, lon, ownViews) { + 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(ownViews); + 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++); + } + } +} diff --git a/static/calendar.html b/static/calendar.html index 45dc6e7..08a463f 100644 --- a/static/calendar.html +++ b/static/calendar.html @@ -93,9 +93,6 @@ - - - - + diff --git a/static/calendar.js b/static/calendar.js index e0a4d85..94bc13f 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -1,15 +1,14 @@ -"use strict"; // Thermograph calendar subpage — two years of daily grades as a month-grid heatmap. -// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode). Shared tier colors, -// scales, formatters, the weather summary and range chunking live in shared.js. - -const { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord, - fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, - CHUNK_MONTHS, buildChunks, clickOpensPicker, locHash, - weatherType: wxType } = window.Thermograph; -// Temps go through the shared unit formatter (the day tooltip reads it live, so a -// unit switch shows on the next hover). -const fmtTemp = (v) => window.Thermograph.fmtTemp(v); +// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode). +import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; +// The tooltip reads fmtTemp live, so a unit switch shows on the next hover. +import { fmtTemp } from "./units.js"; +import { getJSON, TTL, prefetchViews } from "./cache.js"; +import { initFindButton, setFindLabel } from "./mappicker.js"; +import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord, + fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, + CHUNK_MONTHS, buildChunks, clickOpensPicker, + weatherType as wxType } from "./shared.js"; // The diverging-temperature-scale metrics (colored exactly like High/Low), with a // display name for the key + tooltip and the formatter for their values. @@ -130,7 +129,7 @@ calEl.addEventListener("pointerleave", (e) => { // The Find button opens the shared modal map picker; choosing a spot loads it. const findBtn = document.getElementById("find-btn"); const locLabel = document.getElementById("loc-label"); -window.LocationPicker.initFindButton(findBtn, "Find a location", +initFindButton(findBtn, "Find a location", () => selected, (lat, lon) => selectLocation(lat, lon)); // ---- metric toggle ---- @@ -324,7 +323,7 @@ function syncDayToOtherViews() { const toFirst = rangeEnd.slice(0, 7) + "-01"; const todayIso = isoOfDate(new Date()); const day = toFirst > todayIso ? todayIso : toFirst; - window.Thermograph.saveLastLocation(selected.lat, selected.lon, day); + saveLastLocation(selected.lat, selected.lon, day); setCalSync(day); } @@ -332,7 +331,7 @@ function syncDayToOtherViews() { function selectLocation(lat, lon) { selected = { lat, lon }; history.replaceState(null, "", locHash(lat, lon)); - window.Thermograph.saveLastLocation(lat, lon); // keep date for the other views + saveLastLocation(lat, lon); // keep date for the other views // Show the raw coordinates immediately; they're replaced with the resolved // neighborhood + city name once the first calendar chunk lands. locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`; @@ -382,7 +381,7 @@ async function fetchCalendar() { document.getElementById("metric-block").hidden = false; locLabel.textContent = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`; locLabel.hidden = false; - findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Change location`; + setFindLabel(findBtn, "Change location"); renderFilters(); }; @@ -408,7 +407,7 @@ async function fetchCalendar() { // 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) => { + const d = await getJSON(url, TTL.calendar, true, (upd) => { if (token !== fetchToken) return; results[i] = upd; for (const x of upd.days) dayMap.set(x.date, x); @@ -450,7 +449,7 @@ async function fetchCalendar() { return; } renderHead(chunks.length - donePrefix, false); // final head (flags any gap) - window.Thermograph.prefetchViews(selected.lat, selected.lon, "calendar"); // warm weekly/forecast + prefetchViews(selected.lat, selected.lon, ["calendar"]); // warm weekly/day/forecast } // Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks" @@ -730,7 +729,7 @@ function attachHover(byDate) { // ---- restore (hash from a shared link, else the last place you looked at) ---- (function restore() { - const loc = window.Thermograph.loadLastLocation(); + const loc = loadLastLocation(); if (!loc) return; const saved = loadCalRange(); const sel = loc.date; // the shared selected day (from the hash or last-visited store) diff --git a/static/compare.html b/static/compare.html index 8ccad4e..c26b925 100644 --- a/static/compare.html +++ b/static/compare.html @@ -102,9 +102,6 @@ - - - - + diff --git a/static/compare.js b/static/compare.js index 8b0c85c..9dbb5f9 100644 --- a/static/compare.js +++ b/static/compare.js @@ -1,4 +1,3 @@ -"use strict"; // Compare view: line up several places over one date range and see which best // matches a comfort temperature. For each location we pull the same daily record // the Calendar uses (api/v2/calendar) and, per day, take a chosen temperature @@ -12,9 +11,11 @@ // hand). Editing the date range or the location set doesn't refetch on its own: // it arms the Refresh button, and the load runs when the user taps it. -// Shared date helpers + range chunking live in shared.js. -const { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks, - clickOpensPicker } = window.Thermograph; +import { loadLastLocation, saveLastLocation } from "./nav.js"; +import { getJSON, TTL } from "./cache.js"; +import { initFindButton } from "./mappicker.js"; +import { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks, + clickOpensPicker } from "./shared.js"; const CMP = { comfort: "thermograph:cmpComfort", @@ -104,7 +105,6 @@ const basisToggle = document.getElementById("cmp-basis"); const startInput = document.getElementById("cmp-start"); const endInput = document.getElementById("cmp-end"); -addBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Add location`; // ---- data ---- // Fetch one location's range (chunked) and reduce it to the compact per-day series. @@ -114,7 +114,7 @@ async function fetchSeries(loc, token) { let place = null; for (const ch of chunks) { const url = `api/v2/calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`; - const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true); + const d = await getJSON(url, TTL.calendar, true); if (token !== loadToken) return null; // superseded place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`; for (const r of d.days) days.push(r); @@ -158,7 +158,7 @@ function addLocation(lat, lon) { if (locations.some((l) => Math.abs(l.lat - lat) < 0.05 && Math.abs(l.lon - lon) < 0.05)) return; const loc = { lat, lon, name: null, series: null, loading: false, error: null }; locations.push(loc); - window.Thermograph.saveLastLocation(lat, lon); + saveLastLocation(lat, lon); persistLocations(); writeHashState(); renderAll(); // pending → the Refresh button appears; no fetch until it's tapped @@ -321,10 +321,9 @@ function renderAll() { } // ---- events ---- -addBtn.addEventListener("click", () => { - const cur = locations.length ? locations[locations.length - 1] : window.Thermograph.loadLastLocation(); - window.LocationPicker.open(cur, (lat, lon) => addLocation(lat, lon)); -}); +initFindButton(addBtn, "Add location", + () => (locations.length ? locations[locations.length - 1] : loadLastLocation()), + (lat, lon) => addLocation(lat, lon)); refreshBtn.addEventListener("click", refresh); @@ -384,7 +383,7 @@ clickOpensPicker(startInput, endInput); // whole-field tap opens the month pic // (the tap-to-refresh rule is for later interactive edits, not the initial view). let list = hash && hash.locs; if (!list) { try { const saved = JSON.parse(lsGet(CMP.locs)); if (Array.isArray(saved)) list = saved; } catch (e) {} } - if (!list || !list.length) { const last = window.Thermograph.loadLastLocation(); list = last ? [{ lat: last.lat, lon: last.lon }] : []; } + if (!list || !list.length) { const last = loadLastLocation(); list = last ? [{ lat: last.lat, lon: last.lon }] : []; } locations = list .filter((l) => l && typeof l.lat === "number" && typeof l.lon === "number") diff --git a/static/day.html b/static/day.html index bcfd107..60c9da5 100644 --- a/static/day.html +++ b/static/day.html @@ -65,9 +65,6 @@ - - - - + diff --git a/static/day.js b/static/day.js index 78e363b..8405a9a 100644 --- a/static/day.js +++ b/static/day.js @@ -1,12 +1,12 @@ -"use strict"; // Thermograph single-day detail subpage — for one day + location, shows the value // at every percentile tier boundary in that day-of-year's ±7-day climate window, // and where the observed values land. Talks to /api/v2/day + /api/v2/geocode. -// Shared tier colors, formatters, weather summary and helpers live in shared.js. - -const { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid, - todayISO, weatherType, placeLabel, locHash } = window.Thermograph; -const fmtTemp = (v) => window.Thermograph.fmtTemp(v); +import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; +import { fmtTemp, onUnitChange } from "./units.js"; +import { getJSON, TTL, prefetchViews } from "./cache.js"; +import { initFindButton, setFindLabel } from "./mappicker.js"; +import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid, + todayISO, weatherType, placeLabel } from "./shared.js"; // Color a tier the same way the calendar cell would be colored for it. const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || ""); @@ -24,7 +24,7 @@ const dateInput = document.getElementById("date-input"); // The Find button opens the shared modal map picker; choosing a spot loads it. const findBtn = document.getElementById("find-btn"); const locLabel = document.getElementById("loc-label"); -window.LocationPicker.initFindButton(findBtn, "Find a location", () => selected, +initFindButton(findBtn, "Find a location", () => selected, (lat, lon) => { selected = { lat, lon }; // Show the raw coordinates immediately; render() replaces them with the @@ -70,8 +70,8 @@ async function fetchDay() { try { // 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, + data = await getJSON( + `api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, TTL.day, false, (upd) => { if (seq === daySeq) finishDay(upd); }); } catch (err) { clearTimeout(spin); @@ -93,13 +93,13 @@ function finishDay(data) { // forecast bundle), not just the latest fully-archived day. dateInput.max = todayISO(); document.getElementById("next-day").disabled = curDate >= todayISO(); - window.Thermograph.saveLastLocation(selected.lat, selected.lon, curDate); + saveLastLocation(selected.lat, selected.lon, curDate); render(data); - window.Thermograph.prefetchViews(selected.lat, selected.lon, "day"); // warm weekly/calendar/forecast + prefetchViews(selected.lat, selected.lon, ["day"]); // warm weekly/calendar/forecast } let lastData = null; // most recent /day response, for repainting on a unit switch -window.Thermograph.onUnitChange(() => { if (lastData) render(lastData); }); +onUnitChange(() => { if (lastData) render(lastData); }); function render(data) { lastData = data; @@ -107,7 +107,7 @@ function render(data) { const place = placeLabel(data); locLabel.textContent = place; locLabel.hidden = false; - window.LocationPicker.setFindLabel(findBtn, "Change location"); + setFindLabel(findBtn, "Change location"); const dt = new Date(d.date + "T00:00:00"); const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); const yrs = d.year_range ? `${d.year_range[0]}–${d.year_range[1]}` : "—"; @@ -187,7 +187,7 @@ function ladderCard(title, m, fmt, kind) { // ---- restore (hash from a shared link, else the last place you looked at) ---- (function restore() { - const loc = window.Thermograph.loadLastLocation(); + const loc = loadLastLocation(); if (loc) { selected = { lat: loc.lat, lon: loc.lon }; curDate = loc.date || todayISO(); // default to today when no date is given diff --git a/static/index.html b/static/index.html index 6ec6935..aaf29c1 100644 --- a/static/index.html +++ b/static/index.html @@ -68,9 +68,6 @@ - - - - + diff --git a/static/legend.html b/static/legend.html index ff5b00a..6ccb109 100644 --- a/static/legend.html +++ b/static/legend.html @@ -84,12 +84,12 @@

← Back to Thermograph

- - -