add more info
This commit is contained in:
parent
7a1aafaa84
commit
dae64cc434
4 changed files with 170 additions and 4 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.5"
|
version = "0.0.6"
|
||||||
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.5"
|
version = "0.0.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
133
frontend/app.js
133
frontend/app.js
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue