2026-07-11 00:29:47 +00:00
|
|
|
|
"use strict";
|
|
|
|
|
|
// Thermograph calendar subpage — two years of daily grades as a month-grid heatmap.
|
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
|
|
|
|
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode). Shared tier colors,
|
|
|
|
|
|
// scales, formatters, the weather summary and range chunking live in shared.js.
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
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 { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
|
|
|
|
|
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
|
|
|
|
|
CHUNK_MONTHS, buildChunks, clickOpensPicker, locHash,
|
|
|
|
|
|
weatherType: wxType } = window.Thermograph;
|
2026-07-11 08:10:40 +00:00
|
|
|
|
// Temps go through the shared unit formatter (the day tooltip reads it live, so a
|
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
|
|
|
|
// unit switch shows on the next hover).
|
2026-07-11 08:10:40 +00:00
|
|
|
|
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
|
|
|
|
|
// display name for the key + tooltip and the formatter for their values.
|
|
|
|
|
|
const TEMP_METRIC_INFO = {
|
|
|
|
|
|
tmax: { name: "High", label: "daily high", fmt: fmtTemp },
|
|
|
|
|
|
tmin: { name: "Low", label: "daily low", fmt: fmtTemp },
|
2026-07-11 02:58:56 +00:00
|
|
|
|
feels: { name: "Feels", label: "feels like (apparent temperature)", fmt: fmtTemp },
|
2026-07-11 00:29:47 +00:00
|
|
|
|
humid: { name: "Humid", label: "absolute humidity (g/m³ of water vapor)", fmt: fmtHumid },
|
|
|
|
|
|
wind: { name: "Wind", label: "wind speed", fmt: fmtWind },
|
|
|
|
|
|
gust: { name: "Gust", label: "wind gust", fmt: fmtWind },
|
|
|
|
|
|
};
|
|
|
|
|
|
const IC_POINTER = `<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="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></svg>`;
|
|
|
|
|
|
|
|
|
|
|
|
// The filterable day-type vocabulary (the weather filter's two checkbox groups).
|
|
|
|
|
|
// Keys match what weatherType() reports for each day.
|
|
|
|
|
|
const FEEL_WORDS = [
|
|
|
|
|
|
["scorching", "Scorching"], ["hot", "Hot"], ["warm", "Warm"], ["mild", "Mild"],
|
|
|
|
|
|
["cool", "Cool"], ["cold", "Cold"], ["frigid", "Frigid"],
|
|
|
|
|
|
];
|
|
|
|
|
|
const SKY_WORDS = [
|
|
|
|
|
|
["clear", "Clear"], ["dry", "Dry spell"], ["drizzle", "Light rain"],
|
|
|
|
|
|
["rain", "Rain"], ["downpour", "Downpour"], ["snow", "Snow"],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
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
|
|
|
|
// The shared weather summary, adapted to the calendar's compact day records
|
|
|
|
|
|
// (dsr included, so a long-dry no-rain day reads "dry" rather than "clear").
|
|
|
|
|
|
const weatherType = (rec) =>
|
|
|
|
|
|
wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
let selected = null; // {lat, lon}
|
|
|
|
|
|
let data = null; // last /api/v2/calendar response
|
|
|
|
|
|
// Coloring metric. Persisted so a refresh keeps your selection.
|
|
|
|
|
|
let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr
|
|
|
|
|
|
|
|
|
|
|
|
// Season/year filters: only months whose season AND year are checked are shown.
|
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
|
|
|
|
// Keys group months (Dec–Feb = "winter", etc.); in the southern hemisphere those
|
|
|
|
|
|
// same months are the opposite season, so only the display label flips (s[2]).
|
|
|
|
|
|
const SEASONS = [["winter", "Winter", "Summer"], ["spring", "Spring", "Fall"], ["summer", "Summer", "Winter"], ["fall", "Fall", "Spring"]];
|
2026-07-11 00:29:47 +00:00
|
|
|
|
const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall";
|
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
|
|
|
|
const seasonLabel = (s) => (selected && selected.lat < 0 ? s[2] : s[1]);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
let checkedSeasons = new Set(SEASONS.map((s) => s[0])); // persists across reloads
|
|
|
|
|
|
let checkedYears = null; // reset to all available on each fetch
|
|
|
|
|
|
|
|
|
|
|
|
// Weather filter (day feel × sky). Unlike seasons/years it filters DAYS, not
|
|
|
|
|
|
// months: a day must match a checked feel AND a checked sky, or it's dimmed on
|
|
|
|
|
|
// the grid (and left out of the category totals) — a "find days like this" tool.
|
|
|
|
|
|
let checkedFeels = new Set(FEEL_WORDS.map((w) => w[0]));
|
|
|
|
|
|
let checkedSkies = new Set(SKY_WORDS.map((w) => w[0]));
|
|
|
|
|
|
const weatherFilterActive = () =>
|
|
|
|
|
|
checkedFeels.size < FEEL_WORDS.length || checkedSkies.size < SKY_WORDS.length;
|
|
|
|
|
|
function weatherPass(rec) {
|
|
|
|
|
|
const wt = weatherType(rec);
|
|
|
|
|
|
return (!wt.feel || checkedFeels.has(wt.feel)) && checkedSkies.has(wt.sky);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Custom date range (ISO strings) or null = default last-24-months. Any span is
|
|
|
|
|
|
// allowed; the frontend splits it into ~2-year server requests (see fetchCalendar).
|
|
|
|
|
|
let rangeStart = null, rangeEnd = null;
|
|
|
|
|
|
// The full requested span (drives the filters + pickers); data.range tracks only
|
|
|
|
|
|
// the portion graded so far, which grows as chunks arrive.
|
|
|
|
|
|
let reqStart = null, reqEnd = null;
|
|
|
|
|
|
// Bumped on every new fetch so an in-flight chunked load abandons itself when a
|
|
|
|
|
|
// newer selection/range supersedes it.
|
|
|
|
|
|
let fetchToken = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// The last range the user explicitly picked, persisted so a page refresh (or a
|
|
|
|
|
|
// return visit) keeps the same dates instead of snapping back to the last 2 years.
|
|
|
|
|
|
const CAL_RANGE_KEY = "thermograph:calRange";
|
|
|
|
|
|
function saveCalRange(s, e) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (s && e) localStorage.setItem(CAL_RANGE_KEY, JSON.stringify({ start: s, end: e }));
|
|
|
|
|
|
else localStorage.removeItem(CAL_RANGE_KEY);
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
function loadCalRange() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const o = JSON.parse(localStorage.getItem(CAL_RANGE_KEY));
|
|
|
|
|
|
if (o && o.start && o.end) return o;
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The shared "selected day" (thermograph:loc.date) that the calendar last aligned
|
|
|
|
|
|
// its To-month to. Lets restore tell a plain refresh (day unchanged since we set
|
|
|
|
|
|
// it) apart from a real change made on the Weekly/Day view — needed because a
|
|
|
|
|
|
// future To-month clamps the shared day to today, so day-month ≠ To-month.
|
|
|
|
|
|
const CAL_SYNC_KEY = "thermograph:calDateSync";
|
|
|
|
|
|
function getCalSync() { try { return localStorage.getItem(CAL_SYNC_KEY); } catch (e) { return null; } }
|
|
|
|
|
|
function setCalSync(d) { try { d ? localStorage.setItem(CAL_SYNC_KEY, d) : localStorage.removeItem(CAL_SYNC_KEY); } catch (e) {} }
|
|
|
|
|
|
// Day-open interaction state (see attachHover). Module-scoped so the "tap outside
|
|
|
|
|
|
// to dismiss" listener can be registered once, not re-added on every re-render.
|
|
|
|
|
|
let armedCell = null, lastPointer = "mouse";
|
|
|
|
|
|
|
|
|
|
|
|
const placeholder = document.getElementById("cal-placeholder");
|
|
|
|
|
|
const calHead = document.getElementById("cal-head");
|
|
|
|
|
|
const calEl = document.getElementById("calendar");
|
|
|
|
|
|
const keyEl = document.getElementById("cal-key");
|
|
|
|
|
|
|
|
|
|
|
|
// A click-activated (touch) tooltip stays up until something that isn't a day is
|
|
|
|
|
|
// tapped: tapping another day moves it (that cell's own handler re-shows it),
|
|
|
|
|
|
// while tapping empty space, a header, the key, or anywhere off the grid dismisses
|
|
|
|
|
|
// it and cancels a pending "second tap to open". Registered once (not per re-render).
|
|
|
|
|
|
document.addEventListener("pointerdown", (e) => {
|
|
|
|
|
|
if (!e.target.closest(".cal-cell.filled")) {
|
|
|
|
|
|
document.getElementById("cal-tip").hidden = true;
|
|
|
|
|
|
armedCell = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-11 19:26:42 +00:00
|
|
|
|
// Mouse only: hovering off the grid hides the preview. On touch the tooltip is
|
|
|
|
|
|
// click-activated and must persist until a non-day tap (the handler above).
|
|
|
|
|
|
// Registered once — #calendar persists across re-renders (only its cells are
|
|
|
|
|
|
// replaced), so registering inside attachHover would stack a copy per render.
|
|
|
|
|
|
calEl.addEventListener("pointerleave", (e) => {
|
|
|
|
|
|
if (e.pointerType === "mouse") document.getElementById("cal-tip").hidden = true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
// ---- 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");
|
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
|
|
|
|
window.LocationPicker.initFindButton(findBtn, "Find a location",
|
|
|
|
|
|
() => selected, (lat, lon) => selectLocation(lat, lon));
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- metric toggle ----
|
|
|
|
|
|
// Reflect the restored metric on the toggle (the HTML defaults to High).
|
|
|
|
|
|
document.querySelectorAll("#metric-toggle button").forEach((b) =>
|
|
|
|
|
|
b.classList.toggle("active", b.dataset.metric === metric));
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("metric-toggle").addEventListener("click", (e) => {
|
|
|
|
|
|
const btn = e.target.closest("button[data-metric]");
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
metric = btn.dataset.metric;
|
|
|
|
|
|
try { localStorage.setItem("thermograph:calMetric", metric); } catch (err) {}
|
|
|
|
|
|
document.querySelectorAll("#metric-toggle button").forEach((b) => b.classList.toggle("active", b === btn));
|
|
|
|
|
|
renderCalendar();
|
|
|
|
|
|
renderKey();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-11 01:58:40 +00:00
|
|
|
|
// ---- category totals: share vs. count toggle ----
|
|
|
|
|
|
// The strip normally prints each category's share; ticking "Show count" swaps every
|
|
|
|
|
|
// figure for the raw day count. State is persisted and applied by renderTotals; the
|
|
|
|
|
|
// checkbox lives inside the (re-rendered) totals block, so the listener is delegated
|
|
|
|
|
|
// on the stable #cal-totals container and re-renders from the last shown days.
|
|
|
|
|
|
let showCount = false;
|
|
|
|
|
|
try { showCount = localStorage.getItem("thermograph:calShowCount") === "1"; } catch (e) {}
|
|
|
|
|
|
let lastVisible = null;
|
|
|
|
|
|
document.getElementById("cal-totals").addEventListener("change", (e) => {
|
|
|
|
|
|
if (!e.target.closest("#ct-count-cb")) return;
|
|
|
|
|
|
showCount = e.target.checked;
|
|
|
|
|
|
try { localStorage.setItem("thermograph:calShowCount", showCount ? "1" : "0"); } catch (err) {}
|
|
|
|
|
|
if (lastVisible) renderTotals(lastVisible);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
// ---- season / year filters ----
|
|
|
|
|
|
const filtersEl = document.getElementById("cal-filters");
|
|
|
|
|
|
const seasonYearEl = document.getElementById("cal-seasonyear");
|
|
|
|
|
|
|
|
|
|
|
|
function calYears() {
|
|
|
|
|
|
// Years span the full requested range, not just the portion graded so far, so
|
|
|
|
|
|
// the Years dropdown is complete from the first chunk on.
|
|
|
|
|
|
const sy = new Date((reqStart || data.range.start) + "T00:00:00").getFullYear();
|
|
|
|
|
|
const ey = new Date((reqEnd || data.range.end) + "T00:00:00").getFullYear();
|
|
|
|
|
|
const out = []; for (let y = sy; y <= ey; y++) out.push(y);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let filterYears = []; // the year set behind the Years dropdown (for its summary)
|
|
|
|
|
|
|
|
|
|
|
|
// A collapsed summary of a multi-select: "All" when everything's on, "None" when
|
|
|
|
|
|
// nothing is, otherwise the picked labels joined.
|
|
|
|
|
|
function seasonSummaryText() {
|
|
|
|
|
|
if (checkedSeasons.size === SEASONS.length) return "All seasons";
|
|
|
|
|
|
if (checkedSeasons.size === 0) return "None";
|
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
|
|
|
|
return SEASONS.filter((s) => checkedSeasons.has(s[0])).map(seasonLabel).join(", ");
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
function yearSummaryText() {
|
|
|
|
|
|
const n = checkedYears ? checkedYears.size : 0;
|
|
|
|
|
|
if (!filterYears.length || n === filterYears.length) return "All years";
|
|
|
|
|
|
if (n === 0) return "None";
|
|
|
|
|
|
return filterYears.filter((y) => checkedYears.has(y)).join(", ");
|
|
|
|
|
|
}
|
|
|
|
|
|
function weatherSummaryText() {
|
|
|
|
|
|
const allF = checkedFeels.size === FEEL_WORDS.length;
|
|
|
|
|
|
const allS = checkedSkies.size === SKY_WORDS.length;
|
|
|
|
|
|
if (allF && allS) return "All days";
|
|
|
|
|
|
if (checkedFeels.size === 0 || checkedSkies.size === 0) return "None";
|
|
|
|
|
|
const f = allF ? "Any feel" : FEEL_WORDS.filter((w) => checkedFeels.has(w[0])).map((w) => w[1]).join(", ");
|
|
|
|
|
|
const s = allS ? "any sky" : SKY_WORDS.filter((w) => checkedSkies.has(w[0])).map((w) => w[1]).join(", ");
|
|
|
|
|
|
return `${f} · ${s}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function updateFilterSummaries() {
|
|
|
|
|
|
const s = seasonYearEl.querySelector('[data-dd="season"] .cal-dd-sum');
|
|
|
|
|
|
const y = seasonYearEl.querySelector('[data-dd="year"] .cal-dd-sum');
|
|
|
|
|
|
const w = seasonYearEl.querySelector('[data-dd="weather"] .cal-dd-sum');
|
|
|
|
|
|
if (s) s.textContent = seasonSummaryText();
|
|
|
|
|
|
if (y) y.textContent = yearSummaryText();
|
|
|
|
|
|
if (w) w.textContent = weatherSummaryText();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// One filter as a compact dropdown: a summary button that expands to the checkbox
|
|
|
|
|
|
// list. Uses <details> so it needs no extra open/close wiring.
|
|
|
|
|
|
const filterDropdown = (dd, label, attr, items, summary) => `
|
|
|
|
|
|
<details class="cal-dd" data-dd="${dd}">
|
|
|
|
|
|
<summary>
|
|
|
|
|
|
<span class="cal-filter-label">${label}</span>
|
|
|
|
|
|
<span class="cal-dd-sum">${summary}</span>
|
|
|
|
|
|
<span class="cal-dd-caret" aria-hidden="true">▾</span>
|
|
|
|
|
|
</summary>
|
|
|
|
|
|
<div class="cal-dd-panel">
|
|
|
|
|
|
${items.map(([val, lab, on]) =>
|
|
|
|
|
|
`<label class="cal-dd-opt"><input type="checkbox" data-${attr}="${val}"${on ? " checked" : ""}>${lab}</label>`).join("")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</details>`;
|
|
|
|
|
|
|
|
|
|
|
|
// The weather filter: one dropdown, two checkbox groups (day feel + sky), so a
|
|
|
|
|
|
// pick like "Mild or Warm · Clear" finds exactly the days you'd love. Sits after
|
|
|
|
|
|
// (below) the season/year dropdowns and spans the full filter row.
|
|
|
|
|
|
const weatherDropdown = () => `
|
|
|
|
|
|
<details class="cal-dd cal-weather" data-dd="weather">
|
|
|
|
|
|
<summary>
|
|
|
|
|
|
<span class="cal-filter-label">Weather</span>
|
|
|
|
|
|
<span class="cal-dd-sum">${weatherSummaryText()}</span>
|
|
|
|
|
|
<span class="cal-dd-caret" aria-hidden="true">▾</span>
|
|
|
|
|
|
</summary>
|
|
|
|
|
|
<div class="cal-dd-panel">
|
|
|
|
|
|
<div class="cal-dd-group">Day feel — from the daily high</div>
|
|
|
|
|
|
${FEEL_WORDS.map(([val, lab]) =>
|
|
|
|
|
|
`<label class="cal-dd-opt"><input type="checkbox" data-wfeel="${val}"${checkedFeels.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
|
|
|
|
|
|
<div class="cal-dd-group">Sky</div>
|
|
|
|
|
|
${SKY_WORDS.map(([val, lab]) =>
|
|
|
|
|
|
`<label class="cal-dd-opt"><input type="checkbox" data-wsky="${val}"${checkedSkies.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</details>`;
|
|
|
|
|
|
|
|
|
|
|
|
function renderFilters() {
|
|
|
|
|
|
const years = calYears();
|
|
|
|
|
|
checkedYears = new Set(years); // reset to all available whenever the range changes
|
|
|
|
|
|
filterYears = years;
|
|
|
|
|
|
filtersEl.hidden = false;
|
|
|
|
|
|
// The month/year range picker stays as static HTML (keeps its listeners); only the
|
|
|
|
|
|
// season/year/weather dropdowns are rebuilt here, since the year set depends on
|
|
|
|
|
|
// the span (the weather sets survive the rebuild — they're module state).
|
|
|
|
|
|
seasonYearEl.innerHTML =
|
|
|
|
|
|
filterDropdown("season", "Seasons", "season",
|
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
|
|
|
|
SEASONS.map((s) => [s[0], seasonLabel(s), checkedSeasons.has(s[0])]), seasonSummaryText()) +
|
2026-07-11 00:29:47 +00:00
|
|
|
|
filterDropdown("year", "Years", "year",
|
|
|
|
|
|
years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) +
|
|
|
|
|
|
weatherDropdown();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
filtersEl.addEventListener("change", (e) => {
|
|
|
|
|
|
const cb = e.target.closest("input[type=checkbox]");
|
|
|
|
|
|
if (!cb) return;
|
|
|
|
|
|
if (cb.dataset.season) {
|
|
|
|
|
|
cb.checked ? checkedSeasons.add(cb.dataset.season) : checkedSeasons.delete(cb.dataset.season);
|
|
|
|
|
|
} else if (cb.dataset.year) {
|
|
|
|
|
|
const y = +cb.dataset.year;
|
|
|
|
|
|
cb.checked ? checkedYears.add(y) : checkedYears.delete(y);
|
|
|
|
|
|
} else if (cb.dataset.wfeel) {
|
|
|
|
|
|
cb.checked ? checkedFeels.add(cb.dataset.wfeel) : checkedFeels.delete(cb.dataset.wfeel);
|
|
|
|
|
|
} else if (cb.dataset.wsky) {
|
|
|
|
|
|
cb.checked ? checkedSkies.add(cb.dataset.wsky) : checkedSkies.delete(cb.dataset.wsky);
|
|
|
|
|
|
}
|
|
|
|
|
|
updateFilterSummaries();
|
|
|
|
|
|
renderCalendar();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Close any open filter dropdown when tapping outside it (native <details> stays
|
|
|
|
|
|
// open otherwise). Doesn't touch the day tooltip's own outside-tap handler.
|
|
|
|
|
|
document.addEventListener("pointerdown", (e) => {
|
|
|
|
|
|
seasonYearEl.querySelectorAll("details.cal-dd[open]").forEach((d) => {
|
|
|
|
|
|
if (!d.contains(e.target)) d.open = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, true);
|
|
|
|
|
|
|
|
|
|
|
|
// ---- date-range picker (month + year only) ----
|
|
|
|
|
|
// The inputs are <input type="month"> ("YYYY-MM"); a picked month is expanded to a
|
|
|
|
|
|
// full-month ISO span so every displayed month is shown in full (no partial weeks).
|
|
|
|
|
|
const startInput = document.getElementById("range-start");
|
|
|
|
|
|
const endInput = document.getElementById("range-end");
|
|
|
|
|
|
const refreshBtn = document.getElementById("range-refresh");
|
|
|
|
|
|
// Selectable bounds: 1970 through the current year. Fixed (not derived from the
|
|
|
|
|
|
// loaded span) so the To field always reaches the present; the actual data starts
|
|
|
|
|
|
// ~1980 and ends a few days ago, and out-of-range picks are clamped server-side.
|
|
|
|
|
|
startInput.min = endInput.min = "1970-01";
|
|
|
|
|
|
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
|
|
|
|
|
|
// Editing a date doesn't fetch — it reveals the Refresh button so the user
|
|
|
|
|
|
// confirms before a (possibly multi-request) load kicks off.
|
|
|
|
|
|
function markRangeDirty() { refreshBtn.hidden = false; }
|
|
|
|
|
|
startInput.addEventListener("change", markRangeDirty);
|
|
|
|
|
|
endInput.addEventListener("change", markRangeDirty);
|
|
|
|
|
|
|
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
|
|
|
|
clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker
|
2026-07-11 18:47:52 +00:00
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
// Confirm: read the pickers into the active range and (re)load.
|
|
|
|
|
|
function applyRange() {
|
|
|
|
|
|
let s = startInput.value ? monthStart(startInput.value) : null;
|
|
|
|
|
|
let e = endInput.value ? monthEnd(endInput.value) : null;
|
|
|
|
|
|
if (s && e && s > e) { const t = s; s = e; e = t; } // keep start ≤ end
|
|
|
|
|
|
rangeStart = s; rangeEnd = e;
|
|
|
|
|
|
saveCalRange(s, e); // keep these dates across refreshes
|
|
|
|
|
|
syncDayToOtherViews(); // push the To-month to Weekly/Day
|
|
|
|
|
|
refreshBtn.hidden = true;
|
|
|
|
|
|
if (selected) fetchCalendar();
|
|
|
|
|
|
}
|
|
|
|
|
|
refreshBtn.addEventListener("click", applyRange);
|
|
|
|
|
|
|
|
|
|
|
|
// A To-Date change updates the day the Weekly/Day views open on: the 1st of the
|
|
|
|
|
|
// To month, or today if that month is still in the future (no data ahead of today).
|
|
|
|
|
|
function syncDayToOtherViews() {
|
|
|
|
|
|
if (!selected || !rangeEnd) return;
|
|
|
|
|
|
const toFirst = rangeEnd.slice(0, 7) + "-01";
|
|
|
|
|
|
const todayIso = isoOfDate(new Date());
|
|
|
|
|
|
const day = toFirst > todayIso ? todayIso : toFirst;
|
|
|
|
|
|
window.Thermograph.saveLastLocation(selected.lat, selected.lon, day);
|
|
|
|
|
|
setCalSync(day);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- fetch + render ----
|
|
|
|
|
|
function selectLocation(lat, lon) {
|
|
|
|
|
|
selected = { lat, lon };
|
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(lat, lon));
|
2026-07-11 00:29:47 +00:00
|
|
|
|
window.Thermograph.saveLastLocation(lat, lon); // keep date for the other views
|
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; they're replaced with the resolved
|
|
|
|
|
|
// neighborhood + city name once the first calendar chunk lands.
|
|
|
|
|
|
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
|
|
|
|
|
|
locLabel.hidden = false;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
fetchCalendar();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Up to this many chunk requests are in flight at once. The server grades every
|
|
|
|
|
|
// chunk against one shared, already-cached history record, so a few parallel
|
|
|
|
|
|
// requests cut wall-clock time on long spans without extra upstream fetches.
|
|
|
|
|
|
const CHUNK_CONCURRENCY = 4;
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchCalendar() {
|
|
|
|
|
|
if (!selected) return;
|
|
|
|
|
|
const token = ++fetchToken; // supersede any in-flight chunked load
|
|
|
|
|
|
placeholder.hidden = true;
|
|
|
|
|
|
refreshBtn.hidden = true;
|
|
|
|
|
|
calHead.hidden = false;
|
|
|
|
|
|
data = null;
|
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
|
|
|
|
// Delay the spinner: cache-served chunks render in a few ms, so clearing the
|
|
|
|
|
|
// grid immediately would just flash-blank a warm load. A genuinely slow
|
|
|
|
|
|
// (network) load still gets the spinner and a cleared grid.
|
|
|
|
|
|
const spin = setTimeout(() => {
|
|
|
|
|
|
if (token !== fetchToken || data) return;
|
|
|
|
|
|
calHead.innerHTML = `<p class="spinner">Grading daily weather across the range…</p>`;
|
|
|
|
|
|
calEl.innerHTML = "";
|
|
|
|
|
|
keyEl.innerHTML = "";
|
|
|
|
|
|
}, 150);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
const q = `lat=${selected.lat}&lon=${selected.lon}`;
|
|
|
|
|
|
// A custom span is split into ≤2-year requests; the default (no range) is one
|
|
|
|
|
|
// last-24-months request that also discovers the latest available date.
|
|
|
|
|
|
const chunks = (rangeStart && rangeEnd) ? buildChunks(rangeStart, rangeEnd) : [null];
|
|
|
|
|
|
const dayMap = new Map();
|
|
|
|
|
|
const results = new Array(chunks.length); // per-chunk response (or {error}), by index
|
|
|
|
|
|
let donePrefix = 0; // chunks [0, donePrefix) are all loaded
|
|
|
|
|
|
|
|
|
|
|
|
// Set the location, pickers, and filters once — off whichever chunk lands first
|
|
|
|
|
|
// (cell/place are identical across chunks; the requested span drives the rest).
|
|
|
|
|
|
const initOnce = (d) => {
|
|
|
|
|
|
if (data) return;
|
|
|
|
|
|
data = d;
|
|
|
|
|
|
reqStart = rangeStart || d.range.start;
|
|
|
|
|
|
reqEnd = rangeEnd || d.range.end;
|
|
|
|
|
|
startInput.value = reqStart.slice(0, 7); // "YYYY-MM" for <input type=month>
|
|
|
|
|
|
endInput.value = reqEnd.slice(0, 7);
|
|
|
|
|
|
document.getElementById("metric-block").hidden = false;
|
|
|
|
|
|
locLabel.textContent = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`;
|
|
|
|
|
|
locLabel.hidden = false;
|
|
|
|
|
|
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
|
|
|
|
|
|
renderFilters();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Advance the visible span over the contiguous run of loaded chunks from the
|
|
|
|
|
|
// oldest, so the grid fills top-down with no gaps even when chunks finish out of
|
|
|
|
|
|
// order. A failed chunk halts the visible prefix at its position.
|
|
|
|
|
|
const advance = () => {
|
|
|
|
|
|
while (donePrefix < chunks.length && results[donePrefix] && !results[donePrefix].error) donePrefix++;
|
|
|
|
|
|
if (donePrefix === 0 || !data) return;
|
|
|
|
|
|
data.days = [...dayMap.values()];
|
|
|
|
|
|
data.range = { start: results[0].range.start, end: results[donePrefix - 1].range.end };
|
|
|
|
|
|
renderHead(chunks.length - donePrefix, true);
|
|
|
|
|
|
renderCalendar();
|
|
|
|
|
|
renderKey();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const runChunk = async (i) => {
|
|
|
|
|
|
const ch = chunks[i];
|
|
|
|
|
|
const url = ch
|
|
|
|
|
|
? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}`
|
|
|
|
|
|
: `api/v2/calendar?${q}&months=24`;
|
|
|
|
|
|
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 chunk renders immediately; if the
|
|
|
|
|
|
// background revalidation finds changed data, re-merge that chunk's days
|
|
|
|
|
|
// and repaint (the cell/place metadata is identical across versions).
|
|
|
|
|
|
const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true, (upd) => {
|
|
|
|
|
|
if (token !== fetchToken) return;
|
|
|
|
|
|
results[i] = upd;
|
|
|
|
|
|
for (const x of upd.days) dayMap.set(x.date, x);
|
|
|
|
|
|
advance();
|
|
|
|
|
|
});
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if (token !== fetchToken) return;
|
|
|
|
|
|
results[i] = d;
|
|
|
|
|
|
for (const x of d.days) dayMap.set(x.date, x);
|
|
|
|
|
|
initOnce(d);
|
|
|
|
|
|
advance();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (token !== fetchToken) return;
|
|
|
|
|
|
results[i] = { error: err };
|
|
|
|
|
|
advance();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bounded-parallel pool: keep up to CHUNK_CONCURRENCY requests in flight.
|
|
|
|
|
|
await new Promise((resolve) => {
|
|
|
|
|
|
let launched = 0, settled = 0;
|
|
|
|
|
|
const pump = () => {
|
|
|
|
|
|
if (token !== fetchToken) return resolve();
|
|
|
|
|
|
while (launched < chunks.length && launched - settled < CHUNK_CONCURRENCY) {
|
|
|
|
|
|
runChunk(launched++).finally(() => {
|
|
|
|
|
|
settled++;
|
|
|
|
|
|
if (settled === chunks.length) resolve();
|
|
|
|
|
|
else pump();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
pump();
|
|
|
|
|
|
});
|
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);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if (token !== fetchToken) return; // superseded while loading
|
|
|
|
|
|
|
|
|
|
|
|
if (!data) { // every chunk failed
|
|
|
|
|
|
const firstErr = results.find((r) => r && r.error);
|
|
|
|
|
|
calHead.innerHTML = `<p class="error">${firstErr ? firstErr.error.message : "Failed to load."}</p>`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
renderHead(chunks.length - donePrefix, false); // final head (flags any gap)
|
|
|
|
|
|
window.Thermograph.prefetchViews(selected.lat, selected.lon, "calendar"); // warm weekly/forecast
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
|
|
|
|
|
|
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
|
|
|
|
|
|
function renderHead(remaining, loading) {
|
|
|
|
|
|
const cell = data.cell;
|
|
|
|
|
|
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
|
|
|
|
|
|
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
|
|
|
|
|
|
let note = "";
|
|
|
|
|
|
if (remaining > 0 && loading) {
|
|
|
|
|
|
note = ` · <span class="cal-loading">loading ${remaining} more block${remaining === 1 ? "" : "s"}…</span>`;
|
|
|
|
|
|
} else if (remaining > 0) {
|
|
|
|
|
|
note = ` · <span class="error">${remaining} block${remaining === 1 ? "" : "s"} failed to load</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
calHead.innerHTML = `
|
|
|
|
|
|
<h2>${place}</h2>
|
|
|
|
|
|
<p class="meta">${data.range.start} → ${data.range.end} · ${data.days.length.toLocaleString()} days graded
|
2026-07-11 07:10:46 +00:00
|
|
|
|
(~${years.toFixed(1)} yr)${note}</p>
|
2026-07-11 00:29:47 +00:00
|
|
|
|
<p class="cal-hint">${IC_POINTER} Tap or click any day for its weather and grades</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCalendar() {
|
|
|
|
|
|
if (!data) return;
|
|
|
|
|
|
const byDate = new Map(data.days.map((d) => [d.date, d]));
|
|
|
|
|
|
const start = new Date(data.range.start + "T00:00:00");
|
|
|
|
|
|
const end = new Date(data.range.end + "T00:00:00");
|
|
|
|
|
|
let y = start.getFullYear(), m = start.getMonth();
|
|
|
|
|
|
const endY = end.getFullYear(), endM = end.getMonth();
|
|
|
|
|
|
let html = "";
|
|
|
|
|
|
const wActive = weatherFilterActive();
|
|
|
|
|
|
const visible = []; // every graded day in a shown month: {rec, pass}
|
|
|
|
|
|
while (y < endY || (y === endY && m <= endM)) {
|
|
|
|
|
|
// Only render months that are fully inside the graded range — never a partial
|
|
|
|
|
|
// boundary month showing a stray day or two. The range is month-based, so a
|
|
|
|
|
|
// clipped edge month (e.g. data starting mid-June) is dropped entirely.
|
|
|
|
|
|
const mFirst = new Date(y, m, 1), mLast = new Date(y, m + 1, 0);
|
|
|
|
|
|
if (mFirst < start || mLast > end) {
|
|
|
|
|
|
m++; if (m > 11) { m = 0; y++; }
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Season/year filters: skip months whose season or year is unchecked.
|
|
|
|
|
|
if (!checkedSeasons.has(seasonOf(m)) || (checkedYears && !checkedYears.has(y))) {
|
|
|
|
|
|
m++; if (m > 11) { m = 0; y++; }
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
const lead = new Date(y, m, 1).getDay(); // 0 = Sunday
|
|
|
|
|
|
const dim = new Date(y, m + 1, 0).getDate();
|
|
|
|
|
|
let cells = "";
|
|
|
|
|
|
for (let i = 0; i < lead; i++) cells += `<div class="cal-cell empty"></div>`;
|
|
|
|
|
|
for (let day = 1; day <= dim; day++) {
|
|
|
|
|
|
const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
|
|
|
|
const rec = byDate.get(iso);
|
|
|
|
|
|
let bg = "", pass = true;
|
|
|
|
|
|
if (rec) {
|
|
|
|
|
|
if (metric === "dsr") {
|
|
|
|
|
|
bg = drynessColor(rec.dsr);
|
|
|
|
|
|
} else if (metric === "precip") {
|
|
|
|
|
|
const g = rec.precip;
|
|
|
|
|
|
// Dry days take their dry-streak color; rainy days take the blue ramp.
|
|
|
|
|
|
bg = !g ? "" : g.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[g.c];
|
|
|
|
|
|
} else {
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const g = rec[metric];
|
2026-07-11 00:29:47 +00:00
|
|
|
|
bg = g ? TIER_COLORS[g.c] : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
pass = !wActive || weatherPass(rec);
|
|
|
|
|
|
visible.push({ rec, pass });
|
|
|
|
|
|
}
|
|
|
|
|
|
// Days with no graded record (outside the range) render transparent, not as a
|
|
|
|
|
|
// grey tile — so every displayed month reads as clean color with no dead squares.
|
|
|
|
|
|
const num = `<span class="cal-daynum">${day}</span>`;
|
|
|
|
|
|
cells += rec
|
|
|
|
|
|
? `<div class="cal-cell filled${pass ? "" : " dim"}" data-date="${iso}"${bg ? ` style="background:${bg}"` : ""}>${num}</div>`
|
|
|
|
|
|
: `<div class="cal-cell empty">${num}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += `<div class="cal-month"><h4>${MONTHS[m]} ${y}</h4>
|
|
|
|
|
|
<div class="cal-dows"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>
|
|
|
|
|
|
<div class="cal-grid">${cells}</div></div>`;
|
|
|
|
|
|
m++; if (m > 11) { m = 0; y++; }
|
|
|
|
|
|
}
|
|
|
|
|
|
calEl.innerHTML = html || `<p class="muted">No months match the selected seasons/years.</p>`;
|
|
|
|
|
|
renderTotals(visible);
|
|
|
|
|
|
attachHover(byDate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- category totals ----
|
|
|
|
|
|
// "What share of the shown days lands in each category?" for the current metric,
|
|
|
|
|
|
// rendered above the grid as a stacked share bar + per-category percentages.
|
|
|
|
|
|
// When the weather filter is narrowing, only matching days are totaled (and the
|
|
|
|
|
|
// title says how many matched) — so it doubles as a "days you'd love" counter.
|
|
|
|
|
|
function renderTotals(visible) {
|
|
|
|
|
|
const totEl = document.getElementById("cal-totals");
|
2026-07-11 01:58:40 +00:00
|
|
|
|
lastVisible = visible; // remembered so the "Show count" toggle can re-render in place
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if (!visible.length) { totEl.hidden = true; totEl.innerHTML = ""; return; }
|
|
|
|
|
|
const wActive = weatherFilterActive();
|
|
|
|
|
|
const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec);
|
|
|
|
|
|
|
|
|
|
|
|
// Buckets low→high for the metric: [label, color, count-fn].
|
|
|
|
|
|
let buckets, title;
|
|
|
|
|
|
if (metric === "dsr") {
|
|
|
|
|
|
const B = [
|
|
|
|
|
|
["Rain day", drynessColor(0), (d) => d === 0],
|
|
|
|
|
|
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
|
|
|
|
|
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
|
|
|
|
|
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
|
|
|
|
|
["14+ dry", drynessColor(14), (d) => d >= 14],
|
|
|
|
|
|
];
|
|
|
|
|
|
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
|
|
|
|
|
buckets = B.map(([label, color, fn]) => [label, color, vals.filter(fn).length]);
|
|
|
|
|
|
title = "dry streak";
|
|
|
|
|
|
} else if (metric === "precip") {
|
|
|
|
|
|
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
|
|
|
|
|
|
buckets = [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length]]
|
|
|
|
|
|
.concat(SCALE_RAIN.map((t) =>
|
|
|
|
|
|
[t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length]));
|
|
|
|
|
|
title = "rain intensity";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const classes = days
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.map((r) => { const g = r[metric]; return g && g.c; })
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
buckets = SCALE_TEMP.map((t) =>
|
2026-07-11 01:28:24 +00:00
|
|
|
|
[t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, t.c === "normal"]);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
title = (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const total = buckets.reduce((n, b) => n + b[2], 0);
|
2026-07-11 01:38:34 +00:00
|
|
|
|
const pctText = (n) => { const p = 100 * n / total, r = Math.round(p); return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`; };
|
2026-07-11 01:58:40 +00:00
|
|
|
|
// The strip prints either each category's share (default) or its raw day count,
|
|
|
|
|
|
// toggled by the "Show count" checkbox rendered in the header (state: showCount).
|
|
|
|
|
|
const valText = (n) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n));
|
2026-07-11 00:29:47 +00:00
|
|
|
|
const head = wActive
|
|
|
|
|
|
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:`
|
|
|
|
|
|
: `${visible.length.toLocaleString()} days shown — by ${title}:`;
|
2026-07-11 01:58:40 +00:00
|
|
|
|
const countToggle =
|
|
|
|
|
|
`<label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb"` +
|
|
|
|
|
|
`${showCount ? " checked" : ""} /> Show count</label>`;
|
|
|
|
|
|
const header = `<div class="ct-head"><div class="section-title">${head}</div>${countToggle}</div>`;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if (!total) {
|
2026-07-11 01:58:40 +00:00
|
|
|
|
totEl.innerHTML = `${header}<p class="muted">No matching days.</p>`;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
totEl.hidden = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-11 01:58:40 +00:00
|
|
|
|
// A compact distribution strip: one thin status-colored line per category, ordered
|
|
|
|
|
|
// low→high like the color scale, with each value printed above it in that category's
|
|
|
|
|
|
// own color. Every category — including the neutral "Normal" center of the diverging
|
|
|
|
|
|
// temperature scale — draws a line, so the strip reads as a full distribution.
|
|
|
|
|
|
// Category names live in the color key below the grid too, so this stays terse.
|
|
|
|
|
|
// Line heights scale to the tallest category.
|
|
|
|
|
|
const maxLine = Math.max(1, ...buckets.map((b) => b[2]));
|
|
|
|
|
|
const LINE_MAX = 30; // px — height of the most common category
|
|
|
|
|
|
const cols = buckets.map(([label, color, n]) => {
|
|
|
|
|
|
const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxLine));
|
2026-07-11 01:28:24 +00:00
|
|
|
|
const line = h ? `<span class="ct-line" style="height:${h}px"></span>` : "";
|
2026-07-11 01:58:40 +00:00
|
|
|
|
// Caption stacks the category name over its value; the name reserves two lines
|
|
|
|
|
|
// so the value figures stay aligned across columns whether a name wraps or not.
|
|
|
|
|
|
return `<div class="ct-col" style="--c:${color}" ` +
|
2026-07-11 01:28:24 +00:00
|
|
|
|
`title="${label} · ${pctText(n)} (${n})">` +
|
2026-07-11 01:58:40 +00:00
|
|
|
|
`<span class="ct-cap"><span class="ct-label">${label}</span>` +
|
|
|
|
|
|
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n)}</span></span>` +
|
|
|
|
|
|
`${line}</div>`;
|
2026-07-11 01:28:24 +00:00
|
|
|
|
}).join("");
|
2026-07-11 01:58:40 +00:00
|
|
|
|
totEl.innerHTML = `${header}
|
2026-07-11 01:28:24 +00:00
|
|
|
|
<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
totEl.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const GUIDE_LINK = `<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
|
|
|
|
|
|
|
|
|
|
|
function renderKey() {
|
|
|
|
|
|
if (metric === "dsr") {
|
|
|
|
|
|
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
|
|
|
|
|
|
const segs = steps.map(([lab, d]) =>
|
|
|
|
|
|
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
|
|
|
|
|
|
<span class="tk-label">${lab}</span></div>`).join("");
|
|
|
|
|
|
keyEl.innerHTML = `<div class="section-title">Days since last rain — dry streak (caps at ${DRY_CAP} days)</div>
|
|
|
|
|
|
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (metric === "precip") {
|
|
|
|
|
|
const rain = [...SCALE_RAIN].reverse().map((t) =>
|
|
|
|
|
|
`<div class="tk-seg"><span class="tk-swatch" style="background:${PRECIP_COLORS[t.c]}"></span>
|
|
|
|
|
|
<span class="tk-label">${t.label}</span></div>`).join("");
|
|
|
|
|
|
const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) =>
|
|
|
|
|
|
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
|
|
|
|
|
|
<span class="tk-label">${l}</span></div>`).join("");
|
|
|
|
|
|
keyEl.innerHTML = `<div class="section-title">Rain intensity · light → heavy</div>
|
|
|
|
|
|
<div class="tier-key">${rain}</div>
|
|
|
|
|
|
<div class="section-title" style="margin-top:10px">Dry days · days since rain</div>
|
|
|
|
|
|
<div class="tier-key">${dry}</div>${GUIDE_LINK}`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const label = (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
|
|
|
|
|
// Highest tier first (Near Record hot → Near Record cold).
|
|
|
|
|
|
const segs = [...SCALE_TEMP].reverse().map((t) =>
|
|
|
|
|
|
`<div class="tk-seg"><span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
|
|
|
|
|
|
<span class="tk-label">${t.label}</span></div>`).join("");
|
|
|
|
|
|
keyEl.innerHTML = `<div class="section-title">Coloring by ${label} · low → high vs local normal</div>
|
|
|
|
|
|
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- tooltip ----
|
|
|
|
|
|
function attachHover(byDate) {
|
|
|
|
|
|
const tip = document.getElementById("cal-tip");
|
|
|
|
|
|
// The row for the metric currently being viewed is bolded and moved to the top,
|
|
|
|
|
|
// so it's easy to match a cell's color to the stat it represents. Its category
|
|
|
|
|
|
// (grade) text is tinted with the very color that represents that day's value —
|
|
|
|
|
|
// bold + saturated for the active metric, lifted/dimmed for the rest (see CSS).
|
|
|
|
|
|
// Each metric is one row of the mini grid: name · value(+pct) · category.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
// `activeKey` is the row to bold: the metric currently being viewed.
|
2026-07-11 00:29:47 +00:00
|
|
|
|
let activeKey = metric;
|
|
|
|
|
|
const line = (key, name, g, fmt, color) => {
|
|
|
|
|
|
const rc = key === activeKey ? " tt-r-active" : "";
|
|
|
|
|
|
if (!g) return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">—</span><span class="tt-cat${rc}"></span>`;
|
|
|
|
|
|
const pct = g.pct == null ? "" : `<span class="tt-pct"> · ${ord(g.pct)} pct</span>`;
|
|
|
|
|
|
const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat";
|
|
|
|
|
|
const style = color ? ` style="--cat:${color}"` : "";
|
|
|
|
|
|
return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">${fmt(g.v)}${pct}</span><span class="${catCls}${rc}"${style}>${g.g}</span>`;
|
|
|
|
|
|
};
|
|
|
|
|
|
const show = (cell, e) => {
|
|
|
|
|
|
const rec = byDate.get(cell.dataset.date);
|
|
|
|
|
|
if (!rec) return;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
activeKey = metric;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
const dt = new Date(rec.date + "T00:00:00");
|
|
|
|
|
|
// Color each metric the same way the calendar cell would be colored for it.
|
|
|
|
|
|
const tempColor = (g) => (g ? TIER_COLORS[g.c] : "");
|
|
|
|
|
|
const precipColor = !rec.precip ? "" : rec.precip.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[rec.precip.c];
|
|
|
|
|
|
const dsrStr = rec.dsr == null ? null
|
|
|
|
|
|
: rec.dsr === 0 ? "Rain today"
|
|
|
|
|
|
: `${rec.dsr} day${rec.dsr === 1 ? "" : "s"} since rain`;
|
|
|
|
|
|
const rows = [
|
|
|
|
|
|
["tmax", line("tmax", "High", rec.tmax, fmtTemp, tempColor(rec.tmax))],
|
|
|
|
|
|
["tmin", line("tmin", "Low", rec.tmin, fmtTemp, tempColor(rec.tmin))],
|
2026-07-11 02:58:56 +00:00
|
|
|
|
["feels", line("feels", "Feels", rec.feels, fmtTemp, tempColor(rec.feels))],
|
2026-07-11 00:29:47 +00:00
|
|
|
|
["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))],
|
|
|
|
|
|
["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))],
|
|
|
|
|
|
["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))],
|
|
|
|
|
|
["precip", line("precip", "Precip", rec.precip, fmtPrecip, precipColor)],
|
|
|
|
|
|
];
|
|
|
|
|
|
if (dsrStr) {
|
|
|
|
|
|
const rc = metric === "dsr" ? " tt-r-active" : "";
|
|
|
|
|
|
const catCls = metric === "dsr" ? "tt-cat tt-cat-active" : "tt-cat";
|
|
|
|
|
|
// No separate percentile — let the streak text span the value + category columns.
|
|
|
|
|
|
rows.push(["dsr", `<span class="tt-name${rc}">Dry streak</span><span class="${catCls} tt-span2${rc}" style="--cat:${drynessColor(rec.dsr)}">${dsrStr}</span>`]);
|
|
|
|
|
|
}
|
|
|
|
|
|
// active metric first, the rest keep their order
|
|
|
|
|
|
rows.sort((a, b) => (b[0] === activeKey) - (a[0] === activeKey));
|
|
|
|
|
|
const wt = weatherType(rec);
|
|
|
|
|
|
tip.innerHTML = `<div class="tt-date">${dt.toLocaleDateString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
|
|
|
|
|
|
<div class="tt-type">${wt.icon} ${wt.text}</div>
|
|
|
|
|
|
<div class="tt-grid">${rows.map((r) => r[1]).join("")}</div>`;
|
|
|
|
|
|
tip.hidden = false;
|
|
|
|
|
|
const pad = 14, w = tip.offsetWidth || 170, h = tip.offsetHeight || 70;
|
|
|
|
|
|
let x = e.clientX + pad, ty = e.clientY + pad;
|
|
|
|
|
|
if (x + w > window.innerWidth) x = e.clientX - w - pad;
|
|
|
|
|
|
if (ty + h > window.innerHeight) ty = e.clientY - h - pad;
|
|
|
|
|
|
// Keep it on-screen even when flipped past an edge (narrow phone screens).
|
|
|
|
|
|
x = Math.max(pad, Math.min(x, window.innerWidth - w - pad));
|
|
|
|
|
|
ty = Math.max(pad, Math.min(ty, window.innerHeight - h - pad));
|
|
|
|
|
|
tip.style.left = x + "px";
|
|
|
|
|
|
tip.style.top = ty + "px";
|
|
|
|
|
|
};
|
|
|
|
|
|
// Clicking a day opens its full percentile breakdown. On a mouse, one click
|
|
|
|
|
|
// navigates (the tooltip is just a hover preview). On touch there's no hover, so
|
|
|
|
|
|
// the first tap only raises the tooltip and a second tap on the same day confirms.
|
|
|
|
|
|
const openDay = (cell) => {
|
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, cell.dataset.date)}`;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
calEl.querySelectorAll(".cal-cell.filled").forEach((c) => {
|
|
|
|
|
|
c.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") show(c, e); });
|
|
|
|
|
|
c.addEventListener("pointerdown", (e) => { lastPointer = e.pointerType; show(c, e); });
|
|
|
|
|
|
c.addEventListener("click", () => {
|
|
|
|
|
|
if (lastPointer === "mouse") return openDay(c);
|
|
|
|
|
|
if (armedCell === c) openDay(c); // second tap on the previewed day → open
|
|
|
|
|
|
else armedCell = c; // first tap only showed the tooltip
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- restore (hash from a shared link, else the last place you looked at) ----
|
|
|
|
|
|
(function restore() {
|
|
|
|
|
|
const loc = window.Thermograph.loadLastLocation();
|
|
|
|
|
|
if (!loc) return;
|
|
|
|
|
|
const saved = loadCalRange();
|
|
|
|
|
|
const sel = loc.date; // the shared selected day (from the hash or last-visited store)
|
|
|
|
|
|
if (sel && sel !== getCalSync()) {
|
|
|
|
|
|
// The selected day changed on another view → align the To-month to it. Keep the
|
|
|
|
|
|
// saved From unless the new To is at/before it, in which case pull From 2 years
|
|
|
|
|
|
// back so the window stays valid (start ≤ end). Month/year granularity only.
|
|
|
|
|
|
const d = new Date(sel + "T00:00:00");
|
|
|
|
|
|
const y = d.getFullYear(), m = d.getMonth();
|
|
|
|
|
|
rangeEnd = isoOfDate(new Date(y, m + 1, 0)); // last day of the To month
|
|
|
|
|
|
const toFirst = `${y}-${String(m + 1).padStart(2, "0")}-01`;
|
|
|
|
|
|
const savedFrom = saved && saved.start ? saved.start : null;
|
|
|
|
|
|
rangeStart = (savedFrom && toFirst > savedFrom)
|
|
|
|
|
|
? savedFrom // To after From → keep From
|
|
|
|
|
|
: isoOfDate(new Date(y - 2, m, 1)); // To ≤ From (or none) → same month, 2yr back
|
|
|
|
|
|
saveCalRange(rangeStart, rangeEnd);
|
|
|
|
|
|
setCalSync(sel);
|
|
|
|
|
|
} else if (saved) {
|
|
|
|
|
|
rangeStart = saved.start; rangeEnd = saved.end; // refresh / in-sync → keep range
|
|
|
|
|
|
}
|
|
|
|
|
|
selectLocation(loc.lat, loc.lon);
|
|
|
|
|
|
})();
|