diff --git a/Cargo.lock b/Cargo.lock index 903a836..f4ec22f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1382,7 +1382,7 @@ dependencies = [ [[package]] name = "rs_maps" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index a3a83a8..e15146c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # Cargo.toml [package] name = "rs_maps" -version = "0.0.5" +version = "0.0.6" edition = "2021" [dependencies] diff --git a/frontend/app.js b/frontend/app.js index 4e0f671..f0f9ec4 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -23,6 +23,10 @@ async function loadClientConfig() { } } 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]; @@ -450,6 +454,95 @@ $('#marker-delete').addEventListener('click', async () => { } }); + +/* ─────────────────────── 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 @@ -525,19 +618,55 @@ function showSearchHit(hit) { 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 save = e.popup.getElement().querySelector('[data-save-hit]'); + 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(); diff --git a/frontend/style.css b/frontend/style.css index 515e122..9049358 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -503,3 +503,40 @@ button.link:hover { color: var(--paper); background: none; } @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } + +/* ───────────────────── place details in popups ───────────────────── */ + +.popup-actions { + display: flex; flex-wrap: wrap; gap: 4px 12px; + margin-top: 8px; padding-top: 7px; + border-top: 1px solid var(--hair); +} +.popup-actions .link { padding: 0; font-size: 12px; } + +/* Two-column label/value grid. Values are shown as OSM records them — no + interpretation, so what you see is what's actually in the data. */ +.details { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 10px; + margin-top: 8px; padding-top: 7px; + border-top: 1px solid var(--hair); + font-size: 12px; + line-height: 1.4; +} +.d-label { + font-family: var(--mono); font-size: 10px; letter-spacing: .1em; + text-transform: uppercase; color: var(--paper-dim); + padding-top: 2px; white-space: nowrap; +} +.d-value { color: var(--paper); overflow-wrap: anywhere; } +.d-value a { color: var(--moss-lift); } + +/* opening_hours clauses, one per line. */ +.d-hours { display: flex; flex-direction: column; gap: 1px; font-family: var(--mono); font-size: 11px; } + +.d-empty { + margin: 8px 0 0; padding-top: 7px; + border-top: 1px solid var(--hair); + font-size: 12px; color: var(--paper-dim); +}