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.
This commit is contained in:
Emi Griffith 2026-07-11 08:02:28 -07:00 committed by GitHub
parent 2f38fb8b51
commit 475e0ee7a2
4 changed files with 15 additions and 10 deletions

View file

@ -802,7 +802,7 @@ function updateHash() {
}
// Start where you left off: an explicit link (hash) wins, otherwise the last
// place you looked at (remembered across views), otherwise the default US map.
// place you looked at (remembered across views), otherwise the default world map.
function restoreFromHash() {
const loc = window.Thermograph.loadLastLocation();
if (!loc) return;

View file

@ -135,8 +135,11 @@ let data = null; // last /api/v2/calendar response
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.
const SEASONS = [["winter", "Winter"], ["spring", "Spring"], ["summer", "Summer"], ["fall", "Fall"]];
// Keys group months (DecFeb = "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"]];
const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall";
const seasonLabel = (s) => (selected && selected.lat < 0 ? s[2] : s[1]);
let checkedSeasons = new Set(SEASONS.map((s) => s[0])); // persists across reloads
let checkedYears = null; // reset to all available on each fetch
@ -264,7 +267,7 @@ let filterYears = []; // the year set behind the Years dropdown (for its summa
function seasonSummaryText() {
if (checkedSeasons.size === SEASONS.length) return "All seasons";
if (checkedSeasons.size === 0) return "None";
return SEASONS.filter((s) => checkedSeasons.has(s[0])).map((s) => s[1]).join(", ");
return SEASONS.filter((s) => checkedSeasons.has(s[0])).map(seasonLabel).join(", ");
}
function yearSummaryText() {
const n = checkedYears ? checkedYears.size : 0;
@ -335,7 +338,7 @@ function renderFilters() {
// the span (the weather sets survive the rebuild — they're module state).
seasonYearEl.innerHTML =
filterDropdown("season", "Seasons", "season",
SEASONS.map((s) => [s[0], s[1], checkedSeasons.has(s[0])]), seasonSummaryText()) +
SEASONS.map((s) => [s[0], seasonLabel(s), checkedSeasons.has(s[0])]), seasonSummaryText()) +
filterDropdown("year", "Years", "year",
years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) +
weatherDropdown();

View file

@ -25,7 +25,7 @@
<button type="button" class="mp-close" aria-label="Close">&times;</button>
</div>
<form class="mp-search" autocomplete="off">
<input type="text" placeholder="Search a US or Canadian place…" autocomplete="off" />
<input type="text" placeholder="Search for a place…" autocomplete="off" />
<button type="submit">Search</button>
<ul class="mp-sug" hidden></ul>
</form>
@ -70,8 +70,7 @@
}
function renderSug(list) {
const usca = list.filter((r) => ["US", "CA"].includes(r.country_code));
const shown = usca.length ? usca : list;
const shown = list;
sugEl.innerHTML = "";
if (!shown.length) { sugEl.hidden = true; return; }
shown.forEach((r) => {
@ -117,7 +116,7 @@
overlay.hidden = false;
if (!map) {
map = L.map(mapEl, { worldCopyJump: true, zoomControl: true }).setView([44.5, -95], 4);
map = L.map(mapEl, { worldCopyJump: true, zoomControl: true }).setView([25, 0], 2);
// Colorful CARTO "Voyager" basemap (no key needed), split into two raster
// layers so road weight and label size can be tuned independently:
// • the label-free base is requested one zoom lower and drawn at 512px

View file

@ -3,7 +3,7 @@
// (map, calendar, day) before that page's own script. It remembers the most
// recently selected spot in localStorage and keeps the header's view links
// (Map · Calendar · Day) pointing at that spot — so switching views, or coming
// back to the map, lands you where you were instead of an empty US-wide map.
// back to the map, lands you where you were instead of an empty world map.
const LOC_KEY = "thermograph:loc";
@ -327,10 +327,13 @@ function prefetchNeighbors(lat, lon) {
for (const di of [-1, 0, 1]) {
for (const dj of [-1, 0, 1]) {
if (!di && !dj) continue;
const nLat = centerLat + di * latStep;
if (Math.abs(nLat) > 90) continue; // no rows past the poles
const nLon = ((centerLon + dj * lonStep + 540) % 360) - 180; // wrap across the antimeridian
const cellTag = `${i + di}_${j + dj}`;
if (_warmedCells.has(cellTag)) continue;
_warmedCells.add(cellTag);
const nq = `lat=${(centerLat + di * latStep).toFixed(5)}&lon=${(centerLon + dj * lonStep).toFixed(5)}`;
const nq = `lat=${nLat.toFixed(5)}&lon=${nLon.toFixed(5)}`;
setTimeout(() => { fetch(`api/v2/cell?${nq}&prefetch=1`).catch(() => {}); }, 1500 + 1100 * k++);
}
}