thermograph/static/compare.js
Emi Griffith 2662fa38e5 Compare: comfort range slicer + per-location climate distribution (#62)
* Compare: comfort range slicer + per-location climate distribution

Replace the comfort-point + band sliders with a single dual-handle range
slicer: a day hits comfort when its judged temperature lands in [lo, hi];
below lo is colder, above hi is warmer. State is the range in °F, so old
c/t links and stored prefs migrate to a range. Ranking, average miss, and
the typical-miss figure are all computed against the range.

Add a climate-distribution section below the ranked cards, independent of
the comfort filter: its own metric selector (High/Low/Feels/Humid/Wind/
Gust/Precip/Dry streak) plus a share/count toggle render one Record-Low→
Record-High distribution strip per location, mirroring the calendar.

Extract the calendar's category bucketing and distribution strip into
shared metricBuckets/distStrip so both pages share one implementation;
calendar output is unchanged.

* Carry the calendar's wet/dry scale-group logic into the shared distribution helpers

* Carry the precip strip's per-tier label hook into the shared distribution helper
2026-07-11 22:33:38 +00:00

473 lines
21 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 } from "./shared.js";
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
const CMP = {
lo: "thermograph:cmpLo",
hi: "thermograph:cmpHi",
basis: "thermograph:cmpBasis",
range: "thermograph:cmpRange",
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();
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
// 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" };
// ---- date range (month granularity) ----
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"
// 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("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 (!["lo", "hi", "c", "t", "b", "s", "e", "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 (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 loInput = document.getElementById("cmp-lo");
const hiInput = document.getElementById("cmp-hi");
const rangeVal = document.getElementById("cmp-range-val");
const fillEl = document.getElementById("cmp-fill");
const basisToggle = document.getElementById("cmp-basis");
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;
if (basis === "feels") return r.feels ? r.feels.v : null;
return hi != null && lo != null ? (hi + lo) / 2 : null; // mean
}
function computeStats(series) {
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumMiss = 0;
for (const r of series) {
const t = basisTemp(r);
if (t == null || isNaN(t)) continue;
n++;
// Distance outside the comfort range (0 when inside) drives the "typical miss".
if (t < lo) { below++; const m = lo - t; sumBelow += m; sumMiss += m; }
else if (t > hi) { above++; const m = t - hi; sumAbove += m; sumMiss += m; }
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: sumMiss / n,
};
}
// ---- 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";
// 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 `<li class="${cls}"><span class="cmp-chip-name">${label}</span>` +
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`;
}).join("");
}
function rankCard(loc, s, rank) {
const bar = `<div class="cmp-bar" role="img" aria-label="${pct(s.belowPct)} colder, ${pct(s.comfortPct)} in comfort, ${pct(s.abovePct)} warmer">` +
`<span class="cmp-seg cmp-below" style="flex-basis:${s.belowPct}%"></span>` +
`<span class="cmp-seg cmp-comfort" style="flex-basis:${s.comfortPct}%"></span>` +
`<span class="cmp-seg cmp-above" style="flex-basis:${s.abovePct}%"></span></div>`;
const stats =
`<div class="cmp-stats">` +
`<div class="cmp-stat cmp-s-below"><span class="cmp-k">Colder</span><span class="cmp-v">${pct(s.belowPct)}</span><span class="cmp-d">${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}</span></div>` +
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">${fmtTemp(lo)}${fmtTemp(hi)}</span></div>` +
`<div class="cmp-stat cmp-s-above"><span class="cmp-k">Warmer</span><span class="cmp-v">${pct(s.abovePct)}</span><span class="cmp-d">${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}</span></div>` +
`</div>`;
return `<div class="cmp-card">` +
`<div class="cmp-card-top">` +
`<span class="cmp-rank">#${rank}</span>` +
`<div class="cmp-card-name"><span class="cmp-name">${loc.name}</span><span class="cmp-daycount">${s.n} days</span></div>` +
`<div class="cmp-big"><b>${pct(s.comfortPct)}</b><span>in comfort</span></div>` +
`</div>` +
bar + stats +
`<div class="cmp-mad">Typical day misses comfort by <b>${deg(s.mad)}</b></div>` +
`</div>`;
}
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 ? `<p class="spinner">Loading daily weather…</p>` : "";
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
? `<b>${win.loc.name}</b> best fits ${fmtTemp(lo)}${fmtTemp(hi)}${pct(win.s.comfortPct)} of days in range`
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days land in ${fmtTemp(lo)}${fmtTemp(hi)}`;
head.hidden = false;
head.innerHTML = `<h2>${lead}</h2>` +
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}</p>`;
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("");
}
// ---- distribution (independent of the comfort filter) ----
// One calendar-style Record-Low→Record-High strip per loaded location for the
// selected metric; the share/count toggle flips every figure. The categories are
// percentiles, so the strip is unit-agnostic (no re-render on °C/°F).
function renderDist() {
const withData = locations.filter((l) => l.series && l.series.length);
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
distBody.innerHTML = withData.map((l) => {
const buckets = metricBuckets(l.series, distMetric);
const total = buckets.reduce((n, b) => n + b[2], 0);
const name = 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">${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;
placeholder.hidden = has;
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
refreshBtn.hidden = !isDirty();
renderLocList();
renderResults();
renderDist();
}
// ---- events ----
initFindButton(addBtn, "Add location",
() => (locations.length ? locations[locations.length - 1] : loadLastLocation()),
(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 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() {
loInput.value = lo; hiInput.value = hi;
rangeVal.textContent = `${fmtTemp(lo)} ${fmtTemp(hi)}`;
fillEl.style.left = `${pctPos(lo)}%`;
fillEl.style.right = `${100 - pctPos(hi)}%`;
}
function slicerChanged() {
lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi));
syncSlicer();
writeHashState();
renderResults(); // the distribution is comfort-independent — no need to touch it
}
loInput.addEventListener("input", () => { lo = Math.min(+loInput.value, hi); slicerChanged(); });
hiInput.addEventListener("input", () => { hi = Math.max(+hiInput.value, lo); slicerChanged(); });
// The °F/°C toggle only flips what's shown — the range and series stay in °F (the
// API's unit) — so just re-render the range label and the cards. (The distribution
// strip is percentile categories, so units don't affect it.)
onUnitChange(() => { syncSlicer(); renderResults(); });
// 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();
});
// 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.basis) basis = hash.basis;
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
}
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;
// 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();
})();