diff --git a/static/compare.html b/static/compare.html index 89e3bc1..651d90d 100644 --- a/static/compare.html +++ b/static/compare.html @@ -30,7 +30,10 @@
Locations - +
+ + +
@@ -66,7 +69,7 @@ - + Editing the range arms Refresh — tap it to reload. diff --git a/static/compare.js b/static/compare.js index 113f978..4540077 100644 --- a/static/compare.js +++ b/static/compare.js @@ -6,9 +6,11 @@ // 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 location set and the date range are the only things that require a data -// load. Comfort temperature, band width and the judged temperature are all pure -// re-computations over data already in hand, so moving those re-ranks instantly. +// 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. const CMP = { comfort: "thermograph:cmpComfort", @@ -22,12 +24,14 @@ const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { retur 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 = ["tmax", "tmin", "mean", "feels"].includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax"; +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; @@ -36,16 +40,17 @@ const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight // ---- date range (month granularity) ---- const pad = (n) => String(n).padStart(2, "0"); +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 && o.start && o.end) return o; } catch (e) {} + 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(); // {start, end} as "YYYY-MM" +let range = loadRange(); // the APPLIED range (what loaded data reflects), "YYYY-MM" const monthStart = (ym) => `${ym}-01`; const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; @@ -74,8 +79,39 @@ function buildChunks(startIso, endIso) { return out; } +// ---- 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"); @@ -88,7 +124,6 @@ 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"); -const refreshBtn = document.getElementById("cmp-refresh"); addBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Add location`; @@ -124,34 +159,58 @@ async function loadLocation(loc, token) { renderAll(); try { const res = await fetchSeries(loc, token); - if (!res || token !== loadToken) return; - loc.name = res.name; loc.series = res.series; loc.loading = false; + if (!res || token !== loadToken) return; // superseded (a newer load owns this loc) + loc.name = res.name; loc.series = res.series; } catch (e) { - loc.loading = false; loc.error = e.message || "couldn't load"; + 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: true, error: null }; - locations.push(loc); + locations.push({ lat, lon, name: null, series: null, loading: false, error: null }); window.Thermograph.saveLastLocation(lat, lon); - loadLocation(loc, loadToken); + persistLocations(); + writeHashState(); + renderAll(); // pending → the Refresh button appears; no fetch until it's tapped } function removeLocation(i) { locations.splice(i, 1); persistLocations(); + writeHashState(); renderAll(); } -// Refetch every location's series (used when the date range changes). -function reloadAll() { +// ---- 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; - for (const loc of locations) { loc.series = null; loadLocation(loc, token); } + if (rangeChanged) for (const loc of locations) loc.series = null; + writeHashState(); + loadPending(token); + renderAll(); } // ---- stats ---- @@ -190,8 +249,11 @@ const deg = (v) => `${Math.round(v)}°`; function renderLocList() { locList.innerHTML = locations.map((l, i) => { - const label = l.loading ? "Locating…" : l.error ? "Couldn't load" : (l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`); - const cls = "cmp-chip" + (l.loading ? " loading" : "") + (l.error ? " error" : ""); + let label, cls = "cmp-chip"; + if (l.loading) { label = "Locating…"; cls += " loading"; } + else if (l.error) { label = "Couldn't load"; cls += " error"; } + else if (!l.series) { label = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; cls += " pending"; } + else { label = l.name; } return `
  • ${label}` + `
  • `; }).join(""); @@ -251,6 +313,7 @@ function renderAll() { params.hidden = !has; placeholder.hidden = has; addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location"; + refreshBtn.hidden = !isDirty(); renderLocList(); renderResults(); } @@ -261,22 +324,26 @@ addBtn.addEventListener("click", () => { window.LocationPicker.open(cur, (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, no refetch. +// 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) => { @@ -285,42 +352,43 @@ basisToggle.addEventListener("click", (e) => { basis = btn.dataset.basis; lsSet(CMP.basis, basis); basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn)); + writeHashState(); renderResults(); }); -// Date range: editing reveals the reload button; confirming refetches everything. +// Editing the dates doesn't load — it just arms Refresh (re-eval via renderAll). startInput.max = endInput.max = `${new Date().getFullYear()}-12`; -function markDirty() { refreshBtn.hidden = locations.length === 0; } -startInput.addEventListener("change", markDirty); -endInput.addEventListener("change", markDirty); -refreshBtn.addEventListener("click", () => { - let s = startInput.value || range.start, e = endInput.value || range.end; - if (s > e) { const t = s; s = e; e = t; } - range = { start: s, end: e }; - startInput.value = s; endInput.value = e; - lsSet(CMP.range, JSON.stringify(range)); - refreshBtn.hidden = true; - reloadAll(); -}); +startInput.addEventListener("change", renderAll); +endInput.addEventListener("change", renderAll); // ---- 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; - let saved = null; - try { saved = JSON.parse(lsGet(CMP.locs)); } catch (e) {} - if (Array.isArray(saved) && saved.length) { - locations = saved - .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: true, error: null })); - for (const loc of locations) loadLocation(loc, loadToken); - } else { - // Seed from the spot the other views last looked at, so Compare isn't empty. - const last = window.Thermograph.loadLastLocation(); - if (last) addLocation(last.lat, last.lon); - } + // 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 = window.Thermograph.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(); })(); diff --git a/static/style.css b/static/style.css index 0e324e5..8c9750d 100644 --- a/static/style.css +++ b/static/style.css @@ -802,7 +802,18 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } border-radius: 999px; padding: 5px 5px 5px 14px; font-size: 14px; } .cmp-chip.loading { color: var(--muted); } +.cmp-chip.pending { color: var(--muted); border-style: dashed; } .cmp-chip.error { border-color: var(--very-hot); color: var(--very-hot); } + +/* Add-location + Refresh sit together; Refresh only shows while changes are pending. */ +.cmp-loc-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.cmp-refresh-btn { + padding: 11px 18px; border-radius: 10px; cursor: pointer; + font-weight: 700; font-size: 15px; min-height: 44px; + background: var(--accent); color: #1a1206; border: 1px solid var(--accent); +} +.cmp-refresh-btn[hidden] { display: none; } +.cmp-refresh-btn:hover { filter: brightness(1.05); } .cmp-chip-name { font-weight: 600; } .cmp-chip-x { border: none; background: transparent; color: var(--muted); cursor: pointer;