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)}` +
- `
`
+ `