thermograph/frontend/static/mappicker.js
Emi Griffith d6df04eab2 Subtree-merge thermograph-frontend (origin/main) into frontend/
git-subtree-dir: frontend
git-subtree-mainline: a4be7066e5
git-subtree-split: 3a98146da4
2026-07-22 22:01:11 -07:00

223 lines
9.7 KiB
JavaScript

// 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.
//
// Leaflet stays a classic script (global L), loaded before the page modules. The
// map itself is created lazily the first time the picker opens (and reused after),
// so pages that never open it pay nothing.
import { uv } from "./account.js";
let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null;
let searchForm, searchInput, sugEl, confirmBtn, hintEl;
let sugSeq = 0, sugTimer = 0, sugAbort = null;
export 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">&times;</button>
</div>
<form class="mp-search" autocomplete="off">
<input type="text" placeholder="Search for a place…" autocomplete="off" />
<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);
// Live suggestions (top 5, single-letter-typo tolerant) as the user types:
// debounced so a fast typist doesn't fire a request per keystroke, aborted
// + sequence-guarded so a slow stale response can't overwrite a newer list.
async function fetchSug(q) {
const seq = ++sugSeq;
if (sugAbort) sugAbort.abort();
sugAbort = new AbortController();
try {
const res = await fetch(uv(`suggest?q=${encodeURIComponent(q)}`),
{ signal: sugAbort.signal, credentials: "include" });
const d = await res.json();
if (seq === sugSeq) renderSug(d.results || []);
} catch (err) { /* aborted, or offline — the map stays the fallback way to pick */ }
}
searchInput.addEventListener("input", () => {
clearTimeout(sugTimer);
// The old list stays visible until fresh results land, but its highlight
// must go — Enter mid-typing shouldn't pick a suggestion for the shorter
// query the list still reflects.
const stale = sugEl.querySelector("li.active");
if (stale) stale.classList.remove("active");
const q = searchInput.value.trim();
if (q.length < 2) { sugEl.hidden = true; return; }
sugTimer = setTimeout(() => fetchSug(q), 250);
});
// Arrow keys walk the list (Enter picks via the submit handler below);
// Escape dismisses the list before the overlay's Escape-to-close kicks in.
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !sugEl.hidden) {
sugEl.hidden = true;
e.stopPropagation();
return;
}
if (sugEl.hidden || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
const items = [...sugEl.children];
if (!items.length) return;
e.preventDefault();
const cur = items.findIndex((li) => li.classList.contains("active"));
const next = e.key === "ArrowDown"
? (cur + 1) % items.length
: (cur <= 0 ? items.length - 1 : cur - 1);
setActiveSug(items, next);
items[next].scrollIntoView({ block: "nearest" });
});
searchForm.addEventListener("submit", (e) => {
e.preventDefault();
clearTimeout(sugTimer);
// Enter picks the highlighted suggestion when the list is up; otherwise
// it searches immediately (no debounce wait).
const active = sugEl.hidden ? null : sugEl.querySelector("li.active");
if (active) { active.click(); return; }
const q = searchInput.value.trim();
if (q) fetchSug(q);
});
// 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 setActiveSug(items, idx) {
items.forEach((li, i) => li.classList.toggle("active", i === idx));
}
function renderSug(list) {
const shown = list;
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 });
};
// Keep one highlight: hovering moves it off whatever the arrows chose.
li.addEventListener("pointerenter", () =>
setActiveSug([...sugEl.children], [...sugEl.children].indexOf(li)));
sugEl.appendChild(li);
});
// Pre-highlight the top match so Enter picks it straight away.
setActiveSug([...sugEl.children], 0);
sugEl.hidden = false;
}
function setPin(lat, lon, zoom) {
pin = { lat, lon };
if (marker) marker.setLatLng([lat, lon]);
else marker = L.marker([lat, lon], pinIcon ? { icon: pinIcon } : undefined).addTo(map);
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);
}
export function open(cur, onPick) {
if (!overlay) build();
onPickCb = onPick;
pin = null;
confirmBtn.disabled = true;
// Cancel any in-flight/pending suggestion fetch from the previous open so
// a late response can't pop a stale list over the fresh empty box.
clearTimeout(sugTimer);
sugSeq++;
sugEl.hidden = true;
searchInput.value = "";
hintEl.textContent = "Search above, or tap the map to drop a pin.";
overlay.hidden = false;
if (!map) {
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
// (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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>';
L.tileLayer(`${CARTO}/voyager_nolabels/{z}/{x}/{y}@2x.png`, {
subdomains: "abcd", maxZoom: 20, tileSize: 512, zoomOffset: -1,
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",
}).addTo(map);
// 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],
});
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; }
// Wire a page's Find/Add button: pin icon + label, and open the picker on
// click seeded with the page's current spot. `setFindLabel` swaps the label
// (e.g. "Find a location" → "Change location") keeping the icon markup.
export function initFindButton(btn, label, getCurrent, onPick) {
setFindLabel(btn, label);
btn.addEventListener("click", () => open(getCurrent(), onPick));
}
export function setFindLabel(btn, label) {
btn.innerHTML = `${PIN_ICON} <span>${label}</span>`;
}