"use strict"; // 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. 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 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 && isYM(o.start) && isYM(o.end)) return o; } catch (e) {} return defaultRange(); } 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())}`; const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0)); // Pretty "Jul 2025 – Jun 2026" for a YYYY-MM range. const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const monthLabel = (ym) => `${MON[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`; // Split a [startIso, endIso] span into consecutive ≤2-year windows so each stays // within the calendar endpoint's per-request cap (mirrors the Calendar view). const CHUNK_MONTHS = 24; function buildChunks(startIso, endIso) { const out = []; 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); let e = isoOfDate(ed); if (e > endIso) e = endIso; out.push({ start: s, end: e }); const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1); s = isoOfDate(ns); } 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"); 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"); addBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Add location`; // ---- 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 window.Thermograph.getJSON(url, window.Thermograph.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; locations.push({ lat, lon, name: null, series: null, loading: false, error: null }); window.Thermograph.saveLastLocation(lat, lon); persistLocations(); writeHashState(); renderAll(); // pending → the Refresh button appears; no fetch until it's tapped } 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 `
  • ${label}` + `
  • `; }).join(""); } function rankCard(loc, s, rank) { const bar = ``; const stats = `
    ` + `
    Colder${pct(s.belowPct)}${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}
    ` + `
    In comfort${pct(s.comfortPct)}±${tol}° of ${comfort}°
    ` + `
    Warmer${pct(s.abovePct)}${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}
    ` + `
    `; return `
    ` + `
    ` + `#${rank}` + `
    ${loc.name}${s.n} days
    ` + `
    ${pct(s.comfortPct)}in comfort
    ` + `
    ` + bar + stats + `
    Typical day misses comfort by ${deg(s.mad)}
    ` + `
    `; } 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 ? `

    Loading daily weather…

    ` : ""; 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 ? `${win.loc.name} best matches ${comfort}° — ${pct(win.s.comfortPct)} of days within ±${tol}°` : `${win.loc.name}: ${pct(win.s.comfortPct)} of days within ±${tol}° of ${comfort}°`; head.hidden = false; head.innerHTML = `

    ${lead}

    ` + `

    By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}

    `; 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 ---- addBtn.addEventListener("click", () => { const cur = locations.length ? locations[locations.length - 1] : window.Thermograph.loadLastLocation(); 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 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); // ---- 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 = 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(); })();