thermograph/static/compare.js

327 lines
13 KiB
JavaScript
Raw Normal View History

Scope app to /thermograph, free the domain root for a portfolio (#10) - Drop the app-level "/" redirect so the FastAPI app no longer claims the domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now 404s at the app, leaving it for another service behind the proxy. - Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS. - Default THERMOGRAPH_BASE to /thermograph in the env example to match. (#11) * Add dev CI/CD pipeline deploying to a LAN server (#6) * Weekly page: no-rain visuals adopt the dry-streak look On the Weekly page, no-rain (0") days now warm with the dry-streak ramp (tan→red, deepening to a 14-day cap) instead of a flat blue/tan, matching the Dry chart and calendar's dry-period language: - Precip trend chart: no-rain day dots colored by drynessColor(dsr). - "Daily, graded" timeline strip: Rain-row dry cells tinted by dsr via a new optional color override on rdCell. Rain days keep their intensity-tier colors; unknown streaks fall back to the prior flat color, so nothing regresses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z * Calendar totals: compact per-category status lines Replace the stacked share bar + labeled percentage chips above the calendar grid with a compact deviation strip: one thin status-colored line per category (height scaled to the tallest non-median category), with each share printed above it in that category's own status color. The median tier — the neutral "Normal" center of the diverging temperature scale — draws no line, just a faint baseline tick, so it reads as the reference the other categories deviate from. Precip and dry-streak have no natural median, so every category there keeps a line. Percentage text is lifted toward the theme text color (color-mix) so even the darkest/lightest tiers stay legible in both light and dark. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Calendar totals strip: wider bars, 0.1%-precision small shares (#5) Widen the per-category deviation bars (3px→10px) so each reads clearly, and print sub-1% shares as e.g. "0.4%" instead of "<1%". Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Weekly timeline: label dry streak instead of a dot On the precip row of the recent/forecast timeline, dry days now show the running dry-streak count ("3d" = 3 days since measurable rain), tinted by the same dryness ramp as the chart, rather than a bare "·". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add dev CI/CD pipeline deploying to a LAN server PRs into dev run a build + boot/health check, auto-merge on green, and deploy the merged branch to a self-hosted runner on the LAN box, which runs the app as a sudo-free systemd --user service on 0.0.0.0:8137. - ci-cd.yml: build -> auto-merge -> deploy (self-hosted) - deploy-dev.yml: deploy on direct pushes / manual dispatch - deploy-dev.sh + thermograph-dev.service + provision-dev-lan.sh - CLAUDE.md: dev is the PR base branch; commit/PR message conventions - DEPLOY-DEV.md: pipeline docs * Match runner service name in docs to the installed user unit * Pin CI Python to 3.12 for prebuilt pyarrow/pandas wheels --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Build LAN dev venv on pinned Python 3.12 via uv (#7) This machine's default python3 is 3.14, which has no prebuilt pyarrow/pandas wheels, so plain venv + pip fell back to a failing source build. Use uv to pin the interpreter (fetching a managed CPython 3.12 if needed), independent of the runner's PATH and pyenv state. * 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. * Scope app to /thermograph, free the domain root for a portfolio (#10) - Drop the app-level "/" redirect so the FastAPI app no longer claims the domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now 404s at the app, leaving it for another service behind the proxy. - Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS. - Default THERMOGRAPH_BASE to /thermograph in the env example to match. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 03:03:39 +00:00
"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();
})();