add search pin, multi result and click a place
This commit is contained in:
parent
0191dd42ed
commit
7a1aafaa84
5 changed files with 190 additions and 16 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1382,7 +1382,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rs_maps"
|
||||
version = "0.0.4"
|
||||
version = "0.0.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Cargo.toml
|
||||
[package]
|
||||
name = "rs_maps"
|
||||
version = "0.0.4"
|
||||
version = "0.0.5"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
166
frontend/app.js
166
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(
|
||||
`<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) => {
|
||||
e.preventDefault();
|
||||
const q = e.target.q.value.trim();
|
||||
if (!q) return;
|
||||
|
||||
hideResults();
|
||||
|
||||
try {
|
||||
const url = `${NOMINATIM}?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
|
||||
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
||||
// Ask for several: a chain name in a city has many equally valid matches,
|
||||
// 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();
|
||||
|
||||
if (!hits.length) { toast('Nothing found for that', true); return; }
|
||||
const hit = hits[0];
|
||||
state.map.setView([parseFloat(hit.lat), parseFloat(hit.lon)], 14);
|
||||
toast(hit.display_name.split(',').slice(0, 2).join(','));
|
||||
|
||||
if (hits.length === 1) {
|
||||
showSearchHit(hits[0]);
|
||||
} else {
|
||||
renderResults(hits);
|
||||
toast(`${hits.length} matches — pick one`);
|
||||
}
|
||||
} catch {
|
||||
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 ────────────────────── */
|
||||
|
||||
$('#locate').addEventListener('click', () => {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
<form id="search" class="search" role="search">
|
||||
<input name="q" type="search" placeholder="Search a place or address" aria-label="Search places">
|
||||
<button type="submit" aria-label="Search">Find</button>
|
||||
<ol id="search-results" class="results" hidden aria-label="Search results"></ol>
|
||||
</form>
|
||||
|
||||
<button id="locate" class="ghost" aria-pressed="false">Live location</button>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue