rs_maps/frontend/app.js

1308 lines
43 KiB
JavaScript
Raw Normal View History

2026-08-03 09:42:13 +01:00
// frontend/app.js
2026-08-03 11:02:29 +01:00
/* rs_maps all URLs relative, resolved against the injected <base href>.
2026-08-03 09:42:13 +01:00
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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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';
2026-08-03 13:13:47 +01:00
// 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';
2026-08-03 09:42:13 +01:00
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';
2026-08-03 09:42:13 +01:00
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
2026-08-03 14:13:58 +01:00
draw: { active: false, points: [], markers: [], line: null, guide: null,
2026-08-03 14:21:40 +01:00
timer: null, seq: 0, snapped: null, editing: null },
2026-08-03 09:42:13 +01:00
};
/* ───────────────────────────── 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);
2026-08-03 13:41:35 +01:00
state.map.on('click', (e) => {
if (state.draw.active) { addWaypoint(e.latlng); return; }
openMarkerSheet(null, e.latlng);
});
2026-08-03 09:42:13 +01:00
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: `<div class="pin" style="background:${colour || DEFAULT_COLOUR}"></div>`,
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(
`<b>${escapeHtml(m.name)}</b>` +
(m.category ? `<span>${escapeHtml(m.category)}</span><br>` : '') +
(m.description ? `${escapeHtml(m.description)}<br>` : '') +
`<span class="mono">${fmtCoord(m.lat, m.lon)}</span>` +
(mine ? '' : '<br><span class="mono">shared with you</span>')
);
if (mine) layer.on('dblclick', () => openMarkerSheet(m));
state.layers.set(m.id, layer);
const li = document.createElement('li');
li.innerHTML =
`<span class="dot" style="background:${m.color || DEFAULT_COLOUR}"></span>` +
`<span class="grow"><span class="name"></span>` +
`<span class="meta">${fmtCoord(m.lat, m.lon)}</span></span>`;
$('.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); }
2026-08-03 09:42:13 +01:00
});
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);
}
2026-08-03 09:42:13 +01:00
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();
}
2026-08-03 09:42:13 +01:00
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);
}
});
2026-08-03 13:13:47 +01:00
2026-08-03 13:41:35 +01:00
/* ─────────────────────── 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';
2026-08-03 14:13:58 +01:00
/* 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;
2026-08-03 13:41:35 +01:00
function drawReset() {
2026-08-03 14:13:58 +01:00
if (state.draw.timer) clearTimeout(state.draw.timer);
2026-08-03 13:41:35 +01:00
state.draw.markers.forEach((m) => state.map.removeLayer(m));
if (state.draw.line) state.map.removeLayer(state.draw.line);
2026-08-03 14:13:58 +01:00
if (state.draw.guide) state.map.removeLayer(state.draw.guide);
state.draw = { active: false, points: [], markers: [], line: null, guide: null,
2026-08-03 14:21:40 +01:00
timer: null, seq: 0, snapped: null, editing: null };
2026-08-03 13:41:35 +01:00
}
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;
2026-08-03 14:21:40 +01:00
if (!on) $('#draw-hint').textContent = 'Snapped to paths for walking';
2026-08-03 13:41:35 +01:00
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
if (on) drawUpdate();
}
function fmtDistance(metres) {
if (!metres) return '—';
return metres < 1000
2026-08-03 14:13:58 +01:00
? `${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);
}
2026-08-03 13:41:35 +01:00
}
function drawUpdate() {
const points = state.draw.points;
$('#draw-n').textContent = points.length;
$('#draw-save').disabled = points.length < 2;
$('#draw-undo').disabled = points.length === 0;
2026-08-03 14:13:58 +01:00
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;
2026-08-03 13:41:35 +01:00
}
2026-08-03 14:13:58 +01:00
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);
2026-08-03 13:41:35 +01:00
}
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: `<div class="wp">${index + 1}</div>`,
iconSize: [22, 22], iconAnchor: [11, 11],
}),
}).addTo(state.map);
2026-08-03 14:13:58 +01:00
// Guide follows the drag live; the snapped preview waits for the debounce.
2026-08-03 13:41:35 +01:00
marker.on('drag', (e) => {
const i = state.draw.markers.indexOf(marker);
2026-08-03 14:13:58 +01:00
if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawGuide(); }
2026-08-03 13:41:35 +01:00
});
2026-08-03 14:13:58 +01:00
marker.on('dragend', drawUpdate);
2026-08-03 13:41:35 +01:00
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');
});
2026-08-03 14:21:40 +01:00
$('#draw-cancel').addEventListener('click', () => {
if (state.draw.editing && !confirm('Discard changes to this route?')) return;
drawSetActive(false);
});
2026-08-03 13:41:35 +01:00
$('#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 })),
2026-08-03 14:21:40 +01:00
// 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,
2026-08-03 13:41:35 +01:00
};
const result = await api('api/routes/calculate', { method: 'POST', ...json(body) });
2026-08-03 14:21:40 +01:00
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`
);
2026-08-03 13:41:35 +01:00
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';
}
});
2026-08-03 13:13:47 +01:00
/* ─────────────────────── 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 };
}
2026-08-03 09:42:13 +01:00
/* ─────────────────────────── 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, ' ');
2026-08-03 13:13:47 +01:00
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(
`<b>${escapeHtml(head)}</b>` +
(kind ? `<span>${escapeHtml(kind)}</span><br>` : '') +
(rest ? `${escapeHtml(rest)}<br>` : '') +
`<span class="mono">${fmtCoord(lat, lon)}</span>` +
2026-08-03 13:13:47 +01:00
`<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
// rather than once at bind time.
state.searchPin.on('popupopen', (e) => {
2026-08-03 13:13:47 +01:00
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 <select>; Nominatim's type rarely matches an option,
// so only preselect on an exact hit and otherwise leave it unclassified.
const match = [...form.category.options].some((o) => o.value === kind);
if (match) form.category.value = kind;
});
});
state.map.setView([lat, lon], Math.max(state.map.getZoom(), 16));
state.searchPin.openPopup();
}
2026-08-03 09:42:13 +01:00
$('#search').addEventListener('submit', async (e) => {
e.preventDefault();
const q = e.target.q.value.trim();
if (!q) return;
hideResults();
2026-08-03 09:42:13 +01:00
try {
// Ask for several: a chain name in a city has many equally valid matches,
// and limit=1 picked one arbitrarily with no way to see the others.
const url = `${NOMINATIM}?format=jsonv2&addressdetails=1&limit=${SEARCH_LIMIT}` +
`&q=${encodeURIComponent(q)}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
2026-08-03 09:42:13 +01:00
const hits = await res.json();
if (!hits.length) { toast('Nothing found for that', true); return; }
if (hits.length === 1) {
showSearchHit(hits[0]);
} else {
renderResults(hits);
toast(`${hits.length} matches — pick one`);
}
2026-08-03 09:42:13 +01:00
} catch {
toast('Search is unavailable right now', true);
}
});
// Dismiss the results list on outside click or Escape.
document.addEventListener('click', (e) => {
if (!e.target.closest('#search')) hideResults();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideResults(); });
2026-08-03 09:42:13 +01:00
/* ────────────────────── live location ────────────────────── */
$('#locate').addEventListener('click', () => {
const button = $('#locate');
if (state.watchId !== null) {
navigator.geolocation.clearWatch(state.watchId);
state.watchId = null;
if (state.hereLayer) { state.map.removeLayer(state.hereLayer); state.hereLayer = null; }
button.setAttribute('aria-pressed', 'false');
return;
}
if (!navigator.geolocation) {
toast('This browser has no location support', true);
return;
}
// watchPosition, not getCurrentPosition — the dot should follow you.
// Requires a secure context, so it will not run over plain HTTP.
state.watchId = navigator.geolocation.watchPosition(
(pos) => {
const { latitude, longitude } = pos.coords;
if (!state.hereLayer) {
state.hereLayer = L.marker([latitude, longitude], {
icon: L.divIcon({ className: '', html: '<div class="here"></div>', iconSize: [15, 15] }),
interactive: false,
}).addTo(state.map);
state.map.setView([latitude, longitude], Math.max(state.map.getZoom(), 15));
} else {
state.hereLayer.setLatLng([latitude, longitude]);
}
},
(err) => {
toast(err.code === err.PERMISSION_DENIED
? 'Location permission was refused'
: 'Could not get a location fix', true);
button.setAttribute('aria-pressed', 'false');
state.watchId = null;
},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 20000 }
);
button.setAttribute('aria-pressed', 'true');
});
/* ─────────────────────────── gpx ─────────────────────────── */
function parseGpx(text) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('That file is not valid GPX.');
const tracks = [];
doc.querySelectorAll('trkseg').forEach((seg) => {
const pts = Array.from(seg.querySelectorAll('trkpt'))
.map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))])
.filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
if (pts.length) tracks.push(pts);
});
const routePts = Array.from(doc.querySelectorAll('rte > rtept'))
.map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))])
.filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
if (routePts.length) tracks.push(routePts);
const waypoints = Array.from(doc.querySelectorAll('gpx > wpt')).map((p) => ({
lat: parseFloat(p.getAttribute('lat')),
lon: parseFloat(p.getAttribute('lon')),
name: (p.querySelector('name') || {}).textContent || '',
}));
if (!tracks.length && !waypoints.length) throw new Error('That file has no track in it.');
return { tracks, waypoints };
}
function drawGpx(text, label) {
const { tracks, waypoints } = parseGpx(text);
if (state.gpxLayer) state.map.removeLayer(state.gpxLayer);
const group = L.layerGroup();
tracks.forEach((pts) => {
L.polyline(pts, { color: '#D2467F', weight: 4, opacity: 0.9 }).addTo(group);
});
waypoints.forEach((w) => {
L.circleMarker([w.lat, w.lon], {
radius: 4, color: '#E2A93C', fillColor: '#E2A93C', fillOpacity: 1,
}).bindPopup(escapeHtml(w.name || 'Waypoint')).addTo(group);
});
group.addTo(state.map);
state.gpxLayer = group;
const bounds = L.latLngBounds([]);
tracks.forEach((pts) => pts.forEach((p) => bounds.extend(p)));
waypoints.forEach((w) => bounds.extend([w.lat, w.lon]));
if (bounds.isValid()) state.map.fitBounds(bounds, { padding: [40, 40] });
toast('Showing ' + label);
}
async function loadGpxList() {
const files = await api('api/gpx');
const list = $('#gpx-list');
list.innerHTML = '';
$('#gpx-empty').hidden = files.length > 0;
files.forEach((f) => {
const li = document.createElement('li');
li.innerHTML =
'<span class="grow"><span class="name"></span>' +
`<span class="meta">${fmtSize(f.size)} · ${fmtDate(f.modified)}</span></span>`;
$('.name', li).textContent = f.name;
const show = document.createElement('button');
show.className = 'link';
show.textContent = 'Show';
show.addEventListener('click', async () => {
try {
const res = await fetch('api/gpx/file/' + encodeURIComponent(f.name), {
credentials: 'same-origin',
});
if (!res.ok) throw new Error('Could not read that file.');
drawGpx(await res.text(), f.name);
} catch (err) {
toast(err.message, true);
}
});
li.appendChild(show);
2026-08-03 14:21:40 +01:00
const edit = document.createElement('button');
edit.className = 'link';
edit.textContent = 'Edit';
edit.addEventListener('click', () => editRoute(f.name));
li.appendChild(edit);
2026-08-03 09:42:13 +01:00
list.appendChild(li);
});
}
2026-08-03 14:21:40 +01:00
/* Reopens a saved route for editing. Only routes created here can be edited:
they carry their original <wpt> waypoints alongside the dense snapped track.
A GPX recorded on a device has no such waypoints, and a few thousand track
points can't be reduced back to the handful someone actually clicked. */
async function editRoute(filename) {
try {
const res = await fetch('api/gpx/file/' + encodeURIComponent(filename), {
credentials: 'same-origin',
});
if (!res.ok) throw new Error('Could not read that file.');
const doc = new DOMParser().parseFromString(await res.text(), 'application/xml');
const wpts = [...doc.getElementsByTagName('wpt')]
.map((w) => ({
lat: parseFloat(w.getAttribute('lat')),
lng: parseFloat(w.getAttribute('lon')),
}))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng));
if (wpts.length < 2) {
toast('That file has no editable waypoints — only routes drawn here can be edited', true);
return;
}
drawSetActive(true);
state.draw.editing = filename;
// Strip the extension: it's re-added on save, and showing it invites
// someone to delete it by accident.
$('#draw-name').value = filename.replace(/\.gpx$/i, '');
$('#draw-hint').textContent = `Editing ${filename}`;
wpts.forEach((p) => addWaypoint(L.latLng(p.lat, p.lng)));
state.map.fitBounds(L.latLngBounds(wpts.map((p) => [p.lat, p.lng])), { padding: [40, 40] });
toast('Drag points to adjust, then save');
} catch (err) {
toast(err.message || 'Could not open that route', true);
}
}
2026-08-03 09:42:13 +01:00
$('#gpx-local').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
drawGpx(text, file.name);
state.localGpx = { name: file.name, text };
$('#local-name').textContent = file.name;
$('#local-actions').hidden = false;
} catch (err) {
toast(err.message, true);
state.localGpx = null;
$('#local-actions').hidden = true;
}
e.target.value = '';
});
$('#gpx-save').addEventListener('click', async () => {
if (!state.localGpx) return;
const button = $('#gpx-save');
button.disabled = true;
try {
const body = new FormData();
body.append('file', new Blob([state.localGpx.text], { type: 'application/gpx+xml' }),
state.localGpx.name);
const result = await api('api/gpx/upload', { method: 'POST', body });
// The server may have renamed it to avoid a collision, so report what it used.
toast(result.name === state.localGpx.name
? `Saved as ${result.name}`
: `Saved as ${result.name} — that name was taken`);
state.localGpx = null;
$('#local-actions').hidden = true;
await loadGpxList();
} catch (err) {
toast(err.status === 413 ? 'That file is too large to upload.' : err.message, true);
} finally {
button.disabled = false;
}
});
$('#gpx-clear').addEventListener('click', () => {
if (state.gpxLayer) { state.map.removeLayer(state.gpxLayer); state.gpxLayer = null; }
});
/* ─────────────────────── panels & account ─────────────────────── */
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
const target = tab.dataset.panel;
$$('.tab').forEach((t) => t.setAttribute('aria-pressed', String(t === tab)));
$('#panel-places').hidden = target !== 'places';
$('#panel-routes').hidden = target !== 'routes';
if (target === 'routes') loadGpxList().catch(() => {});
});
});
$('#close-places').addEventListener('click', () => {
$('#panel-places').hidden = true;
$$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false'));
});
$('#close-routes').addEventListener('click', () => {
$('#panel-routes').hidden = true;
$$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false'));
});
const accountBtn = $('#account-btn');
const accountMenu = $('#account-menu');
accountBtn.addEventListener('click', () => {
const open = accountMenu.hidden;
accountMenu.hidden = !open;
accountBtn.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', (e) => {
if (!accountMenu.hidden && !e.target.closest('.account')) {
accountMenu.hidden = true;
accountBtn.setAttribute('aria-expanded', 'false');
}
});
$$('#account-menu button').forEach((b) => {
b.addEventListener('click', async () => {
accountMenu.hidden = true;
accountBtn.setAttribute('aria-expanded', 'false');
if (b.dataset.action === 'logout') {
await api('api/logout', { method: 'POST' }).catch(() => {});
location.reload();
return;
}
$('#current-email').textContent = state.me.email || 'Not set';
$('#email-form').reset();
$('#password-form').reset();
note($('#email-form'), '');
note($('#password-form'), '');
$('#email-form').new_email.value = state.me.email || '';
openSheet('#account-sheet');
});
});
$('#email-form').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
submitting(form, async () => {
const data = Object.fromEntries(new FormData(form));
try {
await api('api/me/email', {
method: 'POST',
...json({
current_password: data.current_password,
new_email: data.new_email.trim() || null,
}),
});
state.me = await api('api/me');
$('#current-email').textContent = state.me.email || 'Not set';
form.current_password.value = '';
note(form, 'Email updated.', 'good');
} catch (err) {
note(form, err.status === 401 ? 'Incorrect password.'
: err.status === 409 ? 'That email is already in use.'
: err.message, 'bad');
}
});
});
$('#password-form').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/me/password', {
method: 'POST',
...json({
current_password: data.current_password,
new_password: data.new_password,
}),
});
form.reset();
// No redirect to login — existing sessions stay valid by design.
note(form, 'Password updated.', 'good');
} catch (err) {
note(form, err.status === 401 ? 'Incorrect password.' : err.message, 'bad');
}
});
});
/* ─────────────────────────── boot ─────────────────────────── */
async function enterApp() {
$('#auth').hidden = true;
$('#app').hidden = false;
$('#account-name').textContent = state.me.username;
await loadClientConfig();
initMap();
state.map.invalidateSize();
await loadMarkers();
await loadGpxList().catch(() => {});
}
async function boot() {
if (resetToken()) {
$('#auth').hidden = false;
showPane('reset');
return;
}
try {
state.me = await api('api/me');
await enterApp();
} catch {
$('#auth').hidden = false;
showPane('login');
}
}
boot();