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.
Files changed (40) hide show
  1. package/config-sample.json +68 -0
  2. package/dist/app/index.js +2 -1
  3. package/dist/app/index.js.map +1 -1
  4. package/dist/bin/gtfs-to-html.js +16 -9
  5. package/dist/bin/gtfs-to-html.js.map +1 -1
  6. package/dist/index.js +16 -9
  7. package/dist/index.js.map +1 -1
  8. package/docker/Dockerfile +14 -0
  9. package/docker/README.md +5 -0
  10. package/docker/config.json +21 -0
  11. package/docker/docker-compose.yml +10 -0
  12. package/examples/stop_attributes.txt +6 -0
  13. package/examples/timetable_notes.txt +8 -0
  14. package/examples/timetable_notes_references.txt +8 -0
  15. package/examples/timetable_pages.txt +3 -0
  16. package/examples/timetable_stop_order.txt +16 -0
  17. package/examples/timetables.txt +9 -0
  18. package/package.json +8 -3
  19. package/views/default/css/overview_styles.css +198 -0
  20. package/views/default/css/timetable_pdf_styles.css +69 -0
  21. package/views/default/css/timetable_styles.css +522 -0
  22. package/views/default/formatting_functions.pug +104 -0
  23. package/views/default/js/system-map.js +594 -0
  24. package/views/default/js/timetable-map.js +753 -0
  25. package/views/default/js/timetable-menu.js +57 -0
  26. package/views/default/layout.pug +11 -0
  27. package/views/default/overview.pug +27 -0
  28. package/views/default/overview_full.pug +16 -0
  29. package/views/default/timetable_continuation_as.pug +7 -0
  30. package/views/default/timetable_continuation_from.pug +7 -0
  31. package/views/default/timetable_horizontal.pug +42 -0
  32. package/views/default/timetable_hourly.pug +30 -0
  33. package/views/default/timetable_map.pug +15 -0
  34. package/views/default/timetable_menu.pug +48 -0
  35. package/views/default/timetable_note_symbol.pug +5 -0
  36. package/views/default/timetable_stop_name.pug +13 -0
  37. package/views/default/timetable_stoptime.pug +17 -0
  38. package/views/default/timetable_vertical.pug +67 -0
  39. package/views/default/timetablepage.pug +64 -0
  40. 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
+ }