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>
This commit is contained in:
Emi Griffith 2026-07-10 20:03:39 -07:00 committed by GitHub
parent 521a131646
commit 1d27cfd37d
8 changed files with 499 additions and 72 deletions

View file

@ -19,6 +19,7 @@
<a href="./" data-view="map">Weekly</a> <a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar" class="active">Calendar</a> <a href="calendar" data-view="calendar" class="active">Calendar</a>
<a href="day" data-view="day">Day</a> <a href="day" data-view="day">Day</a>
<a href="compare" data-view="compare">Compare</a>
</nav> </nav>
</div> </div>
</header> </header>
@ -60,14 +61,6 @@
<button type="button" data-metric="precip">Precip</button> <button type="button" data-metric="precip">Precip</button>
<button type="button" data-metric="dsr">Dry streak</button> <button type="button" data-metric="dsr">Dry streak</button>
</div> </div>
<!-- Shown only for the Feels metric: the comfort temperature the felt
high/low are measured against (whichever is further away colors the day). -->
<div id="comfort-row" class="comfort-row" hidden>
<label for="comfort-input">Comfort temp <b id="comfort-val"></b></label>
<input id="comfort-input" type="range" min="0" max="100" step="1" />
<span class="hint">Days are colored by the felt high or felt low — whichever
is further from your comfort temperature.</span>
</div>
</div> </div>
<!-- Category totals for the current metric across the shown (and weather- <!-- Category totals for the current metric across the shown (and weather-

View file

@ -62,7 +62,7 @@ const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
const TEMP_METRIC_INFO = { const TEMP_METRIC_INFO = {
tmax: { name: "High", label: "daily high", fmt: fmtTemp }, tmax: { name: "High", label: "daily high", fmt: fmtTemp },
tmin: { name: "Low", label: "daily low", fmt: fmtTemp }, tmin: { name: "Low", label: "daily low", fmt: fmtTemp },
feels: { name: "Feels", label: "feels like (felt high or low furthest from your comfort temp)", fmt: fmtTemp }, feels: { name: "Feels", label: "feels like (apparent temperature)", fmt: fmtTemp },
humid: { name: "Humid", label: "absolute humidity (g/m³ of water vapor)", fmt: fmtHumid }, humid: { name: "Humid", label: "absolute humidity (g/m³ of water vapor)", fmt: fmtHumid },
wind: { name: "Wind", label: "wind speed", fmt: fmtWind }, wind: { name: "Wind", label: "wind speed", fmt: fmtWind },
gust: { name: "Gust", label: "wind gust", fmt: fmtWind }, gust: { name: "Gust", label: "wind gust", fmt: fmtWind },
@ -132,22 +132,6 @@ let data = null; // last /api/v2/calendar response
// Coloring metric. Persisted so a refresh keeps your selection. // Coloring metric. Persisted so a refresh keeps your selection.
let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr
// The user's comfort temperature (°F). The Feels metric shows whichever apparent
// extreme — the felt high (fmax) or felt low (fmin) — sits further from this,
// so the same slider position works for "too hot" and "too cold" alike.
const COMFORT_KEY = "thermograph:comfortF";
let comfort = Math.min(100, Math.max(0, +localStorage.getItem(COMFORT_KEY) || 65));
// Which side of the day the Feels metric reports for this record: the graded
// apparent high or low, whichever is further from the comfort temp. Falls back
// to the server's combined `feels` for responses cached before the split.
function feelsKey(rec) {
const hi = rec.fmax, lo = rec.fmin;
if (hi && lo) return (hi.v - comfort) >= (comfort - lo.v) ? "fmax" : "fmin";
return hi ? "fmax" : lo ? "fmin" : "feels";
}
const feelsPick = (rec) => rec[feelsKey(rec)] || null;
// Season/year filters: only months whose season AND year are checked are shown. // Season/year filters: only months whose season AND year are checked are shown.
const SEASONS = [["winter", "Winter"], ["spring", "Spring"], ["summer", "Summer"], ["fall", "Fall"]]; const SEASONS = [["winter", "Winter"], ["spring", "Spring"], ["summer", "Summer"], ["fall", "Fall"]];
const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall"; const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall";
@ -233,29 +217,12 @@ findBtn.addEventListener("click", () => {
document.querySelectorAll("#metric-toggle button").forEach((b) => document.querySelectorAll("#metric-toggle button").forEach((b) =>
b.classList.toggle("active", b.dataset.metric === metric)); b.classList.toggle("active", b.dataset.metric === metric));
// ---- comfort temperature slider (Feels metric only) ----
const comfortRow = document.getElementById("comfort-row");
const comfortInput = document.getElementById("comfort-input");
const comfortVal = document.getElementById("comfort-val");
comfortInput.value = comfort;
comfortVal.textContent = `${comfort}°F`;
comfortRow.hidden = metric !== "feels";
// Live-recolor while dragging: the grades for both felt sides are already in the
// payload, so moving the slider only re-picks a side per day — no refetch.
comfortInput.addEventListener("input", () => {
comfort = +comfortInput.value;
comfortVal.textContent = `${comfort}°F`;
try { localStorage.setItem(COMFORT_KEY, String(comfort)); } catch (err) {}
renderCalendar();
});
document.getElementById("metric-toggle").addEventListener("click", (e) => { document.getElementById("metric-toggle").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-metric]"); const btn = e.target.closest("button[data-metric]");
if (!btn) return; if (!btn) return;
metric = btn.dataset.metric; metric = btn.dataset.metric;
try { localStorage.setItem("thermograph:calMetric", metric); } catch (err) {} try { localStorage.setItem("thermograph:calMetric", metric); } catch (err) {}
document.querySelectorAll("#metric-toggle button").forEach((b) => b.classList.toggle("active", b === btn)); document.querySelectorAll("#metric-toggle button").forEach((b) => b.classList.toggle("active", b === btn));
comfortRow.hidden = metric !== "feels";
renderCalendar(); renderCalendar();
renderKey(); renderKey();
}); });
@ -629,8 +596,7 @@ function renderCalendar() {
// Dry days take their dry-streak color; rainy days take the blue ramp. // Dry days take their dry-streak color; rainy days take the blue ramp.
bg = !g ? "" : g.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[g.c]; bg = !g ? "" : g.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[g.c];
} else { } else {
// Feels re-picks its side (felt high vs low) around the comfort temp. const g = rec[metric];
const g = metric === "feels" ? feelsPick(rec) : rec[metric];
bg = g ? TIER_COLORS[g.c] : ""; bg = g ? TIER_COLORS[g.c] : "";
} }
pass = !wActive || weatherPass(rec); pass = !wActive || weatherPass(rec);
@ -686,7 +652,7 @@ function renderTotals(visible) {
title = "rain intensity"; title = "rain intensity";
} else { } else {
const classes = days const classes = days
.map((r) => { const g = metric === "feels" ? feelsPick(r) : r[metric]; return g && g.c; }) .map((r) => { const g = r[metric]; return g && g.c; })
.filter(Boolean); .filter(Boolean);
buckets = SCALE_TEMP.map((t) => buckets = SCALE_TEMP.map((t) =>
[t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, t.c === "normal"]); [t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, t.c === "normal"]);
@ -776,8 +742,7 @@ function attachHover(byDate) {
// (grade) text is tinted with the very color that represents that day's value — // (grade) text is tinted with the very color that represents that day's value —
// bold + saturated for the active metric, lifted/dimmed for the rest (see CSS). // bold + saturated for the active metric, lifted/dimmed for the rest (see CSS).
// Each metric is one row of the mini grid: name · value(+pct) · category. // Each metric is one row of the mini grid: name · value(+pct) · category.
// `activeKey` is the effective row to bold: the metric itself, or — for Feels — // `activeKey` is the row to bold: the metric currently being viewed.
// whichever felt side (fmax/fmin) the comfort temp picked for this day.
let activeKey = metric; let activeKey = metric;
const line = (key, name, g, fmt, color) => { const line = (key, name, g, fmt, color) => {
const rc = key === activeKey ? " tt-r-active" : ""; const rc = key === activeKey ? " tt-r-active" : "";
@ -790,7 +755,7 @@ function attachHover(byDate) {
const show = (cell, e) => { const show = (cell, e) => {
const rec = byDate.get(cell.dataset.date); const rec = byDate.get(cell.dataset.date);
if (!rec) return; if (!rec) return;
activeKey = metric === "feels" ? feelsKey(rec) : metric; activeKey = metric;
const dt = new Date(rec.date + "T00:00:00"); const dt = new Date(rec.date + "T00:00:00");
// Color each metric the same way the calendar cell would be colored for it. // Color each metric the same way the calendar cell would be colored for it.
const tempColor = (g) => (g ? TIER_COLORS[g.c] : ""); const tempColor = (g) => (g ? TIER_COLORS[g.c] : "");
@ -798,16 +763,10 @@ function attachHover(byDate) {
const dsrStr = rec.dsr == null ? null const dsrStr = rec.dsr == null ? null
: rec.dsr === 0 ? "Rain today" : rec.dsr === 0 ? "Rain today"
: `${rec.dsr} day${rec.dsr === 1 ? "" : "s"} since rain`; : `${rec.dsr} day${rec.dsr === 1 ? "" : "s"} since rain`;
// Both felt extremes get a row (the apparent high and low); older cached
// responses without the split fall back to the single combined Feels row.
const feelsRows = (rec.fmax || rec.fmin)
? [["fmax", line("fmax", "Feels Hi", rec.fmax, fmtTemp, tempColor(rec.fmax))],
["fmin", line("fmin", "Feels Lo", rec.fmin, fmtTemp, tempColor(rec.fmin))]]
: [["feels", line("feels", "Feels", rec.feels, fmtTemp, tempColor(rec.feels))]];
const rows = [ const rows = [
["tmax", line("tmax", "High", rec.tmax, fmtTemp, tempColor(rec.tmax))], ["tmax", line("tmax", "High", rec.tmax, fmtTemp, tempColor(rec.tmax))],
["tmin", line("tmin", "Low", rec.tmin, fmtTemp, tempColor(rec.tmin))], ["tmin", line("tmin", "Low", rec.tmin, fmtTemp, tempColor(rec.tmin))],
...feelsRows, ["feels", line("feels", "Feels", rec.feels, fmtTemp, tempColor(rec.feels))],
["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))], ["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))],
["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))], ["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))],
["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))], ["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))],

89
static/compare.html Normal file
View file

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph — compare comfort</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="brand">
<span class="logo"></span>
<div>
<h1>Thermograph · Compare</h1>
<p class="tag">Which place best hits your comfort temperature over a date range</p>
</div>
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day">Day</a>
<a href="compare" data-view="compare" class="active">Compare</a>
</nav>
</div>
</header>
<main>
<!-- Locations to line up against each other. -->
<section class="controls">
<div class="cmp-locs">
<span class="cal-filter-label">Locations</span>
<ul id="cmp-loc-list" class="cmp-loc-list"></ul>
<button type="button" id="cmp-add" class="find-btn"></button>
</div>
</section>
<!-- The comfort target, what temperature to judge by, the tolerance band, and
the date range. Comfort/band/basis recompute instantly (no refetch); only
the date range or the location set trigger a data load. -->
<div id="cmp-params" class="cmp-params" hidden>
<div class="cmp-param cmp-comfort">
<label for="cmp-comfort">Comfort temperature <b id="cmp-comfort-val"></b></label>
<input id="cmp-comfort" type="range" min="30" max="100" step="1" />
</div>
<div class="cmp-param cmp-band">
<label for="cmp-tol">Comfort band <b id="cmp-tol-val"></b></label>
<input id="cmp-tol" type="range" min="0" max="15" step="1" />
<span class="hint">A day "hits comfort" when it lands within this many degrees.</span>
</div>
<div class="cmp-param cmp-basis-block">
<span class="cal-filter-label">Judge each day by its</span>
<div class="metric-toggle" id="cmp-basis" role="group" aria-label="Temperature to compare">
<button type="button" data-basis="tmax" class="active">High</button>
<button type="button" data-basis="mean">Mean</button>
<button type="button" data-basis="tmin">Low</button>
<button type="button" data-basis="feels">Feels</button>
</div>
</div>
<div class="cmp-param cmp-range cal-range" id="cmp-range">
<div class="cal-range-head">
<span class="cal-filter-label">Date range</span>
<span class="hint">any span; longer loads take a moment</span>
</div>
<label>From <input id="cmp-start" type="month" /></label>
<label>To <input id="cmp-end" type="month" /></label>
<button type="button" id="cmp-refresh" class="range-refresh" hidden>Reload range ↻</button>
</div>
</div>
<div id="cmp-head" class="cal-head" hidden></div>
<div class="placeholder" id="cmp-placeholder">
<p>Add two or more places to see which one best matches your comfort temperature
across a date range — the share of days that land in your comfort band, how
often it runs colder or warmer, and by how much.</p>
</div>
<div id="cmp-results" class="cmp-results"></div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="mappicker.js"></script>
<script src="compare.js"></script>
</body>
</html>

326
static/compare.js Normal file
View file

@ -0,0 +1,326 @@
"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();
})();

View file

@ -19,6 +19,7 @@
<a href="./" data-view="map">Weekly</a> <a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a> <a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day" class="active">Day</a> <a href="day" data-view="day" class="active">Day</a>
<a href="compare" data-view="compare">Compare</a>
</nav> </nav>
</div> </div>
</header> </header>

View file

@ -19,6 +19,7 @@
<a href="./" data-view="map" class="active">Weekly</a> <a href="./" data-view="map" class="active">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a> <a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day">Day</a> <a href="day" data-view="day">Day</a>
<a href="compare" data-view="compare">Compare</a>
</nav> </nav>
</div> </div>
</header> </header>

View file

@ -18,6 +18,7 @@
<a href="./" data-view="map">Weekly</a> <a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a> <a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day">Day</a> <a href="day" data-view="day">Day</a>
<a href="compare" data-view="compare">Compare</a>
</nav> </nav>
</div> </div>
</header> </header>
@ -52,12 +53,8 @@
<dl class="guide-metrics"> <dl class="guide-metrics">
<dt>High / Low</dt><dd>The day's highest and lowest air temperature (&deg;F).</dd> <dt>High / Low</dt><dd>The day's highest and lowest air temperature (&deg;F).</dd>
<dt>Feels</dt><dd>Apparent temperature — what the air actually felt like once humidity <dt>Feels</dt><dd>Apparent temperature — what the air actually felt like once humidity
and wind are factored in. Every day has a felt <b>high</b> (heat-index territory) and a and wind are factored in, taking whichever apparent extreme (heat-index high or
felt <b>low</b> (wind-chill territory); both are shown in the day tooltip, each graded wind-chill low) sits further from a temperate baseline, graded against its own history.</dd>
against its own history. The Feels color uses whichever side sits <b>further from your
comfort temperature</b> — set with the slider on the Calendar page (0100&deg;F,
default 65&deg;F) — so sweltering days grade by their felt high and bitter days by
their felt low.</dd>
<dt>Humid</dt><dd>Absolute humidity: grams of water vapor per cubic meter of air (g/m&sup3;).</dd> <dt>Humid</dt><dd>Absolute humidity: grams of water vapor per cubic meter of air (g/m&sup3;).</dd>
<dt>Wind</dt><dd>Average sustained wind speed (mph).</dd> <dt>Wind</dt><dd>Average sustained wind speed (mph).</dd>
<dt>Gust</dt><dd>Peak wind gust for the day (mph).</dd> <dt>Gust</dt><dd>Peak wind gust for the day (mph).</dd>

View file

@ -725,17 +725,6 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
} }
.cal-weather .cal-dd-group { grid-column: 1 / -1; } .cal-weather .cal-dd-group { grid-column: 1 / -1; }
/* Comfort temperature slider (Feels metric). 44px-tall slider = full touch target. */
.comfort-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; margin: 10px 0 0; }
.comfort-row[hidden] { display: none; } /* display:flex would defeat the hidden attr */
.comfort-row label { font-size: 13px; color: var(--muted); flex: 0 0 auto; }
.comfort-row label b { color: var(--text); font-size: 14px; }
.comfort-row input[type="range"] {
flex: 1 1 180px; min-width: 150px; height: 44px; margin: 0;
accent-color: var(--accent); background: transparent;
}
.comfort-row .hint { flex: 1 1 100%; margin: 0; font-size: 12px; color: var(--muted); }
/* Category totals above the grid: a compact distribution strip. Each category is a /* Category totals above the grid: a compact distribution strip. Each category is a
column captioned with its name over its value (share by default, raw count when column captioned with its name over its value (share by default, raw count when
"Show count" is ticked, in that category's own status color) above a thin status- "Show count" is ticked, in that category's own status color) above a thin status-
@ -802,3 +791,75 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.guide-metrics { grid-template-columns: 1fr; gap: 2px 0; } .guide-metrics { grid-template-columns: 1fr; gap: 2px 0; }
.guide-metrics dt { margin-top: 10px; } .guide-metrics dt { margin-top: 10px; }
} }
/* ===== Compare view ===== */
.cmp-locs { flex: 1 1 340px; }
.cmp-loc-list { list-style: none; margin: 8px 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.cmp-loc-list:empty { margin: 0; }
.cmp-chip {
display: inline-flex; align-items: center; gap: 6px;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: 999px; padding: 5px 5px 5px 14px; font-size: 14px;
}
.cmp-chip.loading { color: var(--muted); }
.cmp-chip.error { border-color: var(--very-hot); color: var(--very-hot); }
.cmp-chip-name { font-weight: 600; }
.cmp-chip-x {
border: none; background: transparent; color: var(--muted); cursor: pointer;
font-size: 20px; line-height: 1; width: 30px; height: 30px; border-radius: 999px;
}
.cmp-chip-x:hover { color: var(--text); background: var(--border); }
/* Parameter panel: comfort target, band, judged temperature, date range. */
.cmp-params {
display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 18px 24px; margin: 0 0 22px; padding: 16px;
border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
}
.cmp-params[hidden] { display: none; }
.cmp-param > label { display: block; font-size: 13px; color: var(--muted); margin: 0 0 8px; }
.cmp-param > label b { color: var(--text); font-size: 15px; }
.cmp-param input[type="range"] {
width: 100%; height: 44px; margin: 0; accent-color: var(--accent); background: transparent;
}
.cmp-param .hint { display: block; margin: 4px 0 0; font-size: 12px; color: var(--muted); max-width: none; }
.cmp-basis-block .metric-toggle { margin-top: 2px; }
.cmp-basis-block .metric-toggle button { flex: 1 0 auto; padding: 10px 14px; }
.cmp-range { align-content: start; }
/* Ranked result cards. */
.cmp-results { display: grid; gap: 14px; }
.cmp-card { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 16px 18px; }
.cmp-card:first-child { border-color: var(--accent); }
.cmp-card-top { display: flex; align-items: center; gap: 12px; }
.cmp-rank { font-size: 15px; font-weight: 700; color: var(--muted); flex: 0 0 auto; }
.cmp-card:first-child .cmp-rank { color: var(--accent); }
.cmp-card-name { flex: 1 1 auto; min-width: 0; }
.cmp-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
.cmp-daycount { font-size: 12px; color: var(--muted); }
.cmp-big { flex: 0 0 auto; text-align: right; }
.cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; }
.cmp-big span { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.cmp-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; margin: 12px 0 10px; background: var(--surface-2); }
.cmp-seg { display: block; height: 100%; }
.cmp-below { background: var(--cold); }
.cmp-comfort { background: var(--normal); }
.cmp-above { background: var(--hot); }
.cmp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.cmp-stat { text-align: center; padding: 8px 4px; border-radius: 8px; background: var(--surface-2); }
.cmp-k { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
.cmp-v { display: block; font-size: 18px; font-weight: 700; }
.cmp-d { display: block; font-size: 11.5px; color: var(--muted); }
.cmp-s-below .cmp-v { color: var(--cold); }
.cmp-s-comfort .cmp-v { color: var(--normal); }
.cmp-s-above .cmp-v { color: var(--hot); }
.cmp-mad { margin: 12px 0 0; font-size: 13px; color: var(--muted); }
.cmp-mad b { color: var(--text); }
@media (max-width: 640px) {
.cmp-params { grid-template-columns: 1fr; padding: 14px; }
.cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; }
.cmp-big b { font-size: 20px; }
}