On narrow screens the sidebar collapses to a 48px handle strip at the bottom of the screen. Tapping the handle slides the panel up to 65vh, revealing the full stop list and controls. The map takes the full viewport width when the panel is closed or peek-visible. The handle label updates dynamically to show the loaded route name. The panel auto-opens when a route or route_master finishes loading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
522 lines
18 KiB
JavaScript
522 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
|
||
|
||
// ── Mobile panel ───────────────────────────────────────────────────────────
|
||
|
||
function isMobile() { return window.innerWidth < 768; }
|
||
|
||
function openPanel() {
|
||
document.getElementById('sidebar').classList.add('panel-open');
|
||
}
|
||
|
||
function updateHandleLabel(text) {
|
||
const el = document.getElementById('sidebar-handle-label');
|
||
if (el) el.textContent = text;
|
||
}
|
||
|
||
document.getElementById('sidebar-handle').addEventListener('click', () => {
|
||
document.getElementById('sidebar').classList.toggle('panel-open');
|
||
});
|
||
|
||
// ── 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}`;
|
||
updateHandleLabel(data.name);
|
||
if (isMobile()) openPanel();
|
||
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}`;
|
||
updateHandleLabel(data.name);
|
||
if (isMobile()) openPanel();
|
||
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);
|
||
}
|