gtfs-to-html 2.9.0 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config-sample.json +68 -0
- package/dist/app/index.js +2 -1
- package/dist/app/index.js.map +1 -1
- package/dist/bin/gtfs-to-html.js +16 -9
- package/dist/bin/gtfs-to-html.js.map +1 -1
- package/dist/index.js +16 -9
- package/dist/index.js.map +1 -1
- package/docker/Dockerfile +14 -0
- package/docker/README.md +5 -0
- package/docker/config.json +21 -0
- package/docker/docker-compose.yml +10 -0
- package/examples/stop_attributes.txt +6 -0
- package/examples/timetable_notes.txt +8 -0
- package/examples/timetable_notes_references.txt +8 -0
- package/examples/timetable_pages.txt +3 -0
- package/examples/timetable_stop_order.txt +16 -0
- package/examples/timetables.txt +9 -0
- package/package.json +8 -3
- package/views/default/css/overview_styles.css +198 -0
- package/views/default/css/timetable_pdf_styles.css +69 -0
- package/views/default/css/timetable_styles.css +522 -0
- package/views/default/formatting_functions.pug +104 -0
- package/views/default/js/system-map.js +594 -0
- package/views/default/js/timetable-map.js +753 -0
- package/views/default/js/timetable-menu.js +57 -0
- package/views/default/layout.pug +11 -0
- package/views/default/overview.pug +27 -0
- package/views/default/overview_full.pug +16 -0
- package/views/default/timetable_continuation_as.pug +7 -0
- package/views/default/timetable_continuation_from.pug +7 -0
- package/views/default/timetable_horizontal.pug +42 -0
- package/views/default/timetable_hourly.pug +30 -0
- package/views/default/timetable_map.pug +15 -0
- package/views/default/timetable_menu.pug +48 -0
- package/views/default/timetable_note_symbol.pug +5 -0
- package/views/default/timetable_stop_name.pug +13 -0
- package/views/default/timetable_stoptime.pug +17 -0
- package/views/default/timetable_vertical.pug +67 -0
- package/views/default/timetablepage.pug +64 -0
- package/views/default/timetablepage_full.pug +25 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/* global window, document, $, mapboxgl, Pbf, stops, routes, tripIds, gtfsRealtimeUrls, geojsons */
|
|
2
|
+
/* eslint no-var: "off", prefer-arrow-callback: "off", no-unused-vars: "off" */
|
|
3
|
+
|
|
4
|
+
const maps = {};
|
|
5
|
+
const vehicleMarkers = {};
|
|
6
|
+
const vehicleMarkersEventListeners = {};
|
|
7
|
+
let vehiclePopup;
|
|
8
|
+
let gtfsRealtimeInterval;
|
|
9
|
+
|
|
10
|
+
function formatRouteColor(route) {
|
|
11
|
+
return route.route_color || '#000000';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatRouteTextColor(route) {
|
|
15
|
+
return route.route_text_color || '#FFFFFF';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function degToCompass(num) {
|
|
19
|
+
var val = Math.floor(num / 22.5 + 0.5);
|
|
20
|
+
var arr = [
|
|
21
|
+
'N',
|
|
22
|
+
'NNE',
|
|
23
|
+
'NE',
|
|
24
|
+
'ENE',
|
|
25
|
+
'E',
|
|
26
|
+
'ESE',
|
|
27
|
+
'SE',
|
|
28
|
+
'SSE',
|
|
29
|
+
'S',
|
|
30
|
+
'SSW',
|
|
31
|
+
'SW',
|
|
32
|
+
'WSW',
|
|
33
|
+
'W',
|
|
34
|
+
'WNW',
|
|
35
|
+
'NW',
|
|
36
|
+
'NNW',
|
|
37
|
+
];
|
|
38
|
+
return arr[val % 16];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function metersPerSecondToMph(metersPerSecond) {
|
|
42
|
+
return metersPerSecond * 2.23694;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatSpeed(mph) {
|
|
46
|
+
return `${Math.round(mph * 10) / 10} mph`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatSeconds(seconds) {
|
|
50
|
+
return seconds < 60
|
|
51
|
+
? Math.floor(seconds) + ' sec'
|
|
52
|
+
: Math.floor(seconds / 60) + ' min';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatRoute(route) {
|
|
56
|
+
const html = route.route_url
|
|
57
|
+
? $('<a>').attr('href', route.route_url)
|
|
58
|
+
: $('<div>');
|
|
59
|
+
|
|
60
|
+
html.addClass('map-route-item');
|
|
61
|
+
|
|
62
|
+
// Only add color swatch if route has a color
|
|
63
|
+
const routeItemDivs = [];
|
|
64
|
+
|
|
65
|
+
if (route.route_color) {
|
|
66
|
+
routeItemDivs.push(
|
|
67
|
+
$('<div>')
|
|
68
|
+
.addClass('route-color-swatch')
|
|
69
|
+
.css('backgroundColor', formatRouteColor(route))
|
|
70
|
+
.css('color', formatRouteTextColor(route))
|
|
71
|
+
.text(route.route_short_name ?? ''),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
routeItemDivs.push(
|
|
75
|
+
$('<div>')
|
|
76
|
+
.addClass('underline-hover')
|
|
77
|
+
.text(route.route_long_name ?? `Route ${route.route_short_name}`),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
html.append(routeItemDivs);
|
|
81
|
+
|
|
82
|
+
return html.prop('outerHTML');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatStopPopup(feature, stop) {
|
|
86
|
+
const routeIds = JSON.parse(feature.properties.route_ids);
|
|
87
|
+
const html = $('<div>');
|
|
88
|
+
|
|
89
|
+
$('<div>').addClass('popup-title').text(stop.stop_name).appendTo(html);
|
|
90
|
+
|
|
91
|
+
if (stop.stop_code ?? false) {
|
|
92
|
+
$('<div>')
|
|
93
|
+
.html([
|
|
94
|
+
$('<label>').addClass('popup-label').text('Stop Code:'),
|
|
95
|
+
$('<strong>').text(stop.stop_code),
|
|
96
|
+
])
|
|
97
|
+
.appendTo(html);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$('<label>').text('Routes Served:').appendTo(html);
|
|
101
|
+
|
|
102
|
+
$(html).append(
|
|
103
|
+
$('<div>')
|
|
104
|
+
.addClass('route-list')
|
|
105
|
+
.html(routeIds.map((routeId) => formatRoute(routes[routeId]))),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
$('<a>')
|
|
109
|
+
.addClass('btn-blue btn-sm')
|
|
110
|
+
.prop(
|
|
111
|
+
'href',
|
|
112
|
+
`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`,
|
|
113
|
+
)
|
|
114
|
+
.prop('target', '_blank')
|
|
115
|
+
.prop('rel', 'noopener noreferrer')
|
|
116
|
+
.html('View on Streetview')
|
|
117
|
+
.appendTo(html);
|
|
118
|
+
|
|
119
|
+
return html.prop('outerHTML');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getBounds(geojson) {
|
|
123
|
+
const bounds = new mapboxgl.LngLatBounds();
|
|
124
|
+
for (const feature of geojson.features) {
|
|
125
|
+
if (feature.geometry.type.toLowerCase() === 'point') {
|
|
126
|
+
bounds.extend(feature.geometry.coordinates);
|
|
127
|
+
} else if (feature.geometry.type.toLowerCase() === 'linestring') {
|
|
128
|
+
for (const coordinate of feature.geometry.coordinates) {
|
|
129
|
+
bounds.extend(coordinate);
|
|
130
|
+
}
|
|
131
|
+
} else if (feature.geometry.type.toLowerCase() === 'multilinestring') {
|
|
132
|
+
for (const linestring of feature.geometry.coordinates) {
|
|
133
|
+
for (const coordinate of linestring) {
|
|
134
|
+
bounds.extend(coordinate);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return bounds;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function secondsInFuture(dateString) {
|
|
144
|
+
// Takes a dateString in the format of "YYYYMMDD HH:mm:ss" and returns true if the date is more than 15 minutes in the future
|
|
145
|
+
|
|
146
|
+
const inputDate = new Date(
|
|
147
|
+
dateString.substring(0, 4), // Year
|
|
148
|
+
dateString.substring(4, 6) - 1, // Month (zero-indexed)
|
|
149
|
+
dateString.substring(6, 8), // Day
|
|
150
|
+
dateString.substring(9, 11), // Hours
|
|
151
|
+
dateString.substring(12, 14), // Minutes
|
|
152
|
+
dateString.substring(15, 17), // Seconds
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const now = new Date();
|
|
156
|
+
const diffInMilliseconds = inputDate - now;
|
|
157
|
+
const diffInSeconds = Math.floor(diffInMilliseconds / 1000);
|
|
158
|
+
|
|
159
|
+
// If the date is in the future, return the number of seconds, otherwise return 0
|
|
160
|
+
return diffInSeconds > 0 ? diffInSeconds : 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getVehiclePopupHtml(vehiclePosition, vehicleTripUpdate) {
|
|
164
|
+
const lastUpdated = new Date(vehiclePosition.vehicle.timestamp * 1000);
|
|
165
|
+
const directionName = $(
|
|
166
|
+
'.timetable #trip_id_' + vehiclePosition.vehicle.trip.trip_id,
|
|
167
|
+
)
|
|
168
|
+
.parents('.timetable')
|
|
169
|
+
.data('direction-name');
|
|
170
|
+
|
|
171
|
+
const descriptionArray = [];
|
|
172
|
+
|
|
173
|
+
if (directionName) {
|
|
174
|
+
descriptionArray.push(`<div class="popup-title">${directionName}</div>`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let movingText = '';
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
vehiclePosition.vehicle.position.bearing !== undefined &&
|
|
181
|
+
vehiclePosition.vehicle.position.bearing !== 0
|
|
182
|
+
) {
|
|
183
|
+
movingText += `Moving: ${degToCompass(vehiclePosition.vehicle.position.bearing)}`;
|
|
184
|
+
}
|
|
185
|
+
if (vehiclePosition.vehicle.position.speed) {
|
|
186
|
+
movingText += ` at ${formatSpeed(metersPerSecondToMph(vehiclePosition.vehicle.position.speed))}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (movingText) {
|
|
190
|
+
descriptionArray.push(`<div>${movingText}</div>`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
descriptionArray.push(
|
|
194
|
+
`<div><small>Updated: ${lastUpdated.toLocaleTimeString()}</small></div>`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const nextArrivals = [];
|
|
198
|
+
if (vehicleTripUpdate && vehicleTripUpdate.trip_update.stop_time_update) {
|
|
199
|
+
for (const stoptimeUpdate of vehicleTripUpdate.trip_update
|
|
200
|
+
.stop_time_update) {
|
|
201
|
+
if (stoptimeUpdate.arrival) {
|
|
202
|
+
const secondsToArrival =
|
|
203
|
+
stoptimeUpdate.arrival.time - Date.now() / 1000;
|
|
204
|
+
const stopName = stops[stoptimeUpdate.stop_id]?.stop_name;
|
|
205
|
+
|
|
206
|
+
// Don't show arrivals in the past or non-timepoints
|
|
207
|
+
if (secondsToArrival > 0 && stopName) {
|
|
208
|
+
nextArrivals.push({
|
|
209
|
+
delay: stoptimeUpdate.arrival.delay,
|
|
210
|
+
secondsToArrival,
|
|
211
|
+
stopName,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (nextArrivals.length >= 3) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (nextArrivals.length > 0) {
|
|
223
|
+
descriptionArray.push('<div><strong>Upcoming Stops: </strong></div>');
|
|
224
|
+
|
|
225
|
+
for (const arrival of nextArrivals) {
|
|
226
|
+
let delay = '';
|
|
227
|
+
|
|
228
|
+
if (arrival.delay > 0) {
|
|
229
|
+
delay = `<span class="delay">${formatSeconds(arrival.delay)} late</span>`;
|
|
230
|
+
} else if (arrival.delay < 0) {
|
|
231
|
+
delay = `<span class="delay">${formatSeconds(arrival.delay)} early</span>`;
|
|
232
|
+
}
|
|
233
|
+
descriptionArray.push(
|
|
234
|
+
`<div>${arrival.stopName} in <strong>${formatSeconds(arrival.secondsToArrival)}</strong> ${delay}</div>`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return descriptionArray.join('');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function addVehicleMarker(vehiclePosition, vehicleTripUpdate) {
|
|
243
|
+
if (!vehiclePosition.vehicle || !vehiclePosition.vehicle.position) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const visibleTimetableId = $('.timetable:visible').data('timetable-id');
|
|
248
|
+
|
|
249
|
+
// Create a DOM element for each marker
|
|
250
|
+
const el = document.createElement('div');
|
|
251
|
+
el.className = 'vehicle-marker';
|
|
252
|
+
el.style.width = '20px';
|
|
253
|
+
el.style.height = '20px';
|
|
254
|
+
el.innerHTML = `<div class="vehicle-marker-arrow" aria-hidden="true" style="transform:rotate(${vehiclePosition.vehicle.position.bearing}deg)"></div>`;
|
|
255
|
+
|
|
256
|
+
const coordinates = [
|
|
257
|
+
vehiclePosition.vehicle.position.longitude,
|
|
258
|
+
vehiclePosition.vehicle.position.latitude,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
vehicleMarkersEventListeners[vehiclePosition.vehicle.vehicle.id] = () => {
|
|
262
|
+
vehiclePopup
|
|
263
|
+
.setLngLat(coordinates)
|
|
264
|
+
.setHTML(getVehiclePopupHtml(vehiclePosition, vehicleTripUpdate))
|
|
265
|
+
.addTo(maps[visibleTimetableId]);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Vehicle marker popups
|
|
269
|
+
el.addEventListener(
|
|
270
|
+
'mouseenter',
|
|
271
|
+
vehicleMarkersEventListeners[vehiclePosition.vehicle.vehicle.id],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
el.addEventListener('mouseleave', () => {
|
|
275
|
+
vehiclePopup.remove();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Add marker to map
|
|
279
|
+
const marker = new mapboxgl.Marker(el)
|
|
280
|
+
.setLngLat(coordinates)
|
|
281
|
+
.addTo(maps[visibleTimetableId]);
|
|
282
|
+
|
|
283
|
+
return marker;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function animateVehicleMarker(vehicleMarker, newCoordinates) {
|
|
287
|
+
let startTime;
|
|
288
|
+
const duration = 5000;
|
|
289
|
+
const previousCoordinates = vehicleMarker.getLngLat().toArray();
|
|
290
|
+
const longitudeDifference = newCoordinates[0] - previousCoordinates[0];
|
|
291
|
+
const latitudeDifference = newCoordinates[1] - previousCoordinates[1];
|
|
292
|
+
|
|
293
|
+
const animation = (timestamp) => {
|
|
294
|
+
startTime = startTime || timestamp;
|
|
295
|
+
const elapsedTime = timestamp - startTime;
|
|
296
|
+
const progress = elapsedTime / duration;
|
|
297
|
+
const safeProgress = Math.min(progress.toFixed(2), 1);
|
|
298
|
+
const newLongitude =
|
|
299
|
+
previousCoordinates[0] + safeProgress * longitudeDifference;
|
|
300
|
+
const newLatitude =
|
|
301
|
+
previousCoordinates[1] + safeProgress * latitudeDifference;
|
|
302
|
+
|
|
303
|
+
vehicleMarker.setLngLat([newLongitude, newLatitude]);
|
|
304
|
+
vehiclePopup.setLngLat([newLongitude, newLatitude]);
|
|
305
|
+
|
|
306
|
+
if (safeProgress != 1) {
|
|
307
|
+
requestAnimationFrame(animation);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
requestAnimationFrame(animation);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function updateVehicleMarkerLocation(
|
|
315
|
+
vehicleMarker,
|
|
316
|
+
vehiclePosition,
|
|
317
|
+
vehicleTripUpdate,
|
|
318
|
+
) {
|
|
319
|
+
const visibleTimetableId = $('.timetable:visible').data('timetable-id');
|
|
320
|
+
|
|
321
|
+
const coordinates = [
|
|
322
|
+
vehiclePosition.vehicle.position.longitude,
|
|
323
|
+
vehiclePosition.vehicle.position.latitude,
|
|
324
|
+
];
|
|
325
|
+
vehicleMarker.getElement().innerHTML = `<div class="vehicle-marker-arrow" aria-hidden="true" style="transform:rotate(${vehiclePosition.vehicle.position.bearing}deg)"></div>`;
|
|
326
|
+
|
|
327
|
+
vehicleMarker
|
|
328
|
+
.getElement()
|
|
329
|
+
.removeEventListener(
|
|
330
|
+
'mouseenter',
|
|
331
|
+
vehicleMarkersEventListeners[vehiclePosition.vehicle.vehicle.id],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
vehicleMarkersEventListeners[vehiclePosition.vehicle.vehicle.id] = () => {
|
|
335
|
+
vehiclePopup
|
|
336
|
+
.setLngLat(coordinates)
|
|
337
|
+
.setHTML(getVehiclePopupHtml(vehiclePosition, vehicleTripUpdate))
|
|
338
|
+
.addTo(maps[visibleTimetableId]);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
vehicleMarker
|
|
342
|
+
.getElement()
|
|
343
|
+
.addEventListener(
|
|
344
|
+
'mouseenter',
|
|
345
|
+
vehicleMarkersEventListeners[vehiclePosition.vehicle.vehicle.id],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
animateVehicleMarker(vehicleMarker, coordinates);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function fetchGtfsRealtime(url, headers) {
|
|
352
|
+
const response = await fetch(url, {
|
|
353
|
+
headers: { ...(headers ?? {}) },
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
throw new Error(response.status);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const bufferRes = await response.arrayBuffer();
|
|
361
|
+
const pdf = new Pbf(new Uint8Array(bufferRes));
|
|
362
|
+
const obj = FeedMessage.read(pdf);
|
|
363
|
+
return obj.entity;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function updateArrivals() {
|
|
367
|
+
const realtimeVehiclePositions = gtfsRealtimeUrls?.realtimeVehiclePositions;
|
|
368
|
+
const realtimeTripUpdates = gtfsRealtimeUrls?.realtimeTripUpdates;
|
|
369
|
+
|
|
370
|
+
if (!realtimeVehiclePositions || !realtimeTripUpdates) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const [vehiclePositions, tripUpdates] = await Promise.all([
|
|
376
|
+
fetchGtfsRealtime(
|
|
377
|
+
realtimeVehiclePositions.url,
|
|
378
|
+
realtimeVehiclePositions.headers,
|
|
379
|
+
),
|
|
380
|
+
fetchGtfsRealtime(realtimeTripUpdates.url, realtimeTripUpdates.headers),
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
if (!vehiclePositions.length) {
|
|
384
|
+
$('.vehicle-legend-item').hide();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
$('.vehicle-legend-item').show();
|
|
389
|
+
|
|
390
|
+
const routeVehiclePositions = vehiclePositions.filter((vehiclePosition) => {
|
|
391
|
+
if (
|
|
392
|
+
!vehiclePosition ||
|
|
393
|
+
!vehiclePosition.vehicle ||
|
|
394
|
+
!vehiclePosition.vehicle.trip ||
|
|
395
|
+
!vehiclePosition.vehicle.trip.trip_id
|
|
396
|
+
) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Hide vehicles which show up 15 minutes or more before their trip start times
|
|
401
|
+
if (
|
|
402
|
+
secondsInFuture(
|
|
403
|
+
`${vehiclePosition.vehicle.trip.start_date} ${vehiclePosition.vehicle.trip.start_time}`,
|
|
404
|
+
) >
|
|
405
|
+
15 * 60
|
|
406
|
+
) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return tripIds.includes(vehiclePosition.vehicle.trip.trip_id);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
for (const vehiclePosition of routeVehiclePositions) {
|
|
414
|
+
const vehicleId = vehiclePosition.vehicle.vehicle.id;
|
|
415
|
+
|
|
416
|
+
let vehicleTripUpdate = tripUpdates.find(
|
|
417
|
+
(tripUpdate) =>
|
|
418
|
+
tripUpdate.trip_update.trip.trip_id ===
|
|
419
|
+
vehiclePosition.vehicle.trip.trip_id,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
if (!vehicleTripUpdate) {
|
|
423
|
+
vehicleTripUpdate = tripUpdates.find(
|
|
424
|
+
(tripUpdate) => tripUpdate.trip_update.vehicle.id === vehicleId,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let vehicleMarker = vehicleMarkers[vehicleId];
|
|
429
|
+
|
|
430
|
+
// If not on map, add it
|
|
431
|
+
if (vehicleMarker === undefined) {
|
|
432
|
+
vehicleMarkers[vehicleId] = addVehicleMarker(
|
|
433
|
+
vehiclePosition,
|
|
434
|
+
vehicleTripUpdate,
|
|
435
|
+
);
|
|
436
|
+
} else {
|
|
437
|
+
// Otherwise update location
|
|
438
|
+
updateVehicleMarkerLocation(
|
|
439
|
+
vehicleMarker,
|
|
440
|
+
vehiclePosition,
|
|
441
|
+
vehicleTripUpdate,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Remove vehicles not in the feed
|
|
447
|
+
for (const vehicleId of Object.keys(vehicleMarkers)) {
|
|
448
|
+
if (
|
|
449
|
+
!routeVehiclePositions.find(
|
|
450
|
+
(vehiclePosition) => vehiclePosition.vehicle.vehicle.id === vehicleId,
|
|
451
|
+
)
|
|
452
|
+
) {
|
|
453
|
+
vehicleMarkers[vehicleId].remove();
|
|
454
|
+
delete vehicleMarkers[vehicleId];
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error(error);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function toggleMap(id) {
|
|
463
|
+
if (maps[id]) {
|
|
464
|
+
maps[id].resize();
|
|
465
|
+
|
|
466
|
+
for (const vehicleMarker of Object.values(vehicleMarkers)) {
|
|
467
|
+
vehicleMarker.addTo(maps[id]);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function createMap(id) {
|
|
473
|
+
const defaultRouteColor = '#000000';
|
|
474
|
+
const lineLayout = {
|
|
475
|
+
'line-join': 'round',
|
|
476
|
+
'line-cap': 'round',
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const geojson = geojsons[id];
|
|
480
|
+
|
|
481
|
+
if (!geojson || geojson.features.length === 0) {
|
|
482
|
+
$(`#map_timetable_id_${id}`).hide();
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const bounds = getBounds(geojson);
|
|
487
|
+
const map = new mapboxgl.Map({
|
|
488
|
+
container: `map_timetable_id_${id}`,
|
|
489
|
+
style: 'mapbox://styles/mapbox/light-v11',
|
|
490
|
+
center: bounds.getCenter(),
|
|
491
|
+
zoom: 12,
|
|
492
|
+
preserveDrawingBuffer: true,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
map.initialize = () =>
|
|
496
|
+
map.fitBounds(bounds, {
|
|
497
|
+
padding: {
|
|
498
|
+
top: 40,
|
|
499
|
+
bottom: 40,
|
|
500
|
+
left: 20,
|
|
501
|
+
right: 40,
|
|
502
|
+
},
|
|
503
|
+
duration: 0,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
map.scrollZoom.disable();
|
|
507
|
+
map.addControl(new mapboxgl.NavigationControl());
|
|
508
|
+
|
|
509
|
+
map.on('load', () => {
|
|
510
|
+
map.fitBounds(bounds, {
|
|
511
|
+
padding: {
|
|
512
|
+
top: 40,
|
|
513
|
+
bottom: 40,
|
|
514
|
+
left: 20,
|
|
515
|
+
right: 40,
|
|
516
|
+
},
|
|
517
|
+
duration: 0,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Turn off Points of Interest labels
|
|
521
|
+
map.setLayoutProperty('poi-label', 'visibility', 'none');
|
|
522
|
+
|
|
523
|
+
// Find the index of the first symbol layer in the map style to put the route lines underneath
|
|
524
|
+
let firstSymbolId;
|
|
525
|
+
for (const layer of map.getStyle().layers) {
|
|
526
|
+
if (layer.type === 'symbol') {
|
|
527
|
+
firstSymbolId = layer.id;
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Add route drop shadow outline first
|
|
533
|
+
map.addLayer(
|
|
534
|
+
{
|
|
535
|
+
id: 'route-line-shadow',
|
|
536
|
+
type: 'line',
|
|
537
|
+
source: {
|
|
538
|
+
type: 'geojson',
|
|
539
|
+
data: geojson,
|
|
540
|
+
},
|
|
541
|
+
paint: {
|
|
542
|
+
'line-color': '#000000',
|
|
543
|
+
'line-opacity': 0.3,
|
|
544
|
+
'line-width': {
|
|
545
|
+
base: 12,
|
|
546
|
+
stops: [
|
|
547
|
+
[14, 20],
|
|
548
|
+
[18, 42],
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
'line-blur': {
|
|
552
|
+
base: 12,
|
|
553
|
+
stops: [
|
|
554
|
+
[14, 20],
|
|
555
|
+
[18, 42],
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
layout: lineLayout,
|
|
560
|
+
filter: ['!has', 'stop_id'],
|
|
561
|
+
},
|
|
562
|
+
firstSymbolId,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// Add route line outline
|
|
566
|
+
map.addLayer(
|
|
567
|
+
{
|
|
568
|
+
id: 'route-line-outline',
|
|
569
|
+
type: 'line',
|
|
570
|
+
source: {
|
|
571
|
+
type: 'geojson',
|
|
572
|
+
data: geojson,
|
|
573
|
+
},
|
|
574
|
+
paint: {
|
|
575
|
+
'line-color': '#FFFFFF',
|
|
576
|
+
'line-opacity': 1,
|
|
577
|
+
'line-width': {
|
|
578
|
+
base: 8,
|
|
579
|
+
stops: [
|
|
580
|
+
[14, 12],
|
|
581
|
+
[18, 32],
|
|
582
|
+
],
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
layout: lineLayout,
|
|
586
|
+
filter: ['!has', 'stop_id'],
|
|
587
|
+
},
|
|
588
|
+
firstSymbolId,
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
// Add route line
|
|
592
|
+
map.addLayer(
|
|
593
|
+
{
|
|
594
|
+
id: 'route-line',
|
|
595
|
+
type: 'line',
|
|
596
|
+
source: {
|
|
597
|
+
type: 'geojson',
|
|
598
|
+
data: geojson,
|
|
599
|
+
},
|
|
600
|
+
paint: {
|
|
601
|
+
'line-color': ['to-color', ['get', 'route_color'], defaultRouteColor],
|
|
602
|
+
'line-opacity': 1,
|
|
603
|
+
'line-width': {
|
|
604
|
+
base: 4,
|
|
605
|
+
stops: [
|
|
606
|
+
[14, 6],
|
|
607
|
+
[18, 16],
|
|
608
|
+
],
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
layout: lineLayout,
|
|
612
|
+
filter: ['!has', 'stop_id'],
|
|
613
|
+
},
|
|
614
|
+
firstSymbolId,
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// Add stops
|
|
618
|
+
map.addLayer({
|
|
619
|
+
id: 'stops',
|
|
620
|
+
type: 'circle',
|
|
621
|
+
source: {
|
|
622
|
+
type: 'geojson',
|
|
623
|
+
data: geojson,
|
|
624
|
+
},
|
|
625
|
+
paint: {
|
|
626
|
+
'circle-color': '#fff',
|
|
627
|
+
'circle-radius': {
|
|
628
|
+
base: 1.75,
|
|
629
|
+
stops: [
|
|
630
|
+
[12, 4],
|
|
631
|
+
[22, 100],
|
|
632
|
+
],
|
|
633
|
+
},
|
|
634
|
+
'circle-stroke-color': '#3f4a5c',
|
|
635
|
+
'circle-stroke-width': 2,
|
|
636
|
+
},
|
|
637
|
+
filter: ['has', 'stop_id'],
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Layer for highlighted stops
|
|
641
|
+
map.addLayer({
|
|
642
|
+
id: 'stops-highlighted',
|
|
643
|
+
type: 'circle',
|
|
644
|
+
source: {
|
|
645
|
+
type: 'geojson',
|
|
646
|
+
data: geojson,
|
|
647
|
+
},
|
|
648
|
+
paint: {
|
|
649
|
+
'circle-color': '#fff',
|
|
650
|
+
'circle-radius': {
|
|
651
|
+
base: 1.75,
|
|
652
|
+
stops: [
|
|
653
|
+
[12, 5],
|
|
654
|
+
[22, 125],
|
|
655
|
+
],
|
|
656
|
+
},
|
|
657
|
+
'circle-stroke-width': 2,
|
|
658
|
+
'circle-stroke-color': '#3f4a5c',
|
|
659
|
+
},
|
|
660
|
+
filter: ['==', 'stop_id', ''],
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
map.on('mousemove', (event) => {
|
|
664
|
+
const features = map.queryRenderedFeatures(event.point, {
|
|
665
|
+
layers: ['stops'],
|
|
666
|
+
});
|
|
667
|
+
if (features.length > 0) {
|
|
668
|
+
map.getCanvas().style.cursor = 'pointer';
|
|
669
|
+
highlightStop(features[0].properties.stop_id);
|
|
670
|
+
} else {
|
|
671
|
+
map.getCanvas().style.cursor = '';
|
|
672
|
+
unHighlightStop();
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
map.on('click', (event) => {
|
|
677
|
+
// Set bbox as 5px rectangle area around clicked point
|
|
678
|
+
const bbox = [
|
|
679
|
+
[event.point.x - 5, event.point.y - 5],
|
|
680
|
+
[event.point.x + 5, event.point.y + 5],
|
|
681
|
+
];
|
|
682
|
+
const features = map.queryRenderedFeatures(bbox, {
|
|
683
|
+
layers: ['stops-highlighted', 'stops'],
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
if (!features || features.length === 0) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Get the first feature and show popup
|
|
691
|
+
const feature = features[0];
|
|
692
|
+
|
|
693
|
+
new mapboxgl.Popup()
|
|
694
|
+
.setLngLat(feature.geometry.coordinates)
|
|
695
|
+
.setHTML(formatStopPopup(feature, stops[feature.properties.stop_id]))
|
|
696
|
+
.addTo(map);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
function highlightStop(stopId) {
|
|
700
|
+
map.setFilter('stops-highlighted', ['==', 'stop_id', stopId]);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function unHighlightStop() {
|
|
704
|
+
map.setFilter('stops-highlighted', ['==', 'stop_id', '']);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// On table hover, highlight stop on map
|
|
708
|
+
$('th, td', $(`#timetable_id_${id} table`)).hover((event) => {
|
|
709
|
+
let stopId;
|
|
710
|
+
const table = $(event.target).parents('table');
|
|
711
|
+
if (table.data('orientation') === 'vertical') {
|
|
712
|
+
var index = $(event.target).index();
|
|
713
|
+
stopId = $('colgroup col', table).eq(index).data('stop-id');
|
|
714
|
+
} else {
|
|
715
|
+
stopId = $(event.target).parents('tr').data('stop-id');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (stopId === undefined) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
highlightStop(stopId.toString());
|
|
723
|
+
}, unHighlightStop);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
return map;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function createMaps() {
|
|
730
|
+
for (const id of Object.keys(geojsons)) {
|
|
731
|
+
maps[id] = createMap(id);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// GTFS-Realtime Vehicle Positions
|
|
735
|
+
if (
|
|
736
|
+
!gtfsRealtimeInterval &&
|
|
737
|
+
gtfsRealtimeUrls?.realtimeVehiclePositions?.url
|
|
738
|
+
) {
|
|
739
|
+
// Popup for realtime vehicle locations
|
|
740
|
+
vehiclePopup = new mapboxgl.Popup({
|
|
741
|
+
closeButton: false,
|
|
742
|
+
closeOnClick: false,
|
|
743
|
+
className: 'vehicle-popup',
|
|
744
|
+
offset: [0, -10],
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const arrivalUpdateInterval = 10 * 1000; // 10 seconds
|
|
748
|
+
updateArrivals();
|
|
749
|
+
gtfsRealtimeInterval = setInterval(() => {
|
|
750
|
+
updateArrivals();
|
|
751
|
+
}, arrivalUpdateInterval);
|
|
752
|
+
}
|
|
753
|
+
}
|