Files
meubility-workbench/app/static/app.js
2026-07-01 23:29:51 +02:00

4091 lines
160 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 => `
<details class="layer-group" open>
<summary>
<label>
<input type="checkbox" data-layer-group="${escapeHtml(group.id)}" />
<span>${escapeHtml(group.label)}</span>
</label>
</summary>
<div class="layer-children">
${group.children.map(layer => `
<label class="layer-row" data-layer-row="${escapeHtml(layer.id)}">
<input type="checkbox" data-layer-id="${escapeHtml(layer.id)}" />
<span>${escapeHtml(layer.label)}</span>
<span class="layer-count" data-layer-count="${escapeHtml(layer.id)}"></span>
</label>
`).join('')}
</div>
</details>
`).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 = '<span class="spinner spinner-small" aria-hidden="true"></span>';
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]) => `<div><strong>${escapeHtml(key)}</strong>: ${escapeHtml(String(value))}</div>`)
.join('');
layer.bindPopup(html || 'No properties');
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\'': '&#39;', '"': '&quot;' }[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]) => `<div class="stat ${statTone(label, value)}"><strong>${escapeHtml(value)}</strong><span>${escapeHtml(label)}</span></div>`)
.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 = `<p class="badge error">${escapeHtml(err.message)}</p>`;
}
}
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 = `
<div class="qa-decision">
<strong>${escapeHtml(qaDecisionLabel(decision.deployment))}</strong>
<span>${escapeHtml(decision.split_trigger || '')}</span>
</div>
${sections.map(section => `
<section class="qa-section">
<h3>${escapeHtml(section.title || section.id || 'QA')}</h3>
<div class="qa-grid">
${(section.items || []).map(item => `
<div class="qa-item ${escapeHtml(item.tone || 'info')}" title="${escapeHtml(item.description || '')}">
<strong>${escapeHtml(item.value)}</strong>
<span>${escapeHtml(item.label)}</span>
</div>
`).join('')}
</div>
</section>
`).join('')}
${Array.isArray(data.next_actions) && data.next_actions.length ? `
<section class="qa-section">
<h3>Next actions</h3>
<ul class="qa-actions">
${data.next_actions.map(action => `<li>${escapeHtml(action)}</li>`).join('')}
</ul>
</section>
` : ''}
`;
}
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 = `<p class="badge error">${escapeHtml(err.message)}</p>`;
}
}
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 = `
<div class="harmonization-summary">
${[
['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]) => `<div><strong>${escapeHtml(formatCount(value))}</strong><span>${escapeHtml(label)}</span></div>`).join('')}
</div>
<div class="harmonization-feed-list">
${feeds.map(renderGtfsFeedQaCard).join('')}
</div>
`;
}
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 `
<div class="harmonization-feed ${escapeHtml(feed.qa_status || '')}">
<div class="harmonization-feed-title">
<strong>${escapeHtml(source.name || `Source #${source.id}`)}</strong>
<span class="badge ${gtfsQaStatusClass(feed.qa_status)}">${escapeHtml(gtfsQaStatusLabel(feed.qa_status))}</span>
</div>
<div class="muted">${escapeHtml([source.country || 'n/a', source.priority, source.license || 'unknown license'].filter(Boolean).join(' · '))}</div>
<div class="metric-row">${[
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('')}</div>
${issues.length ? `<div class="harmonization-issues">${issues.slice(0, 3).map(renderCompactIssue).join('')}</div>` : '<div class="muted">No blocking feed QA issue detected.</div>'}
<div class="source-actions">
<button type="button" data-gtfs-feed-detail="${escapeHtml(String(source.id))}">Review feed</button>
</div>
</div>
`;
}
function renderCompactIssue(issue) {
return `<span class="badge ${issueSeverityClass(issue.severity)}">${escapeHtml(issue.title || issue.id || '')}</span>`;
}
async function showGtfsHarmonizationDetail(sourceId) {
openOverlay('GTFS feed QA', '<p class="muted">Loading feed QA...</p>');
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 = `<p class="badge error">${escapeHtml(err.message)}</p>`;
}
}
function renderGtfsHarmonizationDetail(feed) {
const source = feed.source || {};
const dataset = feed.active_dataset;
const issues = feed.issues || [];
const review = source.qa_review || {};
return `
<div class="harmonization-detail">
<section>
<div class="job-detail-summary">
<div>
<strong>${escapeHtml(source.name || '')}</strong>
<div class="muted">${escapeHtml([source.country || 'n/a', source.priority, source.source_basis].filter(Boolean).join(' · '))}</div>
</div>
<span class="badge ${gtfsQaStatusClass(feed.qa_status)}">${escapeHtml(gtfsQaStatusLabel(feed.qa_status))}</span>
</div>
<div class="metric-row">${[
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('')}</div>
<div class="muted" title="${escapeHtml(source.url || '')}">${escapeHtml(shorten(source.url || '', 140))}</div>
</section>
<section class="harmonization-review-form-section">
<h3>Review Decision</h3>
<form class="harmonization-review-form" data-gtfs-review-form data-source-id="${escapeHtml(String(source.id || ''))}">
<label>License
<input name="license" value="${escapeHtml(source.license || '')}" placeholder="ODbL / CC-BY / proprietary / unknown" />
</label>
<label>Decision
<select name="review_status">
${[
['unreviewed', 'Unreviewed'],
['approved', 'Approved'],
['needs_review', 'Needs review'],
['blocked', 'Blocked'],
['rejected', 'Rejected']
].map(([value, label]) => `<option value="${value}" ${String(review.status || 'unreviewed') === value ? 'selected' : ''}>${label}</option>`).join('')}
</select>
</label>
<label>Review note
<textarea name="review_note" rows="2" placeholder="Attribution, redistribution, coverage, or conflict note">${escapeHtml(review.note || '')}</textarea>
</label>
<label class="inline-check"><input name="enabled" type="checkbox" ${source.enabled === false ? '' : 'checked'} /> enabled</label>
<div class="source-actions">
<button type="submit" class="primary">Save review</button>
<button type="button" data-gtfs-review-approve="${escapeHtml(String(source.id || ''))}">Approve feed</button>
<button type="button" data-gtfs-add-related-source="${escapeHtml(String(source.id || ''))}">Add another source</button>
</div>
<div class="muted">${review.updated_at ? `last reviewed ${escapeHtml(formatDateTime(review.updated_at))}` : 'No QA review saved yet.'}</div>
</form>
</section>
${issues.length ? `
<section>
<h3>Review Queue</h3>
<div class="harmonization-review-list">
${issues.map(issue => `
<div class="harmonization-review-item ${issueSeverityClass(issue.severity)}">
<strong>${escapeHtml(issue.title || issue.id || '')}</strong>
<span>${escapeHtml(issue.detail || '')}</span>
</div>
`).join('')}
</div>
</section>
` : '<section><h3>Review Queue</h3><p class="muted">No validation issue detected for this first-pass QA.</p></section>'}
${(feed.sections || []).map(section => `
<section>
<h3>${escapeHtml(section.title || section.id || '')}</h3>
<div class="qa-grid">
${(section.items || []).map(item => `
<div class="qa-item ${escapeHtml(item.tone || 'info')}">
<strong>${escapeHtml(formatMetricValue(item.value))}</strong>
<span>${escapeHtml(item.label || '')}</span>
</div>
`).join('')}
</div>
</section>
`).join('')}
<section>
<h3>Datasets</h3>
<div class="harmonization-dataset-list">
${(feed.datasets || []).map(renderGtfsHarmonizationDataset).join('') || '<p class="muted">No GTFS datasets.</p>'}
</div>
</section>
</div>
`;
}
function renderGtfsHarmonizationDataset(dataset) {
const counts = dataset.counts || {};
return `
<div class="dataset-row">
<div class="dataset-title">
<strong>Dataset #${escapeHtml(String(dataset.id))}</strong>
<span class="badge ${dataset.is_active ? 'ok' : ''}">${dataset.is_active ? 'active' : 'inactive'}</span>
</div>
<div class="metric-row">${[
`${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('')}</div>
<div class="muted">${escapeHtml(formatDateTime(dataset.created_at))} · ${escapeHtml(shorten(dataset.local_path || '', 110))}</div>
</div>
`;
}
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)
? '<div class="jobs-toolbar"><button type="button" data-jobs-clear-terminal>Clear finished</button></div>'
: '';
if (!jobs.length) {
container.classList.add('muted');
container.innerHTML = `${workerHtml}<div>No jobs yet.</div>`;
return;
}
container.classList.remove('muted');
container.innerHTML = `${workerHtml}${toolbarHtml}${jobs.map(job => `
<div class="job-row">
<div class="job-title">
<span>${escapeHtml(job.description || job.kind)}</span>
<span class="badge ${job.status}">${escapeHtml(job.status)}</span>
</div>
<div class="job-progress">
<progress max="${Number(job.progress_total || 1)}" value="${Number(job.progress_current || 0)}"></progress>
<span class="muted">priority ${Number(job.priority || 0)} · ${escapeHtml(formatDateTime(job.updated_at || job.created_at))}</span>
</div>
${job.requested_action ? `<div class="muted">Requested: ${escapeHtml(job.requested_action)}</div>` : ''}
${job.lease_owner ? `<div class="muted">Worker: ${escapeHtml(job.lease_owner)}</div>` : ''}
${job.error ? `<div class="badge error">${escapeHtml(job.error)}</div>` : ''}
${job.result && Object.keys(job.result).length ? `<div class="muted">${escapeHtml(shorten(JSON.stringify(job.result), 120))}</div>` : ''}
<div class="job-actions">
${jobActionButtons(job)}
</div>
</div>
`).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 `
<div class="job-step-list">
${steps.map((step, index) => {
const event = latestStepEvent(events, step);
const state = jobStepState(job, index, currentIndex);
return `
<div class="job-step ${state}">
<span class="job-step-index">${index + 1}</span>
<div>
<strong>${escapeHtml(step.label)}</strong>
<div class="muted">${event ? escapeHtml(jobEventProgressLabel(event)) : 'planned'}</div>
</div>
</div>
`;
}).join('')}
</div>
`;
}
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 `<pre>${escapeHtml(JSON.stringify(event.metadata, null, 2))}</pre>`;
}
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
? `<section><h3>Result</h3><pre>${escapeHtml(JSON.stringify(job.result, null, 2))}</pre></section>`
: '';
return `
<div class="job-detail">
<div class="job-detail-summary">
<div>
<strong>${escapeHtml(job.description || job.kind || `Job #${job.id}`)}</strong>
<div class="muted">#${escapeHtml(String(job.id || ''))} · ${escapeHtml(job.kind || '')}</div>
</div>
<span class="badge ${escapeHtml(job.status || '')}">${escapeHtml(job.status || '')}</span>
</div>
<div class="job-detail-progress">
<progress max="${progressMax}" value="${progressValue}"></progress>
<span>${escapeHtml(formatCount(progressValue))} / ${escapeHtml(formatCount(progressMax))}</span>
</div>
<div class="metric-row">
${[
`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('')}
</div>
${job.requested_action ? `<div class="badge warn">Requested: ${escapeHtml(job.requested_action)}</div>` : ''}
${job.error ? `<div class="badge error">${escapeHtml(job.error)}</div>` : ''}
${latestEvent ? `<div class="job-current-event"><strong>Current:</strong> ${escapeHtml(jobEventProgressLabel(latestEvent))}</div>` : ''}
<section>
<h3>Planned / current / done</h3>
${renderJobSteps(job, events) || '<p class="muted">No phase template for this job kind; use the event log below.</p>'}
</section>
<section>
<h3>Queue snapshot</h3>
${queueJobs.length ? `
<div class="job-queue-snapshot">
${queueJobs.map(item => `
<div class="job-queue-item ${String(item.id) === String(job.id) ? 'selected' : ''}">
<span class="badge ${escapeHtml(item.status || '')}">${escapeHtml(item.status || '')}</span>
<span>#${escapeHtml(String(item.id))} ${escapeHtml(shorten(item.description || item.kind || '', 88))}</span>
<span class="muted">${escapeHtml(formatDateTime(item.updated_at || item.created_at))}</span>
</div>
`).join('')}
</div>
` : '<p class="muted">No queue rows returned.</p>'}
</section>
<section>
<h3>Events <span class="badge">${events.length}</span></h3>
<div class="job-event-list">
${events.length ? events.map((event, index) => `
<div class="job-event-row ${event.level || ''}">
<span class="job-step-index">${index + 1}</span>
<div>
<div class="job-event-title">
<strong>${escapeHtml(event.event_type || 'event')}</strong>
<span class="muted">${escapeHtml(formatDateTime(event.created_at))}</span>
</div>
<div>${escapeHtml(event.message || '')}</div>
<div class="muted">${escapeHtml(jobEventProgressLabel(event))}</div>
${renderJobEventMetadata(event)}
</div>
</div>
`).join('') : '<p class="muted">No events yet.</p>'}
</div>
</section>
${resultHtml}
</div>
`;
}
async function showJobDetails(jobId) {
activeJobDetailsId = String(jobId);
openOverlay(`Job #${jobId}`, '<p class="muted">Loading job details...</p>');
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 = `<p class="badge error">${escapeHtml(err.message)}</p>`;
}
}
}
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 '<div class="worker-row"><span class="badge weak">worker disabled</span><span class="muted">Set QUEUE_WORKER_AUTOSTART=true to start workers with the server.</span></div>';
}
return `
<div class="worker-list">
${workers.map(worker => `
<div class="worker-row">
<span class="badge ${worker.running ? 'ok' : 'missing'}">${escapeHtml(worker.worker_id)} ${worker.running ? 'running' : 'stopped'}</span>
<span class="muted">${worker.pid ? `pid ${escapeHtml(String(worker.pid))}` : 'no pid'} · ${escapeHtml(shorten(worker.log_file || '', 80))}</span>
</div>
`).join('')}
</div>
`;
}
function jobActionButtons(job) {
const buttons = [`<button type="button" data-job-details="${job.id}">Details</button>`];
if (job.status === 'queued' || job.status === 'running') {
buttons.push(`<button type="button" data-job-action="pause" data-job-id="${job.id}">Pause</button>`);
buttons.push(`<button type="button" class="danger" data-job-action="stop" data-job-id="${job.id}">Stop</button>`);
} else if (job.status === 'paused') {
buttons.push(`<button type="button" class="primary" data-job-action="resume" data-job-id="${job.id}">Resume</button>`);
buttons.push(`<button type="button" class="danger" data-job-action="stop" data-job-id="${job.id}">Stop</button>`);
}
if (!job.terminal) {
buttons.push(`<button type="button" data-job-action="priority-up" data-job-id="${job.id}" data-job-priority="${Number(job.priority || 0)}">Priority +</button>`);
buttons.push(`<button type="button" data-job-action="priority-down" data-job-id="${job.id}" data-job-priority="${Number(job.priority || 0)}">Priority -</button>`);
} else {
if (job.status === 'failed' || job.status === 'cancelled') {
buttons.push(`<button type="button" class="primary" data-job-action="retry" data-job-id="${job.id}">Retry</button>`);
}
buttons.push(`<button type="button" data-job-action="dismiss" data-job-id="${job.id}">Dismiss</button>`);
}
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 = `<span class="badge error">${escapeHtml(err.message)}</span>`;
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 = `<p class="muted">${escapeHtml(emptyMessage)}</p>`;
return;
}
container.innerHTML = sources.map(sourceCard).join('');
}
function sourceCard(source) {
return `
<div class="source">
<div class="source-title">
<span>${escapeHtml(source.name)} <span class="badge ${sourceStatusBadge(source)}">${escapeHtml(sourceStatusLabel(source))}</span></span>
<span class="muted">#${source.id}</span>
</div>
<div class="source-meta">
<div class="muted">${escapeHtml(source.kind)} · ${escapeHtml(source.country || 'n/a')} · ${escapeHtml(source.license || 'unknown license')}</div>
${source.priority || source.mode_scope ? `<div class="metric-row">${[
source.priority ? `priority ${source.priority}` : null,
source.mode_scope || null
].filter(Boolean).map(metric).join('')}</div>` : ''}
${source.source_basis ? `<div class="muted">${escapeHtml(source.source_basis)}</div>` : ''}
${source.notes ? `<div class="muted">${escapeHtml(shorten(source.notes, 120))}</div>` : ''}
<div class="muted" title="${escapeHtml(source.url)}">${escapeHtml(shorten(source.url, 74))}</div>
<div class="metric-row">${sourceMetrics(source).map(metric).join('')}</div>
</div>
${sourceReadinessWarning(source)}
${source.last_error ? `<div class="badge error">${escapeHtml(source.last_error)}</div>` : ''}
${sourceJobRow(source.active_job)}
${sourceUpdateCheckRow(source.latest_update_check)}
<div class="source-actions">
<button class="primary" data-import-source="${source.id}" ${source.active_job ? 'disabled' : ''}>${source.active_job ? 'Job running' : hasAnyActiveDataset(source) ? 'Reimport source' : 'Import source'}</button>
<button class="primary" data-update-source="${source.id}" ${source.active_job ? 'disabled' : ''}>${source.active_job ? 'Job running' : source.is_online ? 'Update online' : 'Check source'}</button>
<button class="danger" data-delete-source="${source.id}" ${source.active_job ? 'disabled' : ''}>${source.active_job?.kind === 'source_delete' ? 'Delete queued' : 'Delete source'}</button>
</div>
<details>
<summary>Datasets (${source.datasets.length})</summary>
<div class="source-datasets">
${source.datasets.map(dataset => datasetRow(dataset)).join('')}
</div>
</details>
</div>
`;
}
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 `
<div class="source-job-row">
<span class="badge ${escapeHtml(job.status)}">job #${escapeHtml(String(job.id))} ${escapeHtml(job.status)}</span>
<span class="muted">${escapeHtml(job.description || job.kind)} · ${escapeHtml(progress)} · ${escapeHtml(formatDateTime(job.updated_at || job.created_at))}</span>
</div>
`;
}
function hasAnyActiveDataset(source) {
return (source.datasets || []).some(dataset => dataset.is_active);
}
function sourceReadinessWarning(source) {
if (source.kind === 'osm_pbf' && !hasActiveOsmDataset(source)) {
return '<div class="source-warning">No active extracted OSM visual dataset yet. Import this source before matching or building the route layer.</div>';
}
if (source.kind === 'gtfs' && !hasActiveGtfsDataset(source)) {
return '<div class="source-warning">No active GTFS timetable dataset yet. Import this source before routing or matching.</div>';
}
if (source.kind === 'osm_geojson' && !hasActiveOsmDataset(source)) {
return '<div class="source-warning">No active OSM visual dataset yet. Import this source before matching or displaying routes.</div>';
}
return '';
}
function sourceUpdateCheckRow(check) {
if (!check) return '<div class="muted">No online update check yet.</div>';
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 `
<div class="source-update-row">
<span class="badge ${badgeClass}">${escapeHtml(status)}</span>
<span class="muted">${escapeHtml(formatDateTime(check.checked_at))}${details ? ` · ${escapeHtml(shorten(details, 100))}` : ''}</span>
</div>
`;
}
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 `
<div class="dataset-row">
<div class="dataset-title">
<strong>Dataset #${dataset.id}</strong>
<span class="badge ${dataset.is_active ? 'ok' : ''}">${dataset.is_active ? 'active' : 'inactive'}</span>
</div>
<div class="metric-row">${counters.map(metric).join('')}</div>
<div class="muted" title="${escapeHtml(dataset.local_path || '')}">${escapeHtml(shorten(dataset.local_path || '', 80))}</div>
${sourceJobRow(dataset.active_job)}
<div class="dataset-actions">
<button class="danger" data-delete-dataset="${dataset.id}" ${dataset.active_job ? 'disabled' : ''}>${dataset.active_job ? 'Delete queued' : 'Delete dataset'}</button>
</div>
</div>
`;
}
function metric(value) {
return `<span class="metric">${escapeHtml(String(value))}</span>`;
}
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 = '<p class="muted">Searching datasets...</p>';
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 = `<p class="badge error">${escapeHtml(err.message)}</p>`;
} 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 = '<p class="muted">No dataset entries found.</p>';
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 `
<section class="dataset-result-section">
<h3>${escapeHtml(title)} <span class="badge">${rows.length}</span></h3>
${rows.map(renderer).join('')}
</section>
`;
}
function renderGtfsSearchHit(hit) {
const route = hit.route || {};
const timetable = hit.timetable || {};
return `
<div ${searchResultAttributes('gtfs_route', route.id, hit.geometry)}>
<div class="dataset-result-title">
<strong>${escapeHtml(route.ref || route.route_id || '')}</strong>
<span>${geometryBadge(hit.geometry)} <span class="badge ${hit.dataset?.is_active ? 'ok' : ''}">${hit.dataset?.is_active ? 'active' : 'inactive'}</span></span>
</div>
<div>${escapeHtml([route.mode, route.name, route.operator].filter(Boolean).join(' · '))}</div>
<div class="muted">${escapeHtml(hit.source?.name || '')} · dataset ${escapeHtml(String(hit.dataset?.id || ''))}</div>
<div class="metric-row">${[
`${formatCount(timetable.trips || 0)} trips`,
`${formatCount(timetable.stop_times || 0)} stop_times`,
`${formatCount(timetable.shapes || 0)} shapes`,
].map(metric).join('')}</div>
</div>
`;
}
function renderOsmSearchHit(hit) {
const osm = hit.osm || {};
return `
<div ${searchResultAttributes('osm_route', osm.id, hit.geometry)}>
<div class="dataset-result-title">
<strong>${escapeHtml(osm.ref || osm.name || osm.osm_id || '')}</strong>
<span>${geometryBadge(hit.geometry)} <span class="badge ${hit.dataset?.is_active ? 'ok' : ''}">${hit.dataset?.is_active ? 'active' : 'inactive'}</span></span>
</div>
<div>${escapeHtml([osm.mode, osm.name, osm.operator || osm.network].filter(Boolean).join(' · '))}</div>
<div class="muted">${escapeHtml(hit.source?.name || '')} · ${escapeHtml(osm.osm_type || '')} ${escapeHtml(osm.osm_id || '')} · dataset ${escapeHtml(String(hit.dataset?.id || ''))}</div>
</div>
`;
}
function renderRoutePatternSearchHit(hit) {
return `
<div ${searchResultAttributes('route_pattern', hit.id, hit.geometry)}>
<div class="dataset-result-title">
<strong>${escapeHtml(hit.ref || hit.name || `pattern ${hit.id}`)}</strong>
<span>${geometryBadge(hit.geometry)} <span class="badge">${escapeHtml(hit.source_kind || '')}</span></span>
</div>
<div>${escapeHtml([hit.mode, hit.name, hit.status].filter(Boolean).join(' · '))}</div>
<div class="muted">pattern ${escapeHtml(String(hit.id))} · confidence ${Number(hit.confidence || 0).toFixed(1)}</div>
</div>
`;
}
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 `
<span class="geometry-badge ${hasGeometry ? 'ok' : 'missing'}" title="${hasGeometry ? 'Geometry available' : 'No geometry available'}">
<span class="geometry-dot" aria-hidden="true"></span>${hasGeometry ? 'geometry' : 'no geometry'}
</span>
`;
}
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 = '<p class="muted">No catalog entries loaded.</p>';
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 `
<div class="catalog-entry">
<div class="catalog-title">
<span>${escapeHtml(entry.source_name)} <span class="badge">${escapeHtml(entry.priority || 'n/a')}</span></span>
<span class="muted">${escapeHtml(entry.country_code || entry.geography || '')}</span>
</div>
<div class="metric-row">${[
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('')}</div>
<div class="muted">${escapeHtml(shorten(entry.coverage_notes || '', 150))}</div>
${entry.geometry_notes ? `<div class="muted">Geometry QA: ${escapeHtml(shorten(entry.geometry_notes, 150))}</div>` : ''}
${entry.next_pipeline_action ? `<div class="muted">Next: ${escapeHtml(shorten(entry.next_pipeline_action, 150))}</div>` : ''}
${entry.source_url ? `<div class="muted" title="${escapeHtml(entry.source_url)}">${escapeHtml(shorten(entry.source_url, 84))}</div>` : ''}
${entry.source_url ? `<div class="source-actions"><button type="button" data-fill-source-from-catalog="${escapeHtml(entry.id)}">${escapeHtml(actionLabel)}</button></div>` : ''}
</div>
`;
}
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 = '<p class="muted">Loading Geofabrik catalog...</p>';
try {
const data = await api(`/api/geofabrik/catalog?${params.toString()}`);
renderGeofabrikResults(data.entries || []);
} catch (err) {
container.innerHTML = `<p class="badge error">${escapeHtml(err.message)}</p>`;
}
}
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 => `
<div class="catalog-entry">
<div class="catalog-title">
<span>${escapeHtml(entry.name)} <span class="badge">${escapeHtml(entry.id)}</span></span>
<span class="muted">${escapeHtml(entry.parent || 'root')}</span>
</div>
<div class="metric-row">${[
...(entry.country_codes || []),
entry.updates_url ? 'diffs available' : null,
entry.pbf_url ? 'PBF' : null
].filter(Boolean).map(metric).join('')}</div>
<div class="muted" title="${escapeHtml(entry.pbf_url || '')}">${escapeHtml(shorten(entry.pbf_url || '', 92))}</div>
${entry.updates_url ? `<div class="muted" title="${escapeHtml(entry.updates_url)}">Updates: ${escapeHtml(shorten(entry.updates_url, 80))}</div>` : ''}
<div class="source-actions">
<button type="button" data-geofabrik-add="${escapeHtml(entry.id)}">Add source</button>
<button type="button" class="primary" data-geofabrik-import="${escapeHtml(entry.id)}">Add + import job</button>
</div>
</div>
`).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 = '<p class="muted">No matches yet. Run the matcher.</p>';
return;
}
container.innerHTML = matches.map(match => `
<div class="match">
<div class="match-title">
${escapeHtml(match.gtfs.ref || match.gtfs.route_id)}
<span class="badge ${match.status}">${escapeHtml(match.status)}</span>
<span class="badge">${Number(match.confidence).toFixed(1)}</span>
</div>
<div>GTFS: ${escapeHtml([match.gtfs.mode, match.gtfs.name, match.gtfs.operator].filter(Boolean).join(' · '))}</div>
<div>OSM: ${match.osm ? escapeHtml([match.osm.mode, match.osm.ref, match.osm.name, match.osm.operator || match.osm.network].filter(Boolean).join(' · ')) : '<span class="muted">none</span>'}</div>
<div class="muted">${escapeHtml(JSON.stringify(match.reasons))}</div>
<div class="match-actions">
<button class="primary" data-accept="${match.id}">Accept</button>
<button class="danger" data-reject="${match.id}">Reject</button>
<button data-candidates="${match.id}">Candidates</button>
</div>
</div>
`).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 = '<strong>Transit snapshot</strong><span>No active GTFS feed imported.</span>';
return;
}
snapshot.classList.remove('muted');
snapshot.innerHTML = `
<strong>Harmonized transit snapshot</strong>
<span>${formatCount(gtfsSources.length)} active GTFS source${gtfsSources.length === 1 ? '' : 's'} · ${formatCount(activeDatasetCount)} active timetable dataset${activeDatasetCount === 1 ? '' : 's'}</span>
`;
}
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
? '<div class="stop-suggestion muted"><span class="spinner spinner-small" aria-hidden="true"></span><span>Searching...</span></div>'
: '';
}
const sequence = journeyStopSearchSequence[role];
journeySearchTimers[role] = window.setTimeout(() => loadJourneyStops(role, sequence), JOURNEY_STOP_SEARCH_DEBOUNCE_MS);
}
function coordinateSuggestionHtml(role, coordinate) {
return `
<button type="button" class="stop-suggestion" data-stop-role="${role}" data-stop-id="${escapeHtml(coordinate.id)}" data-stop-label="${escapeHtml(coordinate.label)}">
${suggestionIcon({ kind: 'coordinate' })}
<span class="stop-suggestion-text">
<strong>${escapeHtml(coordinate.label)}</strong>
<span>map point</span>
</span>
</button>
`;
}
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 = `<div class="stop-suggestion muted">${escapeHtml(data.message || 'Search timed out')}</div>`;
return;
}
suggestions.innerHTML = stops.length
? `${data.timed_out ? `<div class="stop-suggestion muted">${escapeHtml(data.message || 'Search timed out')}</div>` : ''}${stops.map(stop => `
<button type="button" class="stop-suggestion" data-stop-role="${role}" data-stop-id="${stop.id}" data-stop-label="${escapeHtml(stopLabel(stop))}">
${suggestionIcon(stop)}
<span class="stop-suggestion-text">
<strong>${escapeHtml(stop.display_name || stop.name || stop.stop_id)}</strong>
<span>${escapeHtml(stopMetaText(stop))}</span>
</span>
</button>
`).join('')}`
: '<div class="stop-suggestion muted">No stops or addresses found</div>';
} catch (err) {
if (err.name === 'AbortError') return;
suggestions.innerHTML = `<div class="stop-suggestion badge error">${escapeHtml(err.message)}</div>`;
} 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 '<span class="stop-suggestion-icon address-pin" aria-hidden="true">&#128205;</span>';
}
return '<span class="stop-suggestion-icon stop-place-icon" aria-hidden="true">H</span>';
}
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
? '<div class="muted journey-context-status"><span class="spinner spinner-small" aria-hidden="true"></span><span>Resolving nearest stop or address...</span></div>'
: message && entry?.kind === 'coordinate'
? `<div class="muted journey-context-status">${escapeHtml(message)}</div>`
: errorMessage && entry?.kind === 'coordinate'
? `<div class="muted journey-context-status">${escapeHtml(errorMessage)}</div>`
: resolved && entry?.kind === 'coordinate'
? '<div class="muted journey-context-status">No nearby stop or address found.</div>'
: '';
const menu = document.createElement('div');
menu.className = 'journey-context-menu';
menu.innerHTML = `
${entry ? `
<div class="journey-context-title">
${suggestionIcon({ kind: entry.kind })}
<span>
<strong>${escapeHtml(entry.title)}</strong>
<small>${escapeHtml(entry.meta)}</small>
</span>
</div>
<div class="journey-context-actions">
<button type="button" data-context-role="from" data-context-id="${escapeHtml(entry.id)}" data-context-label="${escapeHtml(entry.label)}">From here</button>
<button type="button" data-context-role="to" data-context-id="${escapeHtml(entry.id)}" data-context-label="${escapeHtml(entry.label)}">To here</button>
</div>
` : ''}
${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 = '<p class="muted">Generating travel options...</p>';
}
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 => `
<div class="itinerary ${itinerary.saved ? 'saved' : ''}">
<div class="journey-title">
<span>${escapeHtml(itinerary.title)} <span class="badge ${itinerary.status}">${escapeHtml(itinerary.status)}</span></span>
<button type="button" data-itinerary-save="${escapeHtml(String(itinerary.id))}" data-itinerary-saved="${itinerary.saved ? 'false' : 'true'}">${itinerary.saved ? 'Unsave' : 'Save'}</button>
</div>
<div class="metric-row">${itineraryMetrics(itinerary).map(metric).join('')}</div>
${itinerary.summary?.note ? `<div class="muted">${escapeHtml(itinerary.summary.note)}</div>` : ''}
${itinerary.legs?.length ? itinerary.legs.map(leg => `
<div class="itinerary-leg">
<span>${modeIcon(leg.mode)} <strong>${escapeHtml(leg.route_ref || leg.mode || `leg ${leg.sequence}`)}</strong> ${escapeHtml([legMetaText(leg.payload?.journey_leg || leg), displayTransitTime(leg, 'departure'), leg.from_name, '→', displayTransitTime(leg, 'arrival'), leg.to_name].filter(Boolean).join(' '))}</span>
<button type="button" data-itinerary-leg-lock="${escapeHtml(String(leg.id))}" data-itinerary-leg-locked="${leg.locked ? 'false' : 'true'}">${leg.locked ? 'Unlock' : 'Lock'}</button>
</div>
`).join('') : ''}
<div class="journey-actions">
${itinerary.payload?.journey || itinerary.payload?.routing ? `<button type="button" data-itinerary-show="${escapeHtml(String(itinerary.id))}">Show</button>` : '<span></span>'}
</div>
</div>
`).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 = `<p class="muted journey-message">${options.busy ? '<span class="spinner spinner-small" aria-hidden="true"></span>' : ''}<span>${escapeHtml(text)}</span></p>`;
}
}
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 ? `<div class="muted journey-message">${!data.complete && data.status === 'running' ? '<span class="spinner spinner-small" aria-hidden="true"></span>' : ''}<span>${escapeHtml(data.message)}</span></div>` : ''}
${journeys.map((journey, index) => `
<div class="journey">
<div class="journey-title">
<span>${escapeHtml(displayTransitTime(journey, 'departure'))}${escapeHtml(displayTransitTime(journey, 'arrival'))}${durationText(journey) ? ` · ${escapeHtml(durationText(journey))}` : ''}</span>
<button data-journey-index="${index}">Show</button>
</div>
${journey.legs.map(leg => `
<div class="journey-leg">
${modeIcon(leg.mode)}
<strong>${escapeHtml(leg.route_ref || leg.route_id)}</strong>
${leg.source_name ? `<span class="muted">${escapeHtml(leg.source_name)}</span>` : ''}
${legMetaText(leg) ? `<span class="muted">${escapeHtml(legMetaText(leg))}</span>` : ''}
${escapeHtml(displayTransitTime(leg, 'departure'))} ${journeyStopLink(leg, 'from')}
${escapeHtml(displayTransitTime(leg, 'arrival'))} ${journeyStopLink(leg, 'to')}
</div>
`).join('')}
</div>
`).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 `<span class="mode-icon mode-${escapeHtml(key)}" title="${escapeHtml(meta[0])}" aria-label="${escapeHtml(meta[0])}">${escapeHtml(meta[1])}</span>`;
}
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 `<div class="muted">${escapeHtml(parts.filter(Boolean).join(' · '))}</div>`;
}
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 `<button type="button" class="inline-link" data-canonical-stop="${escapeHtml(String(canonicalId))}">${escapeHtml(label)}</button>`;
}
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 = `
<div class="journey">
<div class="journey-title">
<span>${modeIcon(route.mode)} ${escapeHtml([distance || null, duration || null].filter(Boolean).join(' · ') || 'Route')}</span>
<button type="button" data-routing-show>Show</button>
</div>
${data.message ? `<div class="muted">${escapeHtml(data.message)}</div>` : ''}
<div class="muted">${escapeHtml(route.engine || 'routing')}</div>
</div>
`;
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', '<p class="muted">Loading candidates...</p>', { mapReview: true });
try {
const data = await api(`/api/matches/${matchId}/candidates?limit=30`);
renderCandidateOverlay(data);
} catch (err) {
openOverlay('Matching candidates', `<p class="badge error">${escapeHtml(err.message)}</p>`);
}
}
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 = `
<div class="candidate-context">
<div class="muted">
GTFS ${escapeHtml([route.mode, route.name, route.operator].filter(Boolean).join(' · '))}
</div>
<div class="candidate-preview-legend">
<span><b class="candidate-swatch gtfs"></b>GTFS</span>
<span><b class="candidate-swatch osm"></b>OSM candidates</span>
<span><b class="candidate-swatch selected"></b>selected</span>
</div>
</div>
${rows.length ? rows.map(candidate => `
<div class="candidate ${String(candidate.osm.id) === String(currentOrFirst?.osm?.id) ? 'selected' : ''}" data-candidate-row="${escapeHtml(candidate.osm.id)}">
<div class="candidate-title">
<span>${escapeHtml(candidate.osm.ref || candidate.osm.name || candidate.osm.osm_id)} ${candidate.current_match ? '<span class="badge ok">current</span>' : ''}</span>
<span><span class="badge ${candidate.status}">${escapeHtml(candidate.status)}</span> <span class="badge">${Number(candidate.score).toFixed(1)}</span></span>
</div>
<div>${escapeHtml([candidate.osm.mode, candidate.osm.name, candidate.osm.operator || candidate.osm.network].filter(Boolean).join(' · '))}</div>
<div class="muted">OSM ${escapeHtml(candidate.osm.osm_type)} ${escapeHtml(candidate.osm.osm_id)} · dataset ${escapeHtml(String(candidate.osm.dataset_id))}</div>
<div class="candidate-actions">
<button type="button" data-preview-candidate="${escapeHtml(String(candidate.osm.id))}">Map</button>
<button class="primary" data-accept-candidate="${escapeHtml(String(candidate.osm.id))}" data-match-id="${escapeHtml(String(data.match_id))}" ${candidate.current_match ? 'disabled' : ''}>Use this match</button>
</div>
<pre>${escapeHtml(JSON.stringify(candidate.reasons, null, 2))}</pre>
</div>
`).join('') : '<p class="muted">No OSM route candidates.</p>'}
`;
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', '<p class="muted">Loading stop detail...</p>');
try {
const data = await api(`/api/canonical-stops/${canonicalStopId}`);
renderCanonicalStopOverlay(data);
} catch (err) {
openOverlay('Stop detail', `<p class="badge error">${escapeHtml(err.message)}</p>`);
}
}
function renderCanonicalStopOverlay(data) {
const stop = data.canonical_stop || {};
const gtfsStops = data.gtfs_stops || [];
const osmFeatures = data.osm_features || [];
const rules = data.rules || [];
const html = `
<div class="canonical-stop-detail">
<div class="canonical-summary">
<strong>${escapeHtml(stop.name || stop.stop_key || '')}</strong>
<span class="muted">#${escapeHtml(String(stop.id || ''))} · ${escapeHtml(stop.stop_key || '')}</span>
<span class="muted">${stop.lat !== null && stop.lon !== null ? `${Number(stop.lat).toFixed(6)}, ${Number(stop.lon).toFixed(6)}` : 'no coordinate'}</span>
</div>
<section>
<h3>Linked timetable stops <span class="badge">${gtfsStops.length}</span></h3>
${gtfsStops.length ? gtfsStops.map(link => `
<div class="canonical-link-row">
<div>
<strong>${escapeHtml(link.stop?.name || link.external_id)}</strong>
<div class="muted">${escapeHtml(link.source_name || '')} · dataset ${escapeHtml(String(link.dataset_id))} · ${escapeHtml(link.external_id || '')}</div>
<div class="metric-row">${[
link.role || null,
link.dataset_active ? 'active dataset' : 'inactive dataset',
link.metadata?.manual_rule || null
].filter(Boolean).map(metric).join('')}</div>
</div>
<button type="button" data-canonical-unlink="${escapeHtml(String(link.link_id))}">Unlink</button>
</div>
`).join('') : '<p class="muted">No timetable stops linked.</p>'}
</section>
<section>
<h3>Linked visual stops <span class="badge">${osmFeatures.length}</span></h3>
${osmFeatures.length ? osmFeatures.map(link => `
<div class="canonical-link-row">
<div>
<strong>${escapeHtml(link.feature?.name || link.external_id)}</strong>
<div class="muted">${escapeHtml(link.source_name || '')} · ${escapeHtml(link.feature?.osm_type || '')} ${escapeHtml(link.feature?.osm_id || '')}</div>
<div class="metric-row">${[
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('')}</div>
</div>
</div>
`).join('') : '<p class="muted">No OSM visual stops linked.</p>'}
</section>
<section>
<h3>Find GTFS stop candidate</h3>
<div class="filter-row">
<input id="canonicalCandidateQuery" placeholder="Stop name or ID" />
<button type="button" id="canonicalCandidateSearchBtn" data-canonical-candidate-search="${escapeHtml(String(stop.id))}">Search</button>
</div>
<div id="canonicalCandidateResults" class="canonical-candidates muted">Nearby active GTFS stops will appear here.</div>
</section>
<section>
<h3>Stored decisions <span class="badge">${rules.length}</span></h3>
${rules.length ? rules.map(rule => `
<div class="rule-row">
<strong>${escapeHtml(rule.rule_type)}</strong>
<span class="muted">#${escapeHtml(String(rule.id))} · ${escapeHtml(rule.created_at || '')}</span>
<pre>${escapeHtml(JSON.stringify({ selector: rule.selector, action: rule.action }, null, 2))}</pre>
</div>
`).join('') : '<p class="muted">No stored manual stop decisions found for this stop.</p>'}
</section>
</div>
`;
openOverlay(`Stop: ${stop.name || stop.stop_key || stop.id}`, html);
loadCanonicalStopCandidates(stop.id).catch(err => {
const results = document.getElementById('canonicalCandidateResults');
if (results) results.innerHTML = `<p class="badge error">${escapeHtml(err.message)}</p>`;
});
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 = '<p class="muted">Loading candidates...</p>';
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 => `
<div class="canonical-candidate-row">
<div>
<strong>${escapeHtml(candidate.name || candidate.stop_id)}</strong>
<div class="muted">${escapeHtml(candidate.source_name || '')} · dataset ${escapeHtml(String(candidate.dataset_id))} · ${escapeHtml(candidate.stop_id || '')}</div>
<div class="metric-row">${[
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('')}</div>
</div>
<button type="button" class="primary" data-canonical-link-candidate="${escapeHtml(String(candidate.id))}" data-canonical-stop-target="${escapeHtml(String(canonicalStopId))}">
${candidate.current_canonical_stop_id === canonicalStopId ? 'Linked' : 'Link'}
</button>
</div>
`).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 = '<p class="muted">Searching datasets...</p>';
} 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 = `<p class="badge error">${escapeHtml(err.message)}</p>`;
}
});
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();
}
});