Update document.title to "<route name> – OSM Public Transport → GeoJSON" when a route or route_master is loaded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
501 lines
18 KiB
JavaScript
501 lines
18 KiB
JavaScript
/**
|
||
* osm-pt-geojson web frontend
|
||
* Handles map rendering, stop selection, segment preview and GeoJSON download.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
// ── Map setup ──────────────────────────────────────────────────────────────
|
||
|
||
const map = L.map('map').setView([20, 0], 2);
|
||
|
||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
maxZoom: 19,
|
||
}).addTo(map);
|
||
|
||
// Leaflet layers
|
||
let fullRouteLayer = null; // thin grey polyline for the full route
|
||
let segmentLayer = null; // bold coloured polyline for the selected segment
|
||
let markerLayer = null; // all stop markers
|
||
let routeMasterLayers = []; // coloured polylines when viewing a route_master
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_TITLE = document.title;
|
||
let currentRelationId = RELATION_ID; // tracks the currently loaded relation
|
||
let routeData = null; // response from /api/route/
|
||
let segmentGeoJson = null; // last response from /api/segment/ (always includes stops)
|
||
let activeSlot = 'from'; // 'from' | 'to' | null
|
||
let selectedFrom = null; // stop name string or null
|
||
let selectedTo = null; // stop name string or null
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Show an error alert in the sidebar.
|
||
* @param {string} msg
|
||
*/
|
||
function showError(msg) {
|
||
const el = document.getElementById('js-alert');
|
||
document.getElementById('js-alert-msg').textContent = msg;
|
||
el.classList.remove('d-none');
|
||
}
|
||
|
||
/**
|
||
* Trigger a file download of text content.
|
||
* @param {string} content
|
||
* @param {string} filename
|
||
*/
|
||
function downloadText(content, filename) {
|
||
const blob = new Blob([content], { type: 'application/geo+json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
/**
|
||
* Return a safe filename fragment from a stop name.
|
||
* @param {string} name
|
||
* @returns {string}
|
||
*/
|
||
function safeName(name) {
|
||
return name.replace(/[^a-zA-Z0-9_\-]/g, '_').replace(/_+/g, '_');
|
||
}
|
||
|
||
// ── Slot UI ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Update the From/To slot boxes to reflect current state.
|
||
*/
|
||
function renderSlots() {
|
||
const slots = { from: selectedFrom, to: selectedTo };
|
||
for (const [id, value] of Object.entries(slots)) {
|
||
const box = document.getElementById(`slot-${id}`);
|
||
const text = document.getElementById(`slot-${id}-text`);
|
||
const pencil = document.getElementById(`slot-${id}-pencil`);
|
||
const isActive = activeSlot === id;
|
||
|
||
box.classList.toggle('active', isActive);
|
||
pencil.classList.toggle('d-none', !isActive);
|
||
|
||
if (value) {
|
||
text.textContent = value;
|
||
text.classList.remove('placeholder');
|
||
} else {
|
||
text.textContent = 'Click a stop…';
|
||
text.classList.add('placeholder');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set which slot is active. Pass null to deactivate both.
|
||
* @param {'from'|'to'|null} slot
|
||
*/
|
||
function setActiveSlot(slot) {
|
||
activeSlot = slot;
|
||
renderSlots();
|
||
}
|
||
|
||
// ── Stop selection ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Handle a stop being selected (from list click or map marker click).
|
||
* @param {string} name
|
||
*/
|
||
function selectStop(name) {
|
||
if (activeSlot === 'from') {
|
||
selectedFrom = name;
|
||
setActiveSlot('to');
|
||
} else if (activeSlot === 'to') {
|
||
selectedTo = name;
|
||
setActiveSlot(null);
|
||
} else {
|
||
// Neither slot active — do nothing, user must click a slot first.
|
||
return;
|
||
}
|
||
renderStopList();
|
||
updateMarkers();
|
||
if (selectedFrom && selectedTo) {
|
||
loadSegment();
|
||
}
|
||
}
|
||
|
||
// ── Stop list ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Render the sidebar stop list from routeData.
|
||
*/
|
||
function renderStopList() {
|
||
if (!routeData) return;
|
||
const list = document.getElementById('stop-list');
|
||
document.getElementById('stop-count').textContent = `(${routeData.stops.length})`;
|
||
list.innerHTML = '';
|
||
for (const stop of routeData.stops) {
|
||
const div = document.createElement('div');
|
||
div.className = 'stop-item';
|
||
div.textContent = stop.name;
|
||
if (stop.name === selectedFrom) div.classList.add('is-from');
|
||
if (stop.name === selectedTo) div.classList.add('is-to');
|
||
div.addEventListener('click', () => selectStop(stop.name));
|
||
list.appendChild(div);
|
||
}
|
||
}
|
||
|
||
// ── Map rendering ──────────────────────────────────────────────────────────
|
||
|
||
/** Marker colours for stop states. */
|
||
const MARKER_COLOURS = {
|
||
from: '#198754', // green
|
||
to: '#dc3545', // red
|
||
segment: '#0d6efd', // blue
|
||
default: '#6c757d', // grey
|
||
};
|
||
|
||
/**
|
||
* Create a small circular Leaflet marker.
|
||
* @param {number} lat
|
||
* @param {number} lon
|
||
* @param {string} colour hex colour
|
||
* @param {string} title
|
||
* @returns {L.CircleMarker}
|
||
*/
|
||
function makeMarker(lat, lon, colour, title) {
|
||
return L.circleMarker([lat, lon], {
|
||
radius: 6,
|
||
color: colour,
|
||
fillColor: colour,
|
||
fillOpacity: 0.9,
|
||
weight: 2,
|
||
}).bindTooltip(title);
|
||
}
|
||
|
||
/**
|
||
* Determine which stops fall within the current segment (by index).
|
||
* Returns a Set of stop names.
|
||
* @returns {Set<string>}
|
||
*/
|
||
function segmentStopNames() {
|
||
if (!segmentGeoJson) return new Set();
|
||
return new Set(
|
||
segmentGeoJson.features
|
||
.filter(f => f.geometry.type === 'Point')
|
||
.map(f => f.properties.name)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Redraw all stop markers to reflect current selection state.
|
||
*/
|
||
function updateMarkers() {
|
||
if (!routeData) return;
|
||
if (markerLayer) markerLayer.remove();
|
||
markerLayer = L.layerGroup();
|
||
const inSegment = segmentStopNames();
|
||
|
||
for (const stop of routeData.stops) {
|
||
let colour;
|
||
if (stop.name === selectedFrom) colour = MARKER_COLOURS.from;
|
||
else if (stop.name === selectedTo) colour = MARKER_COLOURS.to;
|
||
else if (inSegment.has(stop.name)) colour = MARKER_COLOURS.segment;
|
||
else colour = MARKER_COLOURS.default;
|
||
|
||
const marker = makeMarker(stop.lat, stop.lon, colour, stop.name);
|
||
marker.on('click', () => selectStop(stop.name));
|
||
markerLayer.addLayer(marker);
|
||
}
|
||
markerLayer.addTo(map);
|
||
}
|
||
|
||
/**
|
||
* Draw or redraw the segment layer from segmentGeoJson, respecting the stops toggle.
|
||
*/
|
||
function renderSegment() {
|
||
if (segmentLayer) segmentLayer.remove();
|
||
if (!segmentGeoJson) return;
|
||
|
||
const includeStops = document.getElementById('include-stops').checked;
|
||
|
||
// Build a filtered GeoJSON: always keep LineString, conditionally keep Points.
|
||
const filtered = {
|
||
type: 'FeatureCollection',
|
||
features: segmentGeoJson.features.filter(f =>
|
||
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
|
||
),
|
||
};
|
||
|
||
segmentLayer = L.geoJSON(filtered, {
|
||
style: { color: '#0d6efd', weight: 5, opacity: 0.85 },
|
||
pointToLayer: (feature, latlng) =>
|
||
makeMarker(latlng.lat, latlng.lng, MARKER_COLOURS.segment, feature.properties.name),
|
||
}).addTo(map);
|
||
|
||
updateMarkers();
|
||
updateDownloadButtons();
|
||
}
|
||
|
||
// ── Download buttons ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Enable/disable the segment download button and wire up both download buttons.
|
||
*/
|
||
function updateDownloadButtons() {
|
||
const btn = document.getElementById('btn-download-segment');
|
||
if (segmentGeoJson && selectedFrom && selectedTo) {
|
||
btn.classList.remove('disabled');
|
||
} else {
|
||
btn.classList.add('disabled');
|
||
}
|
||
}
|
||
|
||
document.getElementById('btn-download-segment').addEventListener('click', () => {
|
||
if (!segmentGeoJson || !selectedFrom || !selectedTo) return;
|
||
const includeStops = document.getElementById('include-stops').checked;
|
||
const geojson = {
|
||
type: 'FeatureCollection',
|
||
features: segmentGeoJson.features.filter(f =>
|
||
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
|
||
),
|
||
};
|
||
const ref = routeData.ref || 'route';
|
||
const filename = `${safeName(ref)}-${safeName(selectedFrom)}-${safeName(selectedTo)}.geojson`;
|
||
downloadText(JSON.stringify(geojson, null, 2), filename);
|
||
});
|
||
|
||
document.getElementById('btn-download-full').addEventListener('click', () => {
|
||
if (!routeData) return;
|
||
const includeStops = document.getElementById('include-stops').checked;
|
||
const geojson = {
|
||
type: 'FeatureCollection',
|
||
features: routeData.geojson.features.filter(f =>
|
||
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
|
||
),
|
||
};
|
||
const ref = routeData.ref || 'route';
|
||
downloadText(JSON.stringify(geojson, null, 2), `${safeName(ref)}-full.geojson`);
|
||
});
|
||
|
||
// ── Clear button ───────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('btn-clear').addEventListener('click', () => {
|
||
selectedFrom = null;
|
||
selectedTo = null;
|
||
segmentGeoJson = null;
|
||
if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; }
|
||
setActiveSlot('from');
|
||
renderStopList();
|
||
updateMarkers();
|
||
updateDownloadButtons();
|
||
});
|
||
|
||
// ── Stops toggle ───────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('include-stops').addEventListener('change', () => {
|
||
renderSegment(); // re-filters existing data, no network request
|
||
});
|
||
|
||
// ── Slot click handlers ────────────────────────────────────────────────────
|
||
|
||
document.getElementById('slot-from').addEventListener('click', () => setActiveSlot('from'));
|
||
document.getElementById('slot-to').addEventListener('click', () => setActiveSlot('to'));
|
||
|
||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Load a relation without a full page reload, updating the browser URL.
|
||
* Allows middle-click / ctrl-click to still open in a new tab via the href.
|
||
* @param {number} id
|
||
* @param {MouseEvent} e
|
||
*/
|
||
function navigateTo(id, e) {
|
||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||
e.preventDefault();
|
||
history.pushState(null, '', URLS.routePage + id);
|
||
loadRoute(id);
|
||
}
|
||
|
||
// ── API calls ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Fetch the full route and render it on the map.
|
||
* @param {number} relationId
|
||
*/
|
||
async function loadRoute(relationId) {
|
||
try {
|
||
const resp = await fetch(URLS.routeApi + relationId);
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
if (data.error === 'is_route_master') {
|
||
await loadRouteMaster(relationId);
|
||
return;
|
||
}
|
||
showError(data.message || `Error loading relation ${relationId}.`);
|
||
return;
|
||
}
|
||
routeData = data;
|
||
currentRelationId = relationId;
|
||
// Clean up any previous route_master view
|
||
for (const l of routeMasterLayers) l.remove();
|
||
routeMasterLayers = [];
|
||
document.getElementById('route-master-panel').classList.add('d-none');
|
||
selectedFrom = null;
|
||
selectedTo = null;
|
||
segmentGeoJson = null;
|
||
setActiveSlot('from');
|
||
|
||
// Show route panel
|
||
document.getElementById('route-panel').classList.remove('d-none');
|
||
document.getElementById('route-name').textContent = data.name;
|
||
document.title = `${data.name} – ${DEFAULT_TITLE}`;
|
||
document.getElementById('route-osm-link').href =
|
||
`https://www.openstreetmap.org/relation/${relationId}`;
|
||
|
||
// Draw full route in grey
|
||
if (fullRouteLayer) fullRouteLayer.remove();
|
||
fullRouteLayer = L.geoJSON(data.geojson, {
|
||
style: { color: '#adb5bd', weight: 3, opacity: 0.7 },
|
||
pointToLayer: () => null, // don't render stop points here
|
||
filter: f => f.geometry.type === 'LineString',
|
||
}).addTo(map);
|
||
|
||
map.fitBounds(fullRouteLayer.getBounds(), { padding: [20, 20] });
|
||
|
||
renderStopList();
|
||
updateMarkers();
|
||
updateDownloadButtons();
|
||
|
||
// Other directions (sibling routes from the same route_master)
|
||
const dirPanel = document.getElementById('other-directions-panel');
|
||
const dirList = document.getElementById('other-directions-list');
|
||
dirList.innerHTML = '';
|
||
if (data.other_directions && data.other_directions.length > 0) {
|
||
dirPanel.classList.remove('d-none');
|
||
for (const dir of data.other_directions) {
|
||
const a = document.createElement('a');
|
||
a.href = URLS.routePage + dir.id;
|
||
a.className = 'stop-item d-block text-decoration-none';
|
||
a.textContent = dir.name;
|
||
a.addEventListener('click', (e) => navigateTo(dir.id, e));
|
||
dirList.appendChild(a);
|
||
}
|
||
} else {
|
||
dirPanel.classList.add('d-none');
|
||
}
|
||
} catch (e) {
|
||
showError('Network error loading route.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch the segment GeoJSON and render it.
|
||
*/
|
||
async function loadSegment() {
|
||
if (!selectedFrom || !selectedTo || !routeData) return;
|
||
const rid = currentRelationId;
|
||
const params = new URLSearchParams({ from: selectedFrom, to: selectedTo, stops: '1' });
|
||
try {
|
||
const resp = await fetch(URLS.segmentApi + rid + '?' + params);
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
showError(data.message || 'Error loading segment.');
|
||
return;
|
||
}
|
||
segmentGeoJson = data;
|
||
renderSegment();
|
||
} catch (e) {
|
||
showError('Network error loading segment.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch and display all member routes of a route_master relation.
|
||
* @param {number} relationId
|
||
*/
|
||
async function loadRouteMaster(relationId) {
|
||
try {
|
||
const resp = await fetch(URLS.routeMasterApi + relationId);
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
showError(data.message || `Error loading route master ${relationId}.`);
|
||
return;
|
||
}
|
||
|
||
// Clear individual route state
|
||
routeData = null;
|
||
selectedFrom = null;
|
||
selectedTo = null;
|
||
segmentGeoJson = null;
|
||
if (fullRouteLayer) { fullRouteLayer.remove(); fullRouteLayer = null; }
|
||
if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; }
|
||
if (markerLayer) { markerLayer.remove(); markerLayer = null; }
|
||
for (const l of routeMasterLayers) l.remove();
|
||
routeMasterLayers = [];
|
||
|
||
// Hide individual route panel, show route_master panel
|
||
document.getElementById('route-panel').classList.add('d-none');
|
||
document.getElementById('route-master-panel').classList.remove('d-none');
|
||
document.getElementById('route-master-name').textContent = data.name;
|
||
document.title = `${data.name} – ${DEFAULT_TITLE}`;
|
||
document.getElementById('route-master-osm-link').href =
|
||
`https://www.openstreetmap.org/relation/${relationId}`;
|
||
|
||
const colours = ['#0d6efd', '#dc3545', '#198754', '#fd7e14', '#6f42c1'];
|
||
const list = document.getElementById('route-master-list');
|
||
list.innerHTML = '';
|
||
|
||
data.routes.forEach((route, i) => {
|
||
const colour = colours[i % colours.length];
|
||
|
||
if (route.geojson) {
|
||
const layer = L.geoJSON(route.geojson, {
|
||
style: { color: colour, weight: 4, opacity: 0.85 },
|
||
}).addTo(map);
|
||
routeMasterLayers.push(layer);
|
||
}
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'stop-item d-flex align-items-center gap-2';
|
||
|
||
const dot = document.createElement('span');
|
||
dot.style.cssText =
|
||
`display:inline-block;width:10px;height:10px;border-radius:50%;` +
|
||
`background:${colour};flex-shrink:0`;
|
||
|
||
const a = document.createElement('a');
|
||
a.href = URLS.routePage + route.id;
|
||
a.className = 'text-decoration-none text-reset flex-grow-1';
|
||
a.textContent = route.name;
|
||
a.addEventListener('click', (e) => navigateTo(route.id, e));
|
||
|
||
div.appendChild(dot);
|
||
div.appendChild(a);
|
||
list.appendChild(div);
|
||
});
|
||
|
||
if (routeMasterLayers.length > 0) {
|
||
let bounds = routeMasterLayers[0].getBounds();
|
||
for (const l of routeMasterLayers.slice(1)) bounds = bounds.extend(l.getBounds());
|
||
map.fitBounds(bounds, { padding: [20, 20] });
|
||
}
|
||
} catch (e) {
|
||
showError('Network error loading route master.');
|
||
}
|
||
}
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
|
||
window.addEventListener('popstate', () => {
|
||
const prefix = URLS.routePage;
|
||
if (location.pathname.startsWith(prefix)) {
|
||
const id = parseInt(location.pathname.slice(prefix.length), 10);
|
||
if (id) loadRoute(id);
|
||
}
|
||
});
|
||
|
||
if (RELATION_ID) {
|
||
loadRoute(RELATION_ID);
|
||
}
|