thermograph/static/compare.js
Emi Griffith 13bc4cf0ac Add comfort-temperature compare view; simplify Feels calendar filter (#9)
Compare page (frontend/compare.{html,js} + /thermograph/compare route):
line up several places over a date range and rank which best matches a
comfort temperature. Per location it pulls the same daily record the
Calendar uses and, per day, takes a chosen temperature (daytime high /
daily mean / overnight low / feels-like) against the comfort target. A
day "hits comfort" when it lands within an adjustable band; otherwise it
counts as colder or warmer and the average miss is tracked. Results are
ranked by comfort-day share (tie-broken by the smaller typical miss),
with a diverging below/comfort/above bar and per-bucket stats. Comfort,
band and judged temperature re-rank instantly client-side; only the
location set or date range trigger a data load (shared calendar cache).

Feels calendar filter: drop the comfort-temperature slider and the
client-side felt-high/felt-low re-pick. The Feels metric now colors by
the server's combined feels-like value like the other metrics, so its
tab matches the rest of the metric selector.
2026-07-10 19:58:56 -07:00

326 lines
13 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.

"use strict";
// Compare view: line up several places over one date range and see which best
// matches a comfort temperature. For each location we pull the same daily record
// the Calendar uses (api/v2/calendar) and, per day, take a chosen temperature
// (daytime high / daily mean / overnight low / feels-like) and measure it against
// the comfort target. A day "hits comfort" when it lands within a tolerance band;
// otherwise it's colder or warmer, and we track by how much.
//
// The location set and the date range are the only things that require a data
// load. Comfort temperature, band width and the judged temperature are all pure
// re-computations over data already in hand, so moving those re-ranks instantly.
const CMP = {
comfort: "thermograph:cmpComfort",
tol: "thermograph:cmpTol",
basis: "thermograph:cmpBasis",
range: "thermograph:cmpRange",
locs: "thermograph:cmpLocs",
};
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100);
let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15);
let basis = ["tmax", "tmin", "mean", "feels"].includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
// Locations being compared: {lat, lon, name, series|null, loading, error}.
// `series` is a compact per-day array of {hi, lo, feels} (°F, any may be null).
let locations = [];
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
let loadToken = 0;
const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like" };
// ---- date range (month granularity) ----
const pad = (n) => String(n).padStart(2, "0");
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 && o.start && o.end) return o; } catch (e) {}
return defaultRange();
}
let range = loadRange(); // {start, end} as "YYYY-MM"
const monthStart = (ym) => `${ym}-01`;
const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0));
// Pretty "Jul 2025 Jun 2026" for a YYYY-MM range.
const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const monthLabel = (ym) => `${MON[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
// Split a [startIso, endIso] span into consecutive ≤2-year windows so each stays
// within the calendar endpoint's per-request cap (mirrors the Calendar view).
const CHUNK_MONTHS = 24;
function buildChunks(startIso, endIso) {
const out = [];
let s = startIso, guard = 0;
while (s <= endIso && guard++ < 200) {
const sd = new Date(s + "T00:00:00");
const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate());
ed.setDate(ed.getDate() - 1);
let e = isoOfDate(ed);
if (e > endIso) e = endIso;
out.push({ start: s, end: e });
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
s = isoOfDate(ns);
}
return out;
}
// ---- elements ----
const addBtn = document.getElementById("cmp-add");
const locList = document.getElementById("cmp-loc-list");
const params = document.getElementById("cmp-params");
const placeholder = document.getElementById("cmp-placeholder");
const head = document.getElementById("cmp-head");
const results = document.getElementById("cmp-results");
const comfortInput = document.getElementById("cmp-comfort");
const comfortVal = document.getElementById("cmp-comfort-val");
const tolInput = document.getElementById("cmp-tol");
const tolVal = document.getElementById("cmp-tol-val");
const basisToggle = document.getElementById("cmp-basis");
const startInput = document.getElementById("cmp-start");
const endInput = document.getElementById("cmp-end");
const refreshBtn = document.getElementById("cmp-refresh");
addBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Add location</span>`;
// ---- data ----
// Fetch one location's range (chunked) and reduce it to the compact per-day series.
async function fetchSeries(loc, token) {
const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
const days = [];
let place = null;
for (const ch of chunks) {
const url = `api/v2/calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`;
const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true);
if (token !== loadToken) return null; // superseded
place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`;
for (const r of d.days) days.push(r);
}
return {
name: place,
series: days.map((r) => ({
hi: r.tmax ? r.tmax.v : null,
lo: r.tmin ? r.tmin.v : null,
feels: r.feels ? r.feels.v : null,
})),
};
}
function persistLocations() {
lsSet(CMP.locs, JSON.stringify(locations.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name }))));
}
async function loadLocation(loc, token) {
loc.loading = true; loc.error = null;
renderAll();
try {
const res = await fetchSeries(loc, token);
if (!res || token !== loadToken) return;
loc.name = res.name; loc.series = res.series; loc.loading = false;
} catch (e) {
loc.loading = false; loc.error = e.message || "couldn't load";
}
persistLocations();
renderAll();
}
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: true, error: null };
locations.push(loc);
window.Thermograph.saveLastLocation(lat, lon);
loadLocation(loc, loadToken);
}
function removeLocation(i) {
locations.splice(i, 1);
persistLocations();
renderAll();
}
// Refetch every location's series (used when the date range changes).
function reloadAll() {
const token = ++loadToken;
for (const loc of locations) { loc.series = null; loadLocation(loc, token); }
}
// ---- stats ----
function basisTemp(pt) {
if (basis === "tmax") return pt.hi;
if (basis === "tmin") return pt.lo;
if (basis === "feels") return pt.feels;
return pt.hi != null && pt.lo != null ? (pt.hi + pt.lo) / 2 : null; // mean
}
function computeStats(series) {
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumAbs = 0;
for (const pt of series) {
const t = basisTemp(pt);
if (t == null || isNaN(t)) continue;
n++;
const d = t - comfort;
sumAbs += Math.abs(d);
if (d < -tol) { below++; sumBelow += -d; }
else if (d > tol) { above++; sumAbove += d; }
else comf++;
}
if (!n) return null;
return {
n, comf, below, above,
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
avgBelow: below ? sumBelow / below : 0,
avgAbove: above ? sumAbove / above : 0,
mad: sumAbs / n,
};
}
// ---- render ----
const pct = (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}%`;
const deg = (v) => `${Math.round(v)}°`;
function renderLocList() {
locList.innerHTML = locations.map((l, i) => {
const label = l.loading ? "Locating…" : l.error ? "Couldn't load" : (l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`);
const cls = "cmp-chip" + (l.loading ? " loading" : "") + (l.error ? " error" : "");
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">±${tol}° of ${comfort}°</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 matches ${comfort}° — ${pct(win.s.comfortPct)} of days within ±${tol}°`
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days within ±${tol}° of ${comfort}°`;
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("");
}
function renderAll() {
const has = locations.length > 0;
params.hidden = !has;
placeholder.hidden = has;
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
renderLocList();
renderResults();
}
// ---- events ----
addBtn.addEventListener("click", () => {
const cur = locations.length ? locations[locations.length - 1] : window.Thermograph.loadLastLocation();
window.LocationPicker.open(cur, (lat, lon) => addLocation(lat, lon));
});
locList.addEventListener("click", (e) => {
const x = e.target.closest(".cmp-chip-x");
if (x) removeLocation(+x.dataset.i);
});
// Comfort / band / basis: instant re-rank, no refetch.
comfortInput.addEventListener("input", () => {
comfort = +comfortInput.value;
comfortVal.textContent = `${comfort}°`;
lsSet(CMP.comfort, String(comfort));
renderResults();
});
tolInput.addEventListener("input", () => {
tol = +tolInput.value;
tolVal.textContent = `±${tol}°`;
lsSet(CMP.tol, String(tol));
renderResults();
});
basisToggle.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-basis]");
if (!btn) return;
basis = btn.dataset.basis;
lsSet(CMP.basis, basis);
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
renderResults();
});
// Date range: editing reveals the reload button; confirming refetches everything.
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
function markDirty() { refreshBtn.hidden = locations.length === 0; }
startInput.addEventListener("change", markDirty);
endInput.addEventListener("change", markDirty);
refreshBtn.addEventListener("click", () => {
let s = startInput.value || range.start, e = endInput.value || range.end;
if (s > e) { const t = s; s = e; e = t; }
range = { start: s, end: e };
startInput.value = s; endInput.value = e;
lsSet(CMP.range, JSON.stringify(range));
refreshBtn.hidden = true;
reloadAll();
});
// ---- init ----
(function restore() {
comfortInput.value = comfort; comfortVal.textContent = `${comfort}°`;
tolInput.value = tol; tolVal.textContent = `±${tol}°`;
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
startInput.value = range.start; endInput.value = range.end;
let saved = null;
try { saved = JSON.parse(lsGet(CMP.locs)); } catch (e) {}
if (Array.isArray(saved) && saved.length) {
locations = saved
.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: true, error: null }));
for (const loc of locations) loadLocation(loc, loadToken);
} else {
// Seed from the spot the other views last looked at, so Compare isn't empty.
const last = window.Thermograph.loadLastLocation();
if (last) addLocation(last.lat, last.lon);
}
renderAll();
})();