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).
396 lines
17 KiB
JavaScript
396 lines
17 KiB
JavaScript
// Compare view: line up several places over one date range and see which best
|
||
// matches a comfort temperature. For each location we pull the same daily record
|
||
// the Calendar uses (api/v2/calendar) and, per day, take a chosen temperature
|
||
// (daytime high / daily mean / overnight low / feels-like) and measure it against
|
||
// the comfort target. A day "hits comfort" when it lands within a tolerance band;
|
||
// otherwise it's colder or warmer, and we track by how much.
|
||
//
|
||
// The whole comparison — locations, comfort target, band, judged temperature and
|
||
// date range — lives in the URL hash, so a link reproduces exactly what you see.
|
||
// Comfort / band / judged-temperature re-rank instantly (all values are already in
|
||
// 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.
|
||
|
||
import { loadLastLocation, saveLastLocation } from "./nav.js";
|
||
import { getJSON, TTL } from "./cache.js";
|
||
import { initFindButton } from "./mappicker.js";
|
||
import { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks,
|
||
clickOpensPicker } from "./shared.js";
|
||
|
||
const CMP = {
|
||
comfort: "thermograph:cmpComfort",
|
||
tol: "thermograph:cmpTol",
|
||
basis: "thermograph:cmpBasis",
|
||
range: "thermograph:cmpRange",
|
||
locs: "thermograph:cmpLocs",
|
||
};
|
||
|
||
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
||
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
|
||
|
||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||
const BASES = ["tmax", "tmin", "mean", "feels"];
|
||
let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100);
|
||
let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15);
|
||
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
|
||
|
||
// Locations being compared: {lat, lon, name, series|null, loading, error}.
|
||
// `series` is a compact per-day array of {hi, lo, feels} (°F, any may be null).
|
||
// A location with no series and not loading is "pending" — it needs a Refresh tap.
|
||
let locations = [];
|
||
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
|
||
let loadToken = 0;
|
||
|
||
const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like" };
|
||
|
||
// ---- date range (month granularity) ----
|
||
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
|
||
function defaultRange() {
|
||
const now = new Date();
|
||
const s = new Date(now.getFullYear(), now.getMonth() - 11, 1); // last 12 whole months
|
||
return { start: `${s.getFullYear()}-${pad(s.getMonth() + 1)}`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
|
||
}
|
||
function loadRange() {
|
||
try { const o = JSON.parse(lsGet(CMP.range)); if (o && isYM(o.start) && isYM(o.end)) return o; } catch (e) {}
|
||
return defaultRange();
|
||
}
|
||
let range = loadRange(); // the APPLIED range (what loaded data reflects), "YYYY-MM"
|
||
|
||
// Pretty "Jul 2025 – Jun 2026" for a YYYY-MM range.
|
||
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
|
||
|
||
// ---- shareable URL state ----
|
||
// The hash carries the full comparison: c=comfort, t=band, b=basis, s/e=range,
|
||
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
|
||
// wins over localStorage). A plain lat/lon hash from cross-view nav is ignored
|
||
// here and handled by the seed path instead.
|
||
function writeHashState() {
|
||
const p = new URLSearchParams();
|
||
p.set("c", comfort); p.set("t", tol); p.set("b", basis);
|
||
p.set("s", range.start); p.set("e", range.end);
|
||
if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";"));
|
||
history.replaceState(null, "", "#" + p.toString());
|
||
}
|
||
function readHashState() {
|
||
const p = new URLSearchParams(location.hash.slice(1));
|
||
if (!["c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
|
||
const st = {};
|
||
if (p.has("c")) st.comfort = clamp(+p.get("c") || 68, 30, 100);
|
||
if (p.has("t") && p.get("t") !== "") st.tol = clamp(+p.get("t"), 0, 15);
|
||
if (BASES.includes(p.get("b"))) st.basis = p.get("b");
|
||
if (isYM(p.get("s"))) st.start = p.get("s");
|
||
if (isYM(p.get("e"))) st.end = p.get("e");
|
||
if (p.has("loc")) {
|
||
st.locs = p.get("loc").split(";").map((pair) => {
|
||
const [a, b] = pair.split(",").map(Number);
|
||
return { lat: a, lon: b };
|
||
}).filter((l) => !isNaN(l.lat) && !isNaN(l.lon));
|
||
}
|
||
return st;
|
||
}
|
||
|
||
// ---- elements ----
|
||
const addBtn = document.getElementById("cmp-add");
|
||
const refreshBtn = document.getElementById("cmp-refresh");
|
||
const locList = document.getElementById("cmp-loc-list");
|
||
const params = document.getElementById("cmp-params");
|
||
const placeholder = document.getElementById("cmp-placeholder");
|
||
const head = document.getElementById("cmp-head");
|
||
const results = document.getElementById("cmp-results");
|
||
const comfortInput = document.getElementById("cmp-comfort");
|
||
const comfortVal = document.getElementById("cmp-comfort-val");
|
||
const tolInput = document.getElementById("cmp-tol");
|
||
const tolVal = document.getElementById("cmp-tol-val");
|
||
const basisToggle = document.getElementById("cmp-basis");
|
||
const startInput = document.getElementById("cmp-start");
|
||
const endInput = document.getElementById("cmp-end");
|
||
|
||
|
||
// ---- data ----
|
||
// Fetch one location's range (chunked) and reduce it to the compact per-day series.
|
||
async function fetchSeries(loc, token) {
|
||
const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
|
||
const days = [];
|
||
let place = null;
|
||
for (const ch of chunks) {
|
||
const url = `api/v2/calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`;
|
||
const d = await getJSON(url, TTL.calendar, true);
|
||
if (token !== loadToken) return null; // superseded
|
||
place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`;
|
||
for (const r of d.days) days.push(r);
|
||
}
|
||
return {
|
||
name: place,
|
||
series: days.map((r) => ({
|
||
hi: r.tmax ? r.tmax.v : null,
|
||
lo: r.tmin ? r.tmin.v : null,
|
||
feels: r.feels ? r.feels.v : null,
|
||
})),
|
||
};
|
||
}
|
||
|
||
function persistLocations() {
|
||
lsSet(CMP.locs, JSON.stringify(locations.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name }))));
|
||
}
|
||
|
||
async function loadLocation(loc, token) {
|
||
loc.loading = true; loc.error = null;
|
||
renderAll();
|
||
try {
|
||
const res = await fetchSeries(loc, token);
|
||
if (!res || token !== loadToken) return; // superseded (a newer load owns this loc)
|
||
loc.name = res.name; loc.series = res.series;
|
||
} catch (e) {
|
||
if (token === loadToken) loc.error = e.message || "couldn't load";
|
||
}
|
||
if (token === loadToken) loc.loading = false;
|
||
persistLocations();
|
||
renderAll();
|
||
}
|
||
|
||
// Load every location that still needs data under the current range.
|
||
function loadPending(token) {
|
||
for (const loc of locations) if (!loc.series && !loc.loading) loadLocation(loc, token);
|
||
}
|
||
|
||
function addLocation(lat, lon) {
|
||
// Ignore a spot already on the list (same grid neighborhood).
|
||
if (locations.some((l) => Math.abs(l.lat - lat) < 0.05 && Math.abs(l.lon - lon) < 0.05)) return;
|
||
const loc = { lat, lon, name: null, series: null, loading: false, error: null };
|
||
locations.push(loc);
|
||
saveLastLocation(lat, lon);
|
||
persistLocations();
|
||
writeHashState();
|
||
renderAll(); // pending → the Refresh button appears; no fetch until it's tapped
|
||
resolveName(loc); // but resolve the neighborhood+city right away (before Refresh)
|
||
}
|
||
|
||
// Resolve just the place name for a freshly-added location, independent of its
|
||
// (deferred, heavier) series load, so the chip flips from coordinates to the
|
||
// neighborhood + city immediately on add. Best-effort — the series load resolves
|
||
// the name too, so a failure here is harmless.
|
||
async function resolveName(loc) {
|
||
try {
|
||
const res = await fetch(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`);
|
||
if (!res.ok) return;
|
||
const d = await res.json();
|
||
// Skip if the location was removed meanwhile, or already got named by a load.
|
||
if (!locations.includes(loc) || loc.name || !d || !d.place) return;
|
||
loc.name = d.place;
|
||
persistLocations();
|
||
renderAll();
|
||
} catch (e) { /* best-effort — the series load will still resolve the name */ }
|
||
}
|
||
|
||
function removeLocation(i) {
|
||
locations.splice(i, 1);
|
||
persistLocations();
|
||
writeHashState();
|
||
renderAll();
|
||
}
|
||
|
||
// ---- pending / refresh state ----
|
||
const dateChanged = () => startInput.value !== range.start || endInput.value !== range.end;
|
||
const isPending = (l) => !l.series && !l.loading && !l.error;
|
||
const isDirty = () => locations.length > 0 && (dateChanged() || locations.some(isPending));
|
||
|
||
// The Refresh tap: adopt any edited dates (which invalidates every series) and load
|
||
// whatever now needs data.
|
||
function refresh() {
|
||
let s = startInput.value || range.start, e = endInput.value || range.end;
|
||
if (!isYM(s) || !isYM(e)) return;
|
||
if (s > e) { const t = s; s = e; e = t; }
|
||
const rangeChanged = s !== range.start || e !== range.end;
|
||
range = { start: s, end: e };
|
||
startInput.value = s; endInput.value = e;
|
||
lsSet(CMP.range, JSON.stringify(range));
|
||
const token = ++loadToken;
|
||
if (rangeChanged) for (const loc of locations) loc.series = null;
|
||
writeHashState();
|
||
loadPending(token);
|
||
renderAll();
|
||
}
|
||
|
||
// ---- stats ----
|
||
function basisTemp(pt) {
|
||
if (basis === "tmax") return pt.hi;
|
||
if (basis === "tmin") return pt.lo;
|
||
if (basis === "feels") return pt.feels;
|
||
return pt.hi != null && pt.lo != null ? (pt.hi + pt.lo) / 2 : null; // mean
|
||
}
|
||
|
||
function computeStats(series) {
|
||
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumAbs = 0;
|
||
for (const pt of series) {
|
||
const t = basisTemp(pt);
|
||
if (t == null || isNaN(t)) continue;
|
||
n++;
|
||
const d = t - comfort;
|
||
sumAbs += Math.abs(d);
|
||
if (d < -tol) { below++; sumBelow += -d; }
|
||
else if (d > tol) { above++; sumAbove += d; }
|
||
else comf++;
|
||
}
|
||
if (!n) return null;
|
||
return {
|
||
n, comf, below, above,
|
||
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
|
||
avgBelow: below ? sumBelow / below : 0,
|
||
avgAbove: above ? sumAbove / above : 0,
|
||
mad: sumAbs / n,
|
||
};
|
||
}
|
||
|
||
// ---- render ----
|
||
const pct = (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}%`;
|
||
const deg = (v) => `${Math.round(v)}°`;
|
||
|
||
function renderLocList() {
|
||
locList.innerHTML = locations.map((l, i) => {
|
||
let label, cls = "cmp-chip";
|
||
// Show the raw coordinates while pending/loading (with the loading class still
|
||
// driving the spinner); once scored, l.name holds the resolved neighborhood +
|
||
// city, so the coordinates are replaced live by the place name.
|
||
const coords = `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
|
||
if (l.loading) { label = l.name || coords; cls += " loading"; }
|
||
else if (l.error) { label = "Couldn't load"; cls += " error"; }
|
||
else if (!l.series) { label = l.name || coords; cls += " pending"; }
|
||
else { label = l.name; }
|
||
return `<li class="${cls}"><span class="cmp-chip-name">${label}</span>` +
|
||
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">×</button></li>`;
|
||
}).join("");
|
||
}
|
||
|
||
function rankCard(loc, s, rank) {
|
||
const bar = `<div class="cmp-bar" role="img" aria-label="${pct(s.belowPct)} colder, ${pct(s.comfortPct)} in comfort, ${pct(s.abovePct)} warmer">` +
|
||
`<span class="cmp-seg cmp-below" style="flex-basis:${s.belowPct}%"></span>` +
|
||
`<span class="cmp-seg cmp-comfort" style="flex-basis:${s.comfortPct}%"></span>` +
|
||
`<span class="cmp-seg cmp-above" style="flex-basis:${s.abovePct}%"></span></div>`;
|
||
const stats =
|
||
`<div class="cmp-stats">` +
|
||
`<div class="cmp-stat cmp-s-below"><span class="cmp-k">Colder</span><span class="cmp-v">${pct(s.belowPct)}</span><span class="cmp-d">${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}</span></div>` +
|
||
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">±${tol}° of ${comfort}°</span></div>` +
|
||
`<div class="cmp-stat cmp-s-above"><span class="cmp-k">Warmer</span><span class="cmp-v">${pct(s.abovePct)}</span><span class="cmp-d">${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}</span></div>` +
|
||
`</div>`;
|
||
return `<div class="cmp-card">` +
|
||
`<div class="cmp-card-top">` +
|
||
`<span class="cmp-rank">#${rank}</span>` +
|
||
`<div class="cmp-card-name"><span class="cmp-name">${loc.name}</span><span class="cmp-daycount">${s.n} days</span></div>` +
|
||
`<div class="cmp-big"><b>${pct(s.comfortPct)}</b><span>in comfort</span></div>` +
|
||
`</div>` +
|
||
bar + stats +
|
||
`<div class="cmp-mad">Typical day misses comfort by <b>${deg(s.mad)}</b></div>` +
|
||
`</div>`;
|
||
}
|
||
|
||
function renderResults() {
|
||
const loading = locations.filter((l) => l.loading).length;
|
||
const scored = locations
|
||
.map((l) => ({ loc: l, s: l.series ? computeStats(l.series) : null }))
|
||
.filter((x) => x.s);
|
||
// Best comfort share first; break ties by the smaller typical miss.
|
||
scored.sort((a, b) => (b.s.comfortPct - a.s.comfortPct) || (a.s.mad - b.s.mad));
|
||
|
||
if (!scored.length) {
|
||
head.hidden = true;
|
||
results.innerHTML = loading ? `<p class="spinner">Loading daily weather…</p>` : "";
|
||
return;
|
||
}
|
||
|
||
// Headline: the winner + the parameters it was judged under.
|
||
const win = scored[0];
|
||
const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
|
||
const lead = scored.length > 1
|
||
? `<b>${win.loc.name}</b> best matches ${comfort}° — ${pct(win.s.comfortPct)} of days within ±${tol}°`
|
||
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days within ±${tol}° of ${comfort}°`;
|
||
head.hidden = false;
|
||
head.innerHTML = `<h2>${lead}</h2>` +
|
||
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}</p>`;
|
||
|
||
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("");
|
||
}
|
||
|
||
function renderAll() {
|
||
const has = locations.length > 0;
|
||
params.hidden = !has;
|
||
placeholder.hidden = has;
|
||
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
|
||
refreshBtn.hidden = !isDirty();
|
||
renderLocList();
|
||
renderResults();
|
||
}
|
||
|
||
// ---- events ----
|
||
initFindButton(addBtn, "Add location",
|
||
() => (locations.length ? locations[locations.length - 1] : loadLastLocation()),
|
||
(lat, lon) => addLocation(lat, lon));
|
||
|
||
refreshBtn.addEventListener("click", refresh);
|
||
|
||
locList.addEventListener("click", (e) => {
|
||
const x = e.target.closest(".cmp-chip-x");
|
||
if (x) removeLocation(+x.dataset.i);
|
||
});
|
||
|
||
// Comfort / band / basis: instant re-rank over data already in hand — no refetch.
|
||
comfortInput.addEventListener("input", () => {
|
||
comfort = +comfortInput.value;
|
||
comfortVal.textContent = `${comfort}°`;
|
||
lsSet(CMP.comfort, String(comfort));
|
||
writeHashState();
|
||
renderResults();
|
||
});
|
||
tolInput.addEventListener("input", () => {
|
||
tol = +tolInput.value;
|
||
tolVal.textContent = `±${tol}°`;
|
||
lsSet(CMP.tol, String(tol));
|
||
writeHashState();
|
||
renderResults();
|
||
});
|
||
basisToggle.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("button[data-basis]");
|
||
if (!btn) return;
|
||
basis = btn.dataset.basis;
|
||
lsSet(CMP.basis, basis);
|
||
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
|
||
writeHashState();
|
||
renderResults();
|
||
});
|
||
|
||
// Editing the dates doesn't load — it just arms Refresh (re-eval via renderAll).
|
||
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
|
||
startInput.addEventListener("change", renderAll);
|
||
endInput.addEventListener("change", renderAll);
|
||
clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker
|
||
|
||
// ---- init ----
|
||
(function restore() {
|
||
const hash = readHashState();
|
||
if (hash) {
|
||
if (hash.comfort != null) comfort = hash.comfort;
|
||
if (hash.tol != null) tol = hash.tol;
|
||
if (hash.basis) basis = hash.basis;
|
||
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
|
||
}
|
||
|
||
comfortInput.value = comfort; comfortVal.textContent = `${comfort}°`;
|
||
tolInput.value = tol; tolVal.textContent = `±${tol}°`;
|
||
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
|
||
startInput.value = range.start; endInput.value = range.end;
|
||
|
||
// A shared link's locations win; else the last-used set; else seed from the spot
|
||
// the other views were on so the page isn't empty. All three auto-load on arrival
|
||
// (the tap-to-refresh rule is for later interactive edits, not the initial view).
|
||
let list = hash && hash.locs;
|
||
if (!list) { try { const saved = JSON.parse(lsGet(CMP.locs)); if (Array.isArray(saved)) list = saved; } catch (e) {} }
|
||
if (!list || !list.length) { const last = loadLastLocation(); list = last ? [{ lat: last.lat, lon: last.lon }] : []; }
|
||
|
||
locations = list
|
||
.filter((l) => l && typeof l.lat === "number" && typeof l.lon === "number")
|
||
.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name || null, series: null, loading: false, error: null }));
|
||
|
||
writeHashState();
|
||
const token = ++loadToken;
|
||
loadPending(token);
|
||
renderAll();
|
||
})();
|