// frontend/app.js /* rs_maps — all URLs relative, resolved against the injected . Never use a leading slash: it would escape BASE_PATH. */ 'use strict'; // Swap this for a paid provider without rebuilding the binary. // Tile source comes from GET /api/config (TILE_URL in .env) so it can be // changed without rebuilding. These are only the fallback if that call fails. const TILE_FALLBACK = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const TILE_ATTRIB = '© OpenStreetMap contributors'; let tileUrl = TILE_FALLBACK; async function loadClientConfig() { try { const res = await fetch('api/config', { headers: { Accept: 'application/json' } }); if (res.ok) { const cfg = await res.json(); if (cfg.tile_url) tileUrl = cfg.tile_url; } } catch (err) { console.warn('using fallback tile source', err); } } const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; // Nominatim's search endpoint doesn't return object tags, so opening hours, // phone and website need a second lookup by OSM id. Overpass is donated // infrastructure like Nominatim: fine on an explicit click, not per result. const OVERPASS = 'https://overpass-api.de/api/interpreter'; 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)); const state = { me: null, markers: [], layers: new Map(), // marker id -> leaflet layer map: null, watchId: null, hereLayer: null, gpxLayer: null, localGpx: null, // { name, text } awaiting an optional upload 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 draw: { active: false, points: [], markers: [], line: null, guide: null, timer: null, seq: 0, snapped: null, editing: null }, }; /* ───────────────────────────── api ───────────────────────────── */ async function api(path, options = {}) { const res = await fetch(path, { credentials: 'same-origin', headers: options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }, ...options, }); if (res.status === 204) return null; let payload = null; try { payload = await res.json(); } catch { /* empty body */ } if (!res.ok) { const err = new Error((payload && payload.error) || 'Something went wrong.'); err.status = res.status; throw err; } return payload; } const json = (body) => ({ body: JSON.stringify(body) }); /* ───────────────────────── small helpers ───────────────────────── */ function note(form, message, kind) { const el = $('[data-note]', form); if (!el) return; el.textContent = message || ''; el.className = 'note' + (kind ? ' ' + kind : ''); } let toastTimer; function toast(message, bad) { const el = $('#toast'); el.textContent = message; el.className = 'toast' + (bad ? ' bad' : ''); el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 3600); } async function submitting(form, fn) { const button = $('button[type=submit]', form); if (button) button.disabled = true; try { await fn(); } finally { if (button) button.disabled = false; } } const fmtCoord = (lat, lon) => `${lat.toFixed(5)}, ${lon.toFixed(5)}`; function fmtSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } function fmtDate(iso) { if (!iso) return ''; const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(); } /* ───────────────────────────── auth ───────────────────────────── */ function showPane(name) { $$('.pane').forEach((p) => { p.hidden = p.dataset.pane !== name; }); const subs = { login: 'Places worth going back to.', register: 'You will need the registration token.', forgot: 'We will email a link if that address is registered.', reset: 'Choose a new password.', }; $('#auth-sub').textContent = subs[name] || ''; } $$('[data-goto]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); showPane(link.dataset.goto); }); }); $('#form-login').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); try { state.me = await api('api/login', { method: 'POST', ...json(data) }); form.reset(); note(form, ''); enterApp(); } catch (err) { note(form, err.status === 401 ? 'That username or password is not right.' : err.message, 'bad'); } }); }); $('#form-register').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); if (!data.email) data.email = null; try { await api('api/register', { method: 'POST', ...json(data) }); form.reset(); showPane('login'); note($('#form-login'), 'Account created. Log in below.', 'good'); } catch (err) { note(form, err.status === 401 ? 'That registration token is not right.' : err.message, 'bad'); } }); }); $('#form-forgot').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); await api('api/password-reset/request', { method: 'POST', ...json(data) }) .catch(() => {}); // Deliberately the same message either way — the server does not reveal // whether the address is registered, and neither does this. note(form, 'If that address is registered, a reset link is on its way.', 'good'); form.reset(); }); }); $('#form-reset').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form)); if (data.new_password !== data.confirm) { note(form, 'Those two passwords do not match.', 'bad'); return; } submitting(form, async () => { try { await api('api/password-reset/confirm', { method: 'POST', ...json({ token: resetToken(), new_password: data.new_password }), }); form.reset(); history.replaceState(null, '', location.pathname); showPane('login'); note($('#form-login'), 'Password updated. Log in with it now.', 'good'); } catch (err) { note(form, err.message, 'bad'); } }); }); const resetToken = () => new URLSearchParams(location.search).get('token'); /* ───────────────────────────── map ───────────────────────────── */ function initMap() { if (state.map) return; state.map = L.map('map', { zoomControl: true }).setView([54.5, -3.0], 6); L.tileLayer(tileUrl, { maxZoom: 19, attribution: TILE_ATTRIB }).addTo(state.map); state.map.on('click', (e) => { if (state.draw.active) { addWaypoint(e.latlng); return; } openMarkerSheet(null, e.latlng); }); state.map.on('move zoom', updateReadout); updateReadout(); } function updateReadout() { if (!state.map) return; const c = state.map.getCenter(); $('#readout-latlon').textContent = fmtCoord(c.lat, c.lng); $('#readout-zoom').textContent = String(state.map.getZoom()); } function pinIcon(colour) { return L.divIcon({ className: '', html: `
`, iconSize: [15, 15], iconAnchor: [7, 14], }); } /* ─────────────────────────── markers ─────────────────────────── */ async function loadMarkers() { state.markers = await api('api/markers'); renderMarkers(); } function renderMarkers() { state.layers.forEach((layer) => state.map.removeLayer(layer)); state.layers.clear(); const list = $('#marker-list'); list.innerHTML = ''; $('#marker-empty').hidden = state.markers.length > 0; state.markers.forEach((m) => { const mine = m.owner_id === state.me.id; const layer = L.marker([m.lat, m.lon], { icon: pinIcon(m.color) }).addTo(state.map); layer.bindPopup( `${escapeHtml(m.name)}` + (m.category ? `${escapeHtml(m.category)}
` : '') + (m.description ? `${escapeHtml(m.description)}
` : '') + `${fmtCoord(m.lat, m.lon)}` + (mine ? '' : '
shared with you') ); if (mine) layer.on('dblclick', () => openMarkerSheet(m)); state.layers.set(m.id, layer); const li = document.createElement('li'); li.innerHTML = `` + `` + `${fmtCoord(m.lat, m.lon)}`; $('.name', li).textContent = m.name; if (m.is_shared) { const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = mine ? 'shared' : 'theirs'; li.appendChild(badge); } // 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); } }); 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; return div.innerHTML; } function buildSwatches() { const wrap = $('#swatches'); wrap.innerHTML = ''; COLOURS.forEach((c) => { const b = document.createElement('button'); b.type = 'button'; b.style.background = c; b.setAttribute('aria-label', 'Colour ' + c); b.setAttribute('aria-pressed', String(c === state.colour)); b.addEventListener('click', () => { state.colour = c; $$('#swatches button').forEach((x) => x.setAttribute('aria-pressed', String(x.style.background === b.style.background))); }); wrap.appendChild(b); }); } function openSheet(id) { $('#scrim').hidden = false; $(id).hidden = false; } function closeSheets() { $('#scrim').hidden = true; $('#marker-sheet').hidden = true; $('#account-sheet').hidden = true; state.editing = null; state.draftLatLng = null; } $('#scrim').addEventListener('click', closeSheets); $$('[data-close-sheet]').forEach((b) => b.addEventListener('click', closeSheets)); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheets(); }); function openMarkerSheet(marker, latlng) { const form = $('#marker-form'); form.reset(); note(form, ''); state.editing = marker || null; state.draftLatLng = marker ? { lat: marker.lat, lng: marker.lon } : latlng; state.colour = (marker && marker.color) || DEFAULT_COLOUR; $('#sheet-title').textContent = marker ? 'Edit place' : 'New place'; $('#marker-coords').textContent = fmtCoord(state.draftLatLng.lat, state.draftLatLng.lng); $('#marker-delete').hidden = !marker; if (marker) { form.name.value = marker.name; form.description.value = marker.description || ''; form.is_shared.checked = marker.is_shared; const known = Array.from(form.category.options).some((o) => o.value === marker.category); if (marker.category && !known) { form.category.value = '__other'; form.category_other.value = marker.category; } else { form.category.value = marker.category || ''; } } $('#category-other-wrap').hidden = form.category.value !== '__other'; buildSwatches(); openSheet('#marker-sheet'); form.name.focus(); } $('#marker-form').category.addEventListener('change', (e) => { $('#category-other-wrap').hidden = e.target.value !== '__other'; }); $('#marker-form').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); const category = data.category === '__other' ? (data.category_other || '').trim() : data.category; const body = { name: data.name, description: data.description || null, lat: state.draftLatLng.lat, lon: state.draftLatLng.lng, category: category || null, color: state.colour, is_shared: form.is_shared.checked, }; try { if (state.editing) { await api('api/markers/' + state.editing.id, { method: 'PUT', ...json(body) }); } else { await api('api/markers', { method: 'POST', ...json(body) }); } closeSheets(); await loadMarkers(); toast('Place saved'); } catch (err) { note(form, err.message, 'bad'); } }); }); $('#marker-delete').addEventListener('click', async () => { if (!state.editing) return; if (!confirm(`Delete "${state.editing.name}"?`)) return; try { await api('api/markers/' + state.editing.id, { method: 'DELETE' }); closeSheets(); await loadMarkers(); toast('Place deleted'); } catch (err) { toast(err.message, true); } }); /* ─────────────────────── route drawing (snapped) ─────────────────────── */ /* Waypoints are snapped server-side by brouter.de via POST /api/routes/calculate. Nothing is snapped locally: the straight guide line below is only a preview of the order of points, not the route that gets saved. */ const DRAW_COLOUR = '#4FA3D1'; /* brouter.de is donated infrastructure, so previews are debounced rather than fired on every click and every pixel of a drag. */ const PREVIEW_DEBOUNCE_MS = 1000; function drawReset() { if (state.draw.timer) clearTimeout(state.draw.timer); state.draw.markers.forEach((m) => state.map.removeLayer(m)); if (state.draw.line) state.map.removeLayer(state.draw.line); if (state.draw.guide) state.map.removeLayer(state.draw.guide); state.draw = { active: false, points: [], markers: [], line: null, guide: null, timer: null, seq: 0, snapped: null, editing: null }; } function drawSetActive(on) { if (!on) { drawReset(); } state.draw.active = on; $('#draw-panel').hidden = !on; $('#draw-start').textContent = on ? 'Drawing…' : 'New route'; $('#draw-start').disabled = on; if (!on) $('#draw-hint').textContent = 'Snapped to paths for walking'; state.map.getContainer().style.cursor = on ? 'crosshair' : ''; if (on) drawUpdate(); } function fmtDistance(metres) { if (!metres) return '—'; return metres < 1000 ? `${Math.round(metres)} m` : `${(metres / 1000).toFixed(1)} km`; } /* The dashed guide shows the order of waypoints. It's replaced visually by the snapped line once brouter answers, but kept underneath so there's always something on screen while a preview is in flight. */ function drawGuide() { const latlngs = state.draw.points.map((p) => [p.lat, p.lng]); if (state.draw.guide) { state.draw.guide.setLatLngs(latlngs); } else { state.draw.guide = L.polyline(latlngs, { color: DRAW_COLOUR, weight: 1, opacity: .35, dashArray: '3 6', interactive: false, }).addTo(state.map); } } function drawUpdate() { const points = state.draw.points; $('#draw-n').textContent = points.length; $('#draw-save').disabled = points.length < 2; $('#draw-undo').disabled = points.length === 0; drawGuide(); schedulePreview(); } function setPreviewStatus(text) { $('#draw-dist').textContent = text; } function schedulePreview() { if (state.draw.timer) clearTimeout(state.draw.timer); if (state.draw.points.length < 2) { if (state.draw.line) { state.map.removeLayer(state.draw.line); state.draw.line = null; } state.draw.snapped = null; setPreviewStatus('—'); return; } setPreviewStatus('snapping…'); state.draw.timer = setTimeout(runPreview, PREVIEW_DEBOUNCE_MS); } async function runPreview() { const points = state.draw.points.slice(); // Responses can arrive out of order after a fast edit; only the newest counts. const seq = (state.draw.seq += 1); try { const body = { name: 'preview.gpx', waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })) }; const result = await api('api/routes/preview', { method: 'POST', ...json(body) }); if (seq !== state.draw.seq || !state.draw.active) return; state.draw.snapped = result; const latlngs = result.points.map((p) => [p[0], p[1]]); if (state.draw.line) { state.draw.line.setLatLngs(latlngs); } else { state.draw.line = L.polyline(latlngs, { color: DRAW_COLOUR, weight: 4, opacity: .9, }).addTo(state.map); } const ascend = result.ascend_m ? ` · ${Math.round(result.ascend_m)} m ascent` : ''; setPreviewStatus(fmtDistance(result.length_m) + ascend); markUnsnappable(false); } catch (err) { if (seq !== state.draw.seq || !state.draw.active) return; // Keep the last good line rather than clearing the map — usually only the // most recent point is the problem, and it's about to be moved or undone. setPreviewStatus('no route'); markUnsnappable(true); toast(err.message || 'Could not snap that route', true); } } /* Flags the most recently added waypoint, which is nearly always the one that can't be reached — brouter doesn't say which point failed. */ function markUnsnappable(bad) { const last = state.draw.markers[state.draw.markers.length - 1]; if (!last) return; const el = last.getElement(); if (el) el.classList.toggle('wp-bad', bad); } function addWaypoint(latlng) { state.draw.points.push(latlng); const index = state.draw.points.length - 1; const marker = L.marker(latlng, { draggable: true, icon: L.divIcon({ className: 'wp-icon', html: `
${index + 1}
`, iconSize: [22, 22], iconAnchor: [11, 11], }), }).addTo(state.map); // Guide follows the drag live; the snapped preview waits for the debounce. marker.on('drag', (e) => { const i = state.draw.markers.indexOf(marker); if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawGuide(); } }); marker.on('dragend', drawUpdate); state.draw.markers.push(marker); drawUpdate(); } function undoWaypoint() { const marker = state.draw.markers.pop(); if (marker) state.map.removeLayer(marker); state.draw.points.pop(); drawUpdate(); } $('#draw-start').addEventListener('click', () => { $('#draw-name').value = ''; drawSetActive(true); toast('Click the map to add waypoints'); }); $('#draw-cancel').addEventListener('click', () => { if (state.draw.editing && !confirm('Discard changes to this route?')) return; drawSetActive(false); }); $('#draw-undo').addEventListener('click', undoWaypoint); $('#draw-save').addEventListener('click', async () => { const points = state.draw.points; if (points.length < 2) { toast('Add at least two points', true); return; } const name = $('#draw-name').value.trim(); if (!name) { toast('Give the route a name', true); return; } const button = $('#draw-save'); button.disabled = true; button.textContent = 'Calculating…'; try { const body = { name, waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })), // The backend replaces in place only when this matches the new name. // Rename the file and the original is left alone, as a copy. replace: state.draw.editing || null, }; const result = await api('api/routes/calculate', { method: 'POST', ...json(body) }); const renamed = state.draw.editing && result.filename !== state.draw.editing; toast( !state.draw.editing ? `Saved as ${result.filename}` : renamed ? `Saved as ${result.filename} — ${state.draw.editing} kept` : `${result.filename} updated` ); drawSetActive(false); await loadGpxList(); } catch (err) { // The backend forwards brouter's own explanation when a point can't be // snapped to a path, which is more useful than a generic failure. toast(err.message || 'Could not calculate that route', true); } finally { button.disabled = false; button.textContent = 'Calculate & save'; } }); /* ─────────────────────── place details (Overpass) ─────────────────────── */ /* Tags worth showing, in display order. `keys` is tried in order — OSM has both bare and contact:-prefixed forms for most contact details. */ const DETAIL_ROWS = [ { keys: ['opening_hours'], label: 'Hours', kind: 'hours' }, { keys: ['phone', 'contact:phone'], label: 'Phone', kind: 'tel' }, { keys: ['website', 'contact:website', 'url'],label: 'Website', kind: 'link' }, { keys: ['email', 'contact:email'], label: 'Email', kind: 'email' }, { keys: ['operator'], label: 'Operator', kind: 'text' }, { keys: ['brand'], label: 'Brand', kind: 'text' }, { keys: ['cuisine'], label: 'Cuisine', kind: 'text' }, { keys: ['wheelchair'], label: 'Step-free',kind: 'text' }, { keys: ['description'], label: 'Notes', kind: 'text' }, ]; const OSM_SHORT = { node: 'node', way: 'way', relation: 'rel' }; /* One Overpass call for a single object's tags. */ async function fetchOsmTags(osmType, osmId) { const short = OSM_SHORT[osmType]; if (!short) return null; const query = `[out:json][timeout:20];${short}(${osmId});out tags;`; const res = await fetch(OVERPASS, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'data=' + encodeURIComponent(query), }); if (!res.ok) throw new Error('overpass ' + res.status); const body = await res.json(); return (body.elements && body.elements[0] && body.elements[0].tags) || {}; } /* Renders whatever came back. Deliberately raw values — no "open now", no parsing of the opening_hours grammar beyond splitting it onto its own lines. */ function renderDetails(tags) { const wrap = document.createElement('div'); wrap.className = 'details'; let shown = 0; DETAIL_ROWS.forEach((row) => { const key = row.keys.find((k) => tags[k]); if (!key) return; const value = tags[key]; shown += 1; const dt = document.createElement('span'); dt.className = 'd-label'; dt.textContent = row.label; const dd = document.createElement('span'); dd.className = 'd-value'; if (row.kind === 'hours') { // "Mo-Fr 08:00-18:00; Sa 09:00-17:00" is far more readable one clause // per line, and that's as far as this goes — no interpretation. dd.classList.add('d-hours'); value.split(';').forEach((clause) => { const line = document.createElement('span'); line.textContent = clause.trim(); dd.appendChild(line); }); } else if (row.kind === 'link' || row.kind === 'tel' || row.kind === 'email') { const a = document.createElement('a'); a.textContent = value; a.href = row.kind === 'tel' ? 'tel:' + value.replace(/\s+/g, '') : row.kind === 'email' ? 'mailto:' + value : /^https?:/i.test(value) ? value : 'https://' + value; if (row.kind === 'link') { a.target = '_blank'; a.rel = 'noopener noreferrer'; } dd.appendChild(a); } else { dd.textContent = value.replace(/_/g, ' '); } wrap.append(dt, dd); }); if (!shown) { const empty = document.createElement('p'); empty.className = 'd-empty'; empty.textContent = 'No extra details recorded in OpenStreetMap.'; return { node: empty, shown }; } return { node: wrap, shown }; } /* ─────────────────────────── search ─────────────────────────── */ // 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, ' '); const osmLink = hit.osm_type && hit.osm_id ? `https://www.openstreetmap.org/${hit.osm_type}/${hit.osm_id}` : null; 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)}` + `
` + ``, { minWidth: 210, maxWidth: 300 } ); // 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 popup = e.popup; const root = popup.getElement(); // Details are fetched on demand, once per popup opening. Overpass is // donated infrastructure — one call per deliberate click, never per result. const more = root.querySelector('[data-more]'); const slot = root.querySelector('.detail-slot'); if (more && slot) { more.addEventListener('click', async () => { more.disabled = true; more.textContent = 'Loading…'; try { const tags = await fetchOsmTags(hit.osm_type, hit.osm_id); const { node } = renderDetails(tags || {}); slot.replaceChildren(node); more.remove(); } catch { more.disabled = false; more.textContent = 'More details'; toast('Could not load details right now', true); } popup.update(); // re-measure after the content grew }); } const save = root.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