openstreetmap-tools/web/static/app.js
Edward Betts 977008d6bd Fit route bounds to visible map area above open mobile panel
fitBounds was fitting to the full viewport height, placing half the route
behind the panel. Add panel-aware padding (65vh at bottom) on mobile so
the entire route is visible in the uncovered portion of the map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 11:16:08 +00:00

548 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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; }
/** fitBounds padding that accounts for the open panel covering the bottom of the map. */
function fitPadding() {
if (!isMobile()) return { padding: [20, 20] };
// Panel is 65vh; leave 20px margin above it and normal padding elsewhere.
const panelH = Math.round(window.innerHeight * 0.65);
return { paddingTopLeft: [20, 20], paddingBottomRight: [20, panelH + 20] };
}
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(), fitPadding());
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, fitPadding());
}
} 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);
} else if (isMobile()) {
openPanel();
}