Convert the frontend to ES modules; split nav.js by concern (#48)
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).
This commit is contained in:
parent
a251b0df23
commit
4d3a5a1053
15 changed files with 730 additions and 763 deletions
|
|
@ -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 `<div><b style="color:${color}">${label}</b> ${val}${unit}${pct} <span class="tt-grade" style="color:${gc}">${g.grade}</span></div>`;
|
||||
};
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
234
static/cache.js
Normal file
234
static/cache.js
Normal file
|
|
@ -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++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,9 +93,6 @@
|
|||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script src="shared.js"></script>
|
||||
<script src="mappicker.js"></script>
|
||||
<script src="calendar.js"></script>
|
||||
<script type="module" src="calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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} <span>Change location</span>`;
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -102,9 +102,6 @@
|
|||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script src="shared.js"></script>
|
||||
<script src="mappicker.js"></script>
|
||||
<script src="compare.js"></script>
|
||||
<script type="module" src="compare.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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} <span>Add location</span>`;
|
||||
|
||||
// ---- 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")
|
||||
|
|
|
|||
|
|
@ -65,9 +65,6 @@
|
|||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script src="shared.js"></script>
|
||||
<script src="mappicker.js"></script>
|
||||
<script src="day.js"></script>
|
||||
<script type="module" src="day.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -68,9 +68,6 @@
|
|||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script src="shared.js"></script>
|
||||
<script src="mappicker.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -84,12 +84,12 @@
|
|||
<p class="guide-back"><a href="./">← Back to Thermograph</a></p>
|
||||
</main>
|
||||
|
||||
<script src="nav.js"></script>
|
||||
<script src="shared.js"></script>
|
||||
<script>
|
||||
<script type="module">
|
||||
import "./nav.js"; // header view-links follow the last location
|
||||
import "./units.js"; // the °F/°C toggle
|
||||
// The scale rows come from the shared tier tables, so this guide can't
|
||||
// drift from what the app actually shows.
|
||||
const { SCALE_TEMP, SCALE_RAIN } = window.Thermograph;
|
||||
import { SCALE_TEMP, SCALE_RAIN } from "./shared.js";
|
||||
const row = ({ c, label, range }) =>
|
||||
`<div class="guide-seg"><span class="guide-sw" style="background:var(--${c})"></span>` +
|
||||
`<span class="guide-lbl">${label}</span><span class="guide-rng">${range}</span></div>`;
|
||||
|
|
|
|||
|
|
@ -1,225 +1,220 @@
|
|||
"use strict";
|
||||
// Shared modal "location picker". Every view's Find button opens this overlay:
|
||||
// a search box + a Leaflet map. Searching a place jumps the map (and a matching
|
||||
// result selects it immediately); tapping anywhere on the map drops a pin the user
|
||||
// can fine-tune before confirming. Picking closes the overlay and hands the chosen
|
||||
// lat/lon back to the page via the onPick callback.
|
||||
//
|
||||
// Loaded on every page AFTER leaflet.js and BEFORE that page's own script. The map
|
||||
// itself is created lazily the first time the picker opens (and reused after), so
|
||||
// pages that never open it pay nothing.
|
||||
(function () {
|
||||
let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null;
|
||||
let searchForm, searchInput, sugEl, confirmBtn, hintEl;
|
||||
let sugSeq = 0, sugTimer = 0, sugAbort = null;
|
||||
// Leaflet stays a classic script (global L), loaded before the page modules. The
|
||||
// map itself is created lazily the first time the picker opens (and reused after),
|
||||
// so pages that never open it pay nothing.
|
||||
let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null;
|
||||
let searchForm, searchInput, sugEl, confirmBtn, hintEl;
|
||||
let sugSeq = 0, sugTimer = 0, sugAbort = null;
|
||||
|
||||
const PIN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`;
|
||||
export const PIN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`;
|
||||
|
||||
function build() {
|
||||
overlay = document.createElement("div");
|
||||
overlay.className = "mp-overlay";
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML = `
|
||||
<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Pick a location">
|
||||
<div class="mp-head">
|
||||
<h2>Find a location</h2>
|
||||
<button type="button" class="mp-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form class="mp-search" autocomplete="off">
|
||||
<input type="text" placeholder="Search for a place…" autocomplete="off" />
|
||||
<button type="submit">Search</button>
|
||||
<ul class="mp-sug" hidden></ul>
|
||||
</form>
|
||||
<div class="mp-map"></div>
|
||||
<div class="mp-foot">
|
||||
<span class="mp-hint">Search above, or tap the map to drop a pin.</span>
|
||||
<button type="button" class="mp-confirm" disabled>Use this location</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
function build() {
|
||||
overlay = document.createElement("div");
|
||||
overlay.className = "mp-overlay";
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML = `
|
||||
<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Pick a location">
|
||||
<div class="mp-head">
|
||||
<h2>Find a location</h2>
|
||||
<button type="button" class="mp-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form class="mp-search" autocomplete="off">
|
||||
<input type="text" placeholder="Search for a place…" autocomplete="off" />
|
||||
<button type="submit">Search</button>
|
||||
<ul class="mp-sug" hidden></ul>
|
||||
</form>
|
||||
<div class="mp-map"></div>
|
||||
<div class="mp-foot">
|
||||
<span class="mp-hint">Search above, or tap the map to drop a pin.</span>
|
||||
<button type="button" class="mp-confirm" disabled>Use this location</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
mapEl = overlay.querySelector(".mp-map");
|
||||
searchForm = overlay.querySelector(".mp-search");
|
||||
searchInput = searchForm.querySelector("input");
|
||||
sugEl = overlay.querySelector(".mp-sug");
|
||||
confirmBtn = overlay.querySelector(".mp-confirm");
|
||||
hintEl = overlay.querySelector(".mp-hint");
|
||||
mapEl = overlay.querySelector(".mp-map");
|
||||
searchForm = overlay.querySelector(".mp-search");
|
||||
searchInput = searchForm.querySelector("input");
|
||||
sugEl = overlay.querySelector(".mp-sug");
|
||||
confirmBtn = overlay.querySelector(".mp-confirm");
|
||||
hintEl = overlay.querySelector(".mp-hint");
|
||||
|
||||
overlay.querySelector(".mp-close").onclick = close;
|
||||
// Tapping the dimmed backdrop (outside the modal) closes the picker.
|
||||
overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) close(); });
|
||||
confirmBtn.onclick = () => finish(pin);
|
||||
overlay.querySelector(".mp-close").onclick = close;
|
||||
// Tapping the dimmed backdrop (outside the modal) closes the picker.
|
||||
overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) close(); });
|
||||
confirmBtn.onclick = () => finish(pin);
|
||||
|
||||
// Live suggestions (top 5, single-letter-typo tolerant) as the user types:
|
||||
// debounced so a fast typist doesn't fire a request per keystroke, aborted
|
||||
// + sequence-guarded so a slow stale response can't overwrite a newer list.
|
||||
async function fetchSug(q) {
|
||||
const seq = ++sugSeq;
|
||||
if (sugAbort) sugAbort.abort();
|
||||
sugAbort = new AbortController();
|
||||
try {
|
||||
const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal });
|
||||
const d = await res.json();
|
||||
if (seq === sugSeq) renderSug(d.results || []);
|
||||
} catch (err) { /* aborted, or offline — the map stays the fallback way to pick */ }
|
||||
}
|
||||
searchInput.addEventListener("input", () => {
|
||||
clearTimeout(sugTimer);
|
||||
// The old list stays visible until fresh results land, but its highlight
|
||||
// must go — Enter mid-typing shouldn't pick a suggestion for the shorter
|
||||
// query the list still reflects.
|
||||
const stale = sugEl.querySelector("li.active");
|
||||
if (stale) stale.classList.remove("active");
|
||||
const q = searchInput.value.trim();
|
||||
if (q.length < 2) { sugEl.hidden = true; return; }
|
||||
sugTimer = setTimeout(() => fetchSug(q), 250);
|
||||
});
|
||||
// Arrow keys walk the list (Enter picks via the submit handler below);
|
||||
// Escape dismisses the list before the overlay's Escape-to-close kicks in.
|
||||
searchInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !sugEl.hidden) {
|
||||
sugEl.hidden = true;
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (sugEl.hidden || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
|
||||
const items = [...sugEl.children];
|
||||
if (!items.length) return;
|
||||
e.preventDefault();
|
||||
const cur = items.findIndex((li) => li.classList.contains("active"));
|
||||
const next = e.key === "ArrowDown"
|
||||
? (cur + 1) % items.length
|
||||
: (cur <= 0 ? items.length - 1 : cur - 1);
|
||||
setActiveSug(items, next);
|
||||
items[next].scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
searchForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(sugTimer);
|
||||
// Enter picks the highlighted suggestion when the list is up; otherwise
|
||||
// it searches immediately (no debounce wait).
|
||||
const active = sugEl.hidden ? null : sugEl.querySelector("li.active");
|
||||
if (active) { active.click(); return; }
|
||||
const q = searchInput.value.trim();
|
||||
if (q) fetchSug(q);
|
||||
});
|
||||
// Hide the suggestions when tapping elsewhere in the modal (but not the map,
|
||||
// which has its own tap handler).
|
||||
overlay.addEventListener("pointerdown", (e) => {
|
||||
if (!e.target.closest(".mp-search")) sugEl.hidden = true;
|
||||
}, true);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (overlay && !overlay.hidden && e.key === "Escape") close();
|
||||
});
|
||||
// Live suggestions (top 5, single-letter-typo tolerant) as the user types:
|
||||
// debounced so a fast typist doesn't fire a request per keystroke, aborted
|
||||
// + sequence-guarded so a slow stale response can't overwrite a newer list.
|
||||
async function fetchSug(q) {
|
||||
const seq = ++sugSeq;
|
||||
if (sugAbort) sugAbort.abort();
|
||||
sugAbort = new AbortController();
|
||||
try {
|
||||
const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal });
|
||||
const d = await res.json();
|
||||
if (seq === sugSeq) renderSug(d.results || []);
|
||||
} catch (err) { /* aborted, or offline — the map stays the fallback way to pick */ }
|
||||
}
|
||||
|
||||
function setActiveSug(items, idx) {
|
||||
items.forEach((li, i) => li.classList.toggle("active", i === idx));
|
||||
}
|
||||
|
||||
function renderSug(list) {
|
||||
const shown = list;
|
||||
sugEl.innerHTML = "";
|
||||
if (!shown.length) { sugEl.hidden = true; return; }
|
||||
shown.forEach((r) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `${r.name}<span class="sub"> — ${[r.admin1, r.country].filter(Boolean).join(", ")}</span>`;
|
||||
// A picked search result is an unambiguous choice — drop the pin, recenter,
|
||||
// and select it right away (the map is there for manual/fine picks).
|
||||
li.onclick = () => {
|
||||
sugEl.hidden = true;
|
||||
searchInput.value = r.name;
|
||||
setPin(r.lat, r.lon, 10);
|
||||
finish({ lat: r.lat, lon: r.lon });
|
||||
};
|
||||
// Keep one highlight: hovering moves it off whatever the arrows chose.
|
||||
li.addEventListener("pointerenter", () =>
|
||||
setActiveSug([...sugEl.children], [...sugEl.children].indexOf(li)));
|
||||
sugEl.appendChild(li);
|
||||
});
|
||||
// Pre-highlight the top match so Enter picks it straight away.
|
||||
setActiveSug([...sugEl.children], 0);
|
||||
sugEl.hidden = false;
|
||||
}
|
||||
|
||||
function setPin(lat, lon, zoom) {
|
||||
pin = { lat, lon };
|
||||
if (marker) marker.setLatLng([lat, lon]);
|
||||
else marker = L.marker([lat, lon], pinIcon ? { icon: pinIcon } : undefined).addTo(map);
|
||||
map.setView([lat, lon], zoom || map.getZoom());
|
||||
confirmBtn.disabled = false;
|
||||
hintEl.textContent = "Pin set — confirm below, or tap elsewhere to move it.";
|
||||
}
|
||||
|
||||
function finish(p) {
|
||||
if (!p) return;
|
||||
const cb = onPickCb;
|
||||
close();
|
||||
if (cb) cb(p.lat, p.lon);
|
||||
}
|
||||
|
||||
function open(cur, onPick) {
|
||||
if (!overlay) build();
|
||||
onPickCb = onPick;
|
||||
pin = null;
|
||||
confirmBtn.disabled = true;
|
||||
// Cancel any in-flight/pending suggestion fetch from the previous open so
|
||||
// a late response can't pop a stale list over the fresh empty box.
|
||||
searchInput.addEventListener("input", () => {
|
||||
clearTimeout(sugTimer);
|
||||
sugSeq++;
|
||||
sugEl.hidden = true;
|
||||
searchInput.value = "";
|
||||
hintEl.textContent = "Search above, or tap the map to drop a pin.";
|
||||
overlay.hidden = false;
|
||||
|
||||
if (!map) {
|
||||
map = L.map(mapEl, { worldCopyJump: true, zoomControl: true }).setView([25, 0], 2);
|
||||
// Colorful CARTO "Voyager" basemap (no key needed), split into two raster
|
||||
// layers so road weight and label size can be tuned independently:
|
||||
// • the label-free base is requested one zoom lower and drawn at 512px
|
||||
// (tileSize 512 + zoomOffset -1) so roads read bold and prominent;
|
||||
// • the labels-only layer is drawn on top at its natural size, keeping city
|
||||
// names small and crisp rather than scaled up with the roads.
|
||||
// @2x keeps both sharp; a darken/saturate filter (style.css, .mp-base only)
|
||||
// deepens the roads while leaving the label text at full contrast.
|
||||
const CARTO = "https://{s}.basemaps.cartocdn.com/rastertiles";
|
||||
const ATTRIB = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
L.tileLayer(`${CARTO}/voyager_nolabels/{z}/{x}/{y}@2x.png`, {
|
||||
subdomains: "abcd", maxZoom: 20, tileSize: 512, zoomOffset: -1,
|
||||
className: "mp-base", attribution: ATTRIB,
|
||||
}).addTo(map);
|
||||
L.tileLayer(`${CARTO}/voyager_only_labels/{z}/{x}/{y}@2x.png`, {
|
||||
subdomains: "abcd", maxZoom: 20, className: "mp-labels",
|
||||
}).addTo(map);
|
||||
// Branded accent teardrop pin (the same glyph the Find button uses), styled
|
||||
// in style.css — replaces Leaflet's default blue raster marker.
|
||||
pinIcon = L.divIcon({
|
||||
className: "mp-pin", html: PIN_ICON, iconSize: [32, 32], iconAnchor: [16, 30],
|
||||
});
|
||||
map.on("click", (e) => setPin(e.latlng.lat, e.latlng.lng));
|
||||
// The old list stays visible until fresh results land, but its highlight
|
||||
// must go — Enter mid-typing shouldn't pick a suggestion for the shorter
|
||||
// query the list still reflects.
|
||||
const stale = sugEl.querySelector("li.active");
|
||||
if (stale) stale.classList.remove("active");
|
||||
const q = searchInput.value.trim();
|
||||
if (q.length < 2) { sugEl.hidden = true; return; }
|
||||
sugTimer = setTimeout(() => fetchSug(q), 250);
|
||||
});
|
||||
// Arrow keys walk the list (Enter picks via the submit handler below);
|
||||
// Escape dismisses the list before the overlay's Escape-to-close kicks in.
|
||||
searchInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !sugEl.hidden) {
|
||||
sugEl.hidden = true;
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// Leaflet measures the container on creation; it's zero-sized while hidden, so
|
||||
// recalc once the modal is actually visible and (if reopening on a known spot)
|
||||
// drop a pin there as the starting point.
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
if (cur && cur.lat != null && cur.lon != null) setPin(cur.lat, cur.lon, 10);
|
||||
searchInput.focus();
|
||||
}, 60);
|
||||
}
|
||||
if (sugEl.hidden || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
|
||||
const items = [...sugEl.children];
|
||||
if (!items.length) return;
|
||||
e.preventDefault();
|
||||
const cur = items.findIndex((li) => li.classList.contains("active"));
|
||||
const next = e.key === "ArrowDown"
|
||||
? (cur + 1) % items.length
|
||||
: (cur <= 0 ? items.length - 1 : cur - 1);
|
||||
setActiveSug(items, next);
|
||||
items[next].scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
searchForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(sugTimer);
|
||||
// Enter picks the highlighted suggestion when the list is up; otherwise
|
||||
// it searches immediately (no debounce wait).
|
||||
const active = sugEl.hidden ? null : sugEl.querySelector("li.active");
|
||||
if (active) { active.click(); return; }
|
||||
const q = searchInput.value.trim();
|
||||
if (q) fetchSug(q);
|
||||
});
|
||||
// Hide the suggestions when tapping elsewhere in the modal (but not the map,
|
||||
// which has its own tap handler).
|
||||
overlay.addEventListener("pointerdown", (e) => {
|
||||
if (!e.target.closest(".mp-search")) sugEl.hidden = true;
|
||||
}, true);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (overlay && !overlay.hidden && e.key === "Escape") close();
|
||||
});
|
||||
}
|
||||
|
||||
function close() { if (overlay) overlay.hidden = true; }
|
||||
function setActiveSug(items, idx) {
|
||||
items.forEach((li, i) => li.classList.toggle("active", i === idx));
|
||||
}
|
||||
|
||||
// Wire a page's Find/Add button: pin icon + label, and open the picker on
|
||||
// click seeded with the page's current spot. `setFindLabel` swaps the label
|
||||
// (e.g. "Find a location" → "Change location") keeping the icon markup.
|
||||
function initFindButton(btn, label, getCurrent, onPick) {
|
||||
setFindLabel(btn, label);
|
||||
btn.addEventListener("click", () => open(getCurrent(), onPick));
|
||||
}
|
||||
function setFindLabel(btn, label) {
|
||||
btn.innerHTML = `${PIN_ICON} <span>${label}</span>`;
|
||||
}
|
||||
function renderSug(list) {
|
||||
const shown = list;
|
||||
sugEl.innerHTML = "";
|
||||
if (!shown.length) { sugEl.hidden = true; return; }
|
||||
shown.forEach((r) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `${r.name}<span class="sub"> — ${[r.admin1, r.country].filter(Boolean).join(", ")}</span>`;
|
||||
// A picked search result is an unambiguous choice — drop the pin, recenter,
|
||||
// and select it right away (the map is there for manual/fine picks).
|
||||
li.onclick = () => {
|
||||
sugEl.hidden = true;
|
||||
searchInput.value = r.name;
|
||||
setPin(r.lat, r.lon, 10);
|
||||
finish({ lat: r.lat, lon: r.lon });
|
||||
};
|
||||
// Keep one highlight: hovering moves it off whatever the arrows chose.
|
||||
li.addEventListener("pointerenter", () =>
|
||||
setActiveSug([...sugEl.children], [...sugEl.children].indexOf(li)));
|
||||
sugEl.appendChild(li);
|
||||
});
|
||||
// Pre-highlight the top match so Enter picks it straight away.
|
||||
setActiveSug([...sugEl.children], 0);
|
||||
sugEl.hidden = false;
|
||||
}
|
||||
|
||||
window.LocationPicker = { open, PIN_ICON, initFindButton, setFindLabel };
|
||||
})();
|
||||
function setPin(lat, lon, zoom) {
|
||||
pin = { lat, lon };
|
||||
if (marker) marker.setLatLng([lat, lon]);
|
||||
else marker = L.marker([lat, lon], pinIcon ? { icon: pinIcon } : undefined).addTo(map);
|
||||
map.setView([lat, lon], zoom || map.getZoom());
|
||||
confirmBtn.disabled = false;
|
||||
hintEl.textContent = "Pin set — confirm below, or tap elsewhere to move it.";
|
||||
}
|
||||
|
||||
function finish(p) {
|
||||
if (!p) return;
|
||||
const cb = onPickCb;
|
||||
close();
|
||||
if (cb) cb(p.lat, p.lon);
|
||||
}
|
||||
|
||||
export function open(cur, onPick) {
|
||||
if (!overlay) build();
|
||||
onPickCb = onPick;
|
||||
pin = null;
|
||||
confirmBtn.disabled = true;
|
||||
// Cancel any in-flight/pending suggestion fetch from the previous open so
|
||||
// a late response can't pop a stale list over the fresh empty box.
|
||||
clearTimeout(sugTimer);
|
||||
sugSeq++;
|
||||
sugEl.hidden = true;
|
||||
searchInput.value = "";
|
||||
hintEl.textContent = "Search above, or tap the map to drop a pin.";
|
||||
overlay.hidden = false;
|
||||
|
||||
if (!map) {
|
||||
map = L.map(mapEl, { worldCopyJump: true, zoomControl: true }).setView([25, 0], 2);
|
||||
// Colorful CARTO "Voyager" basemap (no key needed), split into two raster
|
||||
// layers so road weight and label size can be tuned independently:
|
||||
// • the label-free base is requested one zoom lower and drawn at 512px
|
||||
// (tileSize 512 + zoomOffset -1) so roads read bold and prominent;
|
||||
// • the labels-only layer is drawn on top at its natural size, keeping city
|
||||
// names small and crisp rather than scaled up with the roads.
|
||||
// @2x keeps both sharp; a darken/saturate filter (style.css, .mp-base only)
|
||||
// deepens the roads while leaving the label text at full contrast.
|
||||
const CARTO = "https://{s}.basemaps.cartocdn.com/rastertiles";
|
||||
const ATTRIB = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
L.tileLayer(`${CARTO}/voyager_nolabels/{z}/{x}/{y}@2x.png`, {
|
||||
subdomains: "abcd", maxZoom: 20, tileSize: 512, zoomOffset: -1,
|
||||
className: "mp-base", attribution: ATTRIB,
|
||||
}).addTo(map);
|
||||
L.tileLayer(`${CARTO}/voyager_only_labels/{z}/{x}/{y}@2x.png`, {
|
||||
subdomains: "abcd", maxZoom: 20, className: "mp-labels",
|
||||
}).addTo(map);
|
||||
// Branded accent teardrop pin (the same glyph the Find button uses), styled
|
||||
// in style.css — replaces Leaflet's default blue raster marker.
|
||||
pinIcon = L.divIcon({
|
||||
className: "mp-pin", html: PIN_ICON, iconSize: [32, 32], iconAnchor: [16, 30],
|
||||
});
|
||||
map.on("click", (e) => setPin(e.latlng.lat, e.latlng.lng));
|
||||
}
|
||||
// Leaflet measures the container on creation; it's zero-sized while hidden, so
|
||||
// recalc once the modal is actually visible and (if reopening on a known spot)
|
||||
// drop a pin there as the starting point.
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
if (cur && cur.lat != null && cur.lon != null) setPin(cur.lat, cur.lon, 10);
|
||||
searchInput.focus();
|
||||
}, 60);
|
||||
}
|
||||
|
||||
function close() { if (overlay) overlay.hidden = true; }
|
||||
|
||||
// Wire a page's Find/Add button: pin icon + label, and open the picker on
|
||||
// click seeded with the page's current spot. `setFindLabel` swaps the label
|
||||
// (e.g. "Find a location" → "Change location") keeping the icon markup.
|
||||
export function initFindButton(btn, label, getCurrent, onPick) {
|
||||
setFindLabel(btn, label);
|
||||
btn.addEventListener("click", () => open(getCurrent(), onPick));
|
||||
}
|
||||
export function setFindLabel(btn, label) {
|
||||
btn.innerHTML = `${PIN_ICON} <span>${label}</span>`;
|
||||
}
|
||||
|
|
|
|||
319
static/nav.js
319
static/nav.js
|
|
@ -1,15 +1,8 @@
|
|||
"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 () {
|
||||
// Cross-view navigation + last-location memory. Remembers the most recently
|
||||
// selected spot in localStorage and keeps the header's view links
|
||||
// (Map · Calendar · Day · Compare) pointing at that spot — so switching views,
|
||||
// or coming back to the map, lands you where you were instead of an empty map.
|
||||
|
||||
const LOC_KEY = "thermograph:loc";
|
||||
|
||||
function readStored() {
|
||||
|
|
@ -22,7 +15,7 @@ function readStored() {
|
|||
|
||||
// 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() {
|
||||
export 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 };
|
||||
|
|
@ -32,14 +25,14 @@ function loadLastLocation() {
|
|||
// 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) {
|
||||
export 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) {
|
||||
export function locHash(lat, lon, date) {
|
||||
let h = `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`;
|
||||
if (date) h += `&date=${date}`;
|
||||
return h;
|
||||
|
|
@ -54,305 +47,11 @@ function refreshNav(lat, lon, date) {
|
|||
});
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
|
||||
// Point the header links at the current spot as soon as the page loads.
|
||||
(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 };
|
||||
})();
|
||||
|
|
|
|||
3
static/package.json
Normal file
3
static/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
317
static/shared.js
317
static/shared.js
|
|
@ -1,170 +1,159 @@
|
|||
"use strict";
|
||||
// Shared presentation constants + small helpers for every page script. Loaded on
|
||||
// all pages right after nav.js (which creates window.Thermograph) and extends
|
||||
// that namespace — each constant and helper here previously existed as a copy in
|
||||
// app.js, calendar.js, day.js, compare.js and/or legend.html; pages destructure
|
||||
// what they need so a tier color, scale label or formatter has exactly one home.
|
||||
(function () {
|
||||
// ---- tier colors ----
|
||||
// style.css's :root custom properties are the source of truth (they're not
|
||||
// re-themed, so reading them once at load is safe). The JS map exists because
|
||||
// inline-SVG work — chart building and the PNG export in particular — needs
|
||||
// literal color values, where CSS variables can't resolve. The hex fallbacks
|
||||
// only cover a missing/blocked stylesheet.
|
||||
const FALLBACK = {
|
||||
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
||||
"normal": "#4a9d5b",
|
||||
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
||||
"dry": "#c9a24a",
|
||||
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f",
|
||||
"wet-5": "#35a1c0", "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97",
|
||||
"wet-9": "#08306b",
|
||||
};
|
||||
const rootCss = getComputedStyle(document.documentElement);
|
||||
const TIER_COLORS = {};
|
||||
for (const [cls, fb] of Object.entries(FALLBACK)) {
|
||||
TIER_COLORS[cls] = rootCss.getPropertyValue(`--${cls}`).trim() || fb;
|
||||
// Shared presentation constants + small helpers for every page script — each
|
||||
// constant and helper here previously existed as a copy in app.js, calendar.js,
|
||||
// day.js, compare.js and/or legend.html; pages import what they need so a tier
|
||||
// color, scale label or formatter has exactly one home.
|
||||
// ---- tier colors ----
|
||||
// style.css's :root custom properties are the source of truth (they're not
|
||||
// re-themed, so reading them once at load is safe). The JS map exists because
|
||||
// inline-SVG work — chart building and the PNG export in particular — needs
|
||||
// literal color values, where CSS variables can't resolve. The hex fallbacks
|
||||
// only cover a missing/blocked stylesheet.
|
||||
const FALLBACK = {
|
||||
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
||||
"normal": "#4a9d5b",
|
||||
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
||||
"dry": "#c9a24a",
|
||||
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f",
|
||||
"wet-5": "#35a1c0", "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97",
|
||||
"wet-9": "#08306b",
|
||||
};
|
||||
const rootCss = getComputedStyle(document.documentElement);
|
||||
export const TIER_COLORS = {};
|
||||
for (const [cls, fb] of Object.entries(FALLBACK)) {
|
||||
TIER_COLORS[cls] = rootCss.getPropertyValue(`--${cls}`).trim() || fb;
|
||||
}
|
||||
// The rain-intensity subset + the dry tint, for pages that color them apart.
|
||||
export const PRECIP_COLORS = Object.fromEntries(
|
||||
Object.entries(TIER_COLORS).filter(([c]) => c.startsWith("wet-")));
|
||||
export const DRY_COLOR = TIER_COLORS.dry;
|
||||
|
||||
// ---- percentile tier scales, low → high ----
|
||||
// Names are deliberately *relative* to each place's own climate history (a
|
||||
// percentile), not absolute temperature words — see the README design note.
|
||||
export const SCALE_TEMP = [
|
||||
{ c: "rec-cold", label: "Near Record", range: "<1" },
|
||||
{ c: "very-cold", label: "Very Low", range: "1–10" },
|
||||
{ c: "cold", label: "Low", range: "10–25" },
|
||||
{ c: "cool", label: "Below Normal", range: "25–40" },
|
||||
{ c: "normal", label: "Normal", range: "40–60" },
|
||||
{ c: "warm", label: "Above Normal", range: "60–75" },
|
||||
{ c: "hot", label: "High", range: "75–90" },
|
||||
{ c: "very-hot", label: "Very High", range: "90–99" },
|
||||
{ c: "rec-hot", label: "Near Record", range: ">99" },
|
||||
];
|
||||
// Rain-intensity tiers by rain-day percentile (dry days show their dry streak).
|
||||
export const SCALE_RAIN = [
|
||||
{ c: "wet-1", label: "Trace", range: "<1" },
|
||||
{ c: "wet-2", label: "Very Light", range: "1–10" },
|
||||
{ c: "wet-3", label: "Light", range: "10–25" },
|
||||
{ c: "wet-4", label: "Light–Mod", range: "25–40" },
|
||||
{ c: "wet-5", label: "Moderate", range: "40–60" },
|
||||
{ c: "wet-6", label: "Mod–Heavy", range: "60–75" },
|
||||
{ c: "wet-7", label: "Heavy", range: "75–90" },
|
||||
{ c: "wet-8", label: "Very Heavy", range: "90–99" },
|
||||
{ c: "wet-9", label: "Extreme", range: ">99" },
|
||||
];
|
||||
|
||||
// ---- dry-streak ramp ----
|
||||
// "Days since last rain": rain days are blue; each dry day warms from a light
|
||||
// base toward dark red, saturating at 14 consecutive dry days.
|
||||
const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14;
|
||||
const lerpRgb = (a, b, t) =>
|
||||
`rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`;
|
||||
export function drynessColor(dsr) {
|
||||
if (dsr == null) return "";
|
||||
if (dsr === 0) return "#2b7bba"; // measurable rain that day
|
||||
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
|
||||
}
|
||||
|
||||
// ---- formatters & tiny utils ----
|
||||
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
||||
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||||
export const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
||||
export const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
||||
export const ord = (n) => {
|
||||
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
||||
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
};
|
||||
export const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
// The heading label for a graded response: the resolved place name, or the
|
||||
// cell-center coordinates while (or if) no name resolves.
|
||||
export const placeLabel = (data) =>
|
||||
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
|
||||
|
||||
// ---- dates & range chunking ----
|
||||
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
export const pad = (n) => String(n).padStart(2, "0");
|
||||
export const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
export const monthStart = (ym) => `${ym}-01`; // 1st of a "YYYY-MM"
|
||||
export const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0));
|
||||
|
||||
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so
|
||||
// each stays within the calendar endpoint's per-request cap; the calendar grid
|
||||
// and the compare series both fill chunk by chunk.
|
||||
export const CHUNK_MONTHS = 24;
|
||||
export function buildChunks(startIso, endIso) {
|
||||
const chunks = [];
|
||||
let s = startIso, guard = 0;
|
||||
while (s <= endIso && guard++ < 200) {
|
||||
const sd = new Date(s + "T00:00:00");
|
||||
const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate());
|
||||
ed.setDate(ed.getDate() - 1); // 24 months inclusive
|
||||
let e = isoOfDate(ed);
|
||||
if (e > endIso) e = endIso;
|
||||
chunks.push({ start: s, end: e });
|
||||
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
|
||||
s = isoOfDate(ns);
|
||||
}
|
||||
// The rain-intensity subset + the dry tint, for pages that color them apart.
|
||||
const PRECIP_COLORS = Object.fromEntries(
|
||||
Object.entries(TIER_COLORS).filter(([c]) => c.startsWith("wet-")));
|
||||
const DRY_COLOR = TIER_COLORS.dry;
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ---- percentile tier scales, low → high ----
|
||||
// Names are deliberately *relative* to each place's own climate history (a
|
||||
// percentile), not absolute temperature words — see the README design note.
|
||||
const SCALE_TEMP = [
|
||||
{ c: "rec-cold", label: "Near Record", range: "<1" },
|
||||
{ c: "very-cold", label: "Very Low", range: "1–10" },
|
||||
{ c: "cold", label: "Low", range: "10–25" },
|
||||
{ c: "cool", label: "Below Normal", range: "25–40" },
|
||||
{ c: "normal", label: "Normal", range: "40–60" },
|
||||
{ c: "warm", label: "Above Normal", range: "60–75" },
|
||||
{ c: "hot", label: "High", range: "75–90" },
|
||||
{ c: "very-hot", label: "Very High", range: "90–99" },
|
||||
{ c: "rec-hot", label: "Near Record", range: ">99" },
|
||||
];
|
||||
// Rain-intensity tiers by rain-day percentile (dry days show their dry streak).
|
||||
const SCALE_RAIN = [
|
||||
{ c: "wet-1", label: "Trace", range: "<1" },
|
||||
{ c: "wet-2", label: "Very Light", range: "1–10" },
|
||||
{ c: "wet-3", label: "Light", range: "10–25" },
|
||||
{ c: "wet-4", label: "Light–Mod", range: "25–40" },
|
||||
{ c: "wet-5", label: "Moderate", range: "40–60" },
|
||||
{ c: "wet-6", label: "Mod–Heavy", range: "60–75" },
|
||||
{ c: "wet-7", label: "Heavy", range: "75–90" },
|
||||
{ c: "wet-8", label: "Very Heavy", range: "90–99" },
|
||||
{ c: "wet-9", label: "Extreme", range: ">99" },
|
||||
];
|
||||
|
||||
// ---- dry-streak ramp ----
|
||||
// "Days since last rain": rain days are blue; each dry day warms from a light
|
||||
// base toward dark red, saturating at 14 consecutive dry days.
|
||||
const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14;
|
||||
const lerpRgb = (a, b, t) =>
|
||||
`rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`;
|
||||
function drynessColor(dsr) {
|
||||
if (dsr == null) return "";
|
||||
if (dsr === 0) return "#2b7bba"; // measurable rain that day
|
||||
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
|
||||
// Clicking anywhere in a month/date field opens the native picker. Desktop
|
||||
// Chrome otherwise only opens it from the tiny calendar glyph and just focuses
|
||||
// a segment; showPicker is missing in some browsers (Safari/Firefox) — ignore.
|
||||
export function clickOpensPicker(...els) {
|
||||
for (const el of els) {
|
||||
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- formatters & tiny utils ----
|
||||
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
||||
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||||
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
||||
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
||||
const ord = (n) => {
|
||||
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
||||
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
};
|
||||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
// The heading label for a graded response: the resolved place name, or the
|
||||
// cell-center coordinates while (or if) no name resolves.
|
||||
const placeLabel = (data) =>
|
||||
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
|
||||
// ---- weather summary ----
|
||||
// Monochrome line icons (currentColor) — no emoji, matching the site's dark
|
||||
// look. Feather-style paths.
|
||||
const WX = (paths) =>
|
||||
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
||||
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
|
||||
export const WX_ICONS = {
|
||||
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
|
||||
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
|
||||
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
|
||||
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
|
||||
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
|
||||
};
|
||||
|
||||
// ---- dates & range chunking ----
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const monthStart = (ym) => `${ym}-01`; // 1st of a "YYYY-MM"
|
||||
const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0));
|
||||
|
||||
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so
|
||||
// each stays within the calendar endpoint's per-request cap; the calendar grid
|
||||
// and the compare series both fill chunk by chunk.
|
||||
const CHUNK_MONTHS = 24;
|
||||
function buildChunks(startIso, endIso) {
|
||||
const chunks = [];
|
||||
let s = startIso, guard = 0;
|
||||
while (s <= endIso && guard++ < 200) {
|
||||
const sd = new Date(s + "T00:00:00");
|
||||
const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate());
|
||||
ed.setDate(ed.getDate() - 1); // 24 months inclusive
|
||||
let e = isoOfDate(ed);
|
||||
if (e > endIso) e = endIso;
|
||||
chunks.push({ start: s, end: e });
|
||||
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
|
||||
s = isoOfDate(ns);
|
||||
}
|
||||
return chunks;
|
||||
// Plain-language "what was the day like" descriptor from the raw values: a
|
||||
// temperature word (from the daily high, °F) crossed with a sky/precip word
|
||||
// (precip in inches — falling as snow at/below freezing). `dsr` is optional:
|
||||
// when a long dry streak is known, a no-rain day reads "dry" instead of
|
||||
// "clear" (the calendar passes it; the day page doesn't track streaks).
|
||||
export function weatherType(t, p, dsr) {
|
||||
const wet = p != null && p >= 0.01;
|
||||
const freezing = t != null && t <= 34;
|
||||
const temp = t == null ? "" :
|
||||
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
||||
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
|
||||
let sky, skyKey, icon;
|
||||
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; skyKey = "snow"; icon = WX_ICONS.snow; }
|
||||
else if (wet && p >= 1.0) { sky = "downpour"; skyKey = "downpour"; icon = WX_ICONS.storm; }
|
||||
else if (wet && p >= 0.3) { sky = "rain"; skyKey = "rain"; icon = WX_ICONS.rain; }
|
||||
else if (wet) { sky = "light rain"; skyKey = "drizzle"; icon = WX_ICONS.drizzle; }
|
||||
else {
|
||||
skyKey = dsr != null && dsr >= 10 ? "dry" : "clear";
|
||||
sky = skyKey === "dry" ? "dry" : "clear";
|
||||
icon = WX_ICONS.sun;
|
||||
}
|
||||
|
||||
// Clicking anywhere in a month/date field opens the native picker. Desktop
|
||||
// Chrome otherwise only opens it from the tiny calendar glyph and just focuses
|
||||
// a segment; showPicker is missing in some browsers (Safari/Firefox) — ignore.
|
||||
function clickOpensPicker(...els) {
|
||||
for (const el of els) {
|
||||
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- weather summary ----
|
||||
// Monochrome line icons (currentColor) — no emoji, matching the site's dark
|
||||
// look. Feather-style paths.
|
||||
const WX = (paths) =>
|
||||
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
||||
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
|
||||
const WX_ICONS = {
|
||||
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
|
||||
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
|
||||
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
|
||||
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
|
||||
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
|
||||
};
|
||||
|
||||
// Plain-language "what was the day like" descriptor from the raw values: a
|
||||
// temperature word (from the daily high, °F) crossed with a sky/precip word
|
||||
// (precip in inches — falling as snow at/below freezing). `dsr` is optional:
|
||||
// when a long dry streak is known, a no-rain day reads "dry" instead of
|
||||
// "clear" (the calendar passes it; the day page doesn't track streaks).
|
||||
function weatherType(t, p, dsr) {
|
||||
const wet = p != null && p >= 0.01;
|
||||
const freezing = t != null && t <= 34;
|
||||
const temp = t == null ? "" :
|
||||
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
||||
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
|
||||
let sky, skyKey, icon;
|
||||
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; skyKey = "snow"; icon = WX_ICONS.snow; }
|
||||
else if (wet && p >= 1.0) { sky = "downpour"; skyKey = "downpour"; icon = WX_ICONS.storm; }
|
||||
else if (wet && p >= 0.3) { sky = "rain"; skyKey = "rain"; icon = WX_ICONS.rain; }
|
||||
else if (wet) { sky = "light rain"; skyKey = "drizzle"; icon = WX_ICONS.drizzle; }
|
||||
else {
|
||||
skyKey = dsr != null && dsr >= 10 ? "dry" : "clear";
|
||||
sky = skyKey === "dry" ? "dry" : "clear";
|
||||
icon = WX_ICONS.sun;
|
||||
}
|
||||
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
|
||||
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1),
|
||||
feel: temp.toLowerCase(), sky: skyKey };
|
||||
}
|
||||
|
||||
Object.assign(window.Thermograph, {
|
||||
TIER_COLORS, PRECIP_COLORS, DRY_COLOR, SCALE_TEMP, SCALE_RAIN,
|
||||
drynessColor, fmtPrecip, fmtWind, fmtHumid, ord, todayISO, esc, placeLabel,
|
||||
MONTHS, pad, isoOfDate, monthStart, monthEnd, CHUNK_MONTHS, buildChunks,
|
||||
clickOpensPicker, WX_ICONS, weatherType,
|
||||
});
|
||||
})();
|
||||
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
|
||||
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1),
|
||||
feel: temp.toLowerCase(), sky: skyKey };
|
||||
}
|
||||
|
|
|
|||
61
static/units.js
Normal file
61
static/units.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Temperature units — the shared °C/°F toggle and unit-aware formatting.
|
||||
// The API always speaks Fahrenheit; the unit is a pure display concern. Pages
|
||||
// format temps through `fmtTemp`/`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 = [];
|
||||
|
||||
export function getUnit() { return _unit; }
|
||||
|
||||
// A Fahrenheit value as a number in the active unit.
|
||||
export 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).
|
||||
export function fmtTemp(vF, withLetter) {
|
||||
if (vF == null || isNaN(vF)) return "—";
|
||||
return `${Math.round(toUnit(vF))}°${withLetter ? _unit : ""}`;
|
||||
}
|
||||
|
||||
export function onUnitChange(cb) { _unitCbs.push(cb); }
|
||||
|
||||
export 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. A page opts out with data-no-unit-toggle on <body> (Compare does:
|
||||
// its comfort slider is inherently °F-based).
|
||||
(function buildUnitToggle() {
|
||||
const brand = document.querySelector(".brand");
|
||||
if (!brand || brand.querySelector(".unit-toggle")) return;
|
||||
if (document.body.dataset.noUnitToggle != null) 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);
|
||||
})();
|
||||
Loading…
Reference in a new issue