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]]
|
[[package]]
|
||||||
name = "rs_maps"
|
name = "rs_maps"
|
||||||
version = "0.0.4"
|
version = "0.0.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
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 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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue