2026-07-11 00:29:47 +00:00
|
|
|
"use strict";
|
|
|
|
|
// Shared modal "location picker". Every view's Find button opens this overlay:
|
|
|
|
|
// a search box + a Leaflet map. Searching a place jumps the map (and a matching
|
|
|
|
|
// result selects it immediately); tapping anywhere on the map drops a pin the user
|
|
|
|
|
// can fine-tune before confirming. Picking closes the overlay and hands the chosen
|
|
|
|
|
// lat/lon back to the page via the onPick callback.
|
|
|
|
|
//
|
|
|
|
|
// Loaded on every page AFTER leaflet.js and BEFORE that page's own script. The map
|
|
|
|
|
// itself is created lazily the first time the picker opens (and reused after), so
|
|
|
|
|
// pages that never open it pay nothing.
|
|
|
|
|
(function () {
|
2026-07-11 08:10:15 +00:00
|
|
|
let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null;
|
2026-07-11 00:29:47 +00:00
|
|
|
let searchForm, searchInput, sugEl, confirmBtn, hintEl;
|
|
|
|
|
|
|
|
|
|
const PIN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`;
|
|
|
|
|
|
|
|
|
|
function build() {
|
|
|
|
|
overlay = document.createElement("div");
|
|
|
|
|
overlay.className = "mp-overlay";
|
|
|
|
|
overlay.hidden = true;
|
|
|
|
|
overlay.innerHTML = `
|
|
|
|
|
<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Pick a location">
|
|
|
|
|
<div class="mp-head">
|
|
|
|
|
<h2>Find a location</h2>
|
|
|
|
|
<button type="button" class="mp-close" aria-label="Close">×</button>
|
|
|
|
|
</div>
|
|
|
|
|
<form class="mp-search" autocomplete="off">
|
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
|
|
|
<input type="text" placeholder="Search for a place…" autocomplete="off" />
|
2026-07-11 00:29:47 +00:00
|
|
|
<button type="submit">Search</button>
|
|
|
|
|
<ul class="mp-sug" hidden></ul>
|
|
|
|
|
</form>
|
|
|
|
|
<div class="mp-map"></div>
|
|
|
|
|
<div class="mp-foot">
|
|
|
|
|
<span class="mp-hint">Search above, or tap the map to drop a pin.</span>
|
|
|
|
|
<button type="button" class="mp-confirm" disabled>Use this location</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
|
|
|
|
|
|
mapEl = overlay.querySelector(".mp-map");
|
|
|
|
|
searchForm = overlay.querySelector(".mp-search");
|
|
|
|
|
searchInput = searchForm.querySelector("input");
|
|
|
|
|
sugEl = overlay.querySelector(".mp-sug");
|
|
|
|
|
confirmBtn = overlay.querySelector(".mp-confirm");
|
|
|
|
|
hintEl = overlay.querySelector(".mp-hint");
|
|
|
|
|
|
|
|
|
|
overlay.querySelector(".mp-close").onclick = close;
|
|
|
|
|
// Tapping the dimmed backdrop (outside the modal) closes the picker.
|
|
|
|
|
overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) close(); });
|
|
|
|
|
confirmBtn.onclick = () => finish(pin);
|
|
|
|
|
|
|
|
|
|
searchForm.addEventListener("submit", async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const q = searchInput.value.trim();
|
|
|
|
|
if (!q) return;
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`api/v2/geocode?q=${encodeURIComponent(q)}`);
|
|
|
|
|
const d = await res.json();
|
|
|
|
|
renderSug(d.results || []);
|
|
|
|
|
} catch (err) { /* leave the map as the fallback way to pick */ }
|
|
|
|
|
});
|
|
|
|
|
// Hide the suggestions when tapping elsewhere in the modal (but not the map,
|
|
|
|
|
// which has its own tap handler).
|
|
|
|
|
overlay.addEventListener("pointerdown", (e) => {
|
|
|
|
|
if (!e.target.closest(".mp-search")) sugEl.hidden = true;
|
|
|
|
|
}, true);
|
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
|
|
|
if (overlay && !overlay.hidden && e.key === "Escape") close();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderSug(list) {
|
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 shown = list;
|
2026-07-11 00:29:47 +00:00
|
|
|
sugEl.innerHTML = "";
|
|
|
|
|
if (!shown.length) { sugEl.hidden = true; return; }
|
|
|
|
|
shown.forEach((r) => {
|
|
|
|
|
const li = document.createElement("li");
|
|
|
|
|
li.innerHTML = `${r.name}<span class="sub"> — ${[r.admin1, r.country].filter(Boolean).join(", ")}</span>`;
|
|
|
|
|
// A picked search result is an unambiguous choice — drop the pin, recenter,
|
|
|
|
|
// and select it right away (the map is there for manual/fine picks).
|
|
|
|
|
li.onclick = () => {
|
|
|
|
|
sugEl.hidden = true;
|
|
|
|
|
searchInput.value = r.name;
|
|
|
|
|
setPin(r.lat, r.lon, 10);
|
|
|
|
|
finish({ lat: r.lat, lon: r.lon });
|
|
|
|
|
};
|
|
|
|
|
sugEl.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
sugEl.hidden = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setPin(lat, lon, zoom) {
|
|
|
|
|
pin = { lat, lon };
|
|
|
|
|
if (marker) marker.setLatLng([lat, lon]);
|
2026-07-11 08:10:15 +00:00
|
|
|
else marker = L.marker([lat, lon], pinIcon ? { icon: pinIcon } : undefined).addTo(map);
|
2026-07-11 00:29:47 +00:00
|
|
|
map.setView([lat, lon], zoom || map.getZoom());
|
|
|
|
|
confirmBtn.disabled = false;
|
|
|
|
|
hintEl.textContent = "Pin set — confirm below, or tap elsewhere to move it.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function finish(p) {
|
|
|
|
|
if (!p) return;
|
|
|
|
|
const cb = onPickCb;
|
|
|
|
|
close();
|
|
|
|
|
if (cb) cb(p.lat, p.lon);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function open(cur, onPick) {
|
|
|
|
|
if (!overlay) build();
|
|
|
|
|
onPickCb = onPick;
|
|
|
|
|
pin = null;
|
|
|
|
|
confirmBtn.disabled = true;
|
|
|
|
|
sugEl.hidden = true;
|
|
|
|
|
searchInput.value = "";
|
|
|
|
|
hintEl.textContent = "Search above, or tap the map to drop a pin.";
|
|
|
|
|
overlay.hidden = false;
|
|
|
|
|
|
|
|
|
|
if (!map) {
|
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
|
|
|
map = L.map(mapEl, { worldCopyJump: true, zoomControl: true }).setView([25, 0], 2);
|
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
|
|
|
// 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
|
|
|
|
|
// (tileSize 512 + zoomOffset -1) so roads read bold and prominent;
|
|
|
|
|
// • the labels-only layer is drawn on top at its natural size, keeping city
|
|
|
|
|
// names small and crisp rather than scaled up with the roads.
|
|
|
|
|
// @2x keeps both sharp; a darken/saturate filter (style.css, .mp-base only)
|
|
|
|
|
// deepens the roads while leaving the label text at full contrast.
|
|
|
|
|
const CARTO = "https://{s}.basemaps.cartocdn.com/rastertiles";
|
|
|
|
|
const ATTRIB = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>';
|
|
|
|
|
L.tileLayer(`${CARTO}/voyager_nolabels/{z}/{x}/{y}@2x.png`, {
|
2026-07-11 08:19:52 +00:00
|
|
|
subdomains: "abcd", maxZoom: 20, tileSize: 512, zoomOffset: -1,
|
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
|
|
|
className: "mp-base", attribution: ATTRIB,
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
L.tileLayer(`${CARTO}/voyager_only_labels/{z}/{x}/{y}@2x.png`, {
|
|
|
|
|
subdomains: "abcd", maxZoom: 20, className: "mp-labels",
|
2026-07-11 00:29:47 +00:00
|
|
|
}).addTo(map);
|
2026-07-11 08:10:15 +00:00
|
|
|
// Branded accent teardrop pin (the same glyph the Find button uses), styled
|
|
|
|
|
// in style.css — replaces Leaflet's default blue raster marker.
|
|
|
|
|
pinIcon = L.divIcon({
|
|
|
|
|
className: "mp-pin", html: PIN_ICON, iconSize: [32, 32], iconAnchor: [16, 30],
|
|
|
|
|
});
|
2026-07-11 00:29:47 +00:00
|
|
|
map.on("click", (e) => setPin(e.latlng.lat, e.latlng.lng));
|
|
|
|
|
}
|
|
|
|
|
// Leaflet measures the container on creation; it's zero-sized while hidden, so
|
|
|
|
|
// recalc once the modal is actually visible and (if reopening on a known spot)
|
|
|
|
|
// drop a pin there as the starting point.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
map.invalidateSize();
|
|
|
|
|
if (cur && cur.lat != null && cur.lon != null) setPin(cur.lat, cur.lon, 10);
|
|
|
|
|
searchInput.focus();
|
|
|
|
|
}, 60);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function close() { if (overlay) overlay.hidden = true; }
|
|
|
|
|
|
|
|
|
|
window.LocationPicker = { open, PIN_ICON };
|
|
|
|
|
})();
|