// Compare view: line up several places over one date range and see which best // fits a comfort temperature *range*. 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 test it // against the comfort range [lo, hi]: inside the range "hits comfort"; below lo is // colder, above hi is warmer, and we track by how much. // // Below the ranked cards a separate climate-distribution section shows how each // place's days spread across a metric's Record-Low→Record-High categories (its own // metric + share/count toggles), reusing the calendar's totals strip. // // The comparison — locations, comfort range, judged temperature and date range — // lives in the URL hash, so a link reproduces exactly what you see. The range and // 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, monthStart, monthEnd, buildChunks, clickOpensPicker, metricBuckets, distStrip, seasonFilterDropdown, seasonSummaryText, syncSeasonChecks, applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths, namePrimary, nameParts } from "./shared.js"; import { fmtTemp, fmtDelta, onUnitChange } from "./units.js"; import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { initFilterSheet } from "./filtersheet.js"; const CMP = { lo: "thermograph:cmpLo", hi: "thermograph:cmpHi", tlo: "thermograph:cmpTlo", thi: "thermograph:cmpThi", basis: "thermograph:cmpBasis", range: "thermograph:cmpRange", months: "thermograph:cmpMonths", locs: "thermograph:cmpLocs", distMetric: "thermograph:cmpDistMetric", distCount: "thermograph:cmpDistCount", }; 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"]; const R_MIN = 30, R_MAX = 100; // comfort-range slider bounds (°F) // The comfort range [lo, hi] in °F. Restored from the new lo/hi keys, else migrated // from the old comfort-point + band (c ± t), else the default 63–73° (was 68 ± 5). function loadRange2() { let lo = +lsGet(CMP.lo), hi = +lsGet(CMP.hi); if (lo >= R_MIN && hi <= R_MAX && lo <= hi) return { lo, hi }; const c = +lsGet("thermograph:cmpComfort"), t = +lsGet("thermograph:cmpTol"); if (c >= R_MIN && c <= R_MAX && t >= 0) return { lo: clamp(c - t, R_MIN, R_MAX), hi: clamp(c + t, R_MIN, R_MAX) }; return { lo: 63, hi: 73 }; } let { lo, hi } = loadRange2(); // Outer tolerance range [tlo, thi], with tlo ≤ lo ≤ hi ≤ thi. Days outside comfort // but inside tolerance still count toward the match score (ramped by how far past // comfort they land); days beyond tolerance count as a full miss. Restored from // storage, else defaulting to comfort widened by 10° on each side. function loadTol() { const t = { tlo: +lsGet(CMP.tlo), thi: +lsGet(CMP.thi) }; if (t.tlo >= R_MIN && t.thi <= R_MAX && t.tlo <= lo && t.thi >= hi) return t; return { tlo: clamp(lo - 10, R_MIN, R_MAX), thi: clamp(hi + 10, R_MIN, R_MAX) }; } let { tlo, thi } = loadTol(); let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax"; // Time-of-year filter: only days whose month is checked count toward the ranking and // the distribution. Seasons are the primary selector, months the nested suboption; // the single source of truth is this Set of month indices (0=Jan … 11=Dec). Restored // from a 12-char bitmask, defaulting to all 12 months (no filtering). function loadMonths() { const m = maskToMonths(lsGet(CMP.months)); return m && m.size ? m : allMonths(); } let checkedMonths = loadMonths(); const monthsActive = () => checkedMonths.size < 12; const monthOf = (r) => +r.date.slice(5, 7) - 1; // A location's days narrowed to the checked months (pass-through when all 12 are on). function visibleSeries(loc) { if (!loc.series) return null; if (!monthsActive()) return loc.series; return loc.series.filter((r) => r.date && checkedMonths.has(monthOf(r))); } // Distribution section (independent of comfort): which metric to bucket by, and // whether the strip prints shares or raw day counts. const DIST_METRICS = ["tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dsr"]; let distMetric = DIST_METRICS.includes(lsGet(CMP.distMetric)) ? lsGet(CMP.distMetric) : "tmax"; let distCount = lsGet(CMP.distCount) === "1"; // Locations being compared: {lat, lon, name, series|null, loading, error}. // `series` is the array of raw daily records from /api/v2/calendar (each metric an // {v, c, …} object) — the comfort ranking reads the values, the distribution the // category classes. 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 high" }; // ---- date range (month granularity) ---- const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s); function defaultRange() { const now = new Date(); // January six years back → the present month (a broad seasonal sample by default). return { start: `${now.getFullYear() - 6}-01`, 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 comparison: lo/hi=comfort range, b=basis, s/e=range, // loc=lat,lon;lat,lon. Written on every state change; read once on load (a link // wins over localStorage). Old links used c=comfort + t=band; those still resolve // (converted to a range). 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("lo", lo); p.set("hi", hi); p.set("tlo", tlo); p.set("thi", thi); p.set("b", basis); p.set("s", range.start); p.set("e", range.end); if (monthsActive()) p.set("m", monthsToMask(checkedMonths)); // omit when all 12 (clean links) 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 (!["lo", "hi", "tlo", "thi", "c", "t", "b", "s", "e", "m", "loc"].some((k) => p.has(k))) return null; // not a compare link const st = {}; if (p.has("lo") || p.has("hi")) { const l = clamp(+p.get("lo"), R_MIN, R_MAX), h = clamp(+p.get("hi"), R_MIN, R_MAX); if (!isNaN(l) && !isNaN(h)) { st.lo = Math.min(l, h); st.hi = Math.max(l, h); } } else if (p.has("c")) { // legacy comfort-point + band link const c = clamp(+p.get("c") || 68, R_MIN, R_MAX); const t = p.get("t") !== null && p.get("t") !== "" ? clamp(+p.get("t"), 0, 15) : 5; st.lo = clamp(c - t, R_MIN, R_MAX); st.hi = clamp(c + t, R_MIN, R_MAX); } if (p.has("tlo") || p.has("thi")) { const a = clamp(+p.get("tlo"), R_MIN, R_MAX), b = clamp(+p.get("thi"), R_MIN, R_MAX); if (!isNaN(a)) st.tlo = a; if (!isNaN(b)) st.thi = b; } 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("m")) { const ms = maskToMonths(p.get("m")); if (ms && ms.size) st.months = ms; } 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"); // A second Load/Refresh lives inside the params sheet's date-range block, so editing // the range on mobile (where the panel is a bottom sheet) can reload without closing. const refreshBtnSheet = document.getElementById("cmp-refresh-sheet"); const refreshBtns = [refreshBtn, refreshBtnSheet]; const locList = document.getElementById("cmp-loc-list"); const params = document.getElementById("cmp-params"); const comfortWrap = document.getElementById("cmp-comfort"); // now inline, above the sheet const placeholder = document.getElementById("cmp-placeholder"); const head = document.getElementById("cmp-head"); const baseline = document.getElementById("cmp-baseline"); const results = document.getElementById("cmp-results"); const loInput = document.getElementById("cmp-lo"); const hiInput = document.getElementById("cmp-hi"); const tloInput = document.getElementById("cmp-tlo"); const thiInput = document.getElementById("cmp-thi"); const comfortValEl = document.getElementById("cmp-comfort-val"); const tolValEl = document.getElementById("cmp-tol-val"); const tickEls = { tlo: document.getElementById("cmp-tick-tlo"), lo: document.getElementById("cmp-tick-lo"), hi: document.getElementById("cmp-tick-hi"), thi: document.getElementById("cmp-tick-thi"), }; const fillEl = document.getElementById("cmp-fill"); const fillTolEl = document.getElementById("cmp-fill-tol"); const basisToggle = document.getElementById("cmp-basis"); const seasonDd = document.getElementById("cmp-season-dd"); const startInput = document.getElementById("cmp-start"); const endInput = document.getElementById("cmp-end"); const distSection = document.getElementById("cmp-dist"); const distToggle = document.getElementById("cmp-dist-metric"); const distToggleSheet = document.getElementById("cmp-dist-metric-sheet"); // The sheet copy mirrors the inline selector's 8 buttons (cloned) and stays in sync. distToggleSheet.innerHTML = distToggle.innerHTML; const distBody = document.getElementById("cmp-dist-body"); // ---- data ---- // Fetch one location's range (chunked), keeping the raw daily records — the comfort // ranking reads their values, the distribution their category classes. 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 = uv(`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 }; } 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(uv(`place?lat=${loc.lat}&lon=${loc.lon}`), { credentials: "include" }); 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 ---- // The judged temperature for one raw daily record, per the basis toggle. function basisTemp(r) { const hi = r.tmax ? r.tmax.v : null, lo = r.tmin ? r.tmin.v : null; if (basis === "tmax") return hi; if (basis === "tmin") return lo; // "Feels" judges the felt daytime high (fmax) — the apparent-temperature peak of // the day. The combined `feels` metric instead reports whichever apparent extreme // is furthest from 65°F, so in mild climates it flips to the cold overnight low in // winter and makes comfortable days vanish; fmax is the like-for-like daytime feel. if (basis === "feels") return r.fmax ? r.fmax.v : null; return hi != null && lo != null ? (hi + lo) / 2 : null; // mean } // A quantile (0–1) of an ascending-sorted array, linearly interpolated. function quantile(sorted, q) { if (!sorted.length) return null; const i = (sorted.length - 1) * q, lo = Math.floor(i), hi = Math.ceil(i); return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); } // Score every day 0–1: full credit inside comfort [lo,hi]; inside tolerance it // ramps 1→0 by how far past the comfort edge it lands (so a near miss beats a far // one); beyond tolerance it's 0. The mean of those credits is the 0–100 match // score we rank on — it folds in comfort days, tolerance days, and miss magnitude. function computeStats(series) { let comf = 0, belowTol = 0, belowOut = 0, aboveTol = 0, aboveOut = 0; let sumBelow = 0, sumAbove = 0, sumMiss = 0, sumTemp = 0, sumCredit = 0; const temps = []; // every valid basis temp (°F), for the average + spread for (const r of series) { const t = basisTemp(r); if (t == null || isNaN(t)) continue; temps.push(t); sumTemp += t; if (t >= lo && t <= hi) { comf++; sumCredit += 1; continue; } const cold = t < lo; const d = cold ? lo - t : t - hi; // degrees past the comfort edge const margin = cold ? lo - tlo : thi - hi; // tolerance width on this side sumMiss += d; if (cold) sumBelow += d; else sumAbove += d; if (margin > 0 && d <= margin) { // within tolerance: partial credit sumCredit += 1 - d / margin; if (cold) belowTol++; else aboveTol++; } else { // beyond tolerance: a full miss if (cold) belowOut++; else aboveOut++; } } const n = temps.length; if (!n) return null; temps.sort((a, b) => a - b); const below = belowTol + belowOut, above = aboveTol + aboveOut; return { n, comf, below, above, belowTol, belowOut, aboveTol, aboveOut, score: 100 * sumCredit / n, // the match score we rank on comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n, belowTolPct: 100 * belowTol / n, belowOutPct: 100 * belowOut / n, aboveTolPct: 100 * aboveTol / n, aboveOutPct: 100 * aboveOut / n, tolPct: 100 * (belowTol + aboveTol) / n, avgBelow: below ? sumBelow / below : 0, avgAbove: above ? sumAbove / above : 0, mad: sumMiss / n, // Baseline strip: the typical temperature (°F) and the middle-80% spread. avgTemp: sumTemp / n, p10: quantile(temps, 0.1), p90: quantile(temps, 0.9), }; } // ---- render ---- const pct = (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}%`; // Temperature *differences* (avg miss, typical miss) in the active unit. const deg = (v) => fmtDelta(v); function renderLocList() { locList.innerHTML = locations.map((l, i) => { let label, cls = "cmp-chip", lead = ""; // Coordinates show until the place name resolves. A spinning ring marks a live // load; a "queued" dot marks a location waiting for the next Refresh tap. Once // scored, l.name holds the resolved neighborhood + city. const coords = `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; if (l.loading) { label = l.name || coords; cls += " loading"; lead = ``; } else if (l.error) { label = "Couldn't load"; cls += " error"; } else if (!l.series) { label = l.name || coords; cls += " pending"; lead = ``; } else { label = l.name; } // The chip shows just the lead segment (compact, several per row); the full name // stays in the title + aria-label and on the ranked card below. return `
Loading daily weather…
` + Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("") : filteredOut ? `No days fall in the selected months. Widen the time-of-year filter.
` : ""; return; } // Headline: the winner + the parameters it was judged under. const win = scored[0]; const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`; const winName = namePrimary(win.loc.name); const lead = scored.length > 1 ? `${winName} is the best match for ${fmtTemp(lo)}–${fmtTemp(hi)}: score ${Math.round(win.s.score)}/100` : `${winName}: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}–${fmtTemp(hi)}`; head.hidden = false; head.innerHTML = `No data for this metric.
`; return `