Replace the unreliable peek-strip approach with a floating '☰ Controls' button on the map. The button is always clearly visible, hides when the panel is open, and reappears when the panel closes. Also fix two iOS Safari issues that were hiding the sidebar entirely: - overflow:hidden on body clips position:fixed elements in mobile Safari; reset to overflow:visible in the mobile media query - Add viewport-fit=cover and env(safe-area-inset-bottom) padding so the sidebar clears the home indicator / browser toolbar - Use 100dvh instead of 100vh to avoid iOS address-bar overflow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
538 lines
19 KiB
JavaScript
538 lines
19 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');
|
||
const fab = document.getElementById('panel-fab');
|
||
if (fab) fab.style.display = 'none';
|
||
}
|
||
|
||
function closePanel() {
|
||
document.getElementById('sidebar').classList.remove('panel-open');
|
||
const fab = document.getElementById('panel-fab');
|
||
if (fab) fab.style.removeProperty('display');
|
||
}
|
||
|
||
function updateHandleLabel(text) {
|
||
const el = document.getElementById('sidebar-handle-label');
|
||
if (el) el.textContent = text;
|
||
const fabLabel = document.getElementById('panel-fab-label');
|
||
if (fabLabel) fabLabel.textContent = text;
|
||
}
|
||
|
||
document.getElementById('sidebar-handle').addEventListener('click', () => {
|
||
if (document.getElementById('sidebar').classList.contains('panel-open')) {
|
||
closePanel();
|
||
} else {
|
||
openPanel();
|
||
}
|
||
});
|
||
|
||
document.getElementById('panel-fab').addEventListener('click', openPanel);
|
||
|
||
// ── 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);
|
||
}
|