"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 () { let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null; let searchForm, searchInput, sugEl, confirmBtn, hintEl; let sugSeq = 0, sugTimer = 0, sugAbort = null; const PIN_ICON = ``; function build() { overlay = document.createElement("div"); overlay.className = "mp-overlay"; overlay.hidden = true; overlay.innerHTML = ` `; 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(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal }); 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} — ${[r.admin1, r.country].filter(Boolean).join(", ")}`; // 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); } 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 = '© OpenStreetMap © CARTO'; 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; } window.LocationPicker = { open, PIN_ICON }; })();