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:
parent
521a131646
commit
1d27cfd37d
8 changed files with 499 additions and 72 deletions
|
|
@ -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-
|
||||||
|
|
|
||||||
|
|
@ -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
89
static/compare.html
Normal 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
326
static/compare.js
Normal 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}">×</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();
|
||||||
|
})();
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (°F).</dd>
|
<dt>High / Low</dt><dd>The day's highest and lowest air temperature (°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 (0–100°F,
|
|
||||||
default 65°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³).</dd>
|
<dt>Humid</dt><dd>Absolute humidity: grams of water vapor per cubic meter of air (g/m³).</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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue