4091 lines
160 KiB
JavaScript
4091 lines
160 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', '\'': ''', '"': '"' }[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">📍</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();
|
||
}
|
||
});
|