UNPKG

gtfs-to-html

Version:

Build human readable transit timetables as HTML, PDF or CSV from GTFS

745 lines (671 loc) 19.5 kB
/* global maplibregl, geojson, mapStyleUrl, MaplibreGeocoder */ /* eslint prefer-arrow-callback: "off", no-unused-vars: "off" */ function formatRouteColor(route) { return route.route_color || '#000000'; } function formatRouteTextColor(route) { return route.route_text_color || '#FFFFFF'; } function formatRoute(route) { const element = route.route_url ? document.createElement('a') : document.createElement('div'); element.className = 'map-route-item'; if (route.route_url) { element.href = route.route_url; } if (route.route_color) { const colorSwatch = document.createElement('div'); colorSwatch.className = 'route-color-swatch'; colorSwatch.style.backgroundColor = formatRouteColor(route); colorSwatch.style.color = formatRouteTextColor(route); colorSwatch.textContent = route.route_short_name ?? ''; element.appendChild(colorSwatch); } const routeName = document.createElement('div'); routeName.className = 'underline-hover'; routeName.textContent = route.route_long_name ?? `Route ${route.route_short_name}`; element.appendChild(routeName); return element.outerHTML; } function formatRoutePopup(features) { const container = document.createElement('div'); if (features.length > 1) { const title = document.createElement('div'); title.className = 'popup-title'; title.textContent = 'Routes'; container.appendChild(title); } features.forEach((feature) => { const routeHTML = formatRoute(feature.properties); container.insertAdjacentHTML('beforeend', routeHTML); }); return container.outerHTML; } function formatStopPopup(feature) { let routes = []; try { routes = JSON.parse(feature.properties.routes); } catch (error) { console.error('Failed to parse routes JSON:', error); } const container = document.createElement('div'); const title = document.createElement('div'); title.className = 'popup-title'; title.textContent = feature.properties.stop_name; container.appendChild(title); if (feature.properties.stop_code ?? false) { const stopCodeContainer = document.createElement('div'); const label = document.createElement('div'); label.className = 'popup-label'; label.textContent = 'Stop Code:'; stopCodeContainer.appendChild(label); const code = document.createElement('strong'); code.textContent = feature.properties.stop_code; stopCodeContainer.appendChild(code); container.appendChild(stopCodeContainer); } const routesLabel = document.createElement('div'); routesLabel.className = 'popup-label'; routesLabel.textContent = 'Routes Served:'; container.appendChild(routesLabel); const routeList = document.createElement('div'); routeList.className = 'route-list'; routes.forEach((route) => { const routeHTML = formatRoute(route); routeList.insertAdjacentHTML('beforeend', routeHTML); }); container.appendChild(routeList); const streetviewLink = document.createElement('a'); streetviewLink.className = 'btn-active btn-sm'; streetviewLink.href = `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${feature.geometry.coordinates[1]},${feature.geometry.coordinates[0]}&heading=0&pitch=0&fov=90`; streetviewLink.target = '_blank'; streetviewLink.rel = 'noopener noreferrer'; streetviewLink.textContent = 'View on Streetview'; container.appendChild(streetviewLink); return container.outerHTML; } function getBounds(geojson) { const bounds = new maplibregl.LngLatBounds(); for (const feature of geojson.features) { if (feature.geometry.type.toLowerCase() === 'point') { bounds.extend(feature.geometry.coordinates); } else if (feature.geometry.type.toLowerCase() === 'linestring') { for (const coordinate of feature.geometry.coordinates) { bounds.extend(coordinate); } } else if (feature.geometry.type.toLowerCase() === 'multilinestring') { for (const linestring of feature.geometry.coordinates) { for (const coordinate of linestring) { bounds.extend(coordinate); } } } } return bounds; } function createSystemMap() { const defaultRouteColor = '#000000'; const lineLayout = { 'line-join': 'round', 'line-cap': 'round', }; if (!geojson || geojson.features.length === 0) { const systemMapElement = document.getElementById('system_map'); if (systemMapElement) { systemMapElement.style.display = 'none'; } return false; } const bounds = getBounds(geojson); const map = new maplibregl.Map({ container: 'system_map', style: mapStyleUrl, center: bounds.getCenter(), zoom: 12, }); map.scrollZoom.disable(); map.addControl(new maplibregl.NavigationControl()); map.addControl(new maplibregl.FullscreenControl()); addGeocoder(map, bounds); map.on('load', () => { fitMapToBounds(map, bounds); disablePointsOfInterest(map); addMapLayers(map, geojson, defaultRouteColor, lineLayout); setupEventListeners(map); }); } function addGeocoder(map, bounds) { map.addControl( new MaplibreGeocoder( { forwardGeocode: async (config) => { const features = []; try { const request = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent( config.query, )}&format=geojson&polygon_geojson=1&addressdetails=1&viewbox=${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}&bounded=1`; const response = await fetch(request); const geocodeResult = await response.json(); for (const feature of geocodeResult.features) { if (!feature.bbox || feature.bbox.length < 4) { continue; } const center = [ feature.bbox[0] + (feature.bbox[2] - feature.bbox[0]) / 2, feature.bbox[1] + (feature.bbox[3] - feature.bbox[1]) / 2, ]; const point = { type: 'Feature', geometry: { type: 'Point', coordinates: center, }, place_name: feature.properties.display_name, properties: feature.properties, text: feature.properties.display_name, place_type: ['place'], center, }; features.push(point); } } catch (e) { console.error(`Failed to forwardGeocode with error: ${e}`); } return { features, type: 'FeatureCollection', }; }, }, { maplibregl, debounceSearch: 800, proximity: { latitude: bounds.getCenter()[0], longitude: bounds.getCenter()[1], }, showResultsWhileTyping: true, zoom: 12, }, ), 'top-left', ); } function fitMapToBounds(map, bounds) { map.fitBounds(bounds, { padding: 20, duration: 0, }); } function disablePointsOfInterest(map) { const layers = map.getStyle().layers; const poiLayerIds = layers .filter((layer) => layer.id.startsWith('poi')) ?.map((layer) => layer.id); poiLayerIds.forEach((layerId) => { map.setLayoutProperty(layerId, 'visibility', 'none'); }); } function addMapLayers(map, geojson, defaultRouteColor, lineLayout) { const layers = map.getStyle().layers; const firstLabelLayerId = layers.find( (layer) => layer.type === 'symbol' && layer.id.includes('label'), )?.id; addRouteLineShadow(map, geojson, lineLayout, firstLabelLayerId); addHighlightedRouteLineShadow(map, geojson, lineLayout, firstLabelLayerId); addRouteLineOutline(map, geojson, lineLayout, firstLabelLayerId); addHighlightedRouteLineOutline(map, geojson, lineLayout, firstLabelLayerId); addRouteLine(map, geojson, defaultRouteColor, lineLayout, firstLabelLayerId); addHighlightedRouteLine( map, geojson, defaultRouteColor, lineLayout, firstLabelLayerId, ); addStops(map, geojson); addHighlightedStops(map, geojson); addRouteLabels(map, geojson); } function addRouteLineShadow(map, geojson, lineLayout, firstSymbolId) { map.addLayer( { id: 'route-line-shadows', type: 'line', source: { type: 'geojson', data: geojson }, paint: { 'line-color': '#000000', 'line-opacity': 0.3, 'line-width': { base: 12, stops: [ [14, 20], [18, 42], ], }, 'line-blur': { base: 12, stops: [ [14, 20], [18, 42], ], }, }, layout: lineLayout, filter: ['!has', 'stop_id'], }, firstSymbolId, ); } function addHighlightedRouteLineShadow( map, geojson, lineLayout, firstSymbolId, ) { map.addLayer( { id: 'highlighted-route-line-shadows', type: 'line', source: { type: 'geojson', data: geojson }, paint: { 'line-color': '#000000', 'line-opacity': 0.3, 'line-width': { base: 16, stops: [ [14, 24], [18, 50], ], }, 'line-blur': { base: 16, stops: [ [14, 24], [18, 50], ], }, }, layout: lineLayout, filter: ['==', ['get', 'route_id'], 'none'], }, firstSymbolId, ); } function addRouteLineOutline(map, geojson, lineLayout, firstSymbolId) { map.addLayer( { id: 'route-outlines', type: 'line', source: { type: 'geojson', data: geojson }, paint: { 'line-color': '#FFFFFF', 'line-opacity': 1, 'line-width': { base: 8, stops: [ [14, 12], [18, 32], ], }, }, layout: lineLayout, filter: ['has', 'route_id'], }, firstSymbolId, ); } function addHighlightedRouteLineOutline( map, geojson, lineLayout, firstSymbolId, ) { map.addLayer( { id: 'highlighted-route-outlines', type: 'line', source: { type: 'geojson', data: geojson }, paint: { 'line-color': '#FFFFFF', 'line-opacity': 1, 'line-width': { base: 10, stops: [ [14, 16], [18, 40], ], }, }, layout: lineLayout, filter: ['==', ['get', 'route_id'], 'none'], }, firstSymbolId, ); } function addRouteLine( map, geojson, defaultRouteColor, lineLayout, firstSymbolId, ) { map.addLayer( { id: 'routes', type: 'line', source: { type: 'geojson', data: geojson }, paint: { 'line-color': ['coalesce', ['get', 'route_color'], defaultRouteColor], 'line-opacity': 1, 'line-width': { base: 4, stops: [ [14, 6], [18, 16], ], }, }, layout: lineLayout, filter: ['has', 'route_id'], }, firstSymbolId, ); } function addHighlightedRouteLine( map, geojson, defaultRouteColor, lineLayout, firstSymbolId, ) { map.addLayer( { id: 'highlighted-routes', type: 'line', source: { type: 'geojson', data: geojson }, paint: { 'line-color': ['coalesce', ['get', 'route_color'], defaultRouteColor], 'line-opacity': 1, 'line-width': { base: 6, stops: [ [14, 8], [18, 20], ], }, }, layout: lineLayout, filter: ['==', ['get', 'route_id'], 'none'], }, firstSymbolId, ); } function addStops(map, geojson) { map.addLayer({ id: 'stops', type: 'circle', source: { type: 'geojson', data: geojson }, paint: { 'circle-color': '#fff', 'circle-radius': { base: 1.75, stops: [ [12, 4], [22, 100], ], }, 'circle-stroke-color': '#3F4A5C', 'circle-stroke-width': 2, 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 13, 0, 13.5, 1], 'circle-stroke-opacity': [ 'interpolate', ['linear'], ['zoom'], 13, 0, 13.5, 1, ], }, filter: ['has', 'stop_id'], }); } function addHighlightedStops(map, geojson) { map.addLayer({ id: 'stops-highlighted', type: 'circle', source: { type: 'geojson', data: geojson }, paint: { 'circle-color': '#fff', 'circle-radius': { base: 1.75, stops: [ [12, 5], [22, 125], ], }, 'circle-stroke-width': 2, 'circle-stroke-color': '#3f4a5c', 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 13, 0, 13.5, 1], 'circle-stroke-opacity': [ 'interpolate', ['linear'], ['zoom'], 13, 0, 13.5, 1, ], }, filter: ['==', 'stop_id', ''], }); } function addRouteLabels(map, geojson) { map.addLayer({ id: 'route-labels', type: 'symbol', source: { type: 'geojson', data: geojson }, layout: { 'symbol-placement': 'line', 'text-field': ['get', 'route_short_name'], 'text-size': 14, }, paint: { 'text-color': '#000000', 'text-halo-width': 2, 'text-halo-color': '#ffffff', }, filter: ['has', 'route_short_name'], }); } function setupEventListeners(map) { map.on('mousemove', (event) => handleMouseMove(event, map)); map.on('click', (event) => handleClick(event, map)); setupTableHoverListeners(map); } function handleMouseMove(event, map) { const features = map.queryRenderedFeatures(event.point, { layers: ['routes', 'route-outlines', 'stops-highlighted', 'stops'], }); if (features.length > 0) { map.getCanvas().style.cursor = 'pointer'; // Get unique route IDs const routeIds = features .map((feature) => feature.properties.route_id) .filter((value, index, self) => value && self.indexOf(value) === index); highlightRoutes(map, routeIds); if (features.some((feature) => feature.layer.id === 'stops')) { highlightStop( map, features.find((feature) => feature.layer.id === 'stops').properties .stop_id, ); } } else { map.getCanvas().style.cursor = ''; unHighlightRoutes(map); unHighlightStop(map); } } function handleClick(event, map) { const bbox = [ [event.point.x - 5, event.point.y - 5], [event.point.x + 5, event.point.y + 5], ]; const stopFeatures = map.queryRenderedFeatures(bbox, { layers: ['stops-highlighted', 'stops'], }); if (stopFeatures && stopFeatures.length > 0) { showStopPopup(map, stopFeatures[0]); } else { const routeFeatures = map.queryRenderedFeatures(bbox, { layers: ['routes', 'route-outlines'], }); if (routeFeatures && routeFeatures.length > 0) { showRoutePopup(map, routeFeatures, event.lngLat); } } } function showStopPopup(map, feature) { new maplibregl.Popup() .setLngLat(feature.geometry.coordinates) .setHTML(formatStopPopup(feature)) .addTo(map); } function showRoutePopup(map, features, lngLat) { // Get list of unique routes, using route_short_name as the key const seen = {}; const uniqueRoutes = []; for (const feature of features) { const routeShortName = feature.properties.route_short_name; if (!seen[routeShortName]) { seen[routeShortName] = true; uniqueRoutes.push(feature); } } // Sort by route_short_name as number, then alphabetically const routes = uniqueRoutes.sort((a, b) => { const aNum = Number.parseInt(a.properties.route_short_name, 10); if (Number.isNaN(aNum) && Number.isNaN(bNum)) { return a.properties.route_short_name.localeCompare( b.properties.route_short_name, ); } if (Number.isNaN(aNum)) return 1; if (Number.isNaN(bNum)) return -1; const bNum = Number.parseInt(b.properties.route_short_name, 10); return aNum - bNum; }); new maplibregl.Popup() .setLngLat(lngLat) .setHTML(formatRoutePopup(routes)) .addTo(map); } function highlightStop(map, stopId) { map.setFilter('stops-highlighted', ['==', 'stop_id', stopId]); } function unHighlightStop(map) { map.setFilter('stops-highlighted', ['==', 'stop_id', '']); } function highlightRoutes(map, routeIds, zoom) { map.setFilter('highlighted-routes', [ 'all', ['has', 'route_short_name'], ['in', ['get', 'route_id'], ['literal', routeIds]], ]); map.setFilter('highlighted-route-outlines', [ 'all', ['has', 'route_short_name'], ['in', ['get', 'route_id'], ['literal', routeIds]], ]); map.setFilter('highlighted-route-line-shadows', [ 'all', ['has', 'route_short_name'], ['in', ['get', 'route_id'], ['literal', routeIds]], ]); map.setFilter('route-labels', [ 'in', ['get', 'route_id'], ['literal', routeIds], ]); const routeLineOpacity = 0.4; map.setPaintProperty('routes', 'line-opacity', routeLineOpacity); map.setPaintProperty('route-outlines', 'line-opacity', routeLineOpacity); map.setPaintProperty('route-line-shadows', 'line-opacity', routeLineOpacity); if (zoom) { const data = map.querySourceFeatures('routes'); if (data) { const highlightedFeatures = data.filter((feature) => routeIds.includes(feature.properties.route_id), ); if (highlightedFeatures.length > 0) { const zoomBounds = getBounds({ type: 'FeatureCollection', features: highlightedFeatures, }); map.fitBounds(zoomBounds, { padding: 20, }); } } } } function unHighlightRoutes(map, zoom) { map.setFilter('highlighted-routes', ['==', ['get', 'route_id'], 'none']); map.setFilter('highlighted-route-outlines', [ '==', ['get', 'route_id'], 'none', ]); map.setFilter('highlighted-route-line-shadows', [ '==', ['get', 'route_id'], 'none', ]); map.setFilter('route-labels', ['has', 'route_short_name']); const routeLineOpacity = 1; map.setPaintProperty('routes', 'line-opacity', routeLineOpacity); map.setPaintProperty('route-outlines', 'line-opacity', routeLineOpacity); map.setPaintProperty('route-line-shadows', 'line-opacity', routeLineOpacity); if (zoom) { const data = map.querySourceFeatures('routes'); if (data) { map.fitBounds( getBounds({ type: 'FeatureCollection', features: data, }), ); } } } function setupTableHoverListeners(map) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { initializeTableHoverListeners(map); }); } else { initializeTableHoverListeners(map); } } function initializeTableHoverListeners(map) { const overviewLinks = document.querySelectorAll('.overview-list a'); overviewLinks.forEach((link) => { link.addEventListener('mouseenter', (event) => { const routeIdString = event.currentTarget.dataset.routeIds; if (routeIdString) { const routeIds = routeIdString.toString().split(','); highlightRoutes(map, routeIds, true); } }); }); const overviewList = document.querySelector('.overview-list'); if (overviewList) { overviewList.addEventListener('mouseleave', () => { unHighlightRoutes(map, true); }); } }