thermograph/static/compare.js
Emi Griffith ee9c9b3045 Season full-year option; metric select into mobile sheet; comfort inline on compare (#81)
- Season filter: add a "Full year" select-all row above the four seasons (checked at
  12 months, clears to none, indeterminate when partial). One shared change in
  seasonFilterDropdown/applySeasonMonthChange/syncSeasonChecks covers both pages; the
  summary already reads "All year" at 12.
- Metric selector rides the filter sheet on phones: initFilterSheet gains a sheetOnly
  option that relocates given controls into the sheet at the 640px breakpoint and
  restores them inline on desktop (matchMedia; reparenting keeps listeners). Compare
  passes its "Distribution by" control, calendar its "Color the calendar by" block.
- Compare comfort/tolerance slider moved out of the params panel into a standalone
  full-width card above the results, so it's always visible instead of buried in the
  mobile pill sheet. The params sheet now holds basis + season + date range.

Frontend-only; no backend or payload change.
2026-07-12 07:11:21 +00:00

705 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { 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 6373° (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 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 = `api/v2/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(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`);
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 (01) 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 01: 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 0100 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 = `<span class="cmp-spinner cmp-spinner-chip" aria-hidden="true"></span>`; }
else if (l.error) { label = "Couldn't load"; cls += " error"; }
else if (!l.series) { label = l.name || coords; cls += " pending"; lead = `<span class="cmp-chip-dot" aria-hidden="true"></span>`; }
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 `<li class="${cls}">${lead}<span class="cmp-chip-name" title="${label}">${namePrimary(label)}</span>` +
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`;
}).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 0100 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 `<span class="cmp-name"><b>${primary}</b>${rest ? `<span class="cmp-name-sub">${rest}</span>` : ""}</span>`;
}
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 = `<div class="cmp-bar" role="img" aria-label="of all days: ${parts.map((p) => `${pct(p.w)} ${p.label.toLowerCase()}`).join(", ")}">` +
parts.map((p) => `<span class="cmp-seg ${p.cls}" style="width:${p.w}%" title="${p.label}${pct(p.w)}"></span>`).join("") +
`</div>`;
// 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 is positioned at its segment's midpoint (the ends anchored to
// the bar edges); on mobile it falls back to a plain wrapping row.
let acc = 0;
const key = `<div class="cmp-key">` +
parts.map((p, i) => {
const mid = acc + p.w / 2; acc += p.w;
const pos = i === 0 ? "left:0" : i === parts.length - 1 ? "right:0" : `left:${mid}%`;
const edge = i === 0 ? " cmp-key-first" : i === parts.length - 1 ? " cmp-key-last" : "";
return `<span class="cmp-key-item${edge}" style="${pos}"><i class="cmp-sw ${p.cls}"></i>${p.label} <b>${pct(p.w)}</b></span>`;
}).join("") +
`</div>`;
const bands = `<div class="cmp-bands"><b>${pct(s.tolPct)}</b> within tolerance · typically <b>${deg(s.mad)}</b> off comfort</div>`;
const sub = win ? `<span class="cmp-best">★ Best match</span> · ${s.n} days` : `${s.n} days`;
return `<div class="cmp-card${win ? " cmp-win" : ""}">` +
`<div class="cmp-card-top">` +
`<span class="cmp-rank">${rank}</span>` +
`<div class="cmp-card-name">${nameHierarchy(loc.name)}<span class="cmp-daycount">${sub}</span></div>` +
`<div class="cmp-big"><b>${Math.round(s.score)}</b><span>match / 100</span></div>` +
`</div>` +
bar + key + bands +
`</div>`;
}
// 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 = () => `<div class="cmp-card cmp-skel" aria-hidden="true">` +
`<div class="cmp-skel-top"><span class="cmp-skel-badge"></span>` +
`<span class="cmp-skel-name"></span><span class="cmp-skel-big"></span></div>` +
`<div class="cmp-skel-bar"></div></div>`;
// 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 `<div class="cmp-bl-row">` +
`<span class="cmp-bl-name" title="${loc.name}">${namePrimary(loc.name)}</span>` +
`<div class="cmp-bl-track" role="img" aria-label="${loc.name}: average ${fmtTemp(s.avgTemp)}, typical ${fmtTemp(s.p10)} to ${fmtTemp(s.p90)}">` +
`<span class="cmp-bl-tol" style="left:${tolL}%;right:${100 - tolR}%"></span>` +
`<span class="cmp-bl-band" style="left:${bandL}%;right:${100 - bandR}%"></span>` +
`<span class="cmp-bl-spread ${cls}" style="left:${posF(s.p10)}%;right:${100 - posF(s.p90)}%"></span>` +
`<span class="cmp-bl-dot ${cls}" style="left:${a}%"></span>` +
`<span class="cmp-bl-val" style="left:${a}%">${fmtTemp(s.avgTemp)}</span>` +
`</div></div>`;
}).join("");
baseline.hidden = false;
baseline.innerHTML =
`<div class="cmp-bl-head">Typical temperature by place ` +
`<span class="hint">dot = average · bar = middle-80% of days · shaded = comfort, faint = tolerance</span></div>` +
`<div class="cmp-bl-rows">${rows}</div>` +
`<div class="cmp-bl-axis"><span>${fmtTemp(min)}</span><span>${fmtTemp((min + max) / 2)}</span><span>${fmtTemp(max)}</span></div>`;
}
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
? `<p class="cmp-loading"><span class="cmp-spinner"></span>Loading daily weather…</p>` +
Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("")
: filteredOut
? `<p class="muted">No days fall in the selected months. Widen the time-of-year filter.</p>`
: "";
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
? `<b>${winName}</b> is the best match for ${fmtTemp(lo)}${fmtTemp(hi)} — score ${Math.round(win.s.score)}/100`
: `<b>${winName}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}${fmtTemp(hi)}`;
head.hidden = false;
head.innerHTML = `<h2>${lead}</h2>` +
`<p class="meta">By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}${fmtTemp(thi)} · ${span}` +
`${loading ? ` · <span class="cmp-loading-inline"><span class="cmp-spinner"></span>loading ${loading} more…</span>` : ""}</p>`;
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("") +
(loading ? Array.from({ length: loading }, skeletonRow).join("") : "");
}
// ---- 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) : `<p class="muted">No data for this metric.</p>`;
return `<div class="cmp-dist-row">` +
`<div class="cmp-dist-name" title="${l.name || ""}">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
strip + `</div>`;
}).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(); });
// Distribution: its own metric selector + share/count toggle, re-rendering in place.
distToggle.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-metric]");
if (!btn) return;
distMetric = btn.dataset.metric;
lsSet(CMP.distMetric, distMetric);
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
renderDist();
});
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));
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric));
document.getElementById("cmp-dist-count").checked = distCount;
startInput.value = range.start; endInput.value = range.end;
buildSeasonFilter();
// On phones the "Distribution by" metric selector rides the filter sheet too.
fabSheet = initFilterSheet({ panel: params, label: "Filters",
sheetOnly: [document.querySelector(".cmp-dist-controls")] });
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();
})();