thermograph/static/compare.js
Emi Griffith 052b0a3e26 Picker map: smaller labels, darker roads; location label coords→place (#27)
* Picker map: smaller city labels, darker/bolder roads (split tile layers)

Split the CARTO Voyager basemap into two raster layers so label size and road
weight are independent: a labels-free base upscaled via tileSize 512 + zoomOffset
-1 (bold, prominent roads) plus a labels-only layer at natural size on top (small,
crisp city names). The darken/saturate filter now targets the base layer only
(.mp-base: brightness .92, contrast 1.12), deepening the roads while leaving label
text at full contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc

* Location label: show coordinates immediately, then resolve to place name

On selecting a location, write the raw coordinates to the location label right
away, so the user sees the picked spot instantly instead of a stale/blank label.
The existing post-fetch render already overwrites it with the reverse-geocoded
"neighborhood, city, region" string (data.place), so the label now reads
coords → place name live.

- app.js / calendar.js / day.js: set coords in the selection handler before the
  fetch; the render/initOnce overwrite is unchanged.
- compare.js: show coordinates through the pending + loading states (instead of
  "Locating…") so each chip's coords are replaced live by the place name once
  scored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:35:43 +00:00

398 lines
17 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 whole comparison — locations, comfort target, band, judged temperature and
// date range — lives in the URL hash, so a link reproduces exactly what you see.
// Comfort / band / 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.
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));
const BASES = ["tmax", "tmin", "mean", "feels"];
let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100);
let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15);
let basis = BASES.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).
// A location with 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 pad = (n) => String(n).padStart(2, "0");
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"
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;
}
// ---- shareable URL state ----
// The hash carries the full comparison: c=comfort, t=band, b=basis, s/e=range,
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
// wins over localStorage). 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("c", comfort); p.set("t", tol); 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 (!["c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
const st = {};
if (p.has("c")) st.comfort = clamp(+p.get("c") || 68, 30, 100);
if (p.has("t") && p.get("t") !== "") st.tol = clamp(+p.get("t"), 0, 15);
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 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");
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; // 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;
locations.push({ lat, lon, name: null, series: null, loading: false, error: null });
window.Thermograph.saveLastLocation(lat, lon);
persistLocations();
writeHashState();
renderAll(); // pending → the Refresh button appears; no fetch until it's tapped
}
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 ----
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) => {
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">±${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";
refreshBtn.hidden = !isDirty();
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));
});
refreshBtn.addEventListener("click", refresh);
locList.addEventListener("click", (e) => {
const x = e.target.closest(".cmp-chip-x");
if (x) removeLocation(+x.dataset.i);
});
// Comfort / band / basis: instant re-rank over data already in hand — no refetch.
comfortInput.addEventListener("input", () => {
comfort = +comfortInput.value;
comfortVal.textContent = `${comfort}°`;
lsSet(CMP.comfort, String(comfort));
writeHashState();
renderResults();
});
tolInput.addEventListener("input", () => {
tol = +tolInput.value;
tolVal.textContent = `±${tol}°`;
lsSet(CMP.tol, String(tol));
writeHashState();
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));
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);
// ---- init ----
(function restore() {
const hash = readHashState();
if (hash) {
if (hash.comfort != null) comfort = hash.comfort;
if (hash.tol != null) tol = hash.tol;
if (hash.basis) basis = hash.basis;
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
}
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;
// 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 = window.Thermograph.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();
})();