let map; let layerLoadTimer; let layerLoadSequence = 0; let layers = {}; let layerState = {}; let layerCounts = {}; let layerLoading = {}; let layerGroups = []; let savedLayerState; let journeyLayer; let datasetSearchLayer; let candidatePreviewLayer; let candidatePreviewData; let selectedCandidatePreviewId; let journeySearchTimers = {}; let journeyStopAbortControllers = {}; let datasetSearchTimer; let datasetSearchAbortController; let datasetSearchSequence = 0; let activeJourneySearchId; let journeySearchPollTimer; let lastJourneyResponse; let lastJourneyDrawSignature; let lastItineraries = []; let journeyStopSearchSequence = {}; let journeyContextPopup; let selectedDatasetSearchKey; let activeJobPollTimer; let activeJobDetailsId; let jobDetailsPollTimer; let jobListRevision; let jobListRefreshTimer; let jobListRefreshInFlight = false; let jobListRefreshFailureShown = false; let layerLoadAbortController; let allSources = []; let sourceCatalogEntries = []; let sourceCatalogSummary = {}; const JOB_DETAILS_POLL_MS = 4000; const JOB_LIST_REFRESH_MS = 5000; const JOB_LIST_REFRESH_HIDDEN_MS = 15000; const JOURNEY_STOP_SEARCH_DEBOUNCE_MS = 400; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'mobilitySidebarCollapsed'; const MAP_VIEW_STORAGE_KEY = 'mobilityMapView'; const DEFAULT_MAP_VIEW = { center: [52.52, 13.405], zoom: 11 }; const osmRouteModes = [ { label: 'Rail: long-distance', mode: 'train', routeScope: 'long_distance', color: '#1d4ed8', enabled: true, minZoom: 5, baseWeight: 3.4, detailWeight: 6, tooltipMinZoom: 10 }, { label: 'Rail: regional', mode: 'train', routeScope: 'regional', color: '#2563eb', enabled: true, minZoom: 7, baseWeight: 3, detailWeight: 5.4, tooltipMinZoom: 11 }, { label: 'Rail: local/S-Bahn', mode: 'train', routeScope: 'local', color: '#0f766e', enabled: true, minZoom: 10, baseWeight: 2.6, detailWeight: 4.8, tooltipMinZoom: 12 }, { label: 'Rail: unknown', mode: 'train', routeScope: 'unknown', color: '#64748b', enabled: false, minZoom: 10, baseWeight: 2.4, detailWeight: 4.4, tooltipMinZoom: 13 }, { label: 'Bus: long-distance', mode: 'bus,coach', routeScope: 'long_distance', color: '#9333ea', enabled: false, minZoom: 7, baseWeight: 2.6, detailWeight: 5, tooltipMinZoom: 11 }, { label: 'Bus: regional', mode: 'bus,trolleybus', routeScope: 'regional', color: '#ea580c', enabled: true, minZoom: 10, baseWeight: 2.2, detailWeight: 4.6, tooltipMinZoom: 13 }, { label: 'Bus: local', mode: 'bus,trolleybus', routeScope: 'local', color: '#d97706', enabled: true, minZoom: 12, baseWeight: 2, detailWeight: 4.2, tooltipMinZoom: 14 }, { label: 'Tram/light rail', mode: 'tram,light_rail', routeScope: 'local', color: '#7c3aed', enabled: true, minZoom: 11, baseWeight: 2.4, detailWeight: 4.8, tooltipMinZoom: 13 }, { label: 'Subway', mode: 'subway', routeScope: 'local', color: '#dc2626', enabled: true, minZoom: 10, baseWeight: 2.8, detailWeight: 5.2, tooltipMinZoom: 12 }, { label: 'Ferry', mode: 'ferry', routeScope: 'local', color: '#0891b2', enabled: true, minZoom: 10, baseWeight: 2.4, detailWeight: 4.6, tooltipMinZoom: 13 }, { label: 'Other routes', mode: 'monorail,funicular,aerialway', routeScope: 'local', color: '#64748b', enabled: false, minZoom: 11, baseWeight: 2.2, detailWeight: 4.2, tooltipMinZoom: 13 } ]; const matchStatusLayers = [ ['Matched', 'matched', '#16a34a'], ['Accepted', 'accepted', '#15803d'], ['Probable', 'probable', '#ca8a04'], ['Weak', 'weak', '#ea580c'], ['Missing', 'missing', '#dc2626'] ]; function zoomLineStyle(color, { baseWeight = 3, detailWeight = 5, opacity = 0.72, detailOpacity = 0.9, dashArray } = {}) { return { color, weight: baseWeight, detailWeight, opacity, detailOpacity, dashArray, zoomResponsive: true }; } function routeLayer(id, label, mode, color, sourceId, defaultEnabled = true, options = {}) { const params = { kind: 'route', mode, source_id: String(sourceId) }; if (options.routeScope) params.route_scope = options.routeScope; return { id, label, category: 'osm-route', endpoint: '/api/map/osm_features.geojson', params, minZoom: options.minZoom ?? 9, defaultEnabled, style: zoomLineStyle(color, { baseWeight: options.baseWeight ?? 3, detailWeight: options.detailWeight ?? 5, opacity: options.opacity ?? 0.68, detailOpacity: options.detailOpacity ?? 0.86, dashArray: options.dashArray }), tooltipMinZoom: options.tooltipMinZoom, limit: 5000 }; } function routePatternLayer(id, label, mode, sourceKind, color, defaultEnabled = true, options = {}) { const params = { mode, source_kind: sourceKind }; if (options.routeScope) params.route_scope = options.routeScope; return { id, label, category: 'route-layer', endpoint: '/api/map/route_patterns.geojson', params, minZoom: options.minZoom ?? 9, defaultEnabled, style: zoomLineStyle(color, { baseWeight: options.baseWeight ?? (sourceKind === 'gtfs_proposed' ? 2.8 : 3.4), detailWeight: options.detailWeight ?? (sourceKind === 'gtfs_proposed' ? 4.2 : 5.6), opacity: sourceKind === 'gtfs_proposed' ? 0.48 : 0.78, detailOpacity: sourceKind === 'gtfs_proposed' ? 0.64 : 0.92, dashArray: sourceKind === 'gtfs_proposed' ? '7 5' : undefined }), tooltipMinZoom: options.tooltipMinZoom, limit: 7000 }; } function setLayerGroupsFromSources(sources) { layerGroups = buildLayerGroupsFromSources(sources); initializeLayerState(); renderLayerControls(); } function buildLayerGroupsFromSources(sources) { const groups = [ { id: 'routeLayer', label: 'Route layer', children: [ routePatternLayer('routeLayerRailLongDistance', 'Rail: long-distance', 'train', 'osm', '#1d4ed8', true, { routeScope: 'long_distance', minZoom: 5, baseWeight: 3.6, detailWeight: 6.2, tooltipMinZoom: 10 }), routePatternLayer('routeLayerRailRegional', 'Rail: regional', 'train', 'osm', '#2563eb', true, { routeScope: 'regional', minZoom: 7, baseWeight: 3.2, detailWeight: 5.8, tooltipMinZoom: 11 }), routePatternLayer('routeLayerRailLocal', 'Rail: local/S-Bahn', 'train', 'osm', '#0f766e', true, { routeScope: 'local', minZoom: 10, baseWeight: 2.8, detailWeight: 5, tooltipMinZoom: 12 }), routePatternLayer('routeLayerRailUnknown', 'Rail: unknown', 'train', 'osm', '#64748b', false, { routeScope: 'unknown', minZoom: 10, baseWeight: 2.4, detailWeight: 4.4, tooltipMinZoom: 13 }), routePatternLayer('routeLayerBusLongDistance', 'Bus: long-distance', 'bus,coach', 'osm', '#9333ea', false, { routeScope: 'long_distance', minZoom: 7, baseWeight: 2.8, detailWeight: 5, tooltipMinZoom: 11 }), routePatternLayer('routeLayerBusRegional', 'Bus: regional', 'bus,trolleybus', 'osm', '#ea580c', true, { routeScope: 'regional', minZoom: 10, baseWeight: 2.4, detailWeight: 4.8, tooltipMinZoom: 13 }), routePatternLayer('routeLayerBusLocal', 'Bus: local', 'bus,trolleybus', 'osm', '#d97706', true, { routeScope: 'local', minZoom: 12, baseWeight: 2.2, detailWeight: 4.4, tooltipMinZoom: 14 }), routePatternLayer('routeLayerTram', 'Tram/light rail', 'tram,light_rail', 'osm', '#7c3aed', true, { routeScope: 'local', minZoom: 11, baseWeight: 2.6, detailWeight: 5, tooltipMinZoom: 13 }), routePatternLayer('routeLayerSubway', 'Subway', 'subway', 'osm', '#dc2626', true, { routeScope: 'local', minZoom: 10, baseWeight: 3, detailWeight: 5.4, tooltipMinZoom: 12 }), routePatternLayer('routeLayerFerry', 'Ferry', 'ferry', 'osm', '#0891b2', true, { routeScope: 'local', minZoom: 10, baseWeight: 2.4, detailWeight: 4.6, tooltipMinZoom: 13 }), routePatternLayer('routeLayerProposed', 'GTFS proposed', 'train,subway,tram,bus,coach,trolleybus,ferry,light_rail', 'gtfs_proposed', '#111827', false) ] } ]; sources.filter(hasActiveGtfsDataset).forEach(source => { const suffix = `Source${source.id}`; groups.push({ id: `gtfs${suffix}`, label: `GTFS: ${source.name}`, children: [ { id: `gtfsRoutes${suffix}`, label: 'Routes', category: 'gtfs-route', endpoint: '/api/map/gtfs_routes.geojson', params: { source_id: String(source.id) }, minZoom: 8, defaultEnabled: true, style: { color: '#18864b', weight: 4, opacity: 0.74 }, limit: 5000 }, { id: `gtfsStops${suffix}`, label: 'Stops', category: 'gtfs-stop', endpoint: '/api/map/gtfs_stops.geojson', params: { source_id: String(source.id) }, minZoom: 13, defaultEnabled: false, pointStyle: { radius: 4, weight: 1, color: '#14532d', fillOpacity: 0.82 }, limit: 4000 } ] }); }); sources.filter(hasActiveOsmDataset).forEach(source => { const suffix = `Source${source.id}`; groups.push({ id: `osm${suffix}`, label: `OSM: ${source.name}`, children: [ ...osmRouteModes.map(config => routeLayer( `osm${config.label.replace(/[^A-Za-z0-9]+/g, '')}Routes${suffix}`, config.label, config.mode, config.color, source.id, config.enabled, { routeScope: config.routeScope, minZoom: config.minZoom, baseWeight: config.baseWeight, detailWeight: config.detailWeight, tooltipMinZoom: config.tooltipMinZoom } ) ), { id: `osmRailPaths${suffix}`, label: 'Rail/tram paths', category: 'osm-infra', endpoint: '/api/map/osm_features.geojson', params: { source_id: String(source.id), kind: 'infra', mode: 'train,light_rail,subway,tram,monorail,funicular' }, minZoom: 13, defaultEnabled: false, style: { color: '#475569', weight: 2, opacity: 0.62 }, limit: 8000 }, { id: `osmFerryPaths${suffix}`, label: 'Ferry paths', category: 'osm-infra', endpoint: '/api/map/osm_features.geojson', params: { source_id: String(source.id), kind: 'infra', mode: 'ferry' }, minZoom: 13, defaultEnabled: false, style: { color: '#0e7490', weight: 2, opacity: 0.62, dashArray: '5 5' }, limit: 4000 }, { id: `osmStops${suffix}`, label: 'Stops', category: 'osm-stop', endpoint: '/api/map/osm_features.geojson', params: { source_id: String(source.id), kind: 'stop,station,terminal', geometry: 'point' }, minZoom: 14, defaultEnabled: false, pointStyle: { radius: 4, weight: 1, color: '#334155', fillOpacity: 0.62 }, limit: 5000 }, { id: `osmStopWays${suffix}`, label: 'Stop ways', category: 'osm-stop', endpoint: '/api/map/osm_features.geojson', params: { source_id: String(source.id), kind: 'stop,station,terminal', geometry: 'nonpoint' }, minZoom: 15, defaultEnabled: false, style: { color: '#111827', weight: 2, opacity: 0.54, fillOpacity: 0.12 }, limit: 5000 } ] }); }); sources.filter(hasActiveGtfsDataset).forEach(source => { const suffix = `Source${source.id}`; groups.push({ id: `review${suffix}`, label: `Match status: ${source.name}`, children: matchStatusLayers.map(([label, status, color]) => { const style = { color, weight: status === 'missing' ? 6 : 5, opacity: 0.88 }; if (status === 'missing') style.dashArray = '8 6'; return { id: `match${status[0].toUpperCase()}${status.slice(1)}${suffix}`, label, category: 'match-status', status, endpoint: '/api/map/matched_gtfs_routes.geojson', params: { source_id: String(source.id), status }, minZoom: 8, defaultEnabled: false, style, limit: 5000 }; }) }); }); return groups; } function hasActiveGtfsDataset(source) { return (source.datasets || []).some(dataset => dataset.kind === 'gtfs' && dataset.is_active); } function hasActiveOsmDataset(source) { return (source.datasets || []).some(dataset => dataset.kind === 'osm_geojson' && dataset.is_active); } function initMap() { const view = loadSavedMapView(); map = L.map('map', { preferCanvas: true }).setView(view.center, view.zoom); map.createPane('searchPane'); map.getPane('searchPane').style.zIndex = 450; map.createPane('candidatePane'); map.getPane('candidatePane').style.zIndex = 470; map.createPane('journeyPane'); map.getPane('journeyPane').style.zIndex = 490; L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors' }).addTo(map); map.on('moveend zoomend', scheduleMapLayerLoad); map.on('moveend zoomend', saveMapViewport); map.on('contextmenu', showJourneyContextMenu); map.getContainer().addEventListener('contextmenu', showJourneyContainerContextMenu, true); } function loadSavedMapView() { try { const saved = JSON.parse(localStorage.getItem(MAP_VIEW_STORAGE_KEY) || 'null'); const lat = Number(saved?.center?.[0]); const lon = Number(saved?.center?.[1]); const zoom = Number(saved?.zoom); if ( Number.isFinite(lat) && Number.isFinite(lon) && Number.isFinite(zoom) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 && zoom >= 0 && zoom <= 22 ) { return { center: [lat, lon], zoom }; } } catch (_) {} return DEFAULT_MAP_VIEW; } function saveMapViewport() { if (!map) return; const center = map.getCenter(); const zoom = map.getZoom(); try { localStorage.setItem(MAP_VIEW_STORAGE_KEY, JSON.stringify({ center: [Number(center.lat.toFixed(6)), Number(center.lng.toFixed(6))], zoom: Number(zoom) })); } catch (_) {} } async function api(path, options = {}) { const response = await fetch(path, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { let detail = response.statusText; try { detail = (await response.json()).detail || detail; } catch (_) {} if (response.status === 409) updateMapStatus(detail); throw new Error(detail); } return response.json(); } function clearLayer(name) { if (layers[name]) { map.removeLayer(layers[name]); delete layers[name]; } } function allLayerConfigs() { return layerGroups.flatMap(group => group.children); } function initializeLayerState() { if (savedLayerState === undefined) { try { savedLayerState = JSON.parse(localStorage.getItem('mobilityLayerState') || '{}'); } catch (_) { savedLayerState = {}; } } allLayerConfigs().forEach(config => { if (typeof layerState[config.id] !== 'boolean') { layerState[config.id] = typeof savedLayerState[config.id] === 'boolean' ? savedLayerState[config.id] : config.defaultEnabled !== false; } }); } function saveLayerState() { localStorage.setItem('mobilityLayerState', JSON.stringify(layerState)); } function renderLayerControls() { const container = document.getElementById('layerControls'); container.innerHTML = layerGroups.map(group => `
${group.children.map(layer => ` `).join('')}
`).join(''); container.onchange = event => { const target = event.target; if (!(target instanceof HTMLInputElement)) return; const groupId = target.dataset.layerGroup; const layerId = target.dataset.layerId; if (groupId) { const group = layerGroups.find(item => item.id === groupId); if (group) { group.children.forEach(layer => { layerState[layer.id] = target.checked; }); } } if (layerId) { layerState[layerId] = target.checked; } saveLayerState(); syncLayerControls(); loadMapLayers(); }; syncLayerControls(); } function syncLayerControls() { layerGroups.forEach(group => { const groupInput = document.querySelector(`[data-layer-group="${group.id}"]`); const enabledCount = group.children.filter(layer => layerState[layer.id]).length; if (groupInput) { groupInput.checked = enabledCount === group.children.length; groupInput.indeterminate = enabledCount > 0 && enabledCount < group.children.length; } group.children.forEach(layer => { const layerInput = document.querySelector(`[data-layer-id="${layer.id}"]`); if (layerInput) layerInput.checked = Boolean(layerState[layer.id]); }); }); updateLayerCounts(); } function applyLayerPreset(preset) { allLayerConfigs().forEach(config => { if (preset === 'network') { layerState[config.id] = ['route-layer', 'osm-infra'].includes(config.category) && config.id !== 'routeLayerProposed'; } else if (preset === 'review') { layerState[config.id] = config.category === 'match-status' || config.category === 'route-layer'; } else if (preset === 'unmatched') { layerState[config.id] = config.id === 'routeLayerProposed' || (config.category === 'match-status' && ['missing', 'weak'].includes(config.status)); } else if (preset === 'all') { layerState[config.id] = true; } }); saveLayerState(); syncLayerControls(); loadMapLayers(); } function updateLayerCounts() { allLayerConfigs().forEach(layer => { const count = document.querySelector(`[data-layer-count="${layer.id}"]`); const row = document.querySelector(`[data-layer-row="${layer.id}"]`); if (!count) return; const isLoading = Boolean(layerLoading[layer.id]); if (row) row.classList.toggle('loading', isLoading); if (isLoading) { count.innerHTML = ''; count.title = 'Loading'; return; } const value = layerCounts[layer.id]; count.title = ''; count.textContent = value === undefined ? '' : value; }); } function scheduleMapLayerLoad() { window.clearTimeout(layerLoadTimer); layerLoadTimer = window.setTimeout(() => loadMapLayers(), 180); } async function loadMapLayers() { const sequence = ++layerLoadSequence; if (layerLoadAbortController) { layerLoadAbortController.abort(); } const controller = new AbortController(); layerLoadAbortController = controller; const zoom = map.getZoom(); const skipped = []; const loadable = []; allLayerConfigs().forEach(config => { if (!layerState[config.id]) { clearLayer(config.id); layerCounts[config.id] = undefined; delete layerLoading[config.id]; return; } if (zoom < config.minZoom) { clearLayer(config.id); layerCounts[config.id] = `z${config.minZoom}+`; delete layerLoading[config.id]; skipped.push(config); return; } layerLoading[config.id] = true; loadable.push(config); }); updateLayerCounts(); if (!loadable.length) { setMapLoading(false); const skippedText = skipped.length ? `${skipped.length} layer${skipped.length === 1 ? '' : 's'} waiting for zoom` : 'No enabled layers in view'; updateMapStatus(skippedText); return; } let completed = 0; let totalFeatures = 0; const errors = []; setMapLoading(true, `Loading 0/${loadable.length} layers...`); updateMapStatus(`Loading 0/${loadable.length} layers...`); await runLimited(loadable, 3, async config => { try { const data = await api(layerUrl(config), { signal: controller.signal }); if (sequence !== layerLoadSequence) return; clearLayer(config.id); layers[config.id] = createGeoJsonLayer(config, data).addTo(map); const count = Array.isArray(data.features) ? data.features.length : 0; layerCounts[config.id] = String(count); totalFeatures += count; } catch (error) { if (error.name === 'AbortError') return; if (sequence !== layerLoadSequence) return; clearLayer(config.id); layerCounts[config.id] = 'error'; errors.push(`${config.label}: ${error.message}`); } finally { if (sequence !== layerLoadSequence) return; delete layerLoading[config.id]; completed += 1; updateLayerCounts(); if (completed < loadable.length) { setMapLoading(true, `Loading ${completed}/${loadable.length} layers...`); updateMapStatus(`Loading ${completed}/${loadable.length} layers...`); } } }); if (sequence !== layerLoadSequence || controller.signal.aborted) return; if (layerLoadAbortController === controller) { layerLoadAbortController = undefined; } setMapLoading(false); updateLayerCounts(); const skippedText = skipped.length ? `, ${skipped.length} waiting for zoom` : ''; const errorText = errors.length ? `, ${errors.length} failed` : ''; updateMapStatus(`${totalFeatures.toLocaleString()} features in view${skippedText}${errorText}`); if (errors.length) console.warn(errors.join('\n')); bringDatasetSearchToFront(); bringCandidatePreviewToFront(); bringJourneyToFront(); } async function runLimited(items, limit, worker) { const queue = [...items]; const workers = Array.from({ length: Math.min(Math.max(1, limit), queue.length) }, async () => { while (queue.length) { const item = queue.shift(); if (!item) return; await worker(item); } }); await Promise.all(workers); } function createGeoJsonLayer(config, data) { const options = { onEachFeature: (feature, layer) => bindFeatureInteractions(config, feature, layer) }; if (config.pointStyle) { options.pointToLayer = (_feature, latlng) => L.circleMarker(latlng, config.pointStyle); } if (config.style) { options.style = feature => layerStyleForZoom(config, feature); } return L.geoJSON(data, options); } function layerStyleForZoom(config, feature) { const base = typeof config.style === 'function' ? config.style(feature) : { ...(config.style || {}) }; if (!base.zoomResponsive) return base; const zoom = map.getZoom(); const minZoom = Number(config.minZoom || 0); const progress = Math.max(0, Math.min(1, (zoom - minZoom) / 5)); const weight = Number(base.weight || 3) + (Number(base.detailWeight || base.weight || 3) - Number(base.weight || 3)) * progress; const opacity = Number(base.opacity ?? 0.7) + (Number(base.detailOpacity ?? base.opacity ?? 0.7) - Number(base.opacity ?? 0.7)) * progress; const style = { ...base, weight, opacity }; delete style.zoomResponsive; delete style.detailWeight; delete style.detailOpacity; return style; } function bindFeatureInteractions(config, feature, layer) { bindPopup(feature, layer); if (!config.tooltipMinZoom || map.getZoom() < config.tooltipMinZoom) return; const props = feature.properties || {}; const label = props.ref || props.route_ref || props.name; if (!label || feature.geometry?.type === 'Point' || feature.geometry?.type === 'MultiPoint') return; layer.bindTooltip(String(label), { permanent: true, direction: 'center', className: 'route-line-label', opacity: 0.82 }); } function clearDatasetSearchLayer() { if (datasetSearchLayer) { map.removeLayer(datasetSearchLayer); datasetSearchLayer = undefined; } } async function showDatasetSearchFeature(type, id, row) { if (!type || !id) return; const key = `${type}:${id}`; selectedDatasetSearchKey = key; document.querySelectorAll('.dataset-result-row.selected').forEach(item => item.classList.remove('selected')); if (row) { row.classList.add('selected', 'loading'); } try { const params = new URLSearchParams({ type, id: String(id) }); const data = await api(`/api/datasets/search/feature.geojson?${params.toString()}`); drawDatasetSearchFeature(data); updateMapStatus('Showing selected search result'); } catch (err) { alert(err.message); } finally { if (row) row.classList.remove('loading'); } } function drawDatasetSearchFeature(data) { clearDatasetSearchLayer(); if (!data?.features?.length) { updateMapStatus('Selected search result has no geometry'); return; } const casing = L.geoJSON(data, { pane: 'searchPane', filter: feature => feature.geometry?.type !== 'Point', style: { color: '#ffffff', weight: 10, opacity: 0.96, lineCap: 'round', lineJoin: 'round' } }); const highlight = L.geoJSON(data, { pane: 'searchPane', filter: feature => feature.geometry?.type !== 'Point', style: { color: '#0f766e', weight: 6, opacity: 0.96, lineCap: 'round', lineJoin: 'round' }, onEachFeature: bindPopup }); const points = L.geoJSON(data, { pane: 'searchPane', filter: feature => feature.geometry?.type === 'Point', pointToLayer: (_feature, latlng) => L.circleMarker(latlng, { radius: 7, color: '#ffffff', weight: 2, fillColor: '#0f766e', fillOpacity: 0.95 }), onEachFeature: bindPopup }); datasetSearchLayer = L.featureGroup([casing, highlight, points]).addTo(map); bringDatasetSearchToFront(); bringJourneyToFront(); const bounds = datasetSearchLayer.getBounds(); if (bounds.isValid()) map.fitBounds(bounds.pad(0.18)); } function bringDatasetSearchToFront() { if (!datasetSearchLayer) return; datasetSearchLayer.eachLayer(layer => { if (typeof layer.bringToFront === 'function') { layer.bringToFront(); } if (typeof layer.eachLayer === 'function') { layer.eachLayer(child => { if (typeof child.bringToFront === 'function') { child.bringToFront(); } }); } }); } function clearCandidatePreviewLayer() { if (candidatePreviewLayer) { map.removeLayer(candidatePreviewLayer); candidatePreviewLayer = undefined; } candidatePreviewData = undefined; selectedCandidatePreviewId = undefined; } function drawCandidatePreview(preview) { clearCandidatePreviewLayer(); if (!preview?.features?.length) { updateMapStatus('Candidate preview has no geometry'); return; } candidatePreviewData = preview; const current = preview.features.find(feature => feature.properties?.preview_role === 'candidate' && feature.properties?.current_match); const firstCandidate = preview.features.find(feature => feature.properties?.preview_role === 'candidate'); selectedCandidatePreviewId = String(current?.properties?.id ?? firstCandidate?.properties?.id ?? ''); redrawCandidatePreviewLayer(true); syncCandidatePreviewRows(); } function redrawCandidatePreviewLayer(fitToBounds = false) { if (!candidatePreviewData?.features?.length) return; if (candidatePreviewLayer) { map.removeLayer(candidatePreviewLayer); } const casing = L.geoJSON(candidatePreviewData, { pane: 'candidatePane', filter: feature => feature.geometry?.type !== 'Point', style: candidatePreviewCasingStyle }); const lines = L.geoJSON(candidatePreviewData, { pane: 'candidatePane', filter: feature => feature.geometry?.type !== 'Point', style: candidatePreviewLineStyle, onEachFeature: bindPopup }); const points = L.geoJSON(candidatePreviewData, { pane: 'candidatePane', filter: feature => feature.geometry?.type === 'Point', pointToLayer: (feature, latlng) => L.circleMarker(latlng, candidatePreviewPointStyle(feature)), onEachFeature: bindPopup }); candidatePreviewLayer = L.featureGroup([casing, lines, points]).addTo(map); bringCandidatePreviewToFront(); if (fitToBounds) { const bounds = candidatePreviewLayer.getBounds(); if (bounds.isValid()) map.fitBounds(bounds.pad(0.18)); } } function candidatePreviewLineStyle(feature) { const props = feature.properties || {}; if (props.preview_role === 'gtfs_route') { return { color: '#0f766e', weight: 7, opacity: 0.95, dashArray: '8 5', lineCap: 'round', lineJoin: 'round' }; } const isSelected = selectedCandidatePreviewId && String(props.id) === selectedCandidatePreviewId; if (isSelected) { return { color: '#f97316', weight: 8, opacity: 0.98, lineCap: 'round', lineJoin: 'round' }; } if (props.current_match) { return { color: '#15803d', weight: 6, opacity: 0.92, lineCap: 'round', lineJoin: 'round' }; } return { color: '#64748b', weight: 3, opacity: 0.48, lineCap: 'round', lineJoin: 'round' }; } function candidatePreviewCasingStyle(feature) { const line = candidatePreviewLineStyle(feature); return { color: '#ffffff', weight: Number(line.weight || 4) + 5, opacity: feature.properties?.preview_role === 'candidate' && !isSelectedCandidateFeature(feature) ? 0.55 : 0.82, lineCap: 'round', lineJoin: 'round' }; } function candidatePreviewPointStyle(feature) { const props = feature.properties || {}; const isSelected = isSelectedCandidateFeature(feature); return { radius: props.preview_role === 'gtfs_route' ? 7 : isSelected ? 7 : 5, color: '#ffffff', weight: 2, fillColor: props.preview_role === 'gtfs_route' ? '#0f766e' : isSelected ? '#f97316' : '#64748b', fillOpacity: props.preview_role === 'candidate' && !isSelected ? 0.62 : 0.95 }; } function isSelectedCandidateFeature(feature) { return Boolean(selectedCandidatePreviewId && String(feature.properties?.id) === selectedCandidatePreviewId); } function focusCandidatePreview(osmFeatureId) { if (!candidatePreviewData) return; selectedCandidatePreviewId = String(osmFeatureId); redrawCandidatePreviewLayer(false); syncCandidatePreviewRows(); const selectedFeature = candidatePreviewData.features.find(feature => feature.properties?.preview_role === 'candidate' && String(feature.properties?.id) === selectedCandidatePreviewId ); if (!selectedFeature) return; const selectedLayer = L.geoJSON(selectedFeature); const bounds = selectedLayer.getBounds(); if (bounds.isValid()) map.fitBounds(bounds.pad(0.22)); updateMapStatus('Showing selected match candidate'); } function syncCandidatePreviewRows() { document.querySelectorAll('[data-candidate-row]').forEach(row => { row.classList.toggle('selected', Boolean(selectedCandidatePreviewId) && row.dataset.candidateRow === selectedCandidatePreviewId); }); } function bringCandidatePreviewToFront() { if (!candidatePreviewLayer) return; candidatePreviewLayer.eachLayer(layer => { if (typeof layer.bringToFront === 'function') { layer.bringToFront(); } if (typeof layer.eachLayer === 'function') { layer.eachLayer(child => { if (typeof child.bringToFront === 'function') { child.bringToFront(); } }); } }); } function layerUrl(config) { const params = new URLSearchParams(config.params || {}); params.set('bbox', currentBbox()); params.set('zoom', String(map.getZoom())); params.set('limit', String(effectiveLayerLimit(config))); const separator = config.endpoint.includes('?') ? '&' : '?'; return `${config.endpoint}${separator}${params.toString()}`; } function effectiveLayerLimit(config) { const base = Number(config.limit || 5000); const zoom = map.getZoom(); if (zoom <= 6) return Math.min(base, 1500); if (zoom <= 8) return Math.min(base, 3000); return base; } function currentBbox() { const bounds = map.getBounds().pad(0.12); return [ bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth() ].map(value => value.toFixed(6)).join(','); } function updateMapStatus(text) { const status = document.getElementById('mapStatus'); if (status) status.textContent = text; } function setMapLoading(isLoading, text = '') { const loading = document.getElementById('mapLoading'); const loadingText = document.getElementById('mapLoadingText'); if (!loading) return; loading.hidden = !isLoading; if (loadingText && text) loadingText.textContent = text; } function bindPopup(feature, layer) { const props = feature.properties || {}; const html = Object.entries(props) .filter(([_, value]) => value !== null && value !== undefined && value !== '') .map(([key, value]) => `
${escapeHtml(key)}: ${escapeHtml(String(value))}
`) .join(''); layer.bindPopup(html || 'No properties'); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, c => ({ '&': '&', '<': '<', '>': '>', '\'': ''', '"': '"' }[c])); } async function loadStats() { const stats = await api('/api/stats'); const container = document.getElementById('stats'); const items = [ ['Sources', stats.sources], ['Active datasets', stats.active_datasets], ['GTFS routes', stats.gtfs_routes], ['GTFS stops', stats.gtfs_stops], ['OSM routes', stats.osm_routes], ['OSM stops/terminals', stats.osm_stops_terminals], ['Visual routes', stats.route_patterns || 0], ['Catalog entries', stats.source_catalog?.catalog_entries || 0], ['Seeded sources', stats.source_catalog?.seeded_ingestable_sources || 0], ['Matched', stats.match_summary?.matched_or_accepted || stats.matches?.matched || 0], ['Probable', stats.match_summary?.probable || 0], ['Weak', stats.match_summary?.weak || 0], ['Missing', stats.match_summary?.missing || 0], ['Coverage', `${stats.match_summary?.coverage_percent || 0}%`], ['In-scope coverage', `${stats.match_summary?.in_scope_coverage_percent || 0}%`], ]; container.innerHTML = items .map(([label, value]) => `
${escapeHtml(value)}${escapeHtml(label)}
`) .join(''); } async function loadQaSummary() { const container = document.getElementById('qaDashboard'); if (!container) return; try { const data = await api('/api/qa/summary'); renderQaSummary(data); } catch (err) { container.classList.remove('muted'); container.innerHTML = `

${escapeHtml(err.message)}

`; } } function renderQaSummary(data) { const container = document.getElementById('qaDashboard'); if (!container) return; const sections = data.sections || []; const decision = data.decision || {}; container.classList.remove('muted'); container.innerHTML = `
${escapeHtml(qaDecisionLabel(decision.deployment))} ${escapeHtml(decision.split_trigger || '')}
${sections.map(section => `

${escapeHtml(section.title || section.id || 'QA')}

${(section.items || []).map(item => `
${escapeHtml(item.value)} ${escapeHtml(item.label)}
`).join('')}
`).join('')} ${Array.isArray(data.next_actions) && data.next_actions.length ? `

Next actions

` : ''} `; } function qaDecisionLabel(value) { if (value === 'same_workbench_for_now') return 'Same workbench for now'; return value || 'Architecture undecided'; } async function loadGtfsHarmonizationInventory() { const container = document.getElementById('gtfsHarmonizationInventory'); if (!container) return; try { const data = await api('/api/harmonization/gtfs/inventory'); renderGtfsHarmonizationInventory(data); } catch (err) { container.classList.remove('muted'); container.innerHTML = `

${escapeHtml(err.message)}

`; } } function renderGtfsHarmonizationInventory(data) { const container = document.getElementById('gtfsHarmonizationInventory'); if (!container) return; const summary = data.summary || {}; const feeds = data.feeds || []; container.classList.remove('muted'); if (!feeds.length) { container.classList.add('muted'); container.innerHTML = 'No GTFS sources registered yet.'; return; } container.innerHTML = `
${[ ['Sources', summary.sources || 0], ['Active', summary.active_sources || 0], ['Ready', summary.ready || 0], ['Review', summary.needs_review || 0], ['Blocked', summary.blocked || 0], ].map(([label, value]) => `
${escapeHtml(formatCount(value))}${escapeHtml(label)}
`).join('')}
${feeds.map(renderGtfsFeedQaCard).join('')}
`; } function renderGtfsFeedQaCard(feed) { const source = feed.source || {}; const dataset = feed.active_dataset; const counts = feed.counts || {}; const service = feed.service || {}; const issues = feed.issues || []; return `
${escapeHtml(source.name || `Source #${source.id}`)} ${escapeHtml(gtfsQaStatusLabel(feed.qa_status))}
${escapeHtml([source.country || 'n/a', source.priority, source.license || 'unknown license'].filter(Boolean).join(' · '))}
${[ dataset ? `dataset #${dataset.id}` : 'no active dataset', `${formatCount(counts.routes || 0)} routes`, `${formatCount(counts.stops || 0)} stops`, `${formatCount(counts.stop_times || 0)} stop_times`, service.end_date ? `service to ${service.end_date}` : 'no service horizon', ].map(metric).join('')}
${issues.length ? `
${issues.slice(0, 3).map(renderCompactIssue).join('')}
` : '
No blocking feed QA issue detected.
'}
`; } function renderCompactIssue(issue) { return `${escapeHtml(issue.title || issue.id || '')}`; } async function showGtfsHarmonizationDetail(sourceId) { openOverlay('GTFS feed QA', '

Loading feed QA...

'); try { const data = await api(`/api/harmonization/gtfs/sources/${encodeURIComponent(sourceId)}`); document.getElementById('overlayTitle').textContent = `GTFS QA: ${data.source?.name || `source #${sourceId}`}`; document.getElementById('overlayContent').innerHTML = renderGtfsHarmonizationDetail(data); } catch (err) { document.getElementById('overlayContent').innerHTML = `

${escapeHtml(err.message)}

`; } } function renderGtfsHarmonizationDetail(feed) { const source = feed.source || {}; const dataset = feed.active_dataset; const issues = feed.issues || []; const review = source.qa_review || {}; return `
${escapeHtml(source.name || '')}
${escapeHtml([source.country || 'n/a', source.priority, source.source_basis].filter(Boolean).join(' · '))}
${escapeHtml(gtfsQaStatusLabel(feed.qa_status))}
${[ source.license ? `license ${source.license}` : 'unknown license', dataset ? `active dataset #${dataset.id}` : 'no active dataset', source.last_run_at ? `last import ${formatDateTime(source.last_run_at)}` : 'never imported', ].map(metric).join('')}
${escapeHtml(shorten(source.url || '', 140))}

Review Decision

${review.updated_at ? `last reviewed ${escapeHtml(formatDateTime(review.updated_at))}` : 'No QA review saved yet.'}
${issues.length ? `

Review Queue

${issues.map(issue => `
${escapeHtml(issue.title || issue.id || '')} ${escapeHtml(issue.detail || '')}
`).join('')}
` : '

Review Queue

No validation issue detected for this first-pass QA.

'} ${(feed.sections || []).map(section => `

${escapeHtml(section.title || section.id || '')}

${(section.items || []).map(item => `
${escapeHtml(formatMetricValue(item.value))} ${escapeHtml(item.label || '')}
`).join('')}
`).join('')}

Datasets

${(feed.datasets || []).map(renderGtfsHarmonizationDataset).join('') || '

No GTFS datasets.

'}
`; } function renderGtfsHarmonizationDataset(dataset) { const counts = dataset.counts || {}; return `
Dataset #${escapeHtml(String(dataset.id))} ${dataset.is_active ? 'active' : 'inactive'}
${[ `${formatCount(counts.routes || 0)} routes`, `${formatCount(counts.stops || 0)} stops`, `${formatCount(counts.trips || 0)} trips`, `${formatCount(counts.stop_times || 0)} stop_times`, `${formatCount(counts.shapes || 0)} shapes`, ].map(metric).join('')}
${escapeHtml(formatDateTime(dataset.created_at))} · ${escapeHtml(shorten(dataset.local_path || '', 110))}
`; } async function saveGtfsFeedReview(sourceId, payload, button) { const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Saving...'; } try { const data = await api(`/api/harmonization/gtfs/sources/${encodeURIComponent(sourceId)}/review`, { method: 'PATCH', body: JSON.stringify(payload) }); document.getElementById('overlayTitle').textContent = `GTFS QA: ${data.source?.name || `source #${sourceId}`}`; document.getElementById('overlayContent').innerHTML = renderGtfsHarmonizationDetail(data); updateMapStatus(`Saved GTFS QA review for ${data.source?.name || `source #${sourceId}`}.`); await Promise.all([loadSources(), loadGtfsHarmonizationInventory()]); } catch (err) { alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } function gtfsReviewPayloadFromForm(form) { const formData = new FormData(form); return { license: String(formData.get('license') || '').trim(), review_status: String(formData.get('review_status') || 'unreviewed'), review_note: String(formData.get('review_note') || '').trim(), enabled: formData.has('enabled') }; } function prepareRelatedGtfsSource(sourceId) { const source = allSources.find(item => String(item.id) === String(sourceId)); const form = document.getElementById('sourceForm'); const section = document.querySelector('[data-sidebar-section="add-gtfs-source"]'); if (!source || !form) return; if (section) section.open = true; form.elements.catalog_entry_id.value = source.catalog_entry_id || ''; form.elements.name.value = source.name ? `${source.name} alternative` : ''; form.elements.url.value = ''; form.elements.country.value = source.country || ''; form.elements.license.value = source.license || ''; if (form.elements.kind) form.elements.kind.value = 'gtfs'; closeOverlay(); form.elements.url.focus(); updateMapStatus(`Prepared related GTFS source for ${source.name || `source #${sourceId}`}.`); } function formatMetricValue(value) { return typeof value === 'number' ? formatCount(value) : String(value ?? ''); } function gtfsQaStatusLabel(status) { if (status === 'ready') return 'Ready'; if (status === 'needs_review') return 'Review'; if (status === 'blocked') return 'Blocked'; return status || 'Unknown'; } function gtfsQaStatusClass(status) { if (status === 'ready') return 'ok'; if (status === 'needs_review') return 'probable'; if (status === 'blocked') return 'error'; return ''; } function issueSeverityClass(severity) { if (severity === 'bad') return 'error'; if (severity === 'warn') return 'probable'; return 'ok'; } function statTone(label, value) { const numeric = parseFloat(String(value).replace('%', '')); if (label === 'Coverage' || label === 'In-scope coverage') { if (numeric >= 70) return 'good'; if (numeric >= 35) return 'warn'; return 'bad'; } if (label === 'Missing') return numeric > 0 ? 'bad' : 'good'; if (label === 'Weak') return numeric > 0 ? 'warn' : 'good'; if (label === 'Probable') return numeric > 0 ? 'warn' : 'info'; if (label === 'Matched') return numeric > 0 ? 'good' : 'info'; return 'info'; } async function loadJobs(options = {}) { const container = document.getElementById('jobs'); if (!container) return; const data = await api('/api/jobs?limit=8'); jobListRevision = data.revision || options.revisionHint || jobListRevision; jobListRefreshFailureShown = false; renderJobs(data.jobs || [], data.workers || []); } function scheduleJobListRefresh(delay) { if (jobListRefreshTimer) { window.clearTimeout(jobListRefreshTimer); } jobListRefreshTimer = window.setTimeout(checkJobListRevision, delay ?? JOB_LIST_REFRESH_MS); } function startJobListRefresh() { scheduleJobListRefresh(JOB_LIST_REFRESH_MS); } async function checkJobListRevision() { jobListRefreshTimer = undefined; const container = document.getElementById('jobs'); if (!container) return; if (jobListRefreshInFlight) { scheduleJobListRefresh(JOB_LIST_REFRESH_MS); return; } jobListRefreshInFlight = true; try { const path = jobListRevision ? `/api/jobs/revision?since=${encodeURIComponent(jobListRevision)}` : '/api/jobs/revision'; const revision = await api(path); if (!jobListRevision || revision.changed) { await loadJobs({ revisionHint: revision.revision }); } else { jobListRevision = revision.revision || jobListRevision; jobListRefreshFailureShown = false; } } catch (err) { if (!jobListRefreshFailureShown) { updateMapStatus(`Job refresh check failed: ${err.message}`); jobListRefreshFailureShown = true; } } finally { jobListRefreshInFlight = false; scheduleJobListRefresh(document.hidden ? JOB_LIST_REFRESH_HIDDEN_MS : JOB_LIST_REFRESH_MS); } } function renderJobs(jobs, workers = []) { const container = document.getElementById('jobs'); if (!container) return; const workerHtml = renderWorkerStatus(workers); const toolbarHtml = jobs.some(job => job.terminal) ? '
' : ''; if (!jobs.length) { container.classList.add('muted'); container.innerHTML = `${workerHtml}
No jobs yet.
`; return; } container.classList.remove('muted'); container.innerHTML = `${workerHtml}${toolbarHtml}${jobs.map(job => `
${escapeHtml(job.description || job.kind)} ${escapeHtml(job.status)}
priority ${Number(job.priority || 0)} · ${escapeHtml(formatDateTime(job.updated_at || job.created_at))}
${job.requested_action ? `
Requested: ${escapeHtml(job.requested_action)}
` : ''} ${job.lease_owner ? `
Worker: ${escapeHtml(job.lease_owner)}
` : ''} ${job.error ? `
${escapeHtml(job.error)}
` : ''} ${job.result && Object.keys(job.result).length ? `
${escapeHtml(shorten(JSON.stringify(job.result), 120))}
` : ''}
${jobActionButtons(job)}
`).join('')}`; } function jobStepDefinitions(job) { const kind = job.kind || ''; const commonStart = [ { label: 'Queued', events: ['queued'] }, { label: 'Claimed', events: ['claimed', 'started'] } ]; if (kind === 'route_layer_rebuild') { return [ ...commonStart, { label: 'Clear derived layer', events: ['route_layer_started', 'route_layer_cleared'] }, { label: 'Canonical GTFS stops', events: ['route_layer_canonical_stops'] }, { label: 'OSM stop links', events: ['route_layer_osm_stop_postgis_started', 'route_layer_osm_stop_batch', 'route_layer_osm_stop_postgis_completed', 'route_layer_osm_stop_links'] }, { label: 'OSM route candidates', events: ['route_layer_osm_route_batch', 'route_layer_osm_routes_indexed'] }, { label: 'GTFS pattern matching', events: ['route_layer_pattern_seeds', 'route_layer_pattern_batch'] }, { label: 'Materialize patterns', events: ['route_layer_patterns_materialized'] }, { label: 'Store links and stops', events: ['route_layer_pattern_links', 'route_layer_pattern_stop_batch'] }, { label: 'Finalize route layer', events: ['route_layer_patterns_completed', 'route_layer_completed', 'completed'] } ]; } if (kind === 'source_import') { return [ ...commonStart, { label: 'Import source', events: ['source_imported'] }, { label: 'Route matching', events: ['matching', 'matched'] }, { label: 'Route layer', events: ['rebuilding_route_layer', 'route_layer_rebuilt'] }, { label: 'Complete', events: ['completed'] } ]; } if (kind === 'source_delete' || kind === 'dataset_delete') { return [ ...commonStart, { label: kind === 'source_delete' ? 'Delete source' : 'Delete dataset', events: ['source_deleted', 'dataset_deleted'] }, { label: 'Prune cache', events: ['pruning_cache'] }, { label: 'Complete', events: ['completed'] } ]; } if (kind === 'route_matching') { return [ ...commonStart, { label: 'Match routes', events: ['matching', 'route_matching_batch', 'route_matching_completed'] }, { label: 'Complete', events: ['completed'] } ]; } if (kind === 'address_index_rebuild') { return [ ...commonStart, { label: 'Extract addresses', events: ['rebuilding', 'address_index_batch', 'address_index_rebuilt'] }, { label: 'Complete', events: ['completed'] } ]; } if (kind === 'osm_relabel') { return [ ...commonStart, { label: 'Relabel OSM features', events: ['relabeling', 'osm_relabel_batch', 'osm_relabel_completed'] }, { label: 'Route layer', events: ['rebuilding_route_layer', 'route_layer_rebuilt'] }, { label: 'Complete', events: ['completed'] } ]; } if (kind === 'maintenance') { return [ ...commonStart, { label: 'Run maintenance action', events: ['started'] }, { label: 'Complete', events: ['completed'] } ]; } return []; } function jobStepState(job, stepIndex, currentIndex) { if (job.status === 'failed' || job.status === 'cancelled') { if (stepIndex < currentIndex) return 'done'; if (stepIndex === currentIndex) return job.status; return 'pending'; } if (job.terminal) return stepIndex <= currentIndex ? 'done' : 'pending'; if (stepIndex < currentIndex) return 'done'; if (stepIndex === currentIndex) return 'current'; return 'pending'; } function latestStepEvent(events, step) { const eventTypes = new Set(step.events || []); for (let index = events.length - 1; index >= 0; index -= 1) { if (eventTypes.has(events[index].event_type)) return events[index]; } return null; } function renderJobSteps(job, events) { const steps = jobStepDefinitions(job); if (!steps.length) return ''; const currentIndex = Math.max( 0, steps.reduce((latest, step, index) => (latestStepEvent(events, step) ? index : latest), -1) ); return `
${steps.map((step, index) => { const event = latestStepEvent(events, step); const state = jobStepState(job, index, currentIndex); return `
${index + 1}
${escapeHtml(step.label)}
${event ? escapeHtml(jobEventProgressLabel(event)) : 'planned'}
`; }).join('')}
`; } function jobEventProgressLabel(event) { const current = event.progress_current; const total = event.progress_total; const progress = current !== null && current !== undefined ? `${formatCount(current)}${total ? ` / ${formatCount(total)}` : ''}` : ''; const parts = [progress, event.message, formatDateTime(event.created_at)].filter(Boolean); return parts.join(' · '); } function renderJobEventMetadata(event) { if (!event.metadata || !Object.keys(event.metadata).length) return ''; return `
${escapeHtml(JSON.stringify(event.metadata, null, 2))}
`; } function renderJobDetails(data, queueData = {}) { const job = data.job || {}; const events = data.events || []; const queueJobs = queueData.jobs || []; const latestEvent = events.length ? events[events.length - 1] : null; const progressMax = Number(job.progress_total || 1); const progressValue = Number(job.progress_current || 0); const resultHtml = job.result && Object.keys(job.result).length ? `

Result

${escapeHtml(JSON.stringify(job.result, null, 2))}
` : ''; return `
${escapeHtml(job.description || job.kind || `Job #${job.id}`)}
#${escapeHtml(String(job.id || ''))} · ${escapeHtml(job.kind || '')}
${escapeHtml(job.status || '')}
${escapeHtml(formatCount(progressValue))} / ${escapeHtml(formatCount(progressMax))}
${[ `priority ${Number(job.priority || 0)}`, job.lease_owner ? `worker ${job.lease_owner}` : null, job.created_at ? `created ${formatDateTime(job.created_at)}` : null, job.started_at ? `started ${formatDateTime(job.started_at)}` : null, job.updated_at ? `updated ${formatDateTime(job.updated_at)}` : null, job.finished_at ? `finished ${formatDateTime(job.finished_at)}` : null ].filter(Boolean).map(metric).join('')}
${job.requested_action ? `
Requested: ${escapeHtml(job.requested_action)}
` : ''} ${job.error ? `
${escapeHtml(job.error)}
` : ''} ${latestEvent ? `
Current: ${escapeHtml(jobEventProgressLabel(latestEvent))}
` : ''}

Planned / current / done

${renderJobSteps(job, events) || '

No phase template for this job kind; use the event log below.

'}

Queue snapshot

${queueJobs.length ? `
${queueJobs.map(item => `
${escapeHtml(item.status || '')} #${escapeHtml(String(item.id))} ${escapeHtml(shorten(item.description || item.kind || '', 88))} ${escapeHtml(formatDateTime(item.updated_at || item.created_at))}
`).join('')}
` : '

No queue rows returned.

'}

Events ${events.length}

${events.length ? events.map((event, index) => `
${index + 1}
${escapeHtml(event.event_type || 'event')} ${escapeHtml(formatDateTime(event.created_at))}
${escapeHtml(event.message || '')}
${escapeHtml(jobEventProgressLabel(event))}
${renderJobEventMetadata(event)}
`).join('') : '

No events yet.

'}
${resultHtml}
`; } async function showJobDetails(jobId) { activeJobDetailsId = String(jobId); openOverlay(`Job #${jobId}`, '

Loading job details...

'); await loadJobDetails(jobId); } async function loadJobDetails(jobId) { if (jobDetailsPollTimer) { window.clearTimeout(jobDetailsPollTimer); jobDetailsPollTimer = undefined; } try { const [details, queue] = await Promise.all([ api(`/api/jobs/${encodeURIComponent(jobId)}/events?limit=200`), api('/api/jobs?limit=20') ]); if (activeJobDetailsId !== String(jobId)) return; document.getElementById('overlayTitle').textContent = `Job #${jobId}`; document.getElementById('overlayContent').innerHTML = renderJobDetails(details, queue); if (!details.job?.terminal && !document.getElementById('overlay')?.hidden) { jobDetailsPollTimer = window.setTimeout(() => loadJobDetails(jobId), JOB_DETAILS_POLL_MS); } } catch (err) { if (activeJobDetailsId === String(jobId)) { document.getElementById('overlayContent').innerHTML = `

${escapeHtml(err.message)}

`; } } } function jobKindLabel(job) { if (job.kind === 'source_import') return 'Source import'; if (job.kind === 'source_delete') return 'Source delete'; if (job.kind === 'dataset_delete') return 'Dataset delete'; if (job.kind === 'maintenance') return job.description || 'Maintenance'; if (job.kind === 'route_layer_rebuild') return 'Route-layer rebuild'; if (job.kind === 'route_matching') return 'Route matching'; if (job.kind === 'osm_relabel') return 'OSM relabeling'; return job.description || job.kind || 'Job'; } function renderWorkerStatus(workers) { if (!workers.length) { return '
worker disabledSet QUEUE_WORKER_AUTOSTART=true to start workers with the server.
'; } return `
${workers.map(worker => `
${escapeHtml(worker.worker_id)} ${worker.running ? 'running' : 'stopped'} ${worker.pid ? `pid ${escapeHtml(String(worker.pid))}` : 'no pid'} · ${escapeHtml(shorten(worker.log_file || '', 80))}
`).join('')}
`; } function jobActionButtons(job) { const buttons = [``]; if (job.status === 'queued' || job.status === 'running') { buttons.push(``); buttons.push(``); } else if (job.status === 'paused') { buttons.push(``); buttons.push(``); } if (!job.terminal) { buttons.push(``); buttons.push(``); } else { if (job.status === 'failed' || job.status === 'cancelled') { buttons.push(``); } buttons.push(``); } return buttons.join(''); } async function handleJobAction(event) { const clearButton = event.target.closest('[data-jobs-clear-terminal]'); if (clearButton) { const originalText = clearButton.textContent; clearButton.disabled = true; clearButton.textContent = 'Clearing...'; try { const result = await api('/api/jobs/dismiss-terminal', { method: 'POST' }); updateMapStatus(`Dismissed ${Number(result.dismissed || 0)} finished jobs.`); await loadJobs(); } catch (err) { alert(err.message); } finally { clearButton.disabled = false; clearButton.textContent = originalText; } return; } const detailsButton = event.target.closest('[data-job-details]'); if (detailsButton) { showJobDetails(detailsButton.dataset.jobDetails).catch(err => alert(err.message)); return; } const button = event.target.closest('[data-job-action]'); if (!button) return; const action = button.dataset.jobAction; const jobId = button.dataset.jobId; const priority = Number(button.dataset.jobPriority || 0); let path = `/api/jobs/${jobId}/${action}`; let options = { method: 'POST' }; if (action === 'priority-up' || action === 'priority-down') { path = `/api/jobs/${jobId}/priority`; options.body = JSON.stringify({ priority: priority + (action === 'priority-up' ? 10 : -10) }); } else if (action === 'dismiss') { path = `/api/jobs/${jobId}/dismiss`; } else if (action === 'retry') { path = `/api/jobs/${jobId}/retry`; } if (action === 'stop' && !confirm(`Stop job #${jobId}?`)) return; const originalText = button.textContent; button.disabled = true; button.textContent = 'Working...'; try { const job = await api(path, options); updateMapStatus(action === 'dismiss' ? `Job #${job.id} dismissed.` : `Job #${job.id} ${job.status}.`); await Promise.all([loadJobs(), loadSources()]); if (action === 'retry') pollJob(job.id); } catch (err) { alert(err.message); } finally { button.disabled = false; button.textContent = originalText; } } async function queueRouteLayerBuild() { const button = document.getElementById('buildRouteLayerBtn'); const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Queued...'; } try { const job = await api('/api/jobs/route-layer-build', { method: 'POST' }); updateMapStatus(`Route-layer rebuild queued as job #${job.id}.`); await loadJobs(); pollJob(job.id); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function queueMatcherRun() { const button = document.getElementById('runMatchBtn'); const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Queued...'; } try { const job = await api('/api/jobs/match-run', { method: 'POST' }); updateMapStatus(`Route matching queued as job #${job.id}.`); await loadJobs(); pollJob(job.id); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function runAdminAction(action, button) { const status = document.getElementById('adminStatus'); const originalText = button?.textContent; const configs = { 'init-db': { path: '/api/jobs/admin/init-db', body: {} }, 'backfill-gtfs-shapes': { path: '/api/jobs/admin/backfill-gtfs-shapes', body: {} }, 'prune-cache-dry': { path: '/api/jobs/admin/prune-cache', body: { dry_run: true } }, 'prune-cache': { path: '/api/jobs/admin/prune-cache', body: { dry_run: false, confirm: 'PRUNE' }, confirmText: 'PRUNE' }, 'prune-inactive-dry': { path: '/api/jobs/admin/prune-inactive-datasets', body: { dry_run: true } }, 'prune-inactive': { path: '/api/jobs/admin/prune-inactive-datasets', body: { dry_run: false, confirm: 'PRUNE' }, confirmText: 'PRUNE' }, 'vacuum-db': { path: '/api/jobs/admin/vacuum-db', body: { confirm: 'VACUUM' }, confirmText: 'VACUUM' }, 'reset-db': { path: '/api/jobs/admin/reset-db', body: { confirm: 'RESET' }, confirmText: 'RESET' } }; const config = configs[action]; if (!config) return; if (config.confirmText) { const value = prompt(`Type ${config.confirmText} to continue`); if (value !== config.confirmText) return; } if (button) { button.disabled = true; button.textContent = 'Queuing...'; } if (status) { status.classList.remove('badge', 'error'); status.textContent = 'Queuing maintenance action...'; } try { const job = await api(config.path, { method: 'POST', body: JSON.stringify(config.body) }); if (status) status.textContent = `Queued job #${job.id}: ${job.description || job.kind}`; updateMapStatus(`Queued ${jobKindLabel(job)} as job #${job.id}.`); await loadJobs(); pollJob(job.id); } catch (err) { if (status) status.innerHTML = `${escapeHtml(err.message)}`; else alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function pollJob(jobId) { if (activeJobPollTimer) { window.clearTimeout(activeJobPollTimer); activeJobPollTimer = undefined; } try { const data = await api(`/api/jobs/${jobId}/events`); const job = data.job; await loadJobs(); const events = data.events || []; const latestEvent = events.length ? events[events.length - 1] : null; if (latestEvent?.message) { updateMapStatus(`Job #${job.id}: ${latestEvent.message}`); } if (job.terminal) { if (job.status === 'completed') { await refreshAll(); updateMapStatus(`${jobKindLabel(job)} completed as job #${job.id}.`); } else if (job.error) { updateMapStatus(`Job #${job.id} failed: ${job.error}`); } return; } activeJobPollTimer = window.setTimeout(() => pollJob(jobId), 2000); } catch (err) { updateMapStatus(`Job polling failed: ${err.message}`); } } async function loadSources() { allSources = await api('/api/sources'); setLayerGroupsFromSources(allSources); setJourneySources(allSources); renderSources(); renderMappingSources(); } function renderSources() { const container = document.getElementById('sources'); renderSourceList({ container, queryId: 'sourceSearch', kinds: ['gtfs'], emptyMessage: 'No matching GTFS sources.' }); } function renderMappingSources() { const container = document.getElementById('mappingSources'); renderSourceList({ container, queryId: 'mappingSourceSearch', kindFilterId: 'mappingSourceKindFilter', kinds: ['osm_geojson', 'osm_pbf', 'osm_diff'], emptyMessage: 'No matching map sources.' }); } function renderSourceList({ container, queryId, kindFilterId = '', kinds = [], emptyMessage = 'No matching sources.' }) { if (!container) return; const query = (document.getElementById(queryId)?.value || '').trim().toLowerCase(); const selectedKind = kindFilterId ? (document.getElementById(kindFilterId)?.value || '') : ''; const allowedKinds = new Set(kinds); const sources = allSources.filter(source => { const text = [ source.name, source.kind, source.country, source.url, source.priority, source.mode_scope, source.source_basis, source.notes ].filter(Boolean).join(' ').toLowerCase(); return (!allowedKinds.size || allowedKinds.has(source.kind)) && (!selectedKind || source.kind === selectedKind) && (!query || text.includes(query)); }); if (!sources.length) { container.innerHTML = `

${escapeHtml(emptyMessage)}

`; return; } container.innerHTML = sources.map(sourceCard).join(''); } function sourceCard(source) { return `
${escapeHtml(source.name)} ${escapeHtml(sourceStatusLabel(source))} #${source.id}
${escapeHtml(source.kind)} · ${escapeHtml(source.country || 'n/a')} · ${escapeHtml(source.license || 'unknown license')}
${source.priority || source.mode_scope ? `
${[ source.priority ? `priority ${source.priority}` : null, source.mode_scope || null ].filter(Boolean).map(metric).join('')}
` : ''} ${source.source_basis ? `
${escapeHtml(source.source_basis)}
` : ''} ${source.notes ? `
${escapeHtml(shorten(source.notes, 120))}
` : ''}
${escapeHtml(shorten(source.url, 74))}
${sourceMetrics(source).map(metric).join('')}
${sourceReadinessWarning(source)} ${source.last_error ? `
${escapeHtml(source.last_error)}
` : ''} ${sourceJobRow(source.active_job)} ${sourceUpdateCheckRow(source.latest_update_check)}
Datasets (${source.datasets.length})
${source.datasets.map(dataset => datasetRow(dataset)).join('')}
`; } function sourceStatusLabel(source) { return source.active_job ? source.active_job.status : source.status; } function sourceStatusBadge(source) { return source.active_job ? source.active_job.status : source.status; } function sourceJobRow(job) { if (!job) return ''; const progress = `${Number(job.progress_current || 0)}/${Number(job.progress_total || 0)}`; return `
job #${escapeHtml(String(job.id))} ${escapeHtml(job.status)} ${escapeHtml(job.description || job.kind)} · ${escapeHtml(progress)} · ${escapeHtml(formatDateTime(job.updated_at || job.created_at))}
`; } function hasAnyActiveDataset(source) { return (source.datasets || []).some(dataset => dataset.is_active); } function sourceReadinessWarning(source) { if (source.kind === 'osm_pbf' && !hasActiveOsmDataset(source)) { return '
No active extracted OSM visual dataset yet. Import this source before matching or building the route layer.
'; } if (source.kind === 'gtfs' && !hasActiveGtfsDataset(source)) { return '
No active GTFS timetable dataset yet. Import this source before routing or matching.
'; } if (source.kind === 'osm_geojson' && !hasActiveOsmDataset(source)) { return '
No active OSM visual dataset yet. Import this source before matching or displaying routes.
'; } return ''; } function sourceUpdateCheckRow(check) { if (!check) return '
No online update check yet.
'; const status = check.status === 'checked' ? check.update_available ? 'update available' : 'up to date' : 'check failed'; const badgeClass = check.status === 'checked' ? check.update_available ? 'probable' : 'ok' : 'error'; const details = [ check.reason, check.etag ? `etag ${check.etag}` : null, check.last_modified ? `modified ${check.last_modified}` : null, check.content_length ? `${formatCount(check.content_length)} bytes` : null, check.local_size ? `${formatCount(check.local_size)} bytes` : null, ].filter(Boolean).join(' · '); return `
${escapeHtml(status)} ${escapeHtml(formatDateTime(check.checked_at))}${details ? ` · ${escapeHtml(shorten(details, 100))}` : ''}
`; } function sourceMetrics(source) { const stats = source.stats || {}; const matches = stats.match_counts || {}; return [ `${stats.active_datasets || 0}/${stats.datasets || 0} active`, `${stats.routes || 0} routes`, `${stats.stops || 0} stops`, `${stats.stop_times || 0} stop_times`, `${matches.matched || 0} matched`, `${matches.probable || 0} probable`, `${matches.weak || 0} weak`, `${matches.missing || 0} missing`, ]; } function datasetRow(dataset) { const stats = dataset.stats || {}; const meta = dataset.metadata || {}; const counters = [ `${dataset.kind}`, dataset.is_active ? 'active' : 'inactive', stats.routes !== undefined ? `${stats.routes} routes` : null, stats.stops !== undefined ? `${stats.stops} stops` : null, stats.features !== undefined ? `${stats.features} features` : null, stats.trips !== undefined ? `${stats.trips} trips` : null, stats.stop_times !== undefined ? `${stats.stop_times} stop_times` : null, meta.stop_times_seen ? `${meta.stop_times_seen} stop_times seen` : null, ].filter(Boolean); return `
Dataset #${dataset.id} ${dataset.is_active ? 'active' : 'inactive'}
${counters.map(metric).join('')}
${escapeHtml(shorten(dataset.local_path || '', 80))}
${sourceJobRow(dataset.active_job)}
`; } function metric(value) { return `${escapeHtml(String(value))}`; } function formatCount(value) { const number = Number(value); return Number.isFinite(number) ? number.toLocaleString() : String(value || ''); } function formatDateTime(value) { if (!value) return 'never'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(); } async function searchDatasets() { const queryInput = document.getElementById('datasetSearchQuery'); const results = document.getElementById('datasetSearchResults'); const query = (queryInput?.value || '').trim(); if (!results) return; const sequence = ++datasetSearchSequence; if (datasetSearchAbortController) { datasetSearchAbortController.abort(); } datasetSearchAbortController = undefined; if (!query) { results.innerHTML = 'Search all imported datasets by label, route ID, and route-layer reference.'; results.classList.add('muted'); return; } const params = new URLSearchParams({ q: query, limit: '80' }); if (document.getElementById('datasetSearchActiveOnly')?.checked) params.set('active_only', 'true'); const controller = new AbortController(); datasetSearchAbortController = controller; results.classList.remove('muted'); results.innerHTML = '

Searching datasets...

'; try { const data = await api(`/api/datasets/search?${params.toString()}`, { signal: controller.signal }); if (sequence !== datasetSearchSequence) return; renderDatasetSearchResults(data); } catch (err) { if (err.name === 'AbortError' || sequence !== datasetSearchSequence) return; results.innerHTML = `

${escapeHtml(err.message)}

`; } finally { if (datasetSearchAbortController === controller) { datasetSearchAbortController = undefined; } } } function renderDatasetSearchResults(data) { const results = document.getElementById('datasetSearchResults'); if (!results) return; const gtfs = data.gtfs_routes || []; const osm = data.osm_routes || []; const patterns = data.route_patterns || []; if (!gtfs.length && !osm.length && !patterns.length) { results.innerHTML = '

No dataset entries found.

'; return; } results.innerHTML = ` ${datasetResultSection('GTFS timetable routes', gtfs, renderGtfsSearchHit)} ${datasetResultSection('OSM visual routes', osm, renderOsmSearchHit)} ${datasetResultSection('Extracted route-layer patterns', patterns, renderRoutePatternSearchHit)} `; } function datasetResultSection(title, rows, renderer) { if (!rows.length) return ''; return `

${escapeHtml(title)} ${rows.length}

${rows.map(renderer).join('')}
`; } function renderGtfsSearchHit(hit) { const route = hit.route || {}; const timetable = hit.timetable || {}; return `
${escapeHtml(route.ref || route.route_id || '')} ${geometryBadge(hit.geometry)} ${hit.dataset?.is_active ? 'active' : 'inactive'}
${escapeHtml([route.mode, route.name, route.operator].filter(Boolean).join(' · '))}
${escapeHtml(hit.source?.name || '')} · dataset ${escapeHtml(String(hit.dataset?.id || ''))}
${[ `${formatCount(timetable.trips || 0)} trips`, `${formatCount(timetable.stop_times || 0)} stop_times`, `${formatCount(timetable.shapes || 0)} shapes`, ].map(metric).join('')}
`; } function renderOsmSearchHit(hit) { const osm = hit.osm || {}; return `
${escapeHtml(osm.ref || osm.name || osm.osm_id || '')} ${geometryBadge(hit.geometry)} ${hit.dataset?.is_active ? 'active' : 'inactive'}
${escapeHtml([osm.mode, osm.name, osm.operator || osm.network].filter(Boolean).join(' · '))}
${escapeHtml(hit.source?.name || '')} · ${escapeHtml(osm.osm_type || '')} ${escapeHtml(osm.osm_id || '')} · dataset ${escapeHtml(String(hit.dataset?.id || ''))}
`; } function renderRoutePatternSearchHit(hit) { return `
${escapeHtml(hit.ref || hit.name || `pattern ${hit.id}`)} ${geometryBadge(hit.geometry)} ${escapeHtml(hit.source_kind || '')}
${escapeHtml([hit.mode, hit.name, hit.status].filter(Boolean).join(' · '))}
pattern ${escapeHtml(String(hit.id))} · confidence ${Number(hit.confidence || 0).toFixed(1)}
`; } function searchResultAttributes(type, id, geometry) { const hasGeometry = Boolean(geometry?.present); const key = `${type}:${id}`; const classes = ['dataset-result-row', hasGeometry ? 'clickable' : 'no-geometry']; if (selectedDatasetSearchKey === key) classes.push('selected'); const attrs = [`class="${classes.join(' ')}"`]; if (hasGeometry && id !== undefined && id !== null) { attrs.push('role="button"', 'tabindex="0"'); attrs.push(`data-search-feature-type="${escapeHtml(type)}"`); attrs.push(`data-search-feature-id="${escapeHtml(String(id))}"`); } else { attrs.push('aria-disabled="true"'); } return attrs.join(' '); } function geometryBadge(geometry) { const hasGeometry = Boolean(geometry?.present); return ` ${hasGeometry ? 'geometry' : 'no geometry'} `; } async function loadSourceCatalog() { const params = new URLSearchParams({ limit: '80' }); const query = (document.getElementById('sourceCatalogSearch')?.value || '').trim(); const country = (document.getElementById('sourceCatalogCountry')?.value || '').trim(); const priority = document.getElementById('sourceCatalogPriority')?.value || ''; if (query) params.set('q', query); if (country) params.set('country', country); if (priority) params.set('priority', priority); const data = await api(`/api/source-catalog?${params.toString()}`); sourceCatalogEntries = data.entries || []; sourceCatalogSummary = data.summary || {}; renderSourceCatalog(); } function renderSourceCatalog() { const summary = document.getElementById('sourceCatalogSummary'); if (summary) { const byPriority = sourceCatalogSummary.catalog_by_priority || {}; const priorities = Object.entries(byPriority) .sort(([left], [right]) => left.localeCompare(right)) .map(([priority, count]) => `${priority}: ${count}`) .join(' · '); summary.textContent = `${sourceCatalogSummary.catalog_entries || 0} backlog entries${priorities ? ` · ${priorities}` : ''}`; } const container = document.getElementById('sourceCatalog'); if (!container) return; if (!sourceCatalogEntries.length) { container.innerHTML = '

No catalog entries loaded.

'; return; } container.innerHTML = sourceCatalogEntries.map(sourceCatalogEntryCard).join(''); } function sourceCatalogEntryCard(entry) { const kind = inferCatalogSourceKind(entry); const actionLabel = kind && kind.startsWith('osm_') ? 'Use as map source' : 'Use as GTFS source'; return `
${escapeHtml(entry.source_name)} ${escapeHtml(entry.priority || 'n/a')} ${escapeHtml(entry.country_code || entry.geography || '')}
${[ entry.source_category, entry.formats_apis, entry.linked_source_count ? `${entry.linked_source_count} source links` : null ].filter(Boolean).map(value => metric(shorten(value, 34))).join('')}
${escapeHtml(shorten(entry.coverage_notes || '', 150))}
${entry.geometry_notes ? `
Geometry QA: ${escapeHtml(shorten(entry.geometry_notes, 150))}
` : ''} ${entry.next_pipeline_action ? `
Next: ${escapeHtml(shorten(entry.next_pipeline_action, 150))}
` : ''} ${entry.source_url ? `
${escapeHtml(shorten(entry.source_url, 84))}
` : ''} ${entry.source_url ? `
` : ''}
`; } function fillSourceFormFromCatalog(entryId) { const entry = sourceCatalogEntries.find(item => String(item.id) === String(entryId)); if (!entry) return; const kind = inferCatalogSourceKind(entry); const isMapSource = kind && kind.startsWith('osm_'); const form = document.getElementById(isMapSource ? 'mappingSourceForm' : 'sourceForm'); if (!form) return; const section = document.querySelector(`[data-sidebar-section="${isMapSource ? 'add-map-source' : 'add-gtfs-source'}"]`); if (section) section.open = true; form.elements.catalog_entry_id.value = entry.id; form.elements.name.value = entry.source_name || ''; form.elements.url.value = entry.source_url || ''; form.elements.country.value = catalogCountry(entry); form.elements.license.value = entry.access_license_notes || ''; if (form.elements.kind) form.elements.kind.value = isMapSource ? kind : 'gtfs'; form.elements.url.focus(); form.elements.url.select(); updateMapStatus(`Prepared source from catalog: ${entry.source_name || entry.id}`); } function inferCatalogSourceKind(entry) { const url = String(entry.source_url || '').toLowerCase(); const text = [ entry.formats_apis, entry.source_name, entry.source_category, entry.coverage_notes, entry.next_pipeline_action, url ].filter(Boolean).join(' ').toLowerCase(); if (text.includes('osm diff') || text.includes('osc.gz')) return 'osm_diff'; if (text.includes('osm pbf') || /\.osm\.pbf($|[?#])/.test(url) || /\.pbf($|[?#])/.test(url)) return 'osm_pbf'; if (text.includes('geojson') || /\.geojson($|[?#])/.test(url)) return 'osm_geojson'; if (text.includes('gtfs') || /\.zip($|[?#])/.test(url)) return 'gtfs'; return ''; } function catalogCountry(entry) { const value = String(entry.country_code || '').trim(); return /^[A-Za-z]{2}$/.test(value) ? value.toUpperCase() : ''; } async function importSourceCatalog() { const button = document.getElementById('importSourceCatalogBtn'); const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Queuing...'; } try { const job = await api('/api/jobs/source-catalog/import', { method: 'POST' }); updateMapStatus(`Queued source catalog import as job #${job.id}.`); await loadJobs(); pollJob(job.id); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function importIngestableSources() { const button = document.getElementById('importIngestableSourcesBtn'); const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Queuing...'; } try { const job = await api('/api/jobs/source-catalog/import-ingestable', { method: 'POST' }); updateMapStatus(`Queued ingestable source import as job #${job.id}.`); await loadJobs(); pollJob(job.id); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function searchGeofabrik() { const container = document.getElementById('geofabrikResults'); if (!container) return; const query = (document.getElementById('geofabrikSearch')?.value || '').trim(); const params = new URLSearchParams({ limit: '80' }); if (query) params.set('q', query); container.classList.remove('muted'); container.innerHTML = '

Loading Geofabrik catalog...

'; try { const data = await api(`/api/geofabrik/catalog?${params.toString()}`); renderGeofabrikResults(data.entries || []); } catch (err) { container.innerHTML = `

${escapeHtml(err.message)}

`; } } function renderGeofabrikResults(entries) { const container = document.getElementById('geofabrikResults'); if (!container) return; if (!entries.length) { container.classList.add('muted'); container.innerHTML = 'No Geofabrik extracts found.'; return; } container.classList.remove('muted'); container.innerHTML = entries.map(entry => `
${escapeHtml(entry.name)} ${escapeHtml(entry.id)} ${escapeHtml(entry.parent || 'root')}
${[ ...(entry.country_codes || []), entry.updates_url ? 'diffs available' : null, entry.pbf_url ? 'PBF' : null ].filter(Boolean).map(metric).join('')}
${escapeHtml(shorten(entry.pbf_url || '', 92))}
${entry.updates_url ? `
Updates: ${escapeHtml(shorten(entry.updates_url, 80))}
` : ''}
`).join(''); } async function createGeofabrikSource(geofabrikId, runImport, button) { const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = runImport ? 'Queueing...' : 'Adding...'; } try { const payload = { geofabrik_id: geofabrikId, import_updates: Boolean(document.getElementById('geofabrikDiffSource')?.checked), run_import: runImport, run_match: true, build_route_layer: true }; const result = await api('/api/geofabrik/sources', { method: 'POST', body: JSON.stringify(payload) }); await Promise.all([loadSources(), loadJobs(), loadStats()]); if (result.job) { pollJob(result.job.id); } updateMapStatus(runImport ? `Queued Geofabrik import job #${result.job?.id}.` : `Added source #${result.source?.id}.`); } catch (err) { alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function loadMatches() { const status = document.getElementById('matchStatusFilter').value; const matches = await api(`/api/matches${status ? `?status=${encodeURIComponent(status)}` : ''}`); const container = document.getElementById('matches'); if (!matches.length) { container.innerHTML = '

No matches yet. Run the matcher.

'; return; } container.innerHTML = matches.map(match => `
${escapeHtml(match.gtfs.ref || match.gtfs.route_id)} ${escapeHtml(match.status)} ${Number(match.confidence).toFixed(1)}
GTFS: ${escapeHtml([match.gtfs.mode, match.gtfs.name, match.gtfs.operator].filter(Boolean).join(' · '))}
OSM: ${match.osm ? escapeHtml([match.osm.mode, match.osm.ref, match.osm.name, match.osm.operator || match.osm.network].filter(Boolean).join(' · ')) : 'none'}
${escapeHtml(JSON.stringify(match.reasons))}
`).join(''); container.querySelectorAll('[data-accept]').forEach(btn => btn.addEventListener('click', () => updateMatch(btn.dataset.accept, 'accept'))); container.querySelectorAll('[data-reject]').forEach(btn => btn.addEventListener('click', () => updateMatch(btn.dataset.reject, 'reject'))); container.querySelectorAll('[data-candidates]').forEach(btn => btn.addEventListener('click', () => showCandidates(btn.dataset.candidates))); } function setJourneySources(sources) { const snapshot = document.getElementById('journeyTransitSnapshot'); if (!snapshot) return; const gtfsSources = sources.filter(hasActiveGtfsDataset); const activeDatasetCount = gtfsSources.reduce((count, source) => ( count + (source.datasets || []).filter(dataset => dataset.kind === 'gtfs' && dataset.is_active).length ), 0); if (!gtfsSources.length) { snapshot.classList.add('muted'); snapshot.innerHTML = 'Transit snapshotNo active GTFS feed imported.'; return; } snapshot.classList.remove('muted'); snapshot.innerHTML = ` Harmonized transit snapshot ${formatCount(gtfsSources.length)} active GTFS source${gtfsSources.length === 1 ? '' : 's'} · ${formatCount(activeDatasetCount)} active timetable dataset${activeDatasetCount === 1 ? '' : 's'} `; } function journeyRoleElements(role) { const title = role === 'from' ? 'From' : role === 'to' ? 'To' : 'Via'; return { query: document.getElementById(`journey${title}Query`), selected: document.getElementById(`journey${title}Stop`), suggestions: document.getElementById(`journey${title}Suggestions`) }; } function scheduleJourneyStopSearch(role) { stopActiveJourneySearch().catch(err => console.warn(err)); const { query, selected, suggestions } = journeyRoleElements(role); if (selected) selected.value = ''; if (query) { query.dataset.selectedLabel = ''; updateJourneySelectionMarker(query, ''); } if (journeyStopAbortControllers[role]) { journeyStopAbortControllers[role].abort(); journeyStopAbortControllers[role] = undefined; } journeyStopSearchSequence[role] = (journeyStopSearchSequence[role] || 0) + 1; window.clearTimeout(journeySearchTimers[role]); const search = query?.value.trim() || ''; if (suggestions) { suggestions.innerHTML = search.length >= 2 ? '
Searching...
' : ''; } const sequence = journeyStopSearchSequence[role]; journeySearchTimers[role] = window.setTimeout(() => loadJourneyStops(role, sequence), JOURNEY_STOP_SEARCH_DEBOUNCE_MS); } function coordinateSuggestionHtml(role, coordinate) { return ` `; } async function loadJourneyStops(role, sequence) { const requestSequence = sequence ?? ((journeyStopSearchSequence[role] || 0) + 1); journeyStopSearchSequence[role] = requestSequence; const { query, suggestions } = journeyRoleElements(role); if (!query || !suggestions) return; const search = query.value.trim(); if (search.length < 2) { suggestions.innerHTML = ''; return; } const coordinate = parseJourneyCoordinateInput(search); if (coordinate) { suggestions.innerHTML = coordinateSuggestionHtml(role, coordinate); return; } if (journeyStopAbortControllers[role]) { journeyStopAbortControllers[role].abort(); } const controller = new AbortController(); journeyStopAbortControllers[role] = controller; const params = new URLSearchParams({ limit: '12', q: search }); if (map) params.set('bbox', currentBbox()); try { const data = await api(`/api/journey/stops?${params.toString()}`, { signal: controller.signal }); if (journeyStopAbortControllers[role] !== controller) return; if (journeyStopSearchSequence[role] !== requestSequence || query.value.trim() !== search) return; const stops = data.stops || []; if (data.timed_out && !stops.length) { suggestions.innerHTML = `
${escapeHtml(data.message || 'Search timed out')}
`; return; } suggestions.innerHTML = stops.length ? `${data.timed_out ? `
${escapeHtml(data.message || 'Search timed out')}
` : ''}${stops.map(stop => ` `).join('')}` : '
No stops or addresses found
'; } catch (err) { if (err.name === 'AbortError') return; suggestions.innerHTML = `
${escapeHtml(err.message)}
`; } finally { if (journeyStopAbortControllers[role] === controller) { journeyStopAbortControllers[role] = undefined; } } } function stopLabel(stop) { return shorten(stop.display_name || stop.name || stop.stop_id || '', 92); } function suggestionIcon(stop) { if (stop.kind === 'address' || stop.kind === 'coordinate') { return ''; } return ''; } function stopMetaText(stop) { if (stop.kind === 'address') { const parts = [stop.source_name || 'OSM address', stop.approximate ? approximateAddressLabel(stop) : stopFoldLabel(stop)]; return parts.filter(Boolean).join(' · '); } return [stop.stop_id, stop.source_name || '', stopFoldLabel(stop)].filter(Boolean).join(' · '); } function stopFoldLabel(stop) { if (stop.kind === 'address') return 'address'; const count = Number(stop.grouped_stop_count || 1); return count > 1 ? `station group · ${count} linked stops` : 'single scheduled stop'; } function approximateAddressLabel(stop) { const count = Number(stop.folded_address_count || 0); return count > 1 ? `street address · ${count} house numbers` : 'street address'; } function selectJourneyStop(role, stopId, label) { const { query, selected, suggestions } = journeyRoleElements(role); if (selected) selected.value = stopId; if (query) { query.value = label; query.dataset.selectedLabel = label; updateJourneySelectionMarker(query, stopId); } if (suggestions) suggestions.innerHTML = ''; } function updateJourneySelectionMarker(input, selectedId) { if (!input) return; input.classList.remove('journey-selected-location', 'journey-selected-address', 'journey-selected-stop'); const kind = journeySelectionKind(selectedId); if (!kind) return; input.classList.add('journey-selected-location', kind === 'address' ? 'journey-selected-address' : 'journey-selected-stop'); } function journeySelectionKind(selectedId) { const token = String(selectedId || ''); if (!token) return ''; return token.startsWith('address:') || token.startsWith('address-point:') || token.startsWith('coord:') ? 'address' : 'stop'; } function showJourneyContextMenu(event) { if (!map || !event?.latlng) return; if (event.originalEvent) { if (event.originalEvent._journeyContextHandled) return; event.originalEvent._journeyContextHandled = true; L.DomEvent.stop(event.originalEvent); } openJourneyContextMenu(event.latlng); } function showJourneyContainerContextMenu(event) { if (!map || !event) return; if (event._journeyContextHandled) return; const target = event.target; if (target instanceof Element && target.closest('.leaflet-popup, .leaflet-control, button, input, select, textarea, a')) { return; } event._journeyContextHandled = true; L.DomEvent.stop(event); openJourneyContextMenu(map.mouseEventToLatLng(event)); } function openJourneyContextMenu(latlng) { if (!map || !latlng) return; const popup = L.popup({ className: 'journey-context-popup', closeButton: true, autoPanPadding: [12, 12] }) .setLatLng(latlng) .openOn(map); journeyContextPopup = popup; renderJourneyContextPopup(popup, latlng, { loading: true }); loadJourneyContextCandidates(popup, latlng); } function coordinateJourneyToken(latlng) { return coordinateJourneyTokenFromValues(latlng.lat, latlng.lng); } function coordinateJourneyTokenFromValues(lat, lon) { return `coord:${Number(lat).toFixed(7)}:${Number(lon).toFixed(7)}`; } function coordinateJourneyLabel(latlng) { return coordinateJourneyLabelFromValues(latlng.lat, latlng.lng); } function coordinateJourneyLabelFromValues(lat, lon) { return `Map point ${Number(lat).toFixed(5)}, ${Number(lon).toFixed(5)}`; } function parseJourneyCoordinateInput(value) { const text = String(value || '').trim(); if (!text) return null; let match = text.match(/^coord:\s*(-?\d+(?:\.\d+)?)\s*:\s*(-?\d+(?:\.\d+)?)$/i); if (!match) { match = text.match(/^(-?\d+(?:\.\d+)?)\s*(?:,|;|\s)\s*(-?\d+(?:\.\d+)?)$/); } if (!match) return null; const lat = Number(match[1]); const lon = Number(match[2]); if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; return { id: coordinateJourneyTokenFromValues(lat, lon), label: coordinateJourneyLabelFromValues(lat, lon), lat, lon }; } async function loadJourneyContextCandidates(popup, latlng) { try { const params = new URLSearchParams({ lat: Number(latlng.lat).toFixed(7), lon: Number(latlng.lng).toFixed(7), limit: '1', stop_radius_m: String(mapClickStopRadiusMeters(latlng)) }); const data = await api(`/api/journey/nearest-location?${params.toString()}`); if (journeyContextPopup !== popup) return; renderJourneyContextPopup(popup, latlng, { location: data.location || null, selectionKind: data.selection_kind || '', resolved: true, message: data.message || '' }); } catch (err) { if (journeyContextPopup !== popup) return; renderJourneyContextPopup(popup, latlng, { errorMessage: err.message }); } } function mapClickStopRadiusMeters(latlng) { const zoom = map?.getZoom?.() ?? 14; const metersPerPixel = 40075016.686 * Math.cos(Number(latlng.lat) * Math.PI / 180) / Math.pow(2, zoom + 8); return Math.round(Math.max(14, Math.min(100, metersPerPixel * 24))); } function renderJourneyContextPopup(popup, latlng, { location = null, selectionKind = '', loading = false, resolved = false, errorMessage = '', message = '' } = {}) { const entry = journeyContextEntry(latlng, location, selectionKind, errorMessage); const status = loading ? '
Resolving nearest stop or address...
' : message && entry?.kind === 'coordinate' ? `
${escapeHtml(message)}
` : errorMessage && entry?.kind === 'coordinate' ? `
${escapeHtml(errorMessage)}
` : resolved && entry?.kind === 'coordinate' ? '
No nearby stop or address found.
' : ''; const menu = document.createElement('div'); menu.className = 'journey-context-menu'; menu.innerHTML = ` ${entry ? `
${suggestionIcon({ kind: entry.kind })} ${escapeHtml(entry.title)} ${escapeHtml(entry.meta)}
` : ''} ${status} `; if (window.L?.DomEvent) { L.DomEvent.disableClickPropagation(menu); L.DomEvent.disableScrollPropagation(menu); } menu.addEventListener('click', event => { const target = event.target instanceof Element ? event.target : event.target?.parentElement; const button = target?.closest('[data-context-role]'); if (!button || !menu.contains(button)) return; event.preventDefault(); event.stopPropagation(); selectJourneyContextLocation(button.dataset.contextRole, button.dataset.contextId, button.dataset.contextLabel, popup); }); popup.setContent(menu); } function journeyContextEntry(latlng, location, selectionKind = '', errorMessage = '') { if (location?.id) { return contextEntryForLocation(location, selectionKind); } return coordinateContextEntry(latlng, errorMessage); } function coordinateContextEntry(latlng, errorMessage = '') { const token = coordinateJourneyToken(latlng); return { id: token, kind: 'coordinate', title: coordinateJourneyLabel(latlng), label: coordinateJourneyLabel(latlng), meta: errorMessage ? `${token} · coordinate fallback` : token }; } function contextEntryForLocation(location, selectionKind = '') { const label = stopLabel(location); const distance = Number(location.distance_m); const distanceLabel = Number.isFinite(distance) ? `${Math.round(distance)} m` : ''; const kindLabel = location.kind === 'address' ? 'address' : 'stop/station'; const reason = location.selection_reason === 'address_polygon' ? 'clicked building/address' : location.selection_reason === 'address_bbox' ? 'address envelope' : selectionKind === 'stop' ? 'near clicked stop' : kindLabel; const meta = [ String(location.id), reason, distanceLabel, location.source_name || '' ].filter(Boolean).join(' · '); return { id: String(location.id), kind: location.kind, title: label, label, meta, }; } function selectJourneyContextLocation(role, stopId, label, popup) { if (!role || !stopId || !label) return; selectJourneyStop(role, stopId, label); stopActiveJourneySearch().catch(err => console.warn(err)); lastJourneyResponse = undefined; lastJourneyDrawSignature = undefined; clearJourneyLayer(); map.closePopup(popup); updateMapStatus(`${role === 'from' ? 'From' : 'To'}: ${label}`); } function swapJourneyEndpoints() { stopActiveJourneySearch().catch(err => console.warn(err)); const from = journeyRoleElements('from'); const to = journeyRoleElements('to'); const fromState = journeyEndpointState(from); const toState = journeyEndpointState(to); setJourneyEndpointState(from, toState); setJourneyEndpointState(to, fromState); lastJourneyResponse = undefined; lastJourneyDrawSignature = undefined; const results = document.getElementById('journeyResults'); if (results) results.innerHTML = ''; clearJourneyLayer(); [from.suggestions, to.suggestions].forEach(suggestions => { if (suggestions) suggestions.innerHTML = ''; }); } function journeyEndpointState(elements) { return { query: elements.query?.value || '', selected: elements.selected?.value || '', selectedLabel: elements.query?.dataset.selectedLabel || '' }; } function setJourneyEndpointState(elements, state) { if (elements.query) { elements.query.value = state.query; elements.query.dataset.selectedLabel = state.selectedLabel; updateJourneySelectionMarker(elements.query, state.selected); } if (elements.selected) elements.selected.value = state.selected; } function journeyStopSelection(role, { required = false } = {}) { const { query, selected } = journeyRoleElements(role); const visibleText = (query?.value || '').trim(); const selectedId = selected?.value || ''; const selectedLabel = (query?.dataset.selectedLabel || '').trim(); const roleLabel = role === 'from' ? 'start' : role === 'to' ? 'destination' : 'via stop'; if (!visibleText) { if (selected) selected.value = ''; updateJourneySelectionMarker(query, ''); if (required) throw new Error(`Select a ${roleLabel}.`); return ''; } if (!selectedId || !selectedLabel || selectedLabel !== visibleText) { const coordinate = parseJourneyCoordinateInput(visibleText); if (coordinate) { selectJourneyStop(role, coordinate.id, coordinate.label); return coordinate.id; } if (selected) selected.value = ''; updateJourneySelectionMarker(query, ''); if (required) throw new Error(`Choose the ${roleLabel} from the suggestions.`); throw new Error('Choose the via stop from the suggestions or clear it.'); } return selectedId; } async function searchJourney() { await stopActiveJourneySearch(); lastJourneyDrawSignature = undefined; const fromStopId = journeyStopSelection('from', { required: true }); const toStopId = journeyStopSelection('to', { required: true }); const payload = { from_stop_id: fromStopId, to_stop_id: toStopId, departure: document.getElementById('journeyDeparture').value || '08:00', mode: journeyMode(), direct_only: Boolean(document.getElementById('journeyDirectOnly')?.checked), ranking: document.getElementById('journeyRanking')?.value || 'recommended', transfer_seconds: journeyTransferSeconds(), limit: 5 }; const serviceDate = document.getElementById('journeyServiceDate')?.value || ''; if (serviceDate) payload.service_date = serviceDate; const viaStopId = journeyStopSelection('via'); if (viaStopId && payload.mode === 'transit') payload.via_stop_id = viaStopId; renderJourneyMessage(payload.mode === 'transit' ? 'Searching direct routes...' : `Searching ${payload.mode} route...`, { busy: true }); const data = await api('/api/journey/searches', { method: 'POST', body: JSON.stringify(payload) }); activeJourneySearchId = data.search_id; renderProgressiveJourneySearch(data); scheduleJourneySearchPoll(activeJourneySearchId); } function journeyMode() { return document.querySelector('input[name="journeyMode"]:checked')?.value || 'transit'; } function updateJourneyModeControls() { const mode = journeyMode(); const directOnly = document.getElementById('journeyDirectOnly'); const transitOnly = mode === 'transit'; if (directOnly) { directOnly.disabled = !transitOnly; if (!transitOnly) directOnly.checked = false; } } async function stopActiveJourneySearch() { if (journeySearchPollTimer) { window.clearTimeout(journeySearchPollTimer); journeySearchPollTimer = undefined; } const searchId = activeJourneySearchId; activeJourneySearchId = undefined; if (!searchId) return; try { await api(`/api/journey/searches/${encodeURIComponent(searchId)}`, { method: 'DELETE' }); } catch (err) { console.warn(err); } } function scheduleJourneySearchPoll(searchId) { if (!searchId || activeJourneySearchId !== searchId) return; if (journeySearchPollTimer) window.clearTimeout(journeySearchPollTimer); journeySearchPollTimer = window.setTimeout(() => pollJourneySearch(searchId), 900); } async function pollJourneySearch(searchId) { if (!searchId || activeJourneySearchId !== searchId) return; try { const data = await api(`/api/journey/searches/${encodeURIComponent(searchId)}`); if (activeJourneySearchId !== searchId) return; renderProgressiveJourneySearch(data); if (data.complete || data.status === 'error' || data.status === 'cancelled') { activeJourneySearchId = undefined; journeySearchPollTimer = undefined; return; } scheduleJourneySearchPoll(searchId); } catch (err) { if (activeJourneySearchId === searchId) { activeJourneySearchId = undefined; journeySearchPollTimer = undefined; renderJourneyMessage(err.message); } } } function renderProgressiveJourneySearch(data) { if (data.status === 'error') { renderJourneyMessage(data.error || data.message || 'Search failed.'); return; } if (data.routing) { renderRoutingResult(data.routing, data); drawRouting(data.routing); lastJourneyDrawSignature = undefined; return; } const journeys = data.journeys || []; if (journeys.length) { const signature = journeyDrawSignature(journeys[0]); lastJourneyResponse = data; renderJourneyResults(data); if (signature !== lastJourneyDrawSignature) { drawJourney(0); lastJourneyDrawSignature = signature; } return; } if (data.complete) { renderJourneyMessage(data.message || 'No route found.'); clearJourneyLayer(); lastJourneyDrawSignature = undefined; return; } renderJourneyMessage(data.message || 'Searching...', { busy: true }); } function journeyTransferSeconds() { const value = Number(document.getElementById('journeyTransferMinutes')?.value || 2); if (!Number.isFinite(value)) return 120; return Math.max(0, Math.min(60, value)) * 60; } function journeyPlannerPayload() { const fromStopId = journeyStopSelection('from', { required: true }); const toStopId = journeyStopSelection('to', { required: true }); const payload = { from_stop_id: fromStopId, to_stop_id: toStopId, departure: document.getElementById('journeyDeparture').value || '08:00', service_date: document.getElementById('journeyServiceDate')?.value || null, max_transfers: document.getElementById('journeyDirectOnly')?.checked ? 0 : 5, transfer_seconds: journeyTransferSeconds(), limit: 5, preferences: { mode: journeyMode(), ranking: document.getElementById('journeyRanking')?.value || 'recommended' } }; const viaStopId = journeyStopSelection('via'); if (viaStopId) payload.via_stop_id = viaStopId; return payload; } async function generateItinerariesFromForm() { const container = document.getElementById('itineraryResults'); if (container) { container.classList.remove('muted'); container.innerHTML = '

Generating travel options...

'; } const payload = journeyPlannerPayload(); const data = await api('/api/itineraries/generate', { method: 'POST', body: JSON.stringify(payload) }); renderItineraryResults(data.itineraries || []); } async function loadItineraries() { const data = await api('/api/itineraries?limit=20'); renderItineraryResults(data.itineraries || []); } function renderItineraryResults(itineraries) { const container = document.getElementById('itineraryResults'); if (!container) return; lastItineraries = itineraries; if (!itineraries.length) { container.classList.add('muted'); container.innerHTML = 'No itinerary options yet.'; return; } container.classList.remove('muted'); container.innerHTML = itineraries.map(itinerary => `
${escapeHtml(itinerary.title)} ${escapeHtml(itinerary.status)}
${itineraryMetrics(itinerary).map(metric).join('')}
${itinerary.summary?.note ? `
${escapeHtml(itinerary.summary.note)}
` : ''} ${itinerary.legs?.length ? itinerary.legs.map(leg => `
${modeIcon(leg.mode)} ${escapeHtml(leg.route_ref || leg.mode || `leg ${leg.sequence}`)} ${escapeHtml([legMetaText(leg.payload?.journey_leg || leg), displayTransitTime(leg, 'departure'), leg.from_name, '→', displayTransitTime(leg, 'arrival'), leg.to_name].filter(Boolean).join(' '))}
`).join('') : ''}
${itinerary.payload?.journey || itinerary.payload?.routing ? `` : ''}
`).join(''); } function itineraryMetrics(itinerary) { const summary = itinerary.summary || {}; const score = itinerary.score || {}; return [ itinerary.family, durationText(summary), summary.distance_km !== null && summary.distance_km !== undefined ? distanceText(Number(summary.distance_km) * 1000) : null, summary.transfers !== null && summary.transfers !== undefined ? `${summary.transfers} transfers` : null, score.emissions ? `emissions ${score.emissions}` : null, score.complexity !== undefined ? `complexity ${score.complexity}` : null, ].filter(Boolean); } function itineraryFromDom(id) { return lastItineraries.find(itinerary => String(itinerary.id) === String(id)) || null; } async function saveItinerary(id, saved, button) { const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Saving...'; } try { await api(`/api/itineraries/${id}/save`, { method: 'POST', body: JSON.stringify({ saved }) }); await loadItineraries(); } catch (err) { alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function lockItineraryLeg(id, locked, button) { const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Saving...'; } try { await api(`/api/itinerary-legs/${id}/lock`, { method: 'POST', body: JSON.stringify({ locked }) }); await loadItineraries(); } catch (err) { alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } function showItinerary(id) { const itinerary = itineraryFromDom(id); const journey = itinerary?.payload?.journey; if (journey) { lastJourneyResponse = { journeys: [journey] }; renderJourneyResults(lastJourneyResponse); drawJourney(0); return; } const routing = itinerary?.payload?.routing; if (routing) { renderRoutingResult(routing); drawRouting(routing); } } function renderJourneyMessage(text, options = {}) { const container = document.getElementById('journeyResults'); if (container) { container.innerHTML = `

${options.busy ? '' : ''}${escapeHtml(text)}

`; } } function renderJourneyResults(data) { const container = document.getElementById('journeyResults'); if (!container) return; const journeys = data.journeys || []; if (!journeys.length) { renderJourneyMessage('No journey found in the imported stop_times for the selected source scope.'); clearJourneyLayer(); return; } container.innerHTML = ` ${journeyContextLine(data)} ${data.message ? `
${!data.complete && data.status === 'running' ? '' : ''}${escapeHtml(data.message)}
` : ''} ${journeys.map((journey, index) => `
${escapeHtml(displayTransitTime(journey, 'departure'))} → ${escapeHtml(displayTransitTime(journey, 'arrival'))}${durationText(journey) ? ` · ${escapeHtml(durationText(journey))}` : ''}
${journey.legs.map(leg => `
${modeIcon(leg.mode)} ${escapeHtml(leg.route_ref || leg.route_id)} ${leg.source_name ? `${escapeHtml(leg.source_name)}` : ''} ${legMetaText(leg) ? `${escapeHtml(legMetaText(leg))}` : ''} ${escapeHtml(displayTransitTime(leg, 'departure'))} ${journeyStopLink(leg, 'from')} → ${escapeHtml(displayTransitTime(leg, 'arrival'))} ${journeyStopLink(leg, 'to')}
`).join('')}
`).join('')}`; container.querySelectorAll('[data-journey-index]').forEach(btn => { btn.addEventListener('click', () => drawJourney(Number(btn.dataset.journeyIndex))); }); container.querySelectorAll('[data-canonical-stop]').forEach(btn => { btn.addEventListener('click', () => showCanonicalStop(btn.dataset.canonicalStop)); }); } function journeyDrawSignature(journey) { return (journey?.legs || []).map(leg => [ leg.dataset_id, leg.mode, leg.route_id, leg.trip_id, leg.from?.stop_id || leg.from?.name, leg.to?.stop_id || leg.to?.name, leg.departure_time, leg.arrival_time ].join('|')).join('||'); } function displayTransitTime(row, kind) { const label = row?.[`${kind}_time_label`]; if (label) return label; return formatGtfsDisplayTime(row?.[`${kind}_time`]); } function formatGtfsDisplayTime(value) { if (!value || typeof value !== 'string') return ''; const parts = value.split(':').map(part => Number(part)); if (parts.length < 2 || parts.some(part => Number.isNaN(part))) return value; const hours = parts[0]; const minutes = parts[1]; const seconds = parts[2] || 0; const day = Math.floor(hours / 24); const clockHours = hours % 24; const clock = seconds ? `${pad2(clockHours)}:${pad2(minutes)}:${pad2(seconds)}` : `${pad2(clockHours)}:${pad2(minutes)}`; return day > 0 ? `+${day}d ${clock}` : clock; } function durationText(row) { return row?.duration_label || formatDurationLabel(row?.duration_seconds, row?.duration_minutes); } function legMetaText(leg) { if (!leg) return ''; if (normalizeMode(leg.mode) === 'walk') { return distanceText(leg.distance_m); } const intermediate = Number(leg.intermediate_stop_count); if (Number.isFinite(intermediate) && intermediate > 0) { return `${intermediate} intermediate ${intermediate === 1 ? 'stop' : 'stops'}`; } const stops = Number(leg.stop_count); if (Number.isFinite(stops) && stops > 1) { return 'direct'; } return ''; } function distanceText(meters) { const value = Number(meters); if (!Number.isFinite(value) || value <= 0) return ''; if (value < 950) return `~${Math.ceil(value / 10) * 10} m`; return `~${(value / 1000).toFixed(value < 9500 ? 1 : 0)} km`; } function formatDurationLabel(seconds, minutesValue) { let totalMinutes = null; const secondsNumber = Number(seconds); if (seconds !== null && seconds !== undefined && Number.isFinite(secondsNumber)) { totalMinutes = Math.ceil(secondsNumber / 60); } else { const minutesNumber = Number(minutesValue); if (minutesValue !== null && minutesValue !== undefined && Number.isFinite(minutesNumber)) { totalMinutes = Math.ceil(minutesNumber); } } if (totalMinutes === null) return ''; totalMinutes = Math.max(0, totalMinutes); const days = Math.floor(totalMinutes / (24 * 60)); const remaining = totalMinutes % (24 * 60); const hours = Math.floor(remaining / 60); const minutes = remaining % 60; if (days > 0) return `${days}d ${pad2(hours)}:${pad2(minutes)}`; if (hours > 0) return `${hours}:${pad2(minutes)}`; return `${minutes} min`; } function pad2(value) { return String(value).padStart(2, '0'); } function modeIcon(mode) { const key = normalizeMode(mode); const meta = { train: ['Rail', 'R'], light_rail: ['Light rail', 'LR'], subway: ['Subway', 'U'], tram: ['Tram', 'T'], bus: ['Bus', 'B'], trolleybus: ['Trolleybus', 'TB'], coach: ['Coach', 'C'], ferry: ['Ferry', 'F'], walk: ['Walk', 'W'], drive: ['Car', 'C'], car: ['Car', 'C'], monorail: ['Monorail', 'M'], funicular: ['Funicular', 'FN'], aerialway: ['Aerialway', 'A'], }[key] || ['Transit', 'PT']; return `${escapeHtml(meta[1])}`; } function normalizeMode(mode) { return String(mode || 'transit').toLowerCase().replace(/[^a-z0-9_]+/g, '_'); } function transportModeColor(mode) { const key = normalizeMode(mode); return { walk: '#16a34a', tram: '#dc2626', light_rail: '#dc2626', subway: '#ef4444', ferry: '#0284c7', bus: '#ca8a04', trolleybus: '#ca8a04', coach: '#a16207', train: '#7c3aed', monorail: '#7c3aed', funicular: '#7c3aed', aerialway: '#7c3aed', drive: '#f97316', car: '#f97316', }[key] || '#64748b'; } function journeyContextLine(data) { const from = data.from?.name || data.from?.stop_id || ''; const to = data.to?.name || data.to?.stop_id || ''; const via = data.via?.name || data.via?.stop_id || ''; const parts = [`${from} → ${to}`]; if (via) parts.push(`via ${via}`); if (data.service_date) parts.push(data.service_date); return `
${escapeHtml(parts.filter(Boolean).join(' · '))}
`; } function journeyStopLink(leg, role) { const stop = role === 'from' ? leg.from : leg.to; const visualStop = role === 'from' ? leg.stops?.[0] : leg.stops?.[leg.stops.length - 1]; const canonicalId = visualStop?.canonical_stop?.id; const label = stop?.name || stop?.stop_id || ''; if (!canonicalId) return escapeHtml(label); return ``; } function clearJourneyLayer() { if (journeyLayer) { map.removeLayer(journeyLayer); journeyLayer = undefined; } } function bindJourneyLayerContextMenu(layer) { if (!layer) return; if (typeof layer.on === 'function') { layer.on('contextmenu', showJourneyContextMenu); } if (typeof layer.eachLayer === 'function') { layer.eachLayer(child => bindJourneyLayerContextMenu(child)); } } function drawJourney(index) { if (!lastJourneyResponse?.journeys?.[index]) return; clearJourneyLayer(); const journey = lastJourneyResponse.journeys[index]; const casing = L.geoJSON(journey.features, { pane: 'journeyPane', filter: feature => feature.geometry?.type !== 'Point', style: { color: '#ffffff', weight: 12, opacity: 0.96, lineCap: 'round', lineJoin: 'round' } }); const highlight = L.geoJSON(journey.features, { pane: 'journeyPane', filter: feature => feature.geometry?.type !== 'Point', style: feature => ({ color: journeyLegColor(feature), weight: 7, opacity: 0.96, lineCap: 'round', lineJoin: 'round' }), onEachFeature: bindPopup }); const stops = L.geoJSON(journey.features, { pane: 'journeyPane', filter: feature => feature.geometry?.type === 'Point', pointToLayer: (feature, latlng) => L.circleMarker(latlng, journeyStopStyle(feature)), onEachFeature: bindPopup }); journeyLayer = L.featureGroup([casing, highlight, stops]).addTo(map); bindJourneyLayerContextMenu(journeyLayer); bringJourneyToFront(); const bounds = journeyLayer.getBounds(); if (bounds.isValid()) map.fitBounds(bounds.pad(0.18)); } function renderRoutingResult(route, data = {}) { const container = document.getElementById('journeyResults'); if (!container) return; const duration = durationText(route); const distance = distanceText(route.distance_m); container.innerHTML = `
${modeIcon(route.mode)} ${escapeHtml([distance || null, duration || null].filter(Boolean).join(' · ') || 'Route')}
${data.message ? `
${escapeHtml(data.message)}
` : ''}
${escapeHtml(route.engine || 'routing')}
`; container.querySelector('[data-routing-show]')?.addEventListener('click', () => drawRouting(route)); } function drawRouting(route) { clearJourneyLayer(); const features = route?.features || { type: 'FeatureCollection', features: [] }; const casing = L.geoJSON(features, { pane: 'journeyPane', filter: feature => feature.geometry?.type !== 'Point', style: { color: '#ffffff', weight: 10, opacity: 0.95, lineCap: 'round', lineJoin: 'round' } }); const highlight = L.geoJSON(features, { pane: 'journeyPane', filter: feature => feature.geometry?.type !== 'Point', style: feature => ({ color: transportModeColor(feature.properties?.mode || route.mode), weight: feature.properties?.feature_type === 'routing_connector' ? 4 : 6, opacity: 0.94, dashArray: feature.properties?.feature_type === 'routing_connector' ? '5 5' : null, lineCap: 'round', lineJoin: 'round' }), onEachFeature: bindPopup }); journeyLayer = L.featureGroup([casing, highlight]).addTo(map); bindJourneyLayerContextMenu(journeyLayer); bringJourneyToFront(); const bounds = journeyLayer.getBounds(); if (bounds.isValid()) map.fitBounds(bounds.pad(0.18)); } function bringJourneyToFront() { if (!journeyLayer) return; journeyLayer.eachLayer(layer => { if (typeof layer.bringToFront === 'function') { layer.bringToFront(); } if (typeof layer.eachLayer === 'function') { layer.eachLayer(child => { if (child.feature?.geometry?.type === 'Point' && typeof child.bringToFront === 'function') { child.bringToFront(); } }); } }); } function journeyLegColor(feature) { return transportModeColor(feature.properties?.mode); } function journeyStopStyle(feature) { const role = feature.properties?.role || 'passed'; const legColor = journeyLegColor(feature); if (role === 'start' || role === 'end') { return { radius: 7, color: '#1f2937', weight: 2, fillColor: '#374151', fillOpacity: 0.96 }; } if (role === 'transfer') { return { radius: 7, color: '#374151', weight: 2.5, fillColor: '#ffffff', fillOpacity: 0.94 }; } return { radius: 3.5, color: '#ffffff', weight: 1, fillColor: legColor, fillOpacity: 0.95 }; } async function handleSourceAction(event) { const target = event.target; if (!(target instanceof HTMLButtonElement)) return; const importSourceId = target.dataset.importSource; const updateSourceId = target.dataset.updateSource; const deleteSourceId = target.dataset.deleteSource; const deleteDatasetId = target.dataset.deleteDataset; if (!importSourceId && !updateSourceId && !deleteSourceId && !deleteDatasetId) return; target.disabled = true; const originalText = target.textContent; try { if (importSourceId) { target.textContent = 'Queuing...'; const job = await api(`/api/jobs/sources/${importSourceId}/import?run_match=true&build_route_layer=true`, { method: 'POST' }); updateMapStatus(`${job.status === 'queued' ? 'Queued' : 'Using active'} source import job #${job.id}.`); await loadJobs(); await loadSources(); pollJob(job.id); return; } if (updateSourceId) { target.textContent = 'Checking...'; const check = await api(`/api/sources/${updateSourceId}/check-update`, { method: 'POST' }); if (!check.update_available) { await loadSources(); alert(check.reason || 'No update available.'); return; } target.textContent = 'Updating...'; const result = await api(`/api/sources/${updateSourceId}/update`, { method: 'POST' }); if (result.job) { updateMapStatus(`${result.status === 'already_running' ? 'Using active' : 'Queued'} source update job #${result.job.id}.`); await loadJobs(); pollJob(result.job.id); } await refreshAll(); return; } if (deleteSourceId) { const source = allSources.find(item => String(item.id) === deleteSourceId); if (!confirm(`Delete source "${source?.name || deleteSourceId}" and all imported datasets?`)) return; target.textContent = 'Queuing...'; const job = await api(`/api/sources/${deleteSourceId}`, { method: 'DELETE' }); updateMapStatus(`${job.status === 'queued' ? 'Queued' : 'Using active'} source delete job #${job.id}.`); await loadJobs(); await loadSources(); pollJob(job.id); return; } if (deleteDatasetId) { if (!confirm(`Delete dataset #${deleteDatasetId}?`)) return; target.textContent = 'Queuing...'; const job = await api(`/api/datasets/${deleteDatasetId}`, { method: 'DELETE' }); updateMapStatus(`${job.status === 'queued' ? 'Queued' : 'Using active'} dataset delete job #${job.id}.`); await loadJobs(); await loadSources(); pollJob(job.id); } } catch (err) { alert(err.message); } finally { target.disabled = false; target.textContent = originalText; } } async function submitSourceForm(event) { event.preventDefault(); const form = event.currentTarget; const payload = Object.fromEntries(new FormData(form).entries()); Object.keys(payload).forEach(key => { if (payload[key] === '') delete payload[key]; }); if (payload.catalog_entry_id) { payload.catalog_entry_id = Number(payload.catalog_entry_id); } await api('/api/sources', { method: 'POST', body: JSON.stringify(payload) }); form.reset(); await Promise.all([loadSources(), loadStats(), loadSourceCatalog(), loadGtfsHarmonizationInventory()]); } async function showCandidates(matchId) { clearCandidatePreviewLayer(); openOverlay('Matching candidates', '

Loading candidates...

', { mapReview: true }); try { const data = await api(`/api/matches/${matchId}/candidates?limit=30`); renderCandidateOverlay(data); } catch (err) { openOverlay('Matching candidates', `

${escapeHtml(err.message)}

`); } } function renderCandidateOverlay(data) { const route = data.route || {}; const rows = data.candidates || []; const title = `Candidates for ${route.ref || route.route_id || `match ${data.match_id}`}`; const currentOrFirst = rows.find(candidate => candidate.current_match) || rows[0]; const content = `
GTFS ${escapeHtml([route.mode, route.name, route.operator].filter(Boolean).join(' · '))}
GTFS OSM candidates selected
${rows.length ? rows.map(candidate => `
${escapeHtml(candidate.osm.ref || candidate.osm.name || candidate.osm.osm_id)} ${candidate.current_match ? 'current' : ''} ${escapeHtml(candidate.status)} ${Number(candidate.score).toFixed(1)}
${escapeHtml([candidate.osm.mode, candidate.osm.name, candidate.osm.operator || candidate.osm.network].filter(Boolean).join(' · '))}
OSM ${escapeHtml(candidate.osm.osm_type)} ${escapeHtml(candidate.osm.osm_id)} · dataset ${escapeHtml(String(candidate.osm.dataset_id))}
${escapeHtml(JSON.stringify(candidate.reasons, null, 2))}
`).join('') : '

No OSM route candidates.

'} `; openOverlay(title, content, { mapReview: true }); drawCandidatePreview(data.preview); } async function acceptCandidate(matchId, osmFeatureId, button) { const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Saving...'; } try { await api(`/api/matches/${encodeURIComponent(matchId)}/candidates/${encodeURIComponent(osmFeatureId)}/accept`, { method: 'POST' }); await Promise.all([loadMatches(), loadStats(), loadMapLayers()]); await showCandidates(matchId); } catch (err) { alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function showCanonicalStop(canonicalStopId) { openOverlay('Stop detail', '

Loading stop detail...

'); try { const data = await api(`/api/canonical-stops/${canonicalStopId}`); renderCanonicalStopOverlay(data); } catch (err) { openOverlay('Stop detail', `

${escapeHtml(err.message)}

`); } } function renderCanonicalStopOverlay(data) { const stop = data.canonical_stop || {}; const gtfsStops = data.gtfs_stops || []; const osmFeatures = data.osm_features || []; const rules = data.rules || []; const html = `
${escapeHtml(stop.name || stop.stop_key || '')} #${escapeHtml(String(stop.id || ''))} · ${escapeHtml(stop.stop_key || '')} ${stop.lat !== null && stop.lon !== null ? `${Number(stop.lat).toFixed(6)}, ${Number(stop.lon).toFixed(6)}` : 'no coordinate'}

Linked timetable stops ${gtfsStops.length}

${gtfsStops.length ? gtfsStops.map(link => ` `).join('') : '

No timetable stops linked.

'}

Linked visual stops ${osmFeatures.length}

${osmFeatures.length ? osmFeatures.map(link => ` `).join('') : '

No OSM visual stops linked.

'}

Find GTFS stop candidate

Nearby active GTFS stops will appear here.

Stored decisions ${rules.length}

${rules.length ? rules.map(rule => `
${escapeHtml(rule.rule_type)} #${escapeHtml(String(rule.id))} · ${escapeHtml(rule.created_at || '')}
${escapeHtml(JSON.stringify({ selector: rule.selector, action: rule.action }, null, 2))}
`).join('') : '

No stored manual stop decisions found for this stop.

'}
`; openOverlay(`Stop: ${stop.name || stop.stop_key || stop.id}`, html); loadCanonicalStopCandidates(stop.id).catch(err => { const results = document.getElementById('canonicalCandidateResults'); if (results) results.innerHTML = `

${escapeHtml(err.message)}

`; }); const input = document.getElementById('canonicalCandidateQuery'); if (input) { input.addEventListener('keydown', event => { if (event.key !== 'Enter') return; event.preventDefault(); loadCanonicalStopCandidates(stop.id).catch(err => alert(err.message)); }); } } async function loadCanonicalStopCandidates(canonicalStopId) { const results = document.getElementById('canonicalCandidateResults'); if (!results) return; const query = (document.getElementById('canonicalCandidateQuery')?.value || '').trim(); const params = new URLSearchParams({ limit: '40' }); if (query) params.set('q', query); results.classList.remove('muted'); results.innerHTML = '

Loading candidates...

'; const data = await api(`/api/canonical-stops/${canonicalStopId}/gtfs-candidates?${params.toString()}`); const candidates = data.candidates || []; if (!candidates.length) { results.classList.add('muted'); results.innerHTML = 'No candidate GTFS stops found.'; return; } results.classList.remove('muted'); results.innerHTML = candidates.map(candidate => `
${escapeHtml(candidate.name || candidate.stop_id)}
${escapeHtml(candidate.source_name || '')} · dataset ${escapeHtml(String(candidate.dataset_id))} · ${escapeHtml(candidate.stop_id || '')}
${[ candidate.scheduled ? 'scheduled' : 'no stop_times', candidate.distance_m !== null ? `${candidate.distance_m} m` : null, candidate.current_canonical_stop_id ? `linked to #${candidate.current_canonical_stop_id}` : 'unlinked' ].filter(Boolean).map(metric).join('')}
`).join(''); } async function linkCanonicalStopCandidate(canonicalStopId, gtfsStopId, button) { const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Saving...'; } try { const data = await api(`/api/canonical-stops/${canonicalStopId}/link-gtfs-stop`, { method: 'POST', body: JSON.stringify({ gtfs_stop_id: Number(gtfsStopId) }) }); renderCanonicalStopOverlay(data); await loadStats(); } catch (err) { alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } async function unlinkCanonicalStopLink(linkId, button) { const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Saving...'; } try { const result = await api(`/api/canonical-stop-links/${linkId}/unlink`, { method: 'POST' }); await showCanonicalStop(result.canonical_stop.id); await loadStats(); } catch (err) { alert(err.message); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } function openOverlay(title, html, options = {}) { const overlay = document.getElementById('overlay'); overlay.classList.toggle('map-review', Boolean(options.mapReview)); document.getElementById('overlayTitle').textContent = title; document.getElementById('overlayContent').innerHTML = html; overlay.hidden = false; } function closeOverlay() { const overlay = document.getElementById('overlay'); overlay.hidden = true; overlay.classList.remove('map-review'); if (jobDetailsPollTimer) { window.clearTimeout(jobDetailsPollTimer); jobDetailsPollTimer = undefined; } activeJobDetailsId = undefined; clearCandidatePreviewLayer(); } function shiftJourneyTime(deltaMinutes) { const input = document.getElementById('journeyDeparture'); const current = parseTimeInput(input.value || '08:00'); const shifted = (current + deltaMinutes + 24 * 60) % (24 * 60); input.value = formatTimeInput(shifted); searchJourney().catch(err => renderJourneyMessage(err.message)); } function parseTimeInput(value) { const [hours, minutes] = value.split(':').map(part => Number(part)); if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return 8 * 60; return Math.max(0, Math.min(23, hours)) * 60 + Math.max(0, Math.min(59, minutes)); } function formatTimeInput(totalMinutes) { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; } async function updateMatch(id, action) { await api(`/api/matches/${id}/${action}`, { method: 'POST' }); await Promise.all([loadMatches(), loadStats(), loadMapLayers()]); } function shorten(str, len) { if (!str || str.length <= len) return str || ''; return str.slice(0, len - 1) + '…'; } async function refreshAll() { await loadSources(); await Promise.all([loadStats(), loadQaSummary(), loadGtfsHarmonizationInventory(), loadJobs(), loadMatches(), loadMapLayers(), loadSourceCatalog(), loadItineraries()]); } function setupSidebarSections() { let savedState = {}; try { savedState = JSON.parse(localStorage.getItem('mobilitySidebarSections') || '{}'); } catch (err) { savedState = {}; } document.querySelectorAll('[data-sidebar-section]').forEach(section => { const key = section.dataset.sidebarSection; if (!key) return; if (typeof savedState[key] === 'boolean') { section.open = savedState[key]; } section.addEventListener('toggle', () => { let current = {}; try { current = JSON.parse(localStorage.getItem('mobilitySidebarSections') || '{}'); } catch (err) { current = {}; } current[key] = section.open; localStorage.setItem('mobilitySidebarSections', JSON.stringify(current)); }); }); } function setupSidebarCollapse() { const main = document.querySelector('main'); const button = document.getElementById('sidebarCollapseBtn'); if (!main || !button) return; const saved = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'; setSidebarCollapsed(saved, { persist: false }); button.addEventListener('click', () => { setSidebarCollapsed(!main.classList.contains('sidebar-collapsed')); }); } function setSidebarCollapsed(collapsed, options = {}) { const main = document.querySelector('main'); const button = document.getElementById('sidebarCollapseBtn'); if (!main || !button) return; main.classList.toggle('sidebar-collapsed', Boolean(collapsed)); button.textContent = collapsed ? '›' : '‹'; button.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); button.setAttribute('aria-label', collapsed ? 'Expand left panel' : 'Collapse left panel'); button.title = collapsed ? 'Expand left panel' : 'Collapse left panel'; if (options.persist !== false) { localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, collapsed ? 'true' : 'false'); } window.setTimeout(() => { if (map) map.invalidateSize(); }, 220); } function bindClick(id, handler) { const element = document.getElementById(id); if (element) element.addEventListener('click', handler); } function setupEvents() { setupSidebarSections(); setupSidebarCollapse(); const serviceDateInput = document.getElementById('journeyServiceDate'); if (serviceDateInput && !serviceDateInput.value) { serviceDateInput.value = new Date().toISOString().slice(0, 10); } bindClick('loadSampleBtn', async () => { if (!confirm('This clears the local database and loads sample data. Continue?')) return; const button = document.getElementById('loadSampleBtn'); const originalText = button?.textContent; if (button) { button.disabled = true; button.textContent = 'Queuing...'; } try { const job = await api('/api/jobs/sample-reset', { method: 'POST' }); updateMapStatus(`Queued sample reset as job #${job.id}.`); await loadJobs(); pollJob(job.id); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } }); bindClick('runMatchBtn', async () => { try { await queueMatcherRun(); } catch (err) { alert(err.message); } }); bindClick('buildRouteLayerBtn', async () => { try { await queueRouteLayerBuild(); } catch (err) { alert(err.message); } }); bindClick('refreshBtn', refreshAll); bindClick('refreshQaBtn', loadQaSummary); bindClick('refreshGtfsHarmonizationBtn', loadGtfsHarmonizationInventory); document.querySelectorAll('[data-admin-action]').forEach(button => { button.addEventListener('click', () => runAdminAction(button.dataset.adminAction, button)); }); document.getElementById('jobs').addEventListener('click', handleJobAction); document.getElementById('reloadMatchesBtn').addEventListener('click', loadMatches); document.getElementById('matchStatusFilter').addEventListener('change', loadMatches); document.querySelectorAll('[data-layer-preset]').forEach(button => { button.addEventListener('click', () => applyLayerPreset(button.dataset.layerPreset)); }); document.getElementById('sourceSearch')?.addEventListener('input', renderSources); document.getElementById('mappingSourceSearch')?.addEventListener('input', renderMappingSources); document.getElementById('mappingSourceKindFilter')?.addEventListener('change', renderMappingSources); document.getElementById('datasetSearchForm').addEventListener('submit', event => { event.preventDefault(); window.clearTimeout(datasetSearchTimer); searchDatasets(); }); document.getElementById('datasetSearchQuery').addEventListener('input', () => { window.clearTimeout(datasetSearchTimer); datasetSearchSequence += 1; if (datasetSearchAbortController) { datasetSearchAbortController.abort(); } const results = document.getElementById('datasetSearchResults'); const query = (document.getElementById('datasetSearchQuery')?.value || '').trim(); if (results && query) { results.classList.remove('muted'); results.innerHTML = '

Searching datasets...

'; } else if (results) { results.classList.add('muted'); results.innerHTML = 'Search all imported datasets by label, route ID, and route-layer reference.'; } datasetSearchTimer = window.setTimeout(() => searchDatasets(), 100); }); document.getElementById('datasetSearchActiveOnly').addEventListener('change', () => { window.clearTimeout(datasetSearchTimer); searchDatasets(); }); document.getElementById('datasetSearchResults').addEventListener('click', event => { const row = event.target.closest('[data-search-feature-type]'); if (!row) return; showDatasetSearchFeature(row.dataset.searchFeatureType, row.dataset.searchFeatureId, row); }); document.getElementById('datasetSearchResults').addEventListener('keydown', event => { if (event.key !== 'Enter' && event.key !== ' ') return; const row = event.target.closest('[data-search-feature-type]'); if (!row) return; event.preventDefault(); showDatasetSearchFeature(row.dataset.searchFeatureType, row.dataset.searchFeatureId, row); }); document.getElementById('sources')?.addEventListener('click', handleSourceAction); document.getElementById('gtfsHarmonizationInventory')?.addEventListener('click', event => { const button = event.target.closest('[data-gtfs-feed-detail]'); if (!button) return; showGtfsHarmonizationDetail(button.dataset.gtfsFeedDetail); }); document.getElementById('mappingSources')?.addEventListener('click', handleSourceAction); document.getElementById('importSourceCatalogBtn').addEventListener('click', () => importSourceCatalog().catch(err => alert(err.message))); document.getElementById('importIngestableSourcesBtn').addEventListener('click', () => importIngestableSources().catch(err => alert(err.message))); document.getElementById('sourceCatalogSearch').addEventListener('input', () => loadSourceCatalog().catch(err => console.warn(err))); document.getElementById('sourceCatalogCountry').addEventListener('input', () => loadSourceCatalog().catch(err => console.warn(err))); document.getElementById('sourceCatalogPriority').addEventListener('change', () => loadSourceCatalog().catch(err => console.warn(err))); document.getElementById('sourceCatalog').addEventListener('click', event => { const button = event.target.closest('[data-fill-source-from-catalog]'); if (!button) return; fillSourceFormFromCatalog(button.dataset.fillSourceFromCatalog); }); document.getElementById('geofabrikSearchBtn').addEventListener('click', () => searchGeofabrik()); document.getElementById('geofabrikSearch').addEventListener('keydown', event => { if (event.key !== 'Enter') return; event.preventDefault(); searchGeofabrik(); }); document.getElementById('geofabrikResults').addEventListener('click', event => { const addButton = event.target.closest('[data-geofabrik-add]'); if (addButton) { createGeofabrikSource(addButton.dataset.geofabrikAdd, false, addButton); return; } const importButton = event.target.closest('[data-geofabrik-import]'); if (importButton) { createGeofabrikSource(importButton.dataset.geofabrikImport, true, importButton); } }); document.getElementById('overlayCloseBtn').addEventListener('click', closeOverlay); document.getElementById('overlay').addEventListener('submit', event => { const form = event.target.closest('[data-gtfs-review-form]'); if (!form) return; event.preventDefault(); const button = form.querySelector('button[type="submit"]'); saveGtfsFeedReview(form.dataset.sourceId, gtfsReviewPayloadFromForm(form), button); }); document.getElementById('overlay').addEventListener('click', event => { const approveButton = event.target.closest('[data-gtfs-review-approve]'); if (approveButton) { const form = approveButton.closest('[data-gtfs-review-form]'); const payload = gtfsReviewPayloadFromForm(form); payload.review_status = 'approved'; saveGtfsFeedReview(approveButton.dataset.gtfsReviewApprove, payload, approveButton); return; } const addRelatedButton = event.target.closest('[data-gtfs-add-related-source]'); if (addRelatedButton) { prepareRelatedGtfsSource(addRelatedButton.dataset.gtfsAddRelatedSource); return; } const previewButton = event.target.closest('[data-preview-candidate]'); if (previewButton) { focusCandidatePreview(previewButton.dataset.previewCandidate); return; } const candidateButton = event.target.closest('[data-accept-candidate]'); if (candidateButton) { acceptCandidate(candidateButton.dataset.matchId, candidateButton.dataset.acceptCandidate, candidateButton); return; } const canonicalSearchButton = event.target.closest('[data-canonical-candidate-search]'); if (canonicalSearchButton) { loadCanonicalStopCandidates(canonicalSearchButton.dataset.canonicalCandidateSearch).catch(err => alert(err.message)); return; } const canonicalLinkButton = event.target.closest('[data-canonical-link-candidate]'); if (canonicalLinkButton) { linkCanonicalStopCandidate( canonicalLinkButton.dataset.canonicalStopTarget, canonicalLinkButton.dataset.canonicalLinkCandidate, canonicalLinkButton ); return; } const canonicalUnlinkButton = event.target.closest('[data-canonical-unlink]'); if (canonicalUnlinkButton) { unlinkCanonicalStopLink(canonicalUnlinkButton.dataset.canonicalUnlink, canonicalUnlinkButton); return; } if (event.target.id === 'overlay') closeOverlay(); }); document.getElementById('sourceForm')?.addEventListener('submit', submitSourceForm); document.getElementById('mappingSourceForm')?.addEventListener('submit', submitSourceForm); document.getElementById('journeyEarlierBtn').addEventListener('click', () => shiftJourneyTime(-15)); document.getElementById('journeySwapBtn').addEventListener('click', () => swapJourneyEndpoints()); document.getElementById('journeyLaterBtn').addEventListener('click', () => shiftJourneyTime(15)); document.getElementById('generateItinerariesBtn').addEventListener('click', async () => { try { await generateItinerariesFromForm(); } catch (err) { const container = document.getElementById('itineraryResults'); if (container) container.innerHTML = `

${escapeHtml(err.message)}

`; } }); document.getElementById('reloadItinerariesBtn').addEventListener('click', () => loadItineraries().catch(err => alert(err.message))); document.getElementById('itineraryResults').addEventListener('click', event => { const saveButton = event.target.closest('[data-itinerary-save]'); if (saveButton) { saveItinerary(saveButton.dataset.itinerarySave, saveButton.dataset.itinerarySaved === 'true', saveButton); return; } const legButton = event.target.closest('[data-itinerary-leg-lock]'); if (legButton) { lockItineraryLeg(legButton.dataset.itineraryLegLock, legButton.dataset.itineraryLegLocked === 'true', legButton); return; } const showButton = event.target.closest('[data-itinerary-show]'); if (showButton) { showItinerary(showButton.dataset.itineraryShow); } }); document.getElementById('journeyFromQuery').addEventListener('input', () => scheduleJourneyStopSearch('from')); document.getElementById('journeyToQuery').addEventListener('input', () => scheduleJourneyStopSearch('to')); document.getElementById('journeyViaQuery').addEventListener('input', () => scheduleJourneyStopSearch('via')); document.querySelectorAll('input[name="journeyMode"]').forEach(input => { input.addEventListener('change', () => { stopActiveJourneySearch().catch(err => console.warn(err)); updateJourneyModeControls(); }); }); document.getElementById('journeyDirectOnly').addEventListener('change', () => stopActiveJourneySearch().catch(err => console.warn(err))); document.getElementById('journeyRanking').addEventListener('change', () => stopActiveJourneySearch().catch(err => console.warn(err))); updateJourneyModeControls(); ['journeyFromSuggestions', 'journeyToSuggestions', 'journeyViaSuggestions'].forEach(id => { document.getElementById(id).addEventListener('click', event => { const button = event.target.closest('[data-stop-id]'); if (!button) return; selectJourneyStop(button.dataset.stopRole, button.dataset.stopId, button.dataset.stopLabel); }); }); document.getElementById('journeyForm').addEventListener('submit', async (event) => { event.preventDefault(); try { await searchJourney(); } catch (err) { renderJourneyMessage(err.message); } }); } window.addEventListener('load', async () => { initMap(); setupEvents(); try { await refreshAll(); } catch (err) { console.error(err); alert(`Startup refresh failed: ${err.message}`); } finally { startJobListRefresh(); } });