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