add more info

This commit is contained in:
TheFozid 2026-08-03 13:13:47 +01:00
parent 7a1aafaa84
commit dae64cc434
4 changed files with 170 additions and 4 deletions

2
Cargo.lock generated
View file

@ -1382,7 +1382,7 @@ dependencies = [
[[package]] [[package]]
name = "rs_maps" name = "rs_maps"
version = "0.0.5" version = "0.0.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View file

@ -1,7 +1,7 @@
# Cargo.toml # Cargo.toml
[package] [package]
name = "rs_maps" name = "rs_maps"
version = "0.0.5" version = "0.0.6"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -23,6 +23,10 @@ async function loadClientConfig() {
} }
} }
const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; 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 COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B'];
const DEFAULT_COLOUR = COLOURS[0]; 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 ─────────────────────────── */ /* ─────────────────────────── search ─────────────────────────── */
// Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style // Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style
@ -525,19 +618,55 @@ function showSearchHit(hit) {
const { head, rest } = splitName(hit); const { head, rest } = splitName(hit);
const kind = (hit.type || hit.category || '').replace(/_/g, ' '); 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 = L.marker([lat, lon], { icon: pinIcon(SEARCH_COLOUR) }).addTo(state.map);
state.searchPin.bindPopup( state.searchPin.bindPopup(
`<b>${escapeHtml(head)}</b>` + `<b>${escapeHtml(head)}</b>` +
(kind ? `<span>${escapeHtml(kind)}</span><br>` : '') + (kind ? `<span>${escapeHtml(kind)}</span><br>` : '') +
(rest ? `${escapeHtml(rest)}<br>` : '') + (rest ? `${escapeHtml(rest)}<br>` : '') +
`<span class="mono">${fmtCoord(lat, lon)}</span>` + `<span class="mono">${fmtCoord(lat, lon)}</span>` +
`<br><button type="button" class="link" data-save-hit>Save as place</button>` `<div class="detail-slot"></div>` +
`<div class="popup-actions">` +
`<button type="button" class="link" data-save-hit>Save as place</button>` +
(osmLink ? `<button type="button" class="link" data-more>More details</button>` : '') +
(osmLink ? `<a class="link" href="${osmLink}" target="_blank" rel="noopener noreferrer">View on OSM</a>` : '') +
`</div>`,
{ minWidth: 210, maxWidth: 300 }
); );
// The popup is rebuilt each time it opens, so the handler is attached here // The popup is rebuilt each time it opens, so the handler is attached here
// rather than once at bind time. // rather than once at bind time.
state.searchPin.on('popupopen', (e) => { 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; if (!save) return;
save.addEventListener('click', () => { save.addEventListener('click', () => {
state.searchPin.closePopup(); state.searchPin.closePopup();

View file

@ -503,3 +503,40 @@ button.link:hover { color: var(--paper); background: none; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; } * { 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);
}