/** * 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 ───────────────────────────────────────────────────────────────── 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 // ── 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.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(), { padding: [20, 20] }); 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.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, { padding: [20, 20] }); } } catch (e) { showError('Network error loading route master.'); } } // ── Init ─────────────────────────────────────────────────────────────────── 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); } }); if (RELATION_ID) { loadRoute(RELATION_ID); }