From 7a1aafaa8401994061d95a25210cad10fee0c17c Mon Sep 17 00:00:00 2001 From: TheFozid Date: Mon, 3 Aug 2026 11:58:00 +0100 Subject: [PATCH] add search pin, multi result and click a place --- Cargo.lock | 2 +- Cargo.toml | 2 +- frontend/app.js | 166 ++++++++++++++++++++++++++++++++++++++++---- frontend/index.html | 1 + frontend/style.css | 35 +++++++++- 5 files changed, 190 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb7e54d..903a836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1382,7 +1382,7 @@ dependencies = [ [[package]] name = "rs_maps" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 958b9ea..a3a83a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # Cargo.toml [package] name = "rs_maps" -version = "0.0.4" +version = "0.0.5" edition = "2021" [dependencies] diff --git a/frontend/app.js b/frontend/app.js index d4c2692..4e0f671 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -26,6 +26,9 @@ const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B']; const DEFAULT_COLOUR = COLOURS[0]; +// Deliberately not one of the saved-place colours: a search hit is transient +// and shouldn't be mistaken for something already saved. +const SEARCH_COLOUR = '#E8B23A'; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); @@ -42,6 +45,7 @@ const state = { editing: null, // marker being edited, or null for a new one draftLatLng: null, colour: DEFAULT_COLOUR, + searchPin: null, // transient pin for the current search hit }; /* ───────────────────────────── api ───────────────────────────── */ @@ -286,20 +290,39 @@ function renderMarkers() { li.appendChild(badge); } - const go = document.createElement('button'); - go.className = 'link'; - go.textContent = mine ? 'Edit' : 'Show'; - go.addEventListener('click', () => { - state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14)); - state.layers.get(m.id).openPopup(); - if (mine) openMarkerSheet(m); + // The whole row jumps to the place and opens its popup. Previously only the + // button did anything, and for your own places it opened the edit sheet on + // top of the popup — so "edit then cancel" was the only way to just look. + li.tabIndex = 0; + li.addEventListener('click', () => focusMarker(m)); + li.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusMarker(m); } }); - li.appendChild(go); + + if (mine) { + const go = document.createElement('button'); + go.className = 'link'; + go.textContent = 'Edit'; + // Without this the row handler also fires and the popup opens behind the sheet. + go.addEventListener('click', (e) => { + e.stopPropagation(); + focusMarker(m); + openMarkerSheet(m); + }); + li.appendChild(go); + } list.appendChild(li); }); } +/* Centre the map on a marker and open its popup. */ +function focusMarker(m) { + state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14)); + const layer = state.layers.get(m.id); + if (layer) layer.openPopup(); +} + function escapeHtml(s) { const div = document.createElement('div'); div.textContent = s == null ? '' : s; @@ -431,25 +454,142 @@ $('#marker-delete').addEventListener('click', async () => { // Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style // search-as-you-type, so there is deliberately no keystroke handler here. + +const SEARCH_LIMIT = 8; + +function clearSearchPin() { + if (state.searchPin) { + state.map.removeLayer(state.searchPin); + state.searchPin = null; + } +} + +function hideResults() { + const box = $('#search-results'); + box.hidden = true; + box.innerHTML = ''; +} + +/* Nominatim's display_name is a long comma-separated string. The first part is + the place itself; the rest is the address, which is what distinguishes one + Tesco from the next. */ +function splitName(hit) { + const parts = (hit.display_name || '').split(',').map((p) => p.trim()); + return { head: parts[0] || hit.name || 'Unnamed', rest: parts.slice(1).join(', ') }; +} + +function renderResults(hits) { + const box = $('#search-results'); + box.innerHTML = ''; + + hits.forEach((hit) => { + const { head, rest } = splitName(hit); + const li = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; + + const name = document.createElement('span'); + name.className = 'r-name'; + name.textContent = head; + + const where = document.createElement('span'); + where.className = 'r-where'; + where.textContent = rest; + + button.append(name, where); + + // e.g. "supermarket", "cafe" — helps tell near-identical entries apart. + const kind = hit.type || hit.category; + if (kind) { + const tag = document.createElement('span'); + tag.className = 'r-kind'; + tag.textContent = String(kind).replace(/_/g, ' '); + button.appendChild(tag); + } + + button.addEventListener('click', () => { showSearchHit(hit); hideResults(); }); + li.appendChild(button); + box.appendChild(li); + }); + + box.hidden = hits.length === 0; +} + +/* Drop a pin at the hit, centre on it, and show what was actually found — + previously the map jumped with nothing to indicate where or what. */ +function showSearchHit(hit) { + clearSearchPin(); + + const lat = parseFloat(hit.lat); + const lon = parseFloat(hit.lon); + const { head, rest } = splitName(hit); + const kind = (hit.type || hit.category || '').replace(/_/g, ' '); + + state.searchPin = L.marker([lat, lon], { icon: pinIcon(SEARCH_COLOUR) }).addTo(state.map); + state.searchPin.bindPopup( + `${escapeHtml(head)}` + + (kind ? `${escapeHtml(kind)}
` : '') + + (rest ? `${escapeHtml(rest)}
` : '') + + `${fmtCoord(lat, lon)}` + + `
` + ); + + // The popup is rebuilt each time it opens, so the handler is attached here + // rather than once at bind time. + state.searchPin.on('popupopen', (e) => { + const save = e.popup.getElement().querySelector('[data-save-hit]'); + if (!save) return; + save.addEventListener('click', () => { + state.searchPin.closePopup(); + openMarkerSheet(null, { lat, lng: lon }); + const form = $('#marker-form'); + form.name.value = head; + + // category is a fixed + diff --git a/frontend/style.css b/frontend/style.css index a0ae35d..515e122 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -219,7 +219,40 @@ button.link:hover { color: var(--paper); background: none; } .bar { pointer-events: none; } .bar > * { pointer-events: auto; } -.search { display: flex; gap: 6px; flex: 1 1 auto; max-width: 420px; } +.search { display: flex; gap: 6px; flex: 1 1 auto; max-width: 420px; position: relative; } + +/* Search results. Nominatim frequently returns many equally-valid matches for + a chain name, so the list is shown rather than silently taking the first. */ +.results { + position: absolute; + top: calc(100% + 6px); left: 0; right: 0; + max-height: min(46vh, 380px); + overflow-y: auto; + margin: 0; padding: 5px; + list-style: none; + background: var(--panel); + border: 1px solid var(--hair); + border-radius: var(--r); + box-shadow: var(--shadow); +} +.results li { margin: 0; } +.results button { + display: block; width: 100%; + background: none; border: none; text-align: left; + padding: 8px 10px; border-radius: calc(var(--r) - 2px); + font-size: 13px; line-height: 1.35; +} +.results button:hover, .results button:focus-visible { background: var(--panel-2); } +.results .r-name { display: block; color: var(--paper); } +.results .r-where { display: block; color: var(--paper-dim); font-size: 11.5px; } +.results .r-kind { + display: inline-block; margin-top: 3px; + font-family: var(--mono); font-size: 10px; letter-spacing: .1em; + text-transform: uppercase; color: var(--moss-lift); +} + +/* Marker list rows are clickable as a whole; the Edit button sits on top. */ +#marker-list li { cursor: pointer; } .search input { margin-top: 0; background: var(--panel); } .search button { flex: none; background: var(--panel); border-color: var(--hair); }