/**
* 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: '© OpenStreetMap 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}
*/
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();