/** * 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();