Compare: put full state in the URL and gate reloads behind Refresh (#12)
The whole comparison — locations, comfort target, band, judged temperature and date range — is now encoded in the URL hash (c/t/b/s/e/loc) and read back on load, so a link reproduces exactly what's on screen. Comfort, band and judged-temperature edits still re-rank instantly and update the hash without a fetch. Editing the date range or the location set no longer refetches on its own: it marks the comparison dirty and reveals a single "Refresh comparison" button next to Add location; the load runs only when it's tapped. Pending locations show a dashed chip until then. The initial view (from a shared link, the saved set, or the last-viewed spot) still loads on arrival.
This commit is contained in:
parent
13bc4cf0ac
commit
5faa6b4281
3 changed files with 128 additions and 46 deletions
|
|
@ -30,7 +30,10 @@
|
|||
<div class="cmp-locs">
|
||||
<span class="cal-filter-label">Locations</span>
|
||||
<ul id="cmp-loc-list" class="cmp-loc-list"></ul>
|
||||
<div class="cmp-loc-actions">
|
||||
<button type="button" id="cmp-add" class="find-btn"></button>
|
||||
<button type="button" id="cmp-refresh" class="cmp-refresh-btn" hidden>Refresh comparison ↻</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -66,7 +69,7 @@
|
|||
</div>
|
||||
<label>From <input id="cmp-start" type="month" /></label>
|
||||
<label>To <input id="cmp-end" type="month" /></label>
|
||||
<button type="button" id="cmp-refresh" class="range-refresh" hidden>Reload range ↻</button>
|
||||
<span class="hint">Editing the range arms Refresh — tap it to reload.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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} <span>Add location</span>`;
|
||||
|
||||
|
|
@ -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 `<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("");
|
||||
|
|
@ -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
|
||||
// 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: 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);
|
||||
}
|
||||
.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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue