// 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 `
  • ${lead}${namePrimary(label)}` + `
  • `; }).join(""); } // The five day-buckets, ordered cold → hot: css color class, plain-language name, // and the stat key holding its share of days. Drives both the bar and its key. const BUCKETS = [ ["cmp-seg-vcold", "Too cold", "belowOutPct"], ["cmp-seg-cool", "Cool", "belowTolPct"], ["cmp-seg-comf", "Comfortable", "comfortPct"], ["cmp-seg-warm", "Warm", "aboveTolPct"], ["cmp-seg-vhot", "Too hot", "aboveOutPct"], ]; // A ranked place: the 0–100 match score up top, then one full-width bar showing how // the place's days split across the five temperature buckets (cold → hot, in the // site's own colors), and directly beneath it a key that names every bucket and its // share — so even a 1% sliver on the bar reads clearly. The whole bar is 100% of the // days, so more blue = runs cold, more red = runs hot. // The place name as a hierarchy: the lead segment bold, the rest (city · region · // country) on a muted line beneath — the one spot that shows the full name. function nameHierarchy(name) { const { primary, rest } = nameParts(name); return `${primary}${rest ? `${rest}` : ""}`; } function rankCard(loc, s, rank) { const win = rank === 1; const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0); const bar = ``; // Key under the bar: a color swatch + name + share for every non-empty bucket, in // the same cold→hot order as the bar, so small spans are still clearly labeled. On // desktop each label wants its segment's midpoint (recorded in data-mid); spreadKeys // then de-collides them after layout. On mobile it falls back to a plain wrapping row. let acc = 0; const key = `
    ` + parts.map((p) => { const mid = acc + p.w / 2; acc += p.w; return `${p.label} ${pct(p.w)}`; }).join("") + `
    `; const bands = `
    ${pct(s.tolPct)} within tolerance · typically ${deg(s.mad)} off comfort
    `; const sub = win ? `★ Best match · ${s.n} days` : `${s.n} days`; return `
    ` + `
    ` + `${rank}` + `
    ${nameHierarchy(loc.name)}${sub}
    ` + `
    ${Math.round(s.score)}match / 100
    ` + `
    ` + bar + key + bands + `
    `; } // A placeholder card shown while a location's data is still streaming in, so the // results don't jump when it resolves. Animated by CSS (.cmp-skel). const skeletonRow = () => ``; // Baseline strip: every place's typical basis temperature on one shared °F axis, // with the comfort range shaded, so the places' baselines line up for direct // comparison. Each row shows the average (dot) and the middle-80% spread (bar), // colored by where the average sits relative to the comfort range. function renderBaseline(scored) { if (!scored.length) { baseline.hidden = true; baseline.innerHTML = ""; return; } let min = Math.min(lo, tlo), max = Math.max(hi, thi); for (const { s } of scored) { min = Math.min(min, s.p10); max = Math.max(max, s.p90); } const pad = Math.max(3, (max - min) * 0.06); // keep markers off the edges min -= pad; max += pad; const span = max - min || 1; const posF = (v) => clamp((v - min) / span * 100, 0, 100); const bandL = posF(lo), bandR = posF(hi), tolL = posF(tlo), tolR = posF(thi); const posClass = (t) => (t < lo ? "cmp-below" : t > hi ? "cmp-above" : "cmp-comfort"); const rows = scored.map(({ loc, s }) => { const cls = posClass(s.avgTemp), a = posF(s.avgTemp); return `
    ` + `${namePrimary(loc.name)}` + `
    `; }).join(""); baseline.hidden = false; baseline.innerHTML = `
    Typical temperature by place ` + `dot = average · bar = middle-80% of days · shaded = comfort, faint = tolerance
    ` + `
    ${rows}
    ` + `
    ${fmtTemp(min)}${fmtTemp((min + max) / 2)}${fmtTemp(max)}
    `; } function renderResults() { const loading = locations.filter((l) => l.loading).length; const scored = locations .map((l) => ({ loc: l, s: l.series ? computeStats(visibleSeries(l)) : null })) .filter((x) => x.s); // Best match score first; break ties by the smaller typical miss. scored.sort((a, b) => (b.s.score - a.s.score) || (a.s.mad - b.s.mad)); renderBaseline(scored); if (!scored.length) { head.hidden = true; // Loaded places can score empty when the month filter excludes all their days. const filteredOut = !loading && monthsActive() && locations.some((l) => l.series && l.series.length); results.innerHTML = loading ? `

    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 = `

    ${lead}

    ` + `

    By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}–${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}–${fmtTemp(thi)} · ${span}` + `${loading ? ` · loading ${loading} more…` : ""}

    `; results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("") + (loading ? Array.from({ length: loading }, skeletonRow).join("") : ""); spreadKeys(); } // The desktop key wants each label centred on its segment's midpoint, but small or // adjacent buckets make labels overlap. After layout, de-collide each row with the // classic 1-D label placement: keep every label at its ideal midpoint, and only where // labels would touch, merge them into a block positioned to minimise the total shift // from their ideals — so an isolated label never moves, and a cluster centres on its // members rather than sliding to one edge. Finally clamp the run inside the bar. // Mobile uses a plain wrapping row, so there we just clear any positions we applied. function spreadKeys() { const desktop = window.matchMedia("(min-width: 641px)").matches; const GAP = 10; results.querySelectorAll(".cmp-key").forEach((keyEl) => { const items = [...keyEl.children]; if (!desktop) { items.forEach((el) => { el.style.left = el.style.transform = ""; }); return; } if (!items.length) return; const W = keyEl.clientWidth; // Each label's ideal left edge = its segment midpoint minus half its width. const labs = items.map((el) => { const w = el.offsetWidth; return { el, w, ideal: (parseFloat(el.dataset.mid) / 100) * W - w / 2 }; }); // Sweep left→right, growing a block whenever the next label would overlap the // current one. A block records Σ(idealₖ − offsetₖ) and its count so its left edge // = that mean, the least-squares spot for its members. const blocks = []; for (const it of labs) { let b = { items: [it], left: it.ideal, width: it.w, sum: it.ideal, n: 1 }; blocks.push(b); while (blocks.length >= 2) { const a = blocks[blocks.length - 2], cur = blocks[blocks.length - 1]; if (a.left + a.width + GAP <= cur.left) break; // no overlap → settled const offset = a.width + GAP; // cur's items shift this far into a a.sum += cur.sum - cur.n * offset; a.n += cur.n; a.width += GAP + cur.width; a.items = a.items.concat(cur.items); a.left = a.sum / a.n; blocks.pop(); } } // Clamp the run inside the bar: off the left wall forward, off the right wall back. let minLeft = 0; for (const b of blocks) { if (b.left < minLeft) b.left = minLeft; minLeft = b.left + b.width + GAP; } let maxRight = W; for (let i = blocks.length - 1; i >= 0; i--) { const b = blocks[i]; if (b.left + b.width > maxRight) b.left = maxRight - b.width; maxRight = b.left - GAP; } // Lay each block's labels out left→right from the block's left edge. for (const b of blocks) { let x = b.left; for (const it of b.items) { it.el.style.left = `${x + it.w / 2}px`; it.el.style.transform = "translateX(-50%)"; x += it.w + GAP; } } }); } // ---- distribution (independent of the comfort filter) ---- // One calendar-style Record-Low→Record-High connected bar chart per loaded location // for the selected metric; the share/count toggle flips every figure. Category names // are percentile tiers, but each bar now shows the tier's average value + range, so it // re-renders on the °C/°F toggle (wired below). function renderDist() { const withData = locations .map((l) => ({ l, vis: visibleSeries(l) })) .filter((x) => x.vis && x.vis.length); if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; } distBody.innerHTML = withData.map(({ l, vis }) => { const buckets = metricBuckets(vis, distMetric); const total = buckets.reduce((n, b) => n + b.n, 0); const name = namePrimary(l.name) || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; const strip = total ? distStrip(buckets, distCount) : `

    No data for this metric.

    `; return `
    ` + `
    ${name}${total.toLocaleString()} days
    ` + strip + `
    `; }).join(""); distSection.hidden = false; } function renderAll() { const has = locations.length > 0; params.hidden = !has; comfortWrap.hidden = !has; placeholder.hidden = has; addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location"; for (const b of refreshBtns) b.hidden = !isDirty(); renderLocList(); renderResults(); renderDist(); } // ---- events ---- initFindButton(addBtn, "Add location", () => (locations.length ? locations[locations.length - 1] : loadLastLocation()), (lat, lon) => addLocation(lat, lon)); for (const b of refreshBtns) b.addEventListener("click", refresh); locList.addEventListener("click", (e) => { const x = e.target.closest(".cmp-chip-x"); if (x) removeLocation(+x.dataset.i); }); // ---- comfort range slicer ---- // Two range inputs overlaid on a shared track; keep lo ≤ hi and mirror the selection // in the label + fill. State stays in °F; the range re-ranks instantly (no refetch). const pctPos = (v) => (v - R_MIN) / (R_MAX - R_MIN) * 100; function syncSlicer() { tloInput.value = tlo; loInput.value = lo; hiInput.value = hi; thiInput.value = thi; comfortValEl.textContent = `${fmtTemp(lo)}–${fmtTemp(hi)}`; tolValEl.textContent = `${fmtTemp(tlo)}–${fmtTemp(thi)}`; fillEl.style.left = `${pctPos(lo)}%`; fillEl.style.right = `${100 - pctPos(hi)}%`; fillTolEl.style.left = `${pctPos(tlo)}%`; fillTolEl.style.right = `${100 - pctPos(thi)}%`; // A temperature marker above each handle (the four band borders). for (const [k, v] of [["tlo", tlo], ["lo", lo], ["hi", hi], ["thi", thi]]) { tickEls[k].style.left = `${pctPos(v)}%`; tickEls[k].textContent = fmtTemp(v); } } function slicerChanged() { lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi)); lsSet(CMP.tlo, String(tlo)); lsSet(CMP.thi, String(thi)); syncSlicer(); writeHashState(); renderResults(); // the distribution is comfort-independent — no need to touch it } // Comfort thumbs stay inside the tolerance thumbs; tolerance thumbs stay outside // comfort — so the four handles can never cross (tlo ≤ lo ≤ hi ≤ thi). loInput.addEventListener("input", () => { lo = clamp(+loInput.value, tlo, hi); slicerChanged(); }); hiInput.addEventListener("input", () => { hi = clamp(+hiInput.value, lo, thi); slicerChanged(); }); tloInput.addEventListener("input", () => { tlo = Math.min(+tloInput.value, lo); slicerChanged(); }); thiInput.addEventListener("input", () => { thi = Math.max(+thiInput.value, hi); slicerChanged(); }); // The °F/°C toggle only flips what's shown — the range and series stay in °F (the // API's unit) — so re-render the range label, the cards, and the distribution chart // (its per-category averages + ranges are in the active unit). onUnitChange(() => { syncSlicer(); renderResults(); renderDist(); }); // Label widths (and the desktop/mobile threshold) move with the viewport, so // re-spread the rank-card keys after the layout settles. let spreadRaf = 0; window.addEventListener("resize", () => { cancelAnimationFrame(spreadRaf); spreadRaf = requestAnimationFrame(spreadKeys); }); // Distribution metric: an inline selector (by the chart) plus a synced copy in the // filter sheet. Either one drives the shared distMetric; both reflect the active state. const syncDistButtons = () => document.querySelectorAll("#cmp-dist-metric button, #cmp-dist-metric-sheet button") .forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric)); function setDistMetric(m) { distMetric = m; lsSet(CMP.distMetric, distMetric); syncDistButtons(); renderDist(); } for (const t of [distToggle, distToggleSheet]) { t.addEventListener("click", (e) => { const btn = e.target.closest("button[data-metric]"); if (btn) setDistMetric(btn.dataset.metric); }); } distSection.addEventListener("change", (e) => { if (!e.target.closest("#cmp-dist-count")) return; distCount = e.target.checked; lsSet(CMP.distCount, distCount ? "1" : "0"); renderDist(); }); // Basis: instant re-rank over data already in hand — no refetch. 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(); }); // ---- time-of-year (season / month) filter ---- // Seasons are primary, months the nested suboption; both drive the same Set. A toggle // re-ranks and re-buckets over data already in hand — no refetch. The dropdown isn't // rebuilt on change (that would collapse it): the native checkboxes hold their own // state, and we only re-derive the season indeterminate state + the summary text. function buildSeasonFilter() { seasonDd.innerHTML = seasonFilterDropdown(checkedMonths); syncSeasonChecks(seasonDd, checkedMonths); } initSeasonExpand(seasonDd); seasonDd.addEventListener("change", (e) => { const cb = e.target.closest("input[type=checkbox]"); if (!cb || !applySeasonMonthChange(cb, checkedMonths)) return; syncSeasonChecks(seasonDd, checkedMonths); seasonDd.querySelector(".cal-dd-sum").textContent = seasonSummaryText(checkedMonths); lsSet(CMP.months, monthsToMask(checkedMonths)); writeHashState(); renderResults(); renderDist(); if (fabSheet) fabSheet.setActive(monthsActive()); }); // Mobile: a floating pill slides the whole params panel up as a bottom sheet. let fabSheet = null; // 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); clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker // ---- init ---- (function restore() { const hash = readHashState(); if (hash) { if (hash.lo != null) lo = hash.lo; if (hash.hi != null) hi = hash.hi; if (hash.tlo != null) tlo = hash.tlo; if (hash.thi != null) thi = hash.thi; if (hash.basis) basis = hash.basis; if (hash.start && hash.end) range = { start: hash.start, end: hash.end }; if (hash.months) checkedMonths = hash.months; } // Keep comfort ordered and inside tolerance even if a hand-edited link isn't. if (lo > hi) { const t = lo; lo = hi; hi = t; } tlo = Math.min(tlo, lo); thi = Math.max(thi, hi); syncSlicer(); basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis)); syncDistButtons(); // active state on both the inline + sheet distribution selectors document.getElementById("cmp-dist-count").checked = distCount; startInput.value = range.start; endInput.value = range.end; buildSeasonFilter(); fabSheet = initFilterSheet({ panel: params, label: "Filters" }); fabSheet.setActive(monthsActive()); // 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 = 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(); })();