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
${data.next_actions.map(action => `- ${escapeHtml(action)}
`).join('')}
` : ''}
`;
}
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))}
${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}
${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 'H';
}
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 => `
${escapeHtml(link.stop?.name || link.external_id)}
${escapeHtml(link.source_name || '')} · dataset ${escapeHtml(String(link.dataset_id))} · ${escapeHtml(link.external_id || '')}
${[
link.role || null,
link.dataset_active ? 'active dataset' : 'inactive dataset',
link.metadata?.manual_rule || null
].filter(Boolean).map(metric).join('')}
`).join('') : 'No timetable stops linked.
'}
Linked visual stops ${osmFeatures.length}
${osmFeatures.length ? osmFeatures.map(link => `
${escapeHtml(link.feature?.name || link.external_id)}
${escapeHtml(link.source_name || '')} · ${escapeHtml(link.feature?.osm_type || '')} ${escapeHtml(link.feature?.osm_id || '')}
${[
link.role || null,
link.feature?.kind || null,
link.feature?.mode || null,
link.distance_m !== null ? `${link.distance_m} m` : null
].filter(Boolean).map(metric).join('')}
`).join('') : 'No OSM visual stops linked.
'}
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();
}
});