thermograph/static/app.js

504 lines
24 KiB
JavaScript
Raw Normal View History

2026-07-11 20:28:33 +00:00
// Weekly (map) page. Imports replace the old script-tag ordering contract.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
import "./account.js"; // header sign-in entry + notification bell
2026-07-11 20:28:33 +00:00
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
2026-07-11 20:28:33 +00:00
import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon}
const dateInput = document.getElementById("date-input");
const todayBtn = document.getElementById("today-btn");
dateInput.value = todayISO();
dateInput.max = dateInput.value;
// The "Today" chip snaps the target date back to today; it stays lit while the
// target already is today, so it doubles as a "you're viewing now" indicator.
function syncTodayBtn() {
todayBtn.classList.toggle("active", dateInput.value === todayISO());
}
syncTodayBtn();
todayBtn.addEventListener("click", () => {
const t = todayISO();
if (dateInput.value === t) return;
dateInput.value = t;
dateInput.max = t;
syncTodayBtn();
if (selected) runGrade();
});
function selectLocation(lat, lon) {
selected = { lat, lon };
Picker map: smaller labels, darker roads; location label coords→place (#27) * Picker map: smaller city labels, darker/bolder roads (split tile layers) Split the CARTO Voyager basemap into two raster layers so label size and road weight are independent: a labels-free base upscaled via tileSize 512 + zoomOffset -1 (bold, prominent roads) plus a labels-only layer at natural size on top (small, crisp city names). The darken/saturate filter now targets the base layer only (.mp-base: brightness .92, contrast 1.12), deepening the roads while leaving label text at full contrast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc * Location label: show coordinates immediately, then resolve to place name On selecting a location, write the raw coordinates to the location label right away, so the user sees the picked spot instantly instead of a stale/blank label. The existing post-fetch render already overwrites it with the reverse-geocoded "neighborhood, city, region" string (data.place), so the label now reads coords → place name live. - app.js / calendar.js / day.js: set coords in the selection handler before the fetch; the render/initOnce overwrite is unchanged. - compare.js: show coordinates through the pending + loading states (instead of "Locating…") so each chip's coords are replaced live by the place name once scored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:35:43 +00:00
// Show the raw coordinates immediately; render() replaces them with the resolved
// neighborhood + city name once the grade fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
runGrade();
}
// ---- location picker ----
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
2026-07-11 20:28:33 +00:00
initFindButton(findBtn, "Find a location",
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
() => selected, (lat, lon) => selectLocation(lat, lon));
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
// ---- grade request + render ----
const placeholder = document.getElementById("placeholder");
const results = document.getElementById("results");
let lastGraded = null; // most recent /api/grade response (for the PNG export)
// Clicking a graded day (any cell or its date header) opens its full breakdown.
results.addEventListener("click", (e) => {
const row = e.target.closest(".rd-grid [data-date]");
if (!row || !selected) return;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
location.href = `day${locHash(selected.lat, selected.lon, row.dataset.date)}`;
});
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21) * Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date
async function runGrade() {
if (!selected) return;
updateHash();
placeholder.hidden = true;
results.hidden = false;
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21) * Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
const seq = ++gradeSeq;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// panel immediately would just flash. Only a genuinely slow (network) load
// ever shows it.
const spin = setTimeout(() => {
if (seq === gradeSeq) results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`;
}, 150);
// Omit the date when it's today, so the URL (and cache key) matches the prefetch
// fired from the other views — a same-tab navigation then reuses the response.
const q = `lat=${selected.lat}&lon=${selected.lon}`;
const url = dateInput.value === todayISO() ? `api/grade?${q}` : `api/grade?${q}&date=${dateInput.value}`;
let data;
try {
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21) * Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
// Stale-while-revalidate: a stale cached copy renders immediately and the
// update callback repaints if the background revalidation finds new data.
2026-07-11 20:28:33 +00:00
data = await getJSON(url, TTL.grade, false, (upd) => {
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21) * Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
if (seq === gradeSeq) render(upd);
});
} catch (err) {
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21) * Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
clearTimeout(spin);
if (seq !== gradeSeq) return;
results.innerHTML = `<p class="error">${err.message}</p>`;
return;
}
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21) * Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
clearTimeout(spin);
if (seq !== gradeSeq) return;
render(data);
}
// Compact cell formatters for the dense recent/forecast table (units live in the
// column headers, so values stay short). Dry days label the running dry streak
// (see precipCell) rather than the rain amount.
2026-07-11 20:28:33 +00:00
const cTemp = fmtTemp; // active unit, bare degree
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`);
const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`);
const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·");
// Monochrome inline icons (currentColor) so the UI chrome matches the dark theme
// instead of using colorful emoji. Feather-style line paths.
const IC = {
link: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
download: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
calendar: `<svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
};
function render(data) {
recentData = data;
// The place name appears once — as the results heading (<h2>) below, which also
// carries the coordinates + climatology context. The top label only holds the
// transient coordinates written on selection, so hide it now that the named
// results have rendered (otherwise the name would show twice).
locLabel.hidden = true;
locLabel.textContent = "";
2026-07-11 20:28:33 +00:00
setFindLabel(findBtn, "Change location");
const c = data.climatology;
const cell = data.cell;
const yrs = c.year_range ? `${c.year_range[0]}${c.year_range[1]}` : "—";
const coords = `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
// Each card compares the target day's value to the normal (median) for that
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// metric, tinted by its grade, and links to the full single-day breakdown. The
// window now runs past the target into the forecast, so the target is looked up
// by date rather than taken as the newest row.
const targetDay = data.recent.find((d) => d.date === data.target_date) || null;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
const dayHref = `day${locHash(selected.lat, selected.lon, data.target_date)}`;
const cmpCard = (label, key, clim, fmt) => {
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const g = targetDay ? targetDay[key] : null;
const normalVal = clim ? fmt(clim.p50) : "—";
const cat = g ? (TIER_COLORS[g.class] || "") : "";
const big = g ? fmt(g.value) : normalVal;
const grade = g
? `<span class="nc-grade" style="--cat:${cat}">${g.grade}</span>`
: "";
return `<a class="normal-card" href="${dayHref}">
<div class="nc-head"><h3>${label}</h3>${grade}</div>
<div class="big"${g ? ` style="--cat:${cat}"` : ""}>${big}</div>
<div class="range">normal: <b>${normalVal}</b></div>
</a>`;
};
results.innerHTML = `
<div class="loc-head">
<div class="loc-title">
<h2>${placeLabel(data)}</h2>
<ul class="loc-meta">
${data.place ? `<li><b>${coords}</b></li>` : ""}
<li>Climatology from <b>${yrs}</b></li>
<li><b>${c.n_samples.toLocaleString()}</b> days sample size (day ±7)</li>
</ul>
</div>
<div class="share">
<button id="btn-link" title="Copy a link back to this exact view">${IC.link} Copy link</button>
<button id="btn-png" title="Download the trend chart as an image">${IC.download} Chart</button>
</div>
</div>
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
<a class="cta-cal" href="calendar${locHash(selected.lat, selected.lon)}">
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
<span>Check historical data</span>
<span class="cta-cal-arrow" aria-hidden="true"></span>
</a>
<div class="section-title">${data.target_date} vs a typical day tap a metric for the full breakdown</div>
<div class="normals">
${cmpCard("High", "tmax", c.tmax, fmtTemp)}
${cmpCard("Low", "tmin", c.tmin, fmtTemp)}
${cmpCard("Feels", "feels", c.feels, fmtTemp)}
${cmpCard("Humidity", "humid", c.humid, fmtHumid)}
${cmpCard("Wind", "wind", c.wind, fmtWind)}
${cmpCard("Gust", "gust", c.gust, fmtWind)}
${cmpCard("Precip", "precip", c.precip, fmtPrecip)}
</div>
<div id="series"></div>
`;
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
renderSeries(); // fills #series (chart + graded-days timeline) from the window
2026-07-11 20:28:33 +00:00
prefetchViews(selected.lat, selected.lon, ["grade", "forecast"]); // warm calendar/day
document.getElementById("btn-link").onclick = copyShareLink;
document.getElementById("btn-png").onclick = downloadChartPng;
}
// One tinted value cell: background + value colored by the day's grade tier for
// that metric (the same diverging scale as everywhere else). Carries the date so
// tapping any cell opens that day's full breakdown.
function rdCell(g, fmt, date, isFc, isToday) {
const dd = date ? ` data-date="${date}"` : "";
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
if (!g) return `<span class="${cls}"${dd}>—</span>`;
const col = TIER_COLORS[g.class] || "";
return `<span class="${cls}"${dd} style="--c:${col}">${fmt(g.value)}</span>`;
}
// Precip cell: rain days show the amount (tinted by intensity tier); dry days
// label the running dry streak — "3d" = 3 days since measurable rain, warmed by
// the same dryness ramp as the chart — instead of a bare dot.
function precipCell(d, isFc, isToday) {
const g = d.precip;
const dd = ` data-date="${d.date}"`;
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
if (!g) return `<span class="${cls}"${dd}>—</span>`;
if (g.value === 0 && d.dsr != null && d.dsr > 0) {
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since measurable rain">${d.dsr}d</span>`;
}
const col = TIER_COLORS[g.class] || "";
return `<span class="${cls}"${dd} style="--c:${col}">${cRain(g.value)}</span>`;
}
// Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first).
// Wide runs (e.g. 2 weeks) scroll horizontally inside their own container; the
// metric-label column stays pinned. Tapping any cell/day opens that day's detail.
const RD_METRICS = [
["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp],
["humid", "Hum", cHum], ["wind", "Wind", cWind], ["gust", "Gust", cWind], ["precip", "Rain", cRain],
];
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
function daysTable(rows, targetDate) {
if (!rows.length) return "";
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const todayDate = todayISO();
const isFc = (d) => d.date > todayDate; // future = forecast columns
const isTarget = (d) => d.date === targetDate; // the graded target day (orange marker)
const cols = `grid-template-columns: 46px repeat(${rows.length}, minmax(42px, 1fr));`;
const header = `<div class="rd-corner"></div>` + rows.map((d) => {
const dt = new Date(d.date + "T00:00:00");
const dow = dt.toLocaleDateString(undefined, { weekday: "short" });
const md = dt.toLocaleDateString(undefined, { month: "numeric", day: "numeric" });
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const cls = "rd-colh" + (isTarget(d) ? " rd-today" : isFc(d) ? " rd-fc" : "");
return `<div class="${cls}" data-date="${d.date}" title="See the full breakdown for this day"><b>${dow}</b><span>${md}</span></div>`;
}).join("");
const body = RD_METRICS.map(([key, label, fmt]) =>
`<div class="rd-mlabel">${label}</div>` +
rows.map((d) => key === "precip"
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
? precipCell(d, isFc(d), isTarget(d))
: rdCell(d[key], fmt, d.date, isFc(d), isTarget(d))).join("")
).join("");
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// ---- graded-days timeline ----
// The header normals stay fixed on the target day; the chart + graded-days table
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// below show one continuous window built server-side: two weeks of history before
// the target, the target itself (orange marker), then the days after it — real
// observations when they're in the past, the forward forecast when they run into
// the future (see backend _build_grade).
let recentData = null; // /grade response — the whole window, newest-first
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// Repaint everything in the newly-picked unit.
2026-07-11 20:28:33 +00:00
onUnitChange(() => {
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
if (recentData) render(recentData);
});
// Rebuild the chart when the window is resized so its drawing width keeps
// tracking the container (debounced — resize fires continuously while dragging).
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { if (recentData) renderSeries(); }, 160);
});
function renderSeries() {
const host = document.getElementById("series");
if (!host || !recentData) return;
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const days = recentData.recent; // newest-first: history · target · after/forecast
const target = recentData.target_date;
const hasFc = days.some((d) => d.date > todayISO()); // window runs into the forecast
host.innerHTML = `
<div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div>
<div id="chart-wrap"></div>
${tierKeyHtml()}
<div class="section-title">Daily, graded${hasFc ? " — recent &amp; 7-day forecast" : ""}</div>
<div class="rd-orient"><span> Older</span><span>Newer </span></div>
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
${daysTable([...days].reverse(), target) || '<p class="muted">Nothing to grade.</p>'}
`;
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
buildChart(recentData);
scrollTableToTarget(host, target);
}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// Open the timeline centered on the target day's column (the orange marker), so
// the two weeks of history and the days after it are both in view.
function scrollTableToTarget(host, target) {
const scroll = host.querySelector(".rd-scroll");
if (!scroll) return;
requestAnimationFrame(() => {
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const col = scroll.querySelector(`.rd-colh[data-date="${target}"]`);
if (!col) { scroll.scrollLeft = scroll.scrollWidth; return; }
scroll.scrollLeft = Math.max(0, col.offsetLeft - (scroll.clientWidth - col.offsetWidth) / 2);
});
}
// ---- trend chart (self-contained inline SVG) ----
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
const segs = [...SCALE_TEMP].reverse().map((t) =>
`<div class="tk-seg">
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
<span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span>
</div>`).join("");
return `<div class="section-title">Grade scale · low → high vs local normal</div>
<div class="tier-key">${segs}</div>
<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades </a></p>`;
}
let chartDays = [];
// Which chart category is shown. Persisted so a refresh keeps your selection.
let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
function buildChart(data) {
const wrap = document.getElementById("chart-wrap");
const C = chartPalette();
// Drawing width tracks the on-screen box (see chart.js) so wide monitors gain
// plot room while phones keep the 720-unit floor (the SVG just scales down).
setChartWidth(wrap.clientWidth);
lastGraded = data; // the series currently drawn (for PNG export)
chartDays = [...data.recent].reverse(); // oldest -> newest
const days = chartDays, n = days.length;
if (!n) { wrap.innerHTML = ""; return; }
const xFor = (i) => (n === 1 ? PL + PW / 2 : PL + (i * PW) / (n - 1));
// shared: x date labels
const step = n > 9 ? 2 : 1;
let xlab = "";
days.forEach((d, i) => {
if (i % step && i !== n - 1) return;
const dt = new Date(d.date + "T00:00:00");
xlab += `<text x="${xFor(i).toFixed(1)}" y="${xLabY}" text-anchor="middle" font-size="10.5" fill="${C.muted}">${dt.getMonth() + 1}/${dt.getDate()}</text>`;
});
// shared: hover hit targets over the plot
let hits = "";
days.forEach((_, i) => {
const w = n === 1 ? PW : PW / (n - 1);
hits += `<rect class="hit" data-i="${i}" x="${(xFor(i) - w / 2).toFixed(1)}" y="${plotTop}" width="${w.toFixed(1)}" height="${plotBot - plotTop}" fill="transparent"/>`;
});
const built = chartMetric === "precip" ? precipChart(days, n, xFor, C)
: chartMetric === "dry" ? dryChart(days, n, xFor, C)
: tempChart(chartMetric, days, n, xFor, C);
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// Solid orange marker line ON the target day, plus a dashed "forecast →" divider
// where the window crosses from the past into the future — drawn only when it's
// clearly separated from the target marker (i.e. the target is in the past; when
// the target is today the marker itself already sits at that boundary).
let marks = "";
const tIdx = days.findIndex((d) => d.date === data.target_date);
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
if (tIdx >= 0) {
const xt = xFor(tIdx).toFixed(1);
const td = new Date(data.target_date + "T00:00:00");
marks += `<line x1="${xt}" y1="${plotTop}" x2="${xt}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.8" opacity="0.9"/>`
+ `<text x="${xt}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">${td.getMonth() + 1}/${td.getDate()}</text>`;
}
const fIdx = days.findIndex((d) => d.date > todayISO()); // first forecast (future) day
if (fIdx > tIdx + 1) {
const xd = ((xFor(fIdx - 1) + xFor(fIdx)) / 2).toFixed(1);
marks += `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.1" stroke-dasharray="4 3" opacity="0.55"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.accent}" opacity="0.8">forecast →</text>`;
}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const title = `${placeLabel(data)} · ${data.target_date}`;
const svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" font-family="system-ui, -apple-system, Segoe UI, sans-serif" role="img" aria-label="${built.aria}">
<rect x="0" y="0" width="${W}" height="${H}" fill="${C.surface}"/>
<text x="${PL - 8}" y="24" font-size="12.5" font-weight="700" fill="${built.color}">${built.subtitle}</text>
<text x="${W - PR}" y="24" text-anchor="end" font-size="11" fill="${C.muted}">${title}</text>
${built.content}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
${marks}
${xlab}
<line id="crosshair" x1="0" y1="${plotTop}" x2="0" y2="${plotBot}" stroke="${C.muted}" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
${hits}
</svg>`;
const btn = (m, label) => `<button type="button" data-metric="${m}"${chartMetric === m ? ' class="active"' : ""}>${label}</button>`;
wrap.innerHTML = `
<div class="chart-toggle" id="chart-toggle" role="group" aria-label="Chart metric">
${btn("tmax", "High")}${btn("tmin", "Low")}${btn("feels", "Feels")}${btn("humid", "Humid")}${btn("wind", "Wind")}${btn("gust", "Gust")}${btn("precip", "Precip")}${btn("dry", "Dry")}
</div>
<div class="chart-legend">${built.legend}</div>
${svg}
<div id="chart-tip" class="chart-tip" hidden></div>`;
document.getElementById("chart-toggle").addEventListener("click", (e) => {
const b = e.target.closest("button[data-metric]");
if (!b || b.dataset.metric === chartMetric) return;
chartMetric = b.dataset.metric;
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
buildChart(data);
});
attachChartHover(wrap, chartDays);
}
// ---- share ----
function copyShareLink() {
if (!selected) return;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
const url = `${location.origin}${location.pathname}${locHash(selected.lat, selected.lon, dateInput.value)}`;
navigator.clipboard.writeText(url).then(
() => flashBtn("btn-link", "✓ Copied!"),
() => { prompt("Copy this link:", url); }
);
}
// Build a table of graded days as SVG markup, stacked below the chart.
function exportTableSvg(C) {
const rows = lastGraded.recent; // newest first (matches the on-screen table)
const rowH = 27, headH = 42, padB = 16;
const top = H + 10;
const cDate = PL, cPcp = PL + 150, cHigh = PL + 322, cLow = PL + 494;
const totalH = top + headH + rows.length * rowH + padB;
const cell = (x, g, isPcp) => {
if (!g) return `<text x="${x}" y="0" font-size="12" fill="${C.muted}">—</text>`;
const color = TIER_COLORS[g.class] || C.ink;
const v = isPcp ? `${g.value.toFixed(2)}"` : fmtTemp(g.value);
return `<text x="${x}" y="0"><tspan font-size="13" font-weight="700" fill="${C.ink}">${v}</tspan>` +
`<tspan dx="7" font-size="11" font-weight="600" fill="${color}">${esc(g.grade)}</tspan></text>`;
};
const th = (x, t) =>
`<text x="${x}" y="${top + 30}" font-size="10.5" font-weight="700" letter-spacing="0.05em" fill="${C.muted}">${t}</text>`;
let body = "";
rows.forEach((d, i) => {
const y = top + headH + i * rowH;
const dt = new Date(d.date + "T00:00:00");
const dstr = dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
body += `<g transform="translate(0 ${y})">` +
(i ? `<line x1="${PL}" y1="-7" x2="${W - PR}" y2="-7" stroke="${C.grid}" stroke-width="1"/>` : "") +
`<text x="${cDate}" y="0" font-size="12.5" fill="${C.ink}">${esc(dstr)}</text>` +
cell(cPcp, d.precip, true) + cell(cHigh, d.tmax, false) + cell(cLow, d.tmin, false) +
`</g>`;
});
const markup = `
<text x="${PL}" y="${top + 8}" font-size="13" font-weight="700" fill="${C.ink}">Recent days, graded ${esc(placeLabel(lastGraded))}</text>
${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")}
<line x1="${PL}" y1="${top + headH - 9}" x2="${W - PR}" y2="${top + headH - 9}" stroke="${C.grid}" stroke-width="1.5"/>
${body}`;
return { markup, totalH };
}
function downloadChartPng() {
const chart = document.querySelector("#chart-wrap svg");
if (!chart || !lastGraded) return;
const C = chartPalette();
// Chart body minus interactive-only layers.
const clone = chart.cloneNode(true);
const cross = clone.querySelector("#crosshair"); if (cross) cross.remove();
clone.querySelectorAll("rect.hit").forEach((r) => r.remove());
const chartInner = clone.innerHTML;
const { markup, totalH } = exportTableSvg(C);
const xml = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${totalH}" width="${W * 2}" height="${totalH * 2}" font-family="system-ui, -apple-system, Segoe UI, sans-serif">` +
`<rect x="0" y="0" width="${W}" height="${totalH}" fill="${C.surface}"/>` +
chartInner + markup + `</svg>`;
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = W * 2; canvas.height = totalH * 2;
canvas.getContext("2d").drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `thermograph_${dateInput.value}.png`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
flashBtn("btn-png", "✓ Saved");
}, "image/png");
};
img.src = url;
}
function flashBtn(id, text) {
const b = document.getElementById(id);
if (!b) return;
const orig = b.innerHTML; // preserve the inline icon markup
b.textContent = text;
setTimeout(() => (b.innerHTML = orig), 1600);
}
function updateHash() {
if (!selected) return;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
history.replaceState(null, "", locHash(selected.lat, selected.lon, dateInput.value));
2026-07-11 20:28:33 +00:00
saveLastLocation(selected.lat, selected.lon, dateInput.value);
}
// Start where you left off: an explicit link (hash) wins, otherwise the last
Worldwide coverage: grade any point on Earth (#32) Remove the US+Canada bounding box so every endpoint accepts any lat/lon. The grading pipeline was already global-ready (ERA5 archive, timezone=auto, day-of-year climatology), so opening it up is mostly deleting the guard — plus the edge cases that only exist once the whole globe is in play: - grid.py: snap() wraps longitude into [-180, 180) and clamps latitude, and cell centers are normalized so the polar row and the cells straddling the antimeridian always report valid coordinates to the weather/geocoding APIs. snap() and from_id() now share one _cell() builder, making id round-trips exact by construction (verified with a 300k-point global sweep). - nav.js: neighbor-cell prefetch skips rows past the poles and wraps longitudes across the dateline instead of sending out-of-range queries. - Nominatim reverse geocoding requests accept-language=en so place labels render in one script worldwide (matching the forward geocoder). - mappicker: search suggestions are no longer filtered to US/CA, the placeholder and default map view are worldwide. - calendar: season filter labels flip for southern-hemisphere locations (Dec-Feb shows as Summer); the underlying month groups are unchanged, so saved filter selections keep meaning the same months. Verified end-to-end on a scratch server: Tokyo and Sydney grade with real labels, a Fiji cell on the antimeridian's east edge builds and serves warm hits from the derived store, and prefetch=1 on a cold cell still answers 204 without spending weather-API quota.
2026-07-11 15:02:28 +00:00
// place you looked at (remembered across views), otherwise the default world map.
function restoreFromHash() {
2026-07-11 20:28:33 +00:00
const loc = loadLastLocation();
if (!loc) return;
if (loc.date) dateInput.value = loc.date;
syncTodayBtn();
selectLocation(loc.lat, loc.lon);
}
restoreFromHash();