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;