openstreetmap-tools/web/static/app.js
Edward Betts a79b0a7098 Make public transport routes discoverable with shareable search
Add one-click examples and route search by number or metadata, with transport type, location and map-area filters. Preserve search filters and bounds in shareable URLs and restore searches on reload and browser navigation.

Validate with 91 passing tests, including desktop and mobile browser coverage, and strict mypy checks.

Closes #2
2026-09-30 22:26:52 +01:00

772 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* osm-pt-geojson web frontend
* Handles map rendering, stop selection, segment preview and GeoJSON download.
*/
'use strict';
// ── Map setup ──────────────────────────────────────────────────────────────
const map = L.map('map').setView([20, 0], 2);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
// Leaflet layers
let fullRouteLayer = null; // thin grey polyline for the full route
let segmentLayer = null; // bold coloured polyline for the selected segment
let markerLayer = null; // all stop markers
let routeMasterLayers = []; // coloured polylines when viewing a route_master
// ── State ─────────────────────────────────────────────────────────────────
const DEFAULT_TITLE = document.title;
let currentRelationId = RELATION_ID; // tracks the currently loaded relation
let routeData = null; // response from /api/route/
let segmentGeoJson = null; // last response from /api/segment/ (always includes stops)
let activeSlot = 'from'; // 'from' | 'to' | null
let selectedFrom = null; // stop name string or null
let selectedTo = null; // stop name string or null
// ── Mobile panel ───────────────────────────────────────────────────────────
function isMobile() { return window.innerWidth < 768; }
/** fitBounds padding that accounts for the open panel covering the bottom of the map. */
function fitPadding() {
if (!isMobile()) return { padding: [20, 20] };
// Panel is 65vh; leave 20px margin above it and normal padding elsewhere.
const panelH = Math.round(window.innerHeight * 0.65);
return { paddingTopLeft: [20, 20], paddingBottomRight: [20, panelH + 20] };
}
function openPanel() {
document.getElementById('sidebar').classList.add('panel-open');
const fab = document.getElementById('panel-fab');
if (fab) fab.style.display = 'none';
}
function closePanel() {
document.getElementById('sidebar').classList.remove('panel-open');
const fab = document.getElementById('panel-fab');
if (fab) fab.style.removeProperty('display');
}
function updateHandleLabel(text) {
const el = document.getElementById('sidebar-handle-label');
if (el) el.textContent = text;
const fabLabel = document.getElementById('panel-fab-label');
if (fabLabel) fabLabel.textContent = text;
}
document.getElementById('sidebar-handle').addEventListener('click', () => {
if (document.getElementById('sidebar').classList.contains('panel-open')) {
closePanel();
} else {
openPanel();
}
});
document.getElementById('panel-fab').addEventListener('click', openPanel);
// ── Helpers ────────────────────────────────────────────────────────────────
/**
* Show an error alert in the sidebar.
* @param {string} msg
*/
function showError(msg) {
const el = document.getElementById('js-alert');
document.getElementById('js-alert-msg').textContent = msg;
el.classList.remove('d-none');
}
/**
* Trigger a file download of text content.
* @param {string} content
* @param {string} filename
*/
function downloadText(content, filename) {
const blob = new Blob([content], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* Return a safe filename fragment from a stop name.
* @param {string} name
* @returns {string}
*/
function safeName(name) {
return name.replace(/[^a-zA-Z0-9_\-]/g, '_').replace(/_+/g, '_');
}
// ── Slot UI ────────────────────────────────────────────────────────────────
/**
* Update the From/To slot boxes to reflect current state.
*/
function renderSlots() {
const slots = { from: selectedFrom, to: selectedTo };
for (const [id, value] of Object.entries(slots)) {
const box = document.getElementById(`slot-${id}`);
const text = document.getElementById(`slot-${id}-text`);
const pencil = document.getElementById(`slot-${id}-pencil`);
const isActive = activeSlot === id;
box.classList.toggle('active', isActive);
pencil.classList.toggle('d-none', !isActive);
if (value) {
text.textContent = value;
text.classList.remove('placeholder');
} else {
text.textContent = 'Click a stop…';
text.classList.add('placeholder');
}
}
}
/**
* Set which slot is active. Pass null to deactivate both.
* @param {'from'|'to'|null} slot
*/
function setActiveSlot(slot) {
activeSlot = slot;
renderSlots();
}
// ── Stop selection ─────────────────────────────────────────────────────────
/**
* Handle a stop being selected (from list click or map marker click).
* @param {string} name
*/
function selectStop(name) {
if (activeSlot === 'from') {
selectedFrom = name;
setActiveSlot('to');
} else if (activeSlot === 'to') {
selectedTo = name;
setActiveSlot(null);
} else {
// Neither slot active — do nothing, user must click a slot first.
return;
}
renderStopList();
updateMarkers();
if (selectedFrom && selectedTo) {
loadSegment();
}
}
// ── Stop list ──────────────────────────────────────────────────────────────
/**
* Render the sidebar stop list from routeData.
*/
function renderStopList() {
if (!routeData) return;
const list = document.getElementById('stop-list');
document.getElementById('stop-count').textContent = `(${routeData.stops.length})`;
list.innerHTML = '';
for (const stop of routeData.stops) {
const div = document.createElement('div');
div.className = 'stop-item';
div.textContent = stop.name;
if (stop.name === selectedFrom) div.classList.add('is-from');
if (stop.name === selectedTo) div.classList.add('is-to');
div.addEventListener('click', () => selectStop(stop.name));
list.appendChild(div);
}
}
// ── Map rendering ──────────────────────────────────────────────────────────
/** Marker colours for stop states. */
const MARKER_COLOURS = {
from: '#198754', // green
to: '#dc3545', // red
segment: '#0d6efd', // blue
default: '#6c757d', // grey
};
/**
* Create a small circular Leaflet marker.
* @param {number} lat
* @param {number} lon
* @param {string} colour hex colour
* @param {string} title
* @returns {L.CircleMarker}
*/
function makeMarker(lat, lon, colour, title) {
return L.circleMarker([lat, lon], {
radius: 6,
color: colour,
fillColor: colour,
fillOpacity: 0.9,
weight: 2,
}).bindTooltip(title);
}
/**
* Determine which stops fall within the current segment (by index).
* Returns a Set of stop names.
* @returns {Set<string>}
*/
function segmentStopNames() {
if (!segmentGeoJson) return new Set();
return new Set(
segmentGeoJson.features
.filter(f => f.geometry.type === 'Point')
.map(f => f.properties.name)
);
}
/**
* Redraw all stop markers to reflect current selection state.
*/
function updateMarkers() {
if (!routeData) return;
if (markerLayer) markerLayer.remove();
markerLayer = L.layerGroup();
const inSegment = segmentStopNames();
for (const stop of routeData.stops) {
let colour;
if (stop.name === selectedFrom) colour = MARKER_COLOURS.from;
else if (stop.name === selectedTo) colour = MARKER_COLOURS.to;
else if (inSegment.has(stop.name)) colour = MARKER_COLOURS.segment;
else colour = MARKER_COLOURS.default;
const marker = makeMarker(stop.lat, stop.lon, colour, stop.name);
marker.on('click', () => selectStop(stop.name));
markerLayer.addLayer(marker);
}
markerLayer.addTo(map);
}
/**
* Draw or redraw the segment layer from segmentGeoJson, respecting the stops toggle.
*/
function renderSegment() {
if (segmentLayer) segmentLayer.remove();
if (!segmentGeoJson) return;
const includeStops = document.getElementById('include-stops').checked;
// Build a filtered GeoJSON: always keep LineString, conditionally keep Points.
const filtered = {
type: 'FeatureCollection',
features: segmentGeoJson.features.filter(f =>
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
),
};
segmentLayer = L.geoJSON(filtered, {
style: { color: '#0d6efd', weight: 5, opacity: 0.85 },
pointToLayer: (feature, latlng) =>
makeMarker(latlng.lat, latlng.lng, MARKER_COLOURS.segment, feature.properties.name),
}).addTo(map);
updateMarkers();
updateDownloadButtons();
}
// ── Download buttons ───────────────────────────────────────────────────────
/**
* Enable/disable the segment download button and wire up both download buttons.
*/
function updateDownloadButtons() {
const btn = document.getElementById('btn-download-segment');
if (segmentGeoJson && selectedFrom && selectedTo) {
btn.classList.remove('disabled');
} else {
btn.classList.add('disabled');
}
}
document.getElementById('btn-download-segment').addEventListener('click', () => {
if (!segmentGeoJson || !selectedFrom || !selectedTo) return;
const includeStops = document.getElementById('include-stops').checked;
const geojson = {
type: 'FeatureCollection',
features: segmentGeoJson.features.filter(f =>
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
),
};
const ref = routeData.ref || 'route';
const filename = `${safeName(ref)}-${safeName(selectedFrom)}-${safeName(selectedTo)}.geojson`;
downloadText(JSON.stringify(geojson, null, 2), filename);
});
document.getElementById('btn-download-full').addEventListener('click', () => {
if (!routeData) return;
const includeStops = document.getElementById('include-stops').checked;
const geojson = {
type: 'FeatureCollection',
features: routeData.geojson.features.filter(f =>
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
),
};
const ref = routeData.ref || 'route';
downloadText(JSON.stringify(geojson, null, 2), `${safeName(ref)}-full.geojson`);
});
// ── Clear button ───────────────────────────────────────────────────────────
document.getElementById('btn-clear').addEventListener('click', () => {
selectedFrom = null;
selectedTo = null;
segmentGeoJson = null;
if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; }
setActiveSlot('from');
renderStopList();
updateMarkers();
updateDownloadButtons();
});
// ── Stops toggle ───────────────────────────────────────────────────────────
document.getElementById('include-stops').addEventListener('change', () => {
renderSegment(); // re-filters existing data, no network request
});
// ── Slot click handlers ────────────────────────────────────────────────────
document.getElementById('slot-from').addEventListener('click', () => setActiveSlot('from'));
document.getElementById('slot-to').addEventListener('click', () => setActiveSlot('to'));
// ── Navigation ─────────────────────────────────────────────────────────────
/**
* Load a relation without a full page reload, updating the browser URL.
* Allows middle-click / ctrl-click to still open in a new tab via the href.
* @param {number} id
* @param {MouseEvent} e
*/
function navigateTo(id, e) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
history.pushState(null, '', URLS.routePage + id);
loadRoute(id);
}
// ── API calls ──────────────────────────────────────────────────────────────
/**
* Fetch the full route and render it on the map.
* @param {number} relationId
*/
async function loadRoute(relationId) {
try {
const resp = await fetch(URLS.routeApi + relationId);
const data = await resp.json();
if (!resp.ok) {
if (data.error === 'is_route_master') {
await loadRouteMaster(relationId);
return;
}
showError(data.message || `Error loading relation ${relationId}.`);
return;
}
routeData = data;
currentRelationId = relationId;
// Clean up any previous route_master view
for (const l of routeMasterLayers) l.remove();
routeMasterLayers = [];
document.getElementById('route-master-panel').classList.add('d-none');
selectedFrom = null;
selectedTo = null;
segmentGeoJson = null;
setActiveSlot('from');
// Show route panel
document.getElementById('route-panel').classList.remove('d-none');
document.getElementById('route-name').textContent = data.name;
document.title = `${data.name} – ${DEFAULT_TITLE}`;
updateHandleLabel(data.name);
if (isMobile()) openPanel();
document.getElementById('route-osm-link').href =
`https://www.openstreetmap.org/relation/${relationId}`;
// Draw full route in grey
if (fullRouteLayer) fullRouteLayer.remove();
fullRouteLayer = L.geoJSON(data.geojson, {
style: { color: '#adb5bd', weight: 3, opacity: 0.7 },
pointToLayer: () => null, // don't render stop points here
filter: f => f.geometry.type === 'LineString',
}).addTo(map);
map.fitBounds(fullRouteLayer.getBounds(), fitPadding());
renderStopList();
updateMarkers();
updateDownloadButtons();
// Other directions (sibling routes from the same route_master)
const dirPanel = document.getElementById('other-directions-panel');
const dirList = document.getElementById('other-directions-list');
dirList.innerHTML = '';
if (data.other_directions && data.other_directions.length > 0) {
dirPanel.classList.remove('d-none');
for (const dir of data.other_directions) {
const a = document.createElement('a');
a.href = URLS.routePage + dir.id;
a.className = 'stop-item d-block text-decoration-none';
a.textContent = dir.name;
a.addEventListener('click', (e) => navigateTo(dir.id, e));
dirList.appendChild(a);
}
} else {
dirPanel.classList.add('d-none');
}
} catch (e) {
showError('Network error loading route.');
}
}
/**
* Fetch the segment GeoJSON and render it.
*/
async function loadSegment() {
if (!selectedFrom || !selectedTo || !routeData) return;
const rid = currentRelationId;
const params = new URLSearchParams({ from: selectedFrom, to: selectedTo, stops: '1' });
try {
const resp = await fetch(URLS.segmentApi + rid + '?' + params);
const data = await resp.json();
if (!resp.ok) {
showError(data.message || 'Error loading segment.');
return;
}
segmentGeoJson = data;
renderSegment();
} catch (e) {
showError('Network error loading segment.');
}
}
/**
* Fetch and display all member routes of a route_master relation.
* @param {number} relationId
*/
async function loadRouteMaster(relationId) {
try {
const resp = await fetch(URLS.routeMasterApi + relationId);
const data = await resp.json();
if (!resp.ok) {
showError(data.message || `Error loading route master ${relationId}.`);
return;
}
// Clear individual route state
routeData = null;
selectedFrom = null;
selectedTo = null;
segmentGeoJson = null;
if (fullRouteLayer) { fullRouteLayer.remove(); fullRouteLayer = null; }
if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; }
if (markerLayer) { markerLayer.remove(); markerLayer = null; }
for (const l of routeMasterLayers) l.remove();
routeMasterLayers = [];
// Hide individual route panel, show route_master panel
document.getElementById('route-panel').classList.add('d-none');
document.getElementById('route-master-panel').classList.remove('d-none');
document.getElementById('route-master-name').textContent = data.name;
document.title = `${data.name} – ${DEFAULT_TITLE}`;
updateHandleLabel(data.name);
if (isMobile()) openPanel();
document.getElementById('route-master-osm-link').href =
`https://www.openstreetmap.org/relation/${relationId}`;
const colours = ['#0d6efd', '#dc3545', '#198754', '#fd7e14', '#6f42c1'];
const list = document.getElementById('route-master-list');
list.innerHTML = '';
data.routes.forEach((route, i) => {
const colour = colours[i % colours.length];
if (route.geojson) {
const layer = L.geoJSON(route.geojson, {
style: { color: colour, weight: 4, opacity: 0.85 },
}).addTo(map);
routeMasterLayers.push(layer);
}
const div = document.createElement('div');
div.className = 'stop-item d-flex align-items-center gap-2';
const dot = document.createElement('span');
dot.style.cssText =
`display:inline-block;width:10px;height:10px;border-radius:50%;` +
`background:${colour};flex-shrink:0`;
const a = document.createElement('a');
a.href = URLS.routePage + route.id;
a.className = 'text-decoration-none text-reset flex-grow-1';
a.textContent = route.name;
a.addEventListener('click', (e) => navigateTo(route.id, e));
div.appendChild(dot);
div.appendChild(a);
list.appendChild(div);
});
if (routeMasterLayers.length > 0) {
let bounds = routeMasterLayers[0].getBounds();
for (const l of routeMasterLayers.slice(1)) bounds = bounds.extend(l.getBounds());
map.fitBounds(bounds, fitPadding());
}
} catch (e) {
showError('Network error loading route master.');
}
}
// ── Init ───────────────────────────────────────────────────────────────────
let selectedLocation = null;
let searchAreaLayer = null;
const locationInput = document.getElementById('search-location');
const mapAreaCheckbox = document.getElementById('search-map-area');
const locationResults = document.getElementById('location-results');
const areaLabel = document.getElementById('search-area-label');
let searchController = null;
/** Remove the previous search outline when its scope changes. */
function clearSearchArea() {
if (searchAreaLayer) searchAreaLayer.remove();
searchAreaLayer = null;
areaLabel.textContent = '';
}
locationInput.addEventListener('input', () => {
selectedLocation = null;
locationResults.replaceChildren();
clearSearchArea();
});
mapAreaCheckbox.addEventListener('change', () => {
locationInput.disabled = mapAreaCheckbox.checked;
locationResults.replaceChildren();
clearSearchArea();
if (!mapAreaCheckbox.checked && selectedLocation) chooseLocation(selectedLocation);
});
/** Centre the map and show the bounds used for a chosen place. */
function chooseLocation(location) {
selectedLocation = location;
locationResults.replaceChildren();
clearSearchArea();
const [south, west, north, east] = location.bounds;
const bounds = L.latLngBounds([south, west], [north, east]);
searchAreaLayer = L.rectangle(bounds, {
color: '#0d6efd', weight: 2, dashArray: '6 4', fill: false, interactive: false,
}).addTo(map);
map.fitBounds(bounds, { ...fitPadding(), animate: false });
areaLabel.textContent = `Search area: ${location.label}`;
}
/** Search relation metadata, resolving an optional place before searching routes. */
async function searchRoutes({ updateUrl = true, restoredBounds = null } = {}) {
if (searchController) searchController.abort();
const controller = new AbortController();
searchController = controller;
const button = document.getElementById('search-button');
const status = document.getElementById('search-status');
const results = document.getElementById('search-results');
const params = new URLSearchParams({
q: document.getElementById('route-search').value.trim(),
mode: document.getElementById('search-mode').value,
});
button.disabled = true;
locationInput.disabled = true;
mapAreaCheckbox.disabled = true;
results.replaceChildren();
status.textContent = 'Searching routes…';
try {
if (mapAreaCheckbox.checked) {
clearSearchArea();
const bounds = map.getBounds();
// Leaflet wraps world copies; normalise longitude without hiding date-line crossings.
const west = ((bounds.getWest() + 180) % 360 + 360) % 360 - 180;
const east = west + bounds.getEast() - bounds.getWest();
const searchBounds = restoredBounds || [Math.max(-90, bounds.getSouth()), west,
Math.min(90, bounds.getNorth()), east];
['south', 'west', 'north', 'east'].forEach((key, index) => params.set(key, searchBounds[index]));
if (restoredBounds) {
const [south, west, north, east] = restoredBounds;
searchAreaLayer = L.rectangle([[south, west], [north, east]], {
color: '#0d6efd', weight: 2, dashArray: '6 4', fill: false, interactive: false,
}).addTo(map);
}
areaLabel.textContent = restoredBounds ? 'Search area: shared map view' : 'Search area: current map view';
} else if (locationInput.value.trim()) {
if (!selectedLocation) {
status.textContent = 'Finding location…';
const response = await fetch(URLS.locationsApi + '?' + new URLSearchParams({ q: locationInput.value.trim() }), { signal: controller.signal });
const data = await response.json();
if (controller.signal.aborted) return;
if (!response.ok) {
status.textContent = data.message || 'Location search failed. Please try again.';
return;
}
if (!data.locations.length) {
status.textContent = 'No location found. Try a more specific place name.';
return;
}
if (data.locations.length > 1) {
status.textContent = 'Choose a location to search for routes:';
locationResults.replaceChildren();
for (const location of data.locations) {
const choice = document.createElement('button');
choice.type = 'button';
choice.className = 'list-group-item list-group-item-action';
choice.textContent = location.label;
choice.addEventListener('click', () => {
chooseLocation(location);
searchRoutes();
});
locationResults.appendChild(choice);
}
return;
}
chooseLocation(data.locations[0]);
}
['south', 'west', 'north', 'east'].forEach((key, index) => params.set(key, selectedLocation.bounds[index]));
} else {
clearSearchArea();
}
if (updateUrl) {
const urlParams = new URLSearchParams(params);
if (!urlParams.get('mode')) urlParams.delete('mode');
if (mapAreaCheckbox.checked) {
urlParams.set('area', 'map');
} else if (selectedLocation && locationInput.value.trim()) {
urlParams.set('location', locationInput.value.trim());
urlParams.set('place', selectedLocation.label);
}
const url = URLS.indexPage + '?' + urlParams;
if (location.pathname + location.search !== url) history.pushState(null, '', url);
}
status.textContent = 'Searching routes…';
const response = await fetch(URLS.searchApi + '?' + params, { signal: controller.signal });
const data = await response.json();
if (controller.signal.aborted) return;
if (!response.ok) {
status.textContent = data.message || 'Route search failed. Please try again.';
return;
}
status.textContent = data.routes.length
? `${data.routes.length} results. Choose a route to load it.${data.truncated ? ' More matches exist; narrow your search.' : ''}`
: 'No routes found. Try a route number, destination or operator.';
for (const route of data.routes) {
const link = document.createElement('a');
link.href = URLS.routePage + route.id;
link.className = 'list-group-item list-group-item-action';
const name = document.createElement('span');
name.className = 'fw-semibold d-block';
name.textContent = route.name;
const details = document.createElement('span');
details.className = 'text-muted d-block';
details.textContent = [
route.ref, route.route.replaceAll('_', ' '),
route.from && route.to ? `${route.from} → ${route.to}` : route.from || route.to,
route.operator || route.network,
route.type === 'route_master' ? 'All directions' : null,
`Relation ${route.id}`,
].filter(Boolean).join(' · ');
link.append(name, details);
results.appendChild(link);
}
} catch (error) {
if (error.name !== 'AbortError') status.textContent = 'Network error searching routes. Please try again.';
} finally {
if (searchController === controller) {
button.disabled = false;
mapAreaCheckbox.disabled = false;
locationInput.disabled = mapAreaCheckbox.checked;
searchController = null;
}
}
}
/** Restore submitted filters and use the saved bounds exactly, on every screen size. */
function restoreSearch() {
if (searchController) searchController.abort();
searchController = null;
document.getElementById('search-button').disabled = false;
mapAreaCheckbox.disabled = false;
const params = new URLSearchParams(location.search);
document.getElementById('route-search').value = params.get('q') || '';
document.getElementById('search-mode').value = params.get('mode') || '';
locationInput.value = params.get('location') || '';
mapAreaCheckbox.checked = params.get('area') === 'map';
locationInput.disabled = mapAreaCheckbox.checked;
selectedLocation = null;
locationResults.replaceChildren();
clearSearchArea();
document.getElementById('search-results').replaceChildren();
const status = document.getElementById('search-status');
status.textContent = '';
if (!params.has('q')) return;
if (isMobile()) openPanel();
const keys = ['south', 'west', 'north', 'east'];
let bounds = null;
if (keys.some(key => params.has(key))) {
bounds = keys.map(key => params.has(key) && params.get(key).trim() ? Number(params.get(key)) : NaN);
const [south, west, north, east] = bounds;
if (!bounds.every(Number.isFinite) || south < -90 || north > 90 || west < -180 || east > 180
|| south >= north || west >= east || north - south > 20 || east - west > 20) {
status.textContent = 'Invalid search area in this URL. Choose a location or zoom in and search again.';
return;
}
if (mapAreaCheckbox.checked) {
map.fitBounds([[south, west], [north, east]], { ...fitPadding(), animate: false });
} else {
locationInput.value ||= params.get('place') || 'Shared search area';
chooseLocation({ label: params.get('place') || locationInput.value, bounds });
}
} else if (mapAreaCheckbox.checked) {
status.textContent = 'This map search URL is missing its search area. Zoom in and search again.';
return;
}
const query = document.getElementById('route-search').value.trim();
const mode = params.get('mode') || '';
if (query.length < 2 || query.length > 100 || /[\x00-\x1f]/.test(query)
|| (mode && document.getElementById('search-mode').value !== mode)) {
status.textContent = 'Invalid search filters in this URL. Update the filters and search again.';
return;
}
searchRoutes({ updateUrl: false, restoredBounds: bounds });
}
document.getElementById('route-search-form').addEventListener('submit', (event) => {
event.preventDefault();
locationResults.replaceChildren();
searchRoutes();
});
window.addEventListener('popstate', () => {
const prefix = URLS.routePage;
if (location.pathname.startsWith(prefix)) {
const id = parseInt(location.pathname.slice(prefix.length), 10);
if (id) loadRoute(id);
}
restoreSearch();
});
if (RELATION_ID) {
loadRoute(RELATION_ID);
} else if (isMobile()) {
openPanel();
}
restoreSearch();