From e3088720a668565c31ef53aa7f1187739987b65b Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 08:36:14 -0700 Subject: [PATCH] Typo-tolerant location search suggestions (#33) Add /api/v2/suggest and wire the location picker's search box to it as a debounced type-ahead: top-5 place suggestions that tolerate a single-letter typo (substituted, missing, or extra letter, or two adjacent letters swapped) anywhere in the query, including the first character. - backend/places.py: local place index built from a GeoNames cities dump (downloaded once into data/geonames/, loaded in a background thread; the app boots and serves without it). Exact-prefix matches rank first by population, then names one edit away; a token vocabulary respells one mistyped word against known place-name tokens ("pest seattle" -> "west seattle") for retry against the upstream geocoder, which covers neighbourhood-level places the dump lacks. THERMOGRAPH_CITIES picks the dump (default cities1000). - /suggest blends local and upstream results by population with an exactness boost, so "Seatle" (a Cumbrian hamlet) can't outrank Seattle when the query is one edit from the city, while "munchen" still surfaces Munich via upstream (the index only knows English names). Upstream lookups are memoized and skipped entirely when the index answers convincingly. - mappicker.js: debounced (250ms) live suggestions with abort + sequence guards against stale responses, arrow-key navigation, Enter-picks-highlight, Escape dismissing the list before closing the overlay. Submit goes through the same typo-tolerant endpoint. - climate.geocode results now carry population (used for ranking). --- static/mappicker.js | 71 ++++++++++++++++++++++++++++++++++++++++----- static/style.css | 2 +- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/static/mappicker.js b/static/mappicker.js index 75b2151..cc18792 100644 --- a/static/mappicker.js +++ b/static/mappicker.js @@ -11,6 +11,7 @@ (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 = ``; @@ -49,15 +50,58 @@ 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; + // 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/geocode?q=${encodeURIComponent(q)}`); + const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal }); const d = await res.json(); - renderSug(d.results || []); - } catch (err) { /* leave the map as the fallback way to pick */ } + 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). @@ -69,6 +113,10 @@ }); } + function setActiveSug(items, idx) { + items.forEach((li, i) => li.classList.toggle("active", i === idx)); + } + function renderSug(list) { const shown = list; sugEl.innerHTML = ""; @@ -84,8 +132,13 @@ 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; } @@ -110,6 +163,10 @@ 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."; diff --git a/static/style.css b/static/style.css index c8e7e07..c7345c1 100644 --- a/static/style.css +++ b/static/style.css @@ -699,7 +699,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } box-shadow: 0 12px 30px rgba(0, 0, 0, .4); } .mp-sug li { padding: 10px 12px; border-radius: 7px; cursor: pointer; font-size: 14px; } -.mp-sug li:hover { background: var(--surface-2); } +.mp-sug li:hover, .mp-sug li.active { background: var(--surface-2); } .mp-sug .sub { color: var(--muted); font-size: 12px; } .mp-map { flex: 1 1 auto; min-height: 300px; margin: 4px 16px; z-index: 0;