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";
|
// Weekly (map) page. Imports replace the old script-tag ordering contract.
|
||||||
// Shared tier colors, scales, formatters and helpers (single home: shared.js).
|
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||||
const { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
|
import { fmtTemp, toUnit, onUnitChange } from "./units.js";
|
||||||
fmtPrecip, fmtWind, fmtHumid, locHash } = window.Thermograph;
|
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}
|
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.
|
// The Find button opens the shared modal map picker; choosing a spot loads it.
|
||||||
const findBtn = document.getElementById("find-btn");
|
const findBtn = document.getElementById("find-btn");
|
||||||
const locLabel = document.getElementById("loc-label");
|
const locLabel = document.getElementById("loc-label");
|
||||||
window.LocationPicker.initFindButton(findBtn, "Find a location",
|
initFindButton(findBtn, "Find a location",
|
||||||
() => selected, (lat, lon) => selectLocation(lat, lon));
|
() => selected, (lat, lon) => selectLocation(lat, lon));
|
||||||
|
|
||||||
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
|
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
|
||||||
|
|
@ -78,7 +81,7 @@ async function runGrade() {
|
||||||
try {
|
try {
|
||||||
// Stale-while-revalidate: a stale cached copy renders immediately and the
|
// Stale-while-revalidate: a stale cached copy renders immediately and the
|
||||||
// update callback repaints if the background revalidation finds new data.
|
// 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);
|
if (seq === gradeSeq) render(upd);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -92,13 +95,10 @@ async function runGrade() {
|
||||||
render(data);
|
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
|
// 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
|
// column headers, so values stay short). Dry days label the running dry streak
|
||||||
// (see precipCell) rather than the rain amount.
|
// (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 cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`);
|
||||||
const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`);
|
const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`);
|
||||||
const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·");
|
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).
|
// results have rendered (otherwise the name would show twice).
|
||||||
locLabel.hidden = true;
|
locLabel.hidden = true;
|
||||||
locLabel.textContent = "";
|
locLabel.textContent = "";
|
||||||
window.LocationPicker.setFindLabel(findBtn, "Change location");
|
setFindLabel(findBtn, "Change location");
|
||||||
const c = data.climatology;
|
const c = data.climatology;
|
||||||
const cell = data.cell;
|
const cell = data.cell;
|
||||||
const yrs = c.year_range ? `${c.year_range[0]}–${c.year_range[1]}` : "—";
|
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
|
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-link").onclick = copyShareLink;
|
||||||
document.getElementById("btn-png").onclick = downloadChartPng;
|
document.getElementById("btn-png").onclick = downloadChartPng;
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +249,7 @@ function daysTable(rows, targetDate) {
|
||||||
let recentData = null; // /grade response — the whole window, newest-first
|
let recentData = null; // /grade response — the whole window, newest-first
|
||||||
|
|
||||||
// Repaint everything in the newly-picked unit.
|
// Repaint everything in the newly-picked unit.
|
||||||
window.Thermograph.onUnitChange(() => {
|
onUnitChange(() => {
|
||||||
if (recentData) render(recentData);
|
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.
|
// 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.
|
// The plot keeps its °F domain — only the labels convert — so positions hold.
|
||||||
const isTemp = s.sfx === "°";
|
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({
|
return lineChart({
|
||||||
days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false,
|
days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false,
|
||||||
getVal: (d) => (d[key] ? d[key].value : null),
|
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 pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
|
||||||
const gc = TIER_COLORS[g.class] || "";
|
const gc = TIER_COLORS[g.class] || "";
|
||||||
// "°" marks a temperature line — show it in the active unit; others are as-is.
|
// "°" 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>`;
|
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() {
|
function updateHash() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
history.replaceState(null, "", locHash(selected.lat, selected.lon, dateInput.value));
|
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
|
// 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.
|
// place you looked at (remembered across views), otherwise the default world map.
|
||||||
function restoreFromHash() {
|
function restoreFromHash() {
|
||||||
const loc = window.Thermograph.loadLastLocation();
|
const loc = loadLastLocation();
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
if (loc.date) dateInput.value = loc.date;
|
if (loc.date) dateInput.value = loc.date;
|
||||||
syncTodayBtn();
|
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>
|
</main>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script type="module" src="calendar.js"></script>
|
||||||
<script src="shared.js"></script>
|
|
||||||
<script src="mappicker.js"></script>
|
|
||||||
<script src="calendar.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
"use strict";
|
|
||||||
// Thermograph calendar subpage — two years of daily grades as a month-grid heatmap.
|
// 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,
|
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode).
|
||||||
// scales, formatters, the weather summary and range chunking live in shared.js.
|
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||||
|
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
|
||||||
const { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
import { fmtTemp } from "./units.js";
|
||||||
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
import { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||||
CHUNK_MONTHS, buildChunks, clickOpensPicker, locHash,
|
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
weatherType: wxType } = window.Thermograph;
|
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
||||||
// Temps go through the shared unit formatter (the day tooltip reads it live, so a
|
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
||||||
// unit switch shows on the next hover).
|
CHUNK_MONTHS, buildChunks, clickOpensPicker,
|
||||||
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
|
weatherType as wxType } from "./shared.js";
|
||||||
|
|
||||||
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
||||||
// display name for the key + tooltip and the formatter for their values.
|
// 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.
|
// The Find button opens the shared modal map picker; choosing a spot loads it.
|
||||||
const findBtn = document.getElementById("find-btn");
|
const findBtn = document.getElementById("find-btn");
|
||||||
const locLabel = document.getElementById("loc-label");
|
const locLabel = document.getElementById("loc-label");
|
||||||
window.LocationPicker.initFindButton(findBtn, "Find a location",
|
initFindButton(findBtn, "Find a location",
|
||||||
() => selected, (lat, lon) => selectLocation(lat, lon));
|
() => selected, (lat, lon) => selectLocation(lat, lon));
|
||||||
|
|
||||||
// ---- metric toggle ----
|
// ---- metric toggle ----
|
||||||
|
|
@ -324,7 +323,7 @@ function syncDayToOtherViews() {
|
||||||
const toFirst = rangeEnd.slice(0, 7) + "-01";
|
const toFirst = rangeEnd.slice(0, 7) + "-01";
|
||||||
const todayIso = isoOfDate(new Date());
|
const todayIso = isoOfDate(new Date());
|
||||||
const day = toFirst > todayIso ? todayIso : toFirst;
|
const day = toFirst > todayIso ? todayIso : toFirst;
|
||||||
window.Thermograph.saveLastLocation(selected.lat, selected.lon, day);
|
saveLastLocation(selected.lat, selected.lon, day);
|
||||||
setCalSync(day);
|
setCalSync(day);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,7 +331,7 @@ function syncDayToOtherViews() {
|
||||||
function selectLocation(lat, lon) {
|
function selectLocation(lat, lon) {
|
||||||
selected = { lat, lon };
|
selected = { lat, lon };
|
||||||
history.replaceState(null, "", locHash(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
|
// Show the raw coordinates immediately; they're replaced with the resolved
|
||||||
// neighborhood + city name once the first calendar chunk lands.
|
// neighborhood + city name once the first calendar chunk lands.
|
||||||
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
|
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
|
||||||
|
|
@ -382,7 +381,7 @@ async function fetchCalendar() {
|
||||||
document.getElementById("metric-block").hidden = false;
|
document.getElementById("metric-block").hidden = false;
|
||||||
locLabel.textContent = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`;
|
locLabel.textContent = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`;
|
||||||
locLabel.hidden = false;
|
locLabel.hidden = false;
|
||||||
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
|
setFindLabel(findBtn, "Change location");
|
||||||
renderFilters();
|
renderFilters();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -408,7 +407,7 @@ async function fetchCalendar() {
|
||||||
// Stale-while-revalidate: a stale cached chunk renders immediately; if the
|
// Stale-while-revalidate: a stale cached chunk renders immediately; if the
|
||||||
// background revalidation finds changed data, re-merge that chunk's days
|
// background revalidation finds changed data, re-merge that chunk's days
|
||||||
// and repaint (the cell/place metadata is identical across versions).
|
// 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;
|
if (token !== fetchToken) return;
|
||||||
results[i] = upd;
|
results[i] = upd;
|
||||||
for (const x of upd.days) dayMap.set(x.date, x);
|
for (const x of upd.days) dayMap.set(x.date, x);
|
||||||
|
|
@ -450,7 +449,7 @@ async function fetchCalendar() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderHead(chunks.length - donePrefix, false); // final head (flags any gap)
|
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"
|
// 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) ----
|
// ---- restore (hash from a shared link, else the last place you looked at) ----
|
||||||
(function restore() {
|
(function restore() {
|
||||||
const loc = window.Thermograph.loadLastLocation();
|
const loc = loadLastLocation();
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
const saved = loadCalRange();
|
const saved = loadCalRange();
|
||||||
const sel = loc.date; // the shared selected day (from the hash or last-visited store)
|
const sel = loc.date; // the shared selected day (from the hash or last-visited store)
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,6 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script type="module" src="compare.js"></script>
|
||||||
<script src="shared.js"></script>
|
|
||||||
<script src="mappicker.js"></script>
|
|
||||||
<script src="compare.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
"use strict";
|
|
||||||
// Compare view: line up several places over one date range and see which best
|
// 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
|
// 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
|
// 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:
|
// 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.
|
// it arms the Refresh button, and the load runs when the user taps it.
|
||||||
|
|
||||||
// Shared date helpers + range chunking live in shared.js.
|
import { loadLastLocation, saveLastLocation } from "./nav.js";
|
||||||
const { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks,
|
import { getJSON, TTL } from "./cache.js";
|
||||||
clickOpensPicker } = window.Thermograph;
|
import { initFindButton } from "./mappicker.js";
|
||||||
|
import { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks,
|
||||||
|
clickOpensPicker } from "./shared.js";
|
||||||
|
|
||||||
const CMP = {
|
const CMP = {
|
||||||
comfort: "thermograph:cmpComfort",
|
comfort: "thermograph:cmpComfort",
|
||||||
|
|
@ -104,7 +105,6 @@ const basisToggle = document.getElementById("cmp-basis");
|
||||||
const startInput = document.getElementById("cmp-start");
|
const startInput = document.getElementById("cmp-start");
|
||||||
const endInput = document.getElementById("cmp-end");
|
const endInput = document.getElementById("cmp-end");
|
||||||
|
|
||||||
addBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Add location</span>`;
|
|
||||||
|
|
||||||
// ---- data ----
|
// ---- data ----
|
||||||
// Fetch one location's range (chunked) and reduce it to the compact per-day series.
|
// 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;
|
let place = null;
|
||||||
for (const ch of chunks) {
|
for (const ch of chunks) {
|
||||||
const url = `api/v2/calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`;
|
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
|
if (token !== loadToken) return null; // superseded
|
||||||
place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`;
|
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);
|
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;
|
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 };
|
const loc = { lat, lon, name: null, series: null, loading: false, error: null };
|
||||||
locations.push(loc);
|
locations.push(loc);
|
||||||
window.Thermograph.saveLastLocation(lat, lon);
|
saveLastLocation(lat, lon);
|
||||||
persistLocations();
|
persistLocations();
|
||||||
writeHashState();
|
writeHashState();
|
||||||
renderAll(); // pending → the Refresh button appears; no fetch until it's tapped
|
renderAll(); // pending → the Refresh button appears; no fetch until it's tapped
|
||||||
|
|
@ -321,10 +321,9 @@ function renderAll() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- events ----
|
// ---- events ----
|
||||||
addBtn.addEventListener("click", () => {
|
initFindButton(addBtn, "Add location",
|
||||||
const cur = locations.length ? locations[locations.length - 1] : window.Thermograph.loadLastLocation();
|
() => (locations.length ? locations[locations.length - 1] : loadLastLocation()),
|
||||||
window.LocationPicker.open(cur, (lat, lon) => addLocation(lat, lon));
|
(lat, lon) => addLocation(lat, lon));
|
||||||
});
|
|
||||||
|
|
||||||
refreshBtn.addEventListener("click", refresh);
|
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).
|
// (the tap-to-refresh rule is for later interactive edits, not the initial view).
|
||||||
let list = hash && hash.locs;
|
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) { 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
|
locations = list
|
||||||
.filter((l) => l && typeof l.lat === "number" && typeof l.lon === "number")
|
.filter((l) => l && typeof l.lat === "number" && typeof l.lon === "number")
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,6 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script type="module" src="day.js"></script>
|
||||||
<script src="shared.js"></script>
|
|
||||||
<script src="mappicker.js"></script>
|
|
||||||
<script src="day.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"use strict";
|
|
||||||
// Thermograph single-day detail subpage — for one day + location, shows the value
|
// 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,
|
// 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.
|
// 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.
|
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||||
|
import { fmtTemp, onUnitChange } from "./units.js";
|
||||||
const { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid,
|
import { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||||
todayISO, weatherType, placeLabel, locHash } = window.Thermograph;
|
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
|
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.
|
// 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] || "");
|
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.
|
// The Find button opens the shared modal map picker; choosing a spot loads it.
|
||||||
const findBtn = document.getElementById("find-btn");
|
const findBtn = document.getElementById("find-btn");
|
||||||
const locLabel = document.getElementById("loc-label");
|
const locLabel = document.getElementById("loc-label");
|
||||||
window.LocationPicker.initFindButton(findBtn, "Find a location", () => selected,
|
initFindButton(findBtn, "Find a location", () => selected,
|
||||||
(lat, lon) => {
|
(lat, lon) => {
|
||||||
selected = { lat, lon };
|
selected = { lat, lon };
|
||||||
// Show the raw coordinates immediately; render() replaces them with the
|
// Show the raw coordinates immediately; render() replaces them with the
|
||||||
|
|
@ -70,8 +70,8 @@ async function fetchDay() {
|
||||||
try {
|
try {
|
||||||
// Stale-while-revalidate: a stale cached copy renders immediately; the update
|
// Stale-while-revalidate: a stale cached copy renders immediately; the update
|
||||||
// callback repaints if the background revalidation finds new data.
|
// callback repaints if the background revalidation finds new data.
|
||||||
data = await window.Thermograph.getJSON(
|
data = await getJSON(
|
||||||
`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day,
|
`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, TTL.day,
|
||||||
false, (upd) => { if (seq === daySeq) finishDay(upd); });
|
false, (upd) => { if (seq === daySeq) finishDay(upd); });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(spin);
|
clearTimeout(spin);
|
||||||
|
|
@ -93,13 +93,13 @@ function finishDay(data) {
|
||||||
// forecast bundle), not just the latest fully-archived day.
|
// forecast bundle), not just the latest fully-archived day.
|
||||||
dateInput.max = todayISO();
|
dateInput.max = todayISO();
|
||||||
document.getElementById("next-day").disabled = curDate >= todayISO();
|
document.getElementById("next-day").disabled = curDate >= todayISO();
|
||||||
window.Thermograph.saveLastLocation(selected.lat, selected.lon, curDate);
|
saveLastLocation(selected.lat, selected.lon, curDate);
|
||||||
render(data);
|
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
|
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) {
|
function render(data) {
|
||||||
lastData = data;
|
lastData = data;
|
||||||
|
|
@ -107,7 +107,7 @@ function render(data) {
|
||||||
const place = placeLabel(data);
|
const place = placeLabel(data);
|
||||||
locLabel.textContent = place;
|
locLabel.textContent = place;
|
||||||
locLabel.hidden = false;
|
locLabel.hidden = false;
|
||||||
window.LocationPicker.setFindLabel(findBtn, "Change location");
|
setFindLabel(findBtn, "Change location");
|
||||||
const dt = new Date(d.date + "T00:00:00");
|
const dt = new Date(d.date + "T00:00:00");
|
||||||
const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
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]}` : "—";
|
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) ----
|
// ---- restore (hash from a shared link, else the last place you looked at) ----
|
||||||
(function restore() {
|
(function restore() {
|
||||||
const loc = window.Thermograph.loadLastLocation();
|
const loc = loadLastLocation();
|
||||||
if (loc) {
|
if (loc) {
|
||||||
selected = { lat: loc.lat, lon: loc.lon };
|
selected = { lat: loc.lat, lon: loc.lon };
|
||||||
curDate = loc.date || todayISO(); // default to today when no date is given
|
curDate = loc.date || todayISO(); // default to today when no date is given
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,6 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
<script src="shared.js"></script>
|
|
||||||
<script src="mappicker.js"></script>
|
|
||||||
<script src="app.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,12 @@
|
||||||
<p class="guide-back"><a href="./">← Back to Thermograph</a></p>
|
<p class="guide-back"><a href="./">← Back to Thermograph</a></p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="nav.js"></script>
|
<script type="module">
|
||||||
<script src="shared.js"></script>
|
import "./nav.js"; // header view-links follow the last location
|
||||||
<script>
|
import "./units.js"; // the °F/°C toggle
|
||||||
// The scale rows come from the shared tier tables, so this guide can't
|
// The scale rows come from the shared tier tables, so this guide can't
|
||||||
// drift from what the app actually shows.
|
// 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 }) =>
|
const row = ({ c, label, range }) =>
|
||||||
`<div class="guide-seg"><span class="guide-sw" style="background:var(--${c})"></span>` +
|
`<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>`;
|
`<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:
|
// 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
|
// 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
|
// 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
|
// can fine-tune before confirming. Picking closes the overlay and hands the chosen
|
||||||
// lat/lon back to the page via the onPick callback.
|
// 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
|
// Leaflet stays a classic script (global L), loaded before the page modules. The
|
||||||
// itself is created lazily the first time the picker opens (and reused after), so
|
// map itself is created lazily the first time the picker opens (and reused after),
|
||||||
// pages that never open it pay nothing.
|
// so pages that never open it pay nothing.
|
||||||
(function () {
|
let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null;
|
||||||
let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null;
|
let searchForm, searchInput, sugEl, confirmBtn, hintEl;
|
||||||
let searchForm, searchInput, sugEl, confirmBtn, hintEl;
|
let sugSeq = 0, sugTimer = 0, sugAbort = null;
|
||||||
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() {
|
function build() {
|
||||||
overlay = document.createElement("div");
|
overlay = document.createElement("div");
|
||||||
overlay.className = "mp-overlay";
|
overlay.className = "mp-overlay";
|
||||||
overlay.hidden = true;
|
overlay.hidden = true;
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Pick a location">
|
<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Pick a location">
|
||||||
<div class="mp-head">
|
<div class="mp-head">
|
||||||
<h2>Find a location</h2>
|
<h2>Find a location</h2>
|
||||||
<button type="button" class="mp-close" aria-label="Close">×</button>
|
<button type="button" class="mp-close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="mp-search" autocomplete="off">
|
<form class="mp-search" autocomplete="off">
|
||||||
<input type="text" placeholder="Search for a place…" autocomplete="off" />
|
<input type="text" placeholder="Search for a place…" autocomplete="off" />
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
<ul class="mp-sug" hidden></ul>
|
<ul class="mp-sug" hidden></ul>
|
||||||
</form>
|
</form>
|
||||||
<div class="mp-map"></div>
|
<div class="mp-map"></div>
|
||||||
<div class="mp-foot">
|
<div class="mp-foot">
|
||||||
<span class="mp-hint">Search above, or tap the map to drop a pin.</span>
|
<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>
|
<button type="button" class="mp-confirm" disabled>Use this location</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
mapEl = overlay.querySelector(".mp-map");
|
mapEl = overlay.querySelector(".mp-map");
|
||||||
searchForm = overlay.querySelector(".mp-search");
|
searchForm = overlay.querySelector(".mp-search");
|
||||||
searchInput = searchForm.querySelector("input");
|
searchInput = searchForm.querySelector("input");
|
||||||
sugEl = overlay.querySelector(".mp-sug");
|
sugEl = overlay.querySelector(".mp-sug");
|
||||||
confirmBtn = overlay.querySelector(".mp-confirm");
|
confirmBtn = overlay.querySelector(".mp-confirm");
|
||||||
hintEl = overlay.querySelector(".mp-hint");
|
hintEl = overlay.querySelector(".mp-hint");
|
||||||
|
|
||||||
overlay.querySelector(".mp-close").onclick = close;
|
overlay.querySelector(".mp-close").onclick = close;
|
||||||
// Tapping the dimmed backdrop (outside the modal) closes the picker.
|
// Tapping the dimmed backdrop (outside the modal) closes the picker.
|
||||||
overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) close(); });
|
overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) close(); });
|
||||||
confirmBtn.onclick = () => finish(pin);
|
confirmBtn.onclick = () => finish(pin);
|
||||||
|
|
||||||
// Live suggestions (top 5, single-letter-typo tolerant) as the user types:
|
// 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
|
// 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.
|
// + sequence-guarded so a slow stale response can't overwrite a newer list.
|
||||||
async function fetchSug(q) {
|
async function fetchSug(q) {
|
||||||
const seq = ++sugSeq;
|
const seq = ++sugSeq;
|
||||||
if (sugAbort) sugAbort.abort();
|
if (sugAbort) sugAbort.abort();
|
||||||
sugAbort = new AbortController();
|
sugAbort = new AbortController();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal });
|
const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal });
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (seq === sugSeq) renderSug(d.results || []);
|
if (seq === sugSeq) renderSug(d.results || []);
|
||||||
} catch (err) { /* aborted, or offline — the map stays the fallback way to pick */ }
|
} 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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
searchInput.addEventListener("input", () => {
|
||||||
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.
|
|
||||||
clearTimeout(sugTimer);
|
clearTimeout(sugTimer);
|
||||||
sugSeq++;
|
// The old list stays visible until fresh results land, but its highlight
|
||||||
sugEl.hidden = true;
|
// must go — Enter mid-typing shouldn't pick a suggestion for the shorter
|
||||||
searchInput.value = "";
|
// query the list still reflects.
|
||||||
hintEl.textContent = "Search above, or tap the map to drop a pin.";
|
const stale = sugEl.querySelector("li.active");
|
||||||
overlay.hidden = false;
|
if (stale) stale.classList.remove("active");
|
||||||
|
const q = searchInput.value.trim();
|
||||||
if (!map) {
|
if (q.length < 2) { sugEl.hidden = true; return; }
|
||||||
map = L.map(mapEl, { worldCopyJump: true, zoomControl: true }).setView([25, 0], 2);
|
sugTimer = setTimeout(() => fetchSug(q), 250);
|
||||||
// Colorful CARTO "Voyager" basemap (no key needed), split into two raster
|
});
|
||||||
// layers so road weight and label size can be tuned independently:
|
// Arrow keys walk the list (Enter picks via the submit handler below);
|
||||||
// • the label-free base is requested one zoom lower and drawn at 512px
|
// Escape dismisses the list before the overlay's Escape-to-close kicks in.
|
||||||
// (tileSize 512 + zoomOffset -1) so roads read bold and prominent;
|
searchInput.addEventListener("keydown", (e) => {
|
||||||
// • the labels-only layer is drawn on top at its natural size, keeping city
|
if (e.key === "Escape" && !sugEl.hidden) {
|
||||||
// names small and crisp rather than scaled up with the roads.
|
sugEl.hidden = true;
|
||||||
// @2x keeps both sharp; a darken/saturate filter (style.css, .mp-base only)
|
e.stopPropagation();
|
||||||
// deepens the roads while leaving the label text at full contrast.
|
return;
|
||||||
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
|
if (sugEl.hidden || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
|
||||||
// recalc once the modal is actually visible and (if reopening on a known spot)
|
const items = [...sugEl.children];
|
||||||
// drop a pin there as the starting point.
|
if (!items.length) return;
|
||||||
setTimeout(() => {
|
e.preventDefault();
|
||||||
map.invalidateSize();
|
const cur = items.findIndex((li) => li.classList.contains("active"));
|
||||||
if (cur && cur.lat != null && cur.lon != null) setPin(cur.lat, cur.lon, 10);
|
const next = e.key === "ArrowDown"
|
||||||
searchInput.focus();
|
? (cur + 1) % items.length
|
||||||
}, 60);
|
: (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
|
function renderSug(list) {
|
||||||
// click seeded with the page's current spot. `setFindLabel` swaps the label
|
const shown = list;
|
||||||
// (e.g. "Find a location" → "Change location") keeping the icon markup.
|
sugEl.innerHTML = "";
|
||||||
function initFindButton(btn, label, getCurrent, onPick) {
|
if (!shown.length) { sugEl.hidden = true; return; }
|
||||||
setFindLabel(btn, label);
|
shown.forEach((r) => {
|
||||||
btn.addEventListener("click", () => open(getCurrent(), onPick));
|
const li = document.createElement("li");
|
||||||
}
|
li.innerHTML = `${r.name}<span class="sub"> — ${[r.admin1, r.country].filter(Boolean).join(", ")}</span>`;
|
||||||
function setFindLabel(btn, label) {
|
// A picked search result is an unambiguous choice — drop the pin, recenter,
|
||||||
btn.innerHTML = `${PIN_ICON} <span>${label}</span>`;
|
// 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";
|
// Cross-view navigation + last-location memory. Remembers the most recently
|
||||||
// Shared cross-view navigation + last-location memory. Loaded on all pages
|
// selected spot in localStorage and keeps the header's view links
|
||||||
// before that page's own script. It remembers the most recently selected spot
|
// (Map · Calendar · Day · Compare) pointing at that spot — so switching views,
|
||||||
// in localStorage and keeps the header's view links (Map · Calendar · Day)
|
// or coming back to the map, lands you where you were instead of an empty map.
|
||||||
// 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 () {
|
|
||||||
const LOC_KEY = "thermograph:loc";
|
const LOC_KEY = "thermograph:loc";
|
||||||
|
|
||||||
function readStored() {
|
function readStored() {
|
||||||
|
|
@ -22,7 +15,7 @@ function readStored() {
|
||||||
|
|
||||||
// Where should this page start? A hash (a shared/opened link) always wins;
|
// Where should this page start? A hash (a shared/opened link) always wins;
|
||||||
// otherwise fall back to the last place the user looked at.
|
// 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 p = new URLSearchParams(location.hash.slice(1));
|
||||||
const lat = parseFloat(p.get("lat")), lon = parseFloat(p.get("lon"));
|
const lat = parseFloat(p.get("lat")), lon = parseFloat(p.get("lon"));
|
||||||
if (!isNaN(lat) && !isNaN(lon)) return { lat, lon, date: p.get("date") || null };
|
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,
|
// 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
|
// so hopping through the calendar (which has no date) doesn't wipe the day you
|
||||||
// were on elsewhere.
|
// were on elsewhere.
|
||||||
function saveLastLocation(lat, lon, date) {
|
export function saveLastLocation(lat, lon, date) {
|
||||||
let d = date;
|
let d = date;
|
||||||
if (d === undefined) { const prev = readStored(); d = prev ? prev.date : null; }
|
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) {}
|
try { localStorage.setItem(LOC_KEY, JSON.stringify({ lat, lon, date: d || null })); } catch (e) {}
|
||||||
refreshNav(lat, lon, d);
|
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)}`;
|
let h = `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`;
|
||||||
if (date) h += `&date=${date}`;
|
if (date) h += `&date=${date}`;
|
||||||
return h;
|
return h;
|
||||||
|
|
@ -54,305 +47,11 @@ function refreshNav(lat, lon, date) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- temperature units (shared °C / °F toggle) ----
|
// Point the header links at the current spot as soon as the page loads.
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
(function initNav() {
|
(function initNav() {
|
||||||
document.querySelectorAll(".view-nav a[data-view]").forEach((a) => {
|
document.querySelectorAll(".view-nav a[data-view]").forEach((a) => {
|
||||||
a.dataset.base = a.getAttribute("href");
|
a.dataset.base = a.getAttribute("href");
|
||||||
});
|
});
|
||||||
buildUnitToggle();
|
|
||||||
const loc = loadLastLocation();
|
const loc = loadLastLocation();
|
||||||
if (loc) refreshNav(loc.lat, loc.lon, loc.date);
|
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 — each
|
||||||
// Shared presentation constants + small helpers for every page script. Loaded on
|
// constant and helper here previously existed as a copy in app.js, calendar.js,
|
||||||
// all pages right after nav.js (which creates window.Thermograph) and extends
|
// day.js, compare.js and/or legend.html; pages import what they need so a tier
|
||||||
// that namespace — each constant and helper here previously existed as a copy in
|
// color, scale label or formatter has exactly one home.
|
||||||
// app.js, calendar.js, day.js, compare.js and/or legend.html; pages destructure
|
// ---- tier colors ----
|
||||||
// what they need so a tier color, scale label or formatter has exactly one home.
|
// style.css's :root custom properties are the source of truth (they're not
|
||||||
(function () {
|
// re-themed, so reading them once at load is safe). The JS map exists because
|
||||||
// ---- tier colors ----
|
// inline-SVG work — chart building and the PNG export in particular — needs
|
||||||
// style.css's :root custom properties are the source of truth (they're not
|
// literal color values, where CSS variables can't resolve. The hex fallbacks
|
||||||
// re-themed, so reading them once at load is safe). The JS map exists because
|
// only cover a missing/blocked stylesheet.
|
||||||
// inline-SVG work — chart building and the PNG export in particular — needs
|
const FALLBACK = {
|
||||||
// literal color values, where CSS variables can't resolve. The hex fallbacks
|
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
||||||
// only cover a missing/blocked stylesheet.
|
"normal": "#4a9d5b",
|
||||||
const FALLBACK = {
|
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
||||||
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
"dry": "#c9a24a",
|
||||||
"normal": "#4a9d5b",
|
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f",
|
||||||
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
"wet-5": "#35a1c0", "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97",
|
||||||
"dry": "#c9a24a",
|
"wet-9": "#08306b",
|
||||||
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f",
|
};
|
||||||
"wet-5": "#35a1c0", "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97",
|
const rootCss = getComputedStyle(document.documentElement);
|
||||||
"wet-9": "#08306b",
|
export const TIER_COLORS = {};
|
||||||
};
|
for (const [cls, fb] of Object.entries(FALLBACK)) {
|
||||||
const rootCss = getComputedStyle(document.documentElement);
|
TIER_COLORS[cls] = rootCss.getPropertyValue(`--${cls}`).trim() || fb;
|
||||||
const TIER_COLORS = {};
|
}
|
||||||
for (const [cls, fb] of Object.entries(FALLBACK)) {
|
// The rain-intensity subset + the dry tint, for pages that color them apart.
|
||||||
TIER_COLORS[cls] = rootCss.getPropertyValue(`--${cls}`).trim() || fb;
|
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.
|
return chunks;
|
||||||
const PRECIP_COLORS = Object.fromEntries(
|
}
|
||||||
Object.entries(TIER_COLORS).filter(([c]) => c.startsWith("wet-")));
|
|
||||||
const DRY_COLOR = TIER_COLORS.dry;
|
|
||||||
|
|
||||||
// ---- percentile tier scales, low → high ----
|
// Clicking anywhere in a month/date field opens the native picker. Desktop
|
||||||
// Names are deliberately *relative* to each place's own climate history (a
|
// Chrome otherwise only opens it from the tiny calendar glyph and just focuses
|
||||||
// percentile), not absolute temperature words — see the README design note.
|
// a segment; showPicker is missing in some browsers (Safari/Firefox) — ignore.
|
||||||
const SCALE_TEMP = [
|
export function clickOpensPicker(...els) {
|
||||||
{ c: "rec-cold", label: "Near Record", range: "<1" },
|
for (const el of els) {
|
||||||
{ c: "very-cold", label: "Very Low", range: "1–10" },
|
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
|
||||||
{ 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);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- formatters & tiny utils ----
|
// ---- weather summary ----
|
||||||
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
// Monochrome line icons (currentColor) — no emoji, matching the site's dark
|
||||||
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
// look. Feather-style paths.
|
||||||
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
const WX = (paths) =>
|
||||||
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
`<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 ord = (n) => {
|
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
|
||||||
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
export const WX_ICONS = {
|
||||||
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
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"/>`),
|
||||||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
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"/>`),
|
||||||
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
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"/>`),
|
||||||
// The heading label for a graded response: the resolved place name, or the
|
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"/>`),
|
||||||
// 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)}`;
|
|
||||||
|
|
||||||
// ---- dates & range chunking ----
|
// Plain-language "what was the day like" descriptor from the raw values: a
|
||||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
// temperature word (from the daily high, °F) crossed with a sky/precip word
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
// (precip in inches — falling as snow at/below freezing). `dsr` is optional:
|
||||||
const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
// when a long dry streak is known, a no-rain day reads "dry" instead of
|
||||||
const monthStart = (ym) => `${ym}-01`; // 1st of a "YYYY-MM"
|
// "clear" (the calendar passes it; the day page doesn't track streaks).
|
||||||
const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0));
|
export function weatherType(t, p, dsr) {
|
||||||
|
const wet = p != null && p >= 0.01;
|
||||||
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so
|
const freezing = t != null && t <= 34;
|
||||||
// each stays within the calendar endpoint's per-request cap; the calendar grid
|
const temp = t == null ? "" :
|
||||||
// and the compare series both fill chunk by chunk.
|
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
||||||
const CHUNK_MONTHS = 24;
|
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
|
||||||
function buildChunks(startIso, endIso) {
|
let sky, skyKey, icon;
|
||||||
const chunks = [];
|
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; skyKey = "snow"; icon = WX_ICONS.snow; }
|
||||||
let s = startIso, guard = 0;
|
else if (wet && p >= 1.0) { sky = "downpour"; skyKey = "downpour"; icon = WX_ICONS.storm; }
|
||||||
while (s <= endIso && guard++ < 200) {
|
else if (wet && p >= 0.3) { sky = "rain"; skyKey = "rain"; icon = WX_ICONS.rain; }
|
||||||
const sd = new Date(s + "T00:00:00");
|
else if (wet) { sky = "light rain"; skyKey = "drizzle"; icon = WX_ICONS.drizzle; }
|
||||||
const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate());
|
else {
|
||||||
ed.setDate(ed.getDate() - 1); // 24 months inclusive
|
skyKey = dsr != null && dsr >= 10 ? "dry" : "clear";
|
||||||
let e = isoOfDate(ed);
|
sky = skyKey === "dry" ? "dry" : "clear";
|
||||||
if (e > endIso) e = endIso;
|
icon = WX_ICONS.sun;
|
||||||
chunks.push({ start: s, end: e });
|
|
||||||
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
|
|
||||||
s = isoOfDate(ns);
|
|
||||||
}
|
|
||||||
return chunks;
|
|
||||||
}
|
}
|
||||||
|
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
|
||||||
// Clicking anywhere in a month/date field opens the native picker. Desktop
|
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1),
|
||||||
// Chrome otherwise only opens it from the tiny calendar glyph and just focuses
|
feel: temp.toLowerCase(), sky: skyKey };
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
|
||||||
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