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
This commit is contained in:
parent
29afb4f115
commit
a79b0a7098
7 changed files with 919 additions and 12 deletions
|
|
@ -533,12 +533,235 @@ async function loadRouteMaster(relationId) {
|
|||
|
||||
// ── 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) {
|
||||
|
|
@ -546,3 +769,4 @@ if (RELATION_ID) {
|
|||
} else if (isMobile()) {
|
||||
openPanel();
|
||||
}
|
||||
restoreSearch();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue