gtfs-to-html
Version:
Build human readable transit timetables as HTML, PDF or CSV from GTFS
694 lines (626 loc) • 17.3 kB
JavaScript
/* global document, jQuery, _, maplibregl, geojson, mapStyleUrl */
/* 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 html = route.route_url
? jQuery('<a>').attr('href', route.route_url)
: jQuery('<div>');
html.addClass('map-route-item');
const routeItemDivs = [];
if (route.route_color) {
routeItemDivs.push(
jQuery('<div>')
.addClass('route-color-swatch')
.css('backgroundColor', formatRouteColor(route))
.css('color', formatRouteTextColor(route))
.text(route.route_short_name ?? ''),
);
}
routeItemDivs.push(
jQuery('<div>')
.addClass('underline-hover')
.text(route.route_long_name ?? `Route ${route.route_short_name}`),
);
html.append(routeItemDivs);
return html.prop('outerHTML');
}
function formatRoutePopup(features) {
const html = jQuery('<div>');
if (features.length > 1) {
jQuery('<div>').addClass('popup-title').text('Routes').appendTo(html);
}
jQuery(html).append(
features.map((feature) => formatRoute(feature.properties)),
);
return html.prop('outerHTML');
}
function formatStopPopup(feature) {
const routes = JSON.parse(feature.properties.routes);
const html = jQuery('<div>');
jQuery('<div>')
.addClass('popup-title')
.text(feature.properties.stop_name)
.appendTo(html);
if (feature.properties.stop_code ?? false) {
jQuery('<div>')
.html([
jQuery('<div>').addClass('popup-label').text('Stop Code:'),
jQuery('<strong>').text(feature.properties.stop_code),
])
.appendTo(html);
}
jQuery('<div>').addClass('popup-label').text('Routes Served:').appendTo(html);
jQuery(html).append(
jQuery('<div>')
.addClass('route-list')
.html(routes.map((route) => formatRoute(route))),
);
jQuery('<a>')
.addClass('btn-blue btn-sm')
.prop(
'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`,
)
.prop('target', '_blank')
.prop('rel', 'noopener noreferrer')
.html('View on Streetview')
.appendTo(html);
return html.prop('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) {
jQuery('#' + id).hide();
return false;
}
const bounds = getBounds(geojson);
const map = new maplibregl.Map({
container: 'system_map',
style: mapStyleUrl,
center: bounds.getCenter(),
zoom: 12,
});
const routes = {};
for (const feature of geojson.features) {
routes[feature.properties.route_id] = feature.properties;
}
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, routes);
});
}
function addGeocoder(map, bounds) {
map.addControl(
new MaplibreGeocoder(
{
forwardGeocode: async (config) => {
const features = [];
try {
const request = `https://nominatim.openstreetmap.org/search?q=${
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 geojson = await response.json();
for (const feature of geojson.features) {
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,
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 getFirstSymbolLayerId(map) {
const layers = map.getStyle().layers;
return layers.find((layer) => layer.type === 'symbol').id;
}
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, routes) {
map.on('mousemove', (event) => handleMouseMove(event, map, routes));
map.on('click', (event) => handleClick(event, map));
setupTableHoverListeners(map);
}
function handleMouseMove(event, map, routes) {
const features = map.queryRenderedFeatures(event.point, {
layers: ['routes', 'route-outlines', 'stops-highlighted', 'stops'],
});
if (features.length > 0) {
map.getCanvas().style.cursor = 'pointer';
highlightRoutes(
map,
_.compact(_.uniq(features.map((feature) => feature.properties.route_id))),
);
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) {
const routes = _.orderBy(
_.uniqBy(features, (feature) => feature.properties.route_short_name),
(feature) => Number.parseInt(feature.properties.route_short_name, 10),
);
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) {
jQuery(() => {
jQuery('.overview-list a').hover((event) => {
const routeIdString = jQuery(event.target).data('route-ids');
if (routeIdString) {
const routeIds = routeIdString.toString().split(',');
highlightRoutes(map, routeIds, true);
}
});
jQuery('.overview-list').hover(
() => {},
() => unHighlightRoutes(map, true),
);
});
}