add search pin, multi result and click a place

This commit is contained in:
TheFozid 2026-08-03 11:58:00 +01:00
parent 0191dd42ed
commit 7a1aafaa84
5 changed files with 190 additions and 16 deletions

2
Cargo.lock generated
View file

@ -1382,7 +1382,7 @@ dependencies = [
[[package]] [[package]]
name = "rs_maps" name = "rs_maps"
version = "0.0.4" version = "0.0.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View file

@ -1,7 +1,7 @@
# Cargo.toml # Cargo.toml
[package] [package]
name = "rs_maps" name = "rs_maps"
version = "0.0.4" version = "0.0.5"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -26,6 +26,9 @@ const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B']; const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B'];
const DEFAULT_COLOUR = COLOURS[0]; 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) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(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 editing: null, // marker being edited, or null for a new one
draftLatLng: null, draftLatLng: null,
colour: DEFAULT_COLOUR, colour: DEFAULT_COLOUR,
searchPin: null, // transient pin for the current search hit
}; };
/* ───────────────────────────── api ───────────────────────────── */ /* ───────────────────────────── api ───────────────────────────── */
@ -286,20 +290,39 @@ function renderMarkers() {
li.appendChild(badge); li.appendChild(badge);
} }
const go = document.createElement('button'); // The whole row jumps to the place and opens its popup. Previously only the
go.className = 'link'; // button did anything, and for your own places it opened the edit sheet on
go.textContent = mine ? 'Edit' : 'Show'; // top of the popup — so "edit then cancel" was the only way to just look.
go.addEventListener('click', () => { li.tabIndex = 0;
state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14)); li.addEventListener('click', () => focusMarker(m));
state.layers.get(m.id).openPopup(); li.addEventListener('keydown', (e) => {
if (mine) openMarkerSheet(m); 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); 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) { function escapeHtml(s) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = s == null ? '' : s; 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 // Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style
// search-as-you-type, so there is deliberately no keystroke handler here. // 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(
`<b>${escapeHtml(head)}</b>` +
(kind ? `<span>${escapeHtml(kind)}</span><br>` : '') +
(rest ? `${escapeHtml(rest)}<br>` : '') +
`<span class="mono">${fmtCoord(lat, lon)}</span>` +
`<br><button type="button" class="link" data-save-hit>Save as place</button>`
);
// 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 <select>; Nominatim's type rarely matches an option,
// so only preselect on an exact hit and otherwise leave it unclassified.
const match = [...form.category.options].some((o) => o.value === kind);
if (match) form.category.value = kind;
});
});
state.map.setView([lat, lon], Math.max(state.map.getZoom(), 16));
state.searchPin.openPopup();
}
$('#search').addEventListener('submit', async (e) => { $('#search').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const q = e.target.q.value.trim(); const q = e.target.q.value.trim();
if (!q) return; if (!q) return;
hideResults();
try { try {
const url = `${NOMINATIM}?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`; // Ask for several: a chain name in a city has many equally valid matches,
const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); // and limit=1 picked one arbitrarily with no way to see the others.
const url = `${NOMINATIM}?format=jsonv2&addressdetails=1&limit=${SEARCH_LIMIT}` +
`&q=${encodeURIComponent(q)}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
const hits = await res.json(); const hits = await res.json();
if (!hits.length) { toast('Nothing found for that', true); return; } if (!hits.length) { toast('Nothing found for that', true); return; }
const hit = hits[0];
state.map.setView([parseFloat(hit.lat), parseFloat(hit.lon)], 14); if (hits.length === 1) {
toast(hit.display_name.split(',').slice(0, 2).join(',')); showSearchHit(hits[0]);
} else {
renderResults(hits);
toast(`${hits.length} matches — pick one`);
}
} catch { } catch {
toast('Search is unavailable right now', true); toast('Search is unavailable right now', true);
} }
}); });
// Dismiss the results list on outside click or Escape.
document.addEventListener('click', (e) => {
if (!e.target.closest('#search')) hideResults();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideResults(); });
/* ────────────────────── live location ────────────────────── */ /* ────────────────────── live location ────────────────────── */
$('#locate').addEventListener('click', () => { $('#locate').addEventListener('click', () => {

View file

@ -72,6 +72,7 @@
<form id="search" class="search" role="search"> <form id="search" class="search" role="search">
<input name="q" type="search" placeholder="Search a place or address" aria-label="Search places"> <input name="q" type="search" placeholder="Search a place or address" aria-label="Search places">
<button type="submit" aria-label="Search">Find</button> <button type="submit" aria-label="Search">Find</button>
<ol id="search-results" class="results" hidden aria-label="Search results"></ol>
</form> </form>
<button id="locate" class="ghost" aria-pressed="false">Live location</button> <button id="locate" class="ghost" aria-pressed="false">Live location</button>

View file

@ -219,7 +219,40 @@ button.link:hover { color: var(--paper); background: none; }
.bar { pointer-events: none; } .bar { pointer-events: none; }
.bar > * { pointer-events: auto; } .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 input { margin-top: 0; background: var(--panel); }
.search button { flex: none; background: var(--panel); border-color: var(--hair); } .search button { flex: none; background: var(--panel); border-color: var(--hair); }