gtfs-to-html 2.9.2 → 2.9.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtfs-to-html",
3
- "version": "2.9.2",
3
+ "version": "2.9.4",
4
4
  "private": false,
5
5
  "description": "Build human readable transit timetables as HTML, PDF or CSV from GTFS",
6
6
  "keywords": [
@@ -1,3 +1,8 @@
1
+ @page {
2
+ margin-top: 20px;
3
+ margin-bottom: 20px;
4
+ }
5
+
1
6
  /* Base styles */
2
7
  body {
3
8
  color: #666;
@@ -463,7 +468,6 @@ a:hover {
463
468
  max-width: 30%;
464
469
  background-color: #fff;
465
470
  border-radius: 3px;
466
- bottom: 30px;
467
471
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
468
472
  padding: 10px;
469
473
  position: absolute;
@@ -520,3 +524,153 @@ a:hover {
520
524
  width: 14px;
521
525
  height: 14px;
522
526
  }
527
+
528
+ .timetable-page .timetable-alerts {
529
+ margin-bottom: 1.5rem;
530
+ }
531
+
532
+ .timetable-page .timetable-alerts .timetable-alerts-list {
533
+ display: grid;
534
+ grid-template-columns: repeat(3, minmax(0, 1fr));
535
+ gap: 1rem;
536
+ margin-top: 0.25rem;
537
+ }
538
+
539
+ .timetable-page .timetable-alert {
540
+ background-color: rgb(224, 230, 245);
541
+ color: rgb(37 99 235);
542
+ padding: 0.75rem 1.25rem;
543
+ border-radius: 0.5rem;
544
+ margin-bottom: 0.5rem;
545
+ }
546
+
547
+ .timetable-page .timetable-alerts .alert-header {
548
+ display: flex;
549
+ flex-direction: row;
550
+ align-items: center;
551
+ gap: 6px;
552
+ }
553
+
554
+ .timetable-page .timetable-alerts .alert-header .route-list {
555
+ display: flex;
556
+ flex-direction: row;
557
+ align-items: center;
558
+ gap: 4px;
559
+ }
560
+
561
+ .timetable-page .timetable-alerts .route-color-swatch {
562
+ width: 30px;
563
+ height: 30px;
564
+ border-radius: 50%;
565
+ text-align: center;
566
+ line-height: 30px;
567
+ font-size: 12px;
568
+ flex-shrink: 0;
569
+ color: white;
570
+ font-weight: bold;
571
+ }
572
+
573
+ .timetable-page .timetable-alerts .alert-header .alert-title {
574
+ font-size: 1.25rem;
575
+ font-weight: bold;
576
+ }
577
+
578
+ .timetable-page .timetable-alerts .alert-body {
579
+ margin-top: 0.5rem;
580
+ display: flex;
581
+ flex-direction: column;
582
+ gap: 6px;
583
+ align-items: start;
584
+ }
585
+
586
+ .timetable-page .timetable-alert-empty {
587
+ display: none;
588
+ background-color: rgb(224, 230, 245);
589
+ color: rgb(37 99 235);
590
+ padding: 0.75rem 1.25rem;
591
+ border-radius: 0.5rem;
592
+ margin-bottom: 0.5rem;
593
+ font-size: 16px;
594
+ }
595
+
596
+ @media print {
597
+ body {
598
+ -webkit-print-color-adjust: exact;
599
+ width: 100%;
600
+ margin: 0 auto;
601
+ font-size: 0.8rem;
602
+ }
603
+
604
+ h1,
605
+ h2,
606
+ h3,
607
+ h4,
608
+ h5,
609
+ p {
610
+ page-break-inside: avoid;
611
+ orphans: 3;
612
+ widows: 3;
613
+ }
614
+
615
+ img,
616
+ table {
617
+ max-width: 100%;
618
+ }
619
+
620
+ .timetable-page h1 {
621
+ padding-top: 0;
622
+ }
623
+
624
+ .timetable-page .timetable:not(:first-of-type) {
625
+ page-break-before: always;
626
+ }
627
+
628
+ .timetable-page .timetable .table-container {
629
+ overflow: auto;
630
+ margin-bottom: 0;
631
+ }
632
+
633
+ .timetable-page .timetable table > thead > tr > th,
634
+ .timetable-page .timetable table > tbody > tr > th,
635
+ .timetable-page .timetable table > tfoot > tr > th {
636
+ padding: 3px 5px;
637
+ line-height: 1.3;
638
+ }
639
+
640
+ .timetable-page .timetable table > thead > tr > td,
641
+ .timetable-page .timetable table > tbody > tr > td,
642
+ .timetable-page .timetable table > tfoot > tr > td {
643
+ padding: 3px 5px;
644
+ line-height: 1;
645
+ }
646
+
647
+ .timetable .table-container {
648
+ overflow: hidden;
649
+ }
650
+
651
+ .timetable table {
652
+ page-break-after: auto;
653
+ }
654
+
655
+ .timetable tr {
656
+ page-break-inside: avoid;
657
+ page-break-after: auto;
658
+ }
659
+
660
+ .timetable td {
661
+ page-break-inside: avoid;
662
+ page-break-after: auto;
663
+ }
664
+
665
+ .timetable thead {
666
+ display: table-header-group;
667
+ }
668
+
669
+ .timetable tfoot {
670
+ display: table-footer-group;
671
+ }
672
+
673
+ .timetable-page .map-legend {
674
+ display: none;
675
+ }
676
+ }
@@ -37,14 +37,11 @@
37
37
  }
38
38
 
39
39
  function prepareMapData(timetablePage, config) {
40
- const routes = {}
41
- const stops = {}
42
- const tripIds = []
40
+ const routeData = {}
41
+ const stopData = {}
43
42
  const geojsons = {}
44
43
 
45
44
  for (const timetable of timetablePage.consolidatedTimetables) {
46
- tripIds.push(...timetable.orderedTrips.map(trip => trip.trip_id))
47
-
48
45
  const minifiedGeojson = {
49
46
  type: 'FeatureCollection',
50
47
  features: []
@@ -53,11 +50,11 @@
53
50
  for (const feature of timetable.geojson.features) {
54
51
  if (feature.geometry.type.toLowerCase() === 'point') {
55
52
  for (const route of feature.properties.routes) {
56
- routes[route.route_id] = route
53
+ routeData[route.route_id] = route
57
54
  }
58
55
 
59
56
  const stop = _.pick(feature.properties, ['stop_id', 'stop_code', 'stop_name'])
60
- stops[stop.stop_id] = stop
57
+ stopData[stop.stop_id] = stop
61
58
 
62
59
  feature.properties = {
63
60
  route_ids: feature.properties.routes.map(route => route.route_id),
@@ -79,11 +76,15 @@
79
76
  }
80
77
 
81
78
  return {
82
- geojsons,
83
- tripIds: _.uniq(tripIds),
84
- routes,
85
- stops,
86
- gtfsRealtimeUrls: _.pick(config.agencies.find(agency => agency.realtimeVehiclePositions?.url), ['realtimeAlerts', 'realtimeTripUpdates', 'realtimeVehiclePositions']),
79
+ pageData: {
80
+ routeIds: _.uniq(_.flatMap(timetablePage.consolidatedTimetables, timetable => timetable.routes.map(route => route.route_id))),
81
+ tripIds: _.uniq(_.flatMap(timetablePage.consolidatedTimetables, timetable => timetable.orderedTrips.map(trip => trip.trip_id))),
82
+ stopIds: Object.keys(stopData),
83
+ geojsons,
84
+ gtfsRealtimeUrls: _.pick(config.agencies.find(agency => agency.realtimeVehiclePositions?.url), ['realtimeAlerts', 'realtimeTripUpdates', 'realtimeVehiclePositions']),
85
+ },
86
+ routeData,
87
+ stopData,
87
88
  }
88
89
  }
89
90
 
@@ -0,0 +1,180 @@
1
+ /* global window, document, $, mapboxgl, Pbf, stopData, routeData, routeIds, tripIds, stopIds, gtfsRealtimeUrls */
2
+ /* eslint no-var: "off", prefer-arrow-callback: "off", no-unused-vars: "off" */
3
+
4
+ let gtfsRealtimeAlertsInterval;
5
+
6
+ async function fetchGtfsRealtime(url, headers) {
7
+ const response = await fetch(url, {
8
+ headers: { ...(headers ?? {}) },
9
+ });
10
+
11
+ if (!response.ok) {
12
+ throw new Error(response.status);
13
+ }
14
+
15
+ const bufferRes = await response.arrayBuffer();
16
+ const pdf = new Pbf(new Uint8Array(bufferRes));
17
+ const obj = FeedMessage.read(pdf);
18
+ return obj.entity;
19
+ }
20
+
21
+ function formatAlertAsHtml(
22
+ alert,
23
+ affectedRouteIdsInTimetable,
24
+ affectedStopsIdsInTimetable,
25
+ ) {
26
+ const $alert = $('<div>').addClass('timetable-alert');
27
+
28
+ const $routeList = $('<div>').addClass('route-list');
29
+
30
+ for (const routeId of affectedRouteIdsInTimetable) {
31
+ const route = routeData[routeId];
32
+
33
+ if (!route) {
34
+ continue;
35
+ }
36
+
37
+ $('<div>')
38
+ .addClass('route-color-swatch')
39
+ .css('background-color', route.route_color || '#000000')
40
+ .css('color', route.route_text_color || '#FFFFFF')
41
+ .text(route.route_short_name)
42
+ .appendTo($routeList);
43
+ }
44
+
45
+ const $alertHeader = $('<div>')
46
+ .addClass('alert-header')
47
+ .append($routeList)
48
+ .append(
49
+ $('<div>')
50
+ .addClass('alert-title')
51
+ .text(alert.alert.header_text.translation[0].text),
52
+ );
53
+
54
+ const $alertBody = $('<div>')
55
+ .addClass('alert-body')
56
+ .append($('<div>').text(alert.alert.description_text.translation[0].text));
57
+
58
+ if (alert.alert.url?.translation?.[0].text) {
59
+ $('<a>')
60
+ .attr('href', alert.alert.url.translation[0].text)
61
+ .addClass('btn-blue btn-sm')
62
+ .text('More Info')
63
+ .appendTo($alertBody);
64
+ }
65
+
66
+ if (affectedStopsIdsInTimetable.length > 0) {
67
+ const $stopList = $('<ul>');
68
+
69
+ for (const stopId of affectedStopsIdsInTimetable) {
70
+ const stop = stopData[stopId];
71
+
72
+ if (!stop) {
73
+ continue;
74
+ }
75
+
76
+ $('<li>')
77
+ .append($('<div>').addClass('stop-name').text(stop.stop_name))
78
+ .appendTo($stopList);
79
+ }
80
+
81
+ $('<div>').text('Stops Affected:').append($stopList).appendTo($alertBody);
82
+
83
+ $stopList.prependTo($alertBody);
84
+ }
85
+
86
+ $alertHeader.appendTo($alert);
87
+ $alertBody.appendTo($alert);
88
+
89
+ return $alert;
90
+ }
91
+
92
+ jQuery(function ($) {
93
+ async function updateAlerts() {
94
+ const realtimeAlerts = gtfsRealtimeUrls?.realtimeAlerts;
95
+
96
+ if (!realtimeAlerts) {
97
+ return;
98
+ }
99
+
100
+ try {
101
+ const alerts = await fetchGtfsRealtime(
102
+ realtimeAlerts.url,
103
+ realtimeAlerts.headers,
104
+ );
105
+
106
+ const formattedAlerts = [];
107
+
108
+ for (const alert of alerts) {
109
+ const affectedRouteIds = alert.alert.informed_entity
110
+ .filter(
111
+ (entity) => entity.route_id !== undefined && entity.route_id !== '',
112
+ )
113
+ .map((entity) => entity.route_id);
114
+ const affectedRouteIdsInTimetable = routeIds.filter((routeId) =>
115
+ affectedRouteIds.includes(routeId),
116
+ );
117
+
118
+ const affectedStopIds = [
119
+ ...new Set([
120
+ alert.alert.informed_entity
121
+ .filter(
122
+ (entity) =>
123
+ entity.stop_id !== undefined && entity.stop_id !== '',
124
+ )
125
+ .map((entity) => entity.stop_id),
126
+ ]),
127
+ ];
128
+
129
+ const affectedStopsIdsInTimetable = stopIds.filter((stopId) =>
130
+ affectedStopIds.includes(stopId),
131
+ );
132
+
133
+ // Hide alerts that don't affect any stops or routes
134
+ if (
135
+ affectedStopsIdsInTimetable.length === 0 &&
136
+ affectedRouteIdsInTimetable.length === 0
137
+ ) {
138
+ continue;
139
+ }
140
+
141
+ try {
142
+ formattedAlerts.push(
143
+ formatAlertAsHtml(
144
+ alert,
145
+ affectedRouteIdsInTimetable,
146
+ affectedStopsIdsInTimetable,
147
+ ),
148
+ );
149
+ } catch (error) {
150
+ console.error(error);
151
+ }
152
+ }
153
+
154
+ // Remove previously posted GTFS-RT alerts
155
+ $('.timetable-alerts-list .timetable-alert').remove();
156
+
157
+ if (formattedAlerts.length > 0) {
158
+ // Remove the empty message if present
159
+ $('.timetable-alert-empty').hide();
160
+
161
+ for (const alert of formattedAlerts) {
162
+ $('.timetable-alerts-list').append(alert);
163
+ }
164
+ } else {
165
+ // Replace the empty message if present
166
+ $('.timetable-alert-empty').show();
167
+ }
168
+ } catch (error) {
169
+ console.error(error);
170
+ }
171
+ }
172
+
173
+ if (!gtfsRealtimeAlertsInterval && gtfsRealtimeUrls?.realtimeAlerts?.url) {
174
+ const alertUpdateInterval = 60 * 1000; // Every Minute
175
+ updateAlerts();
176
+ gtfsRealtimeAlertsInterval = setInterval(() => {
177
+ updateAlerts();
178
+ }, alertUpdateInterval);
179
+ }
180
+ });
@@ -1,4 +1,4 @@
1
- /* global window, document, $, mapboxgl, Pbf, stops, routes, tripIds, gtfsRealtimeUrls, geojsons */
1
+ /* global window, document, $, mapboxgl, Pbf, stopData, routeData, routeIds, tripIds, geojsons, gtfsRealtimeUrls */
2
2
  /* eslint no-var: "off", prefer-arrow-callback: "off", no-unused-vars: "off" */
3
3
 
4
4
  const maps = {};
@@ -102,7 +102,7 @@ function formatStopPopup(feature, stop) {
102
102
  $(html).append(
103
103
  $('<div>')
104
104
  .addClass('route-list')
105
- .html(routeIds.map((routeId) => formatRoute(routes[routeId]))),
105
+ .html(routeIds.map((routeId) => formatRoute(routeData[routeId]))),
106
106
  );
107
107
 
108
108
  $('<a>')
@@ -201,7 +201,7 @@ function getVehiclePopupHtml(vehiclePosition, vehicleTripUpdate) {
201
201
  if (stoptimeUpdate.arrival) {
202
202
  const secondsToArrival =
203
203
  stoptimeUpdate.arrival.time - Date.now() / 1000;
204
- const stopName = stops[stoptimeUpdate.stop_id]?.stop_name;
204
+ const stopName = stopData[stoptimeUpdate.stop_id]?.stop_name;
205
205
 
206
206
  // Don't show arrivals in the past or non-timepoints
207
207
  if (secondsToArrival > 0 && stopName) {
@@ -384,7 +384,6 @@ async function updateArrivals() {
384
384
  $('.vehicle-legend-item').hide();
385
385
  return;
386
386
  }
387
-
388
387
  $('.vehicle-legend-item').show();
389
388
 
390
389
  const routeVehiclePositions = vehiclePositions.filter((vehiclePosition) => {
@@ -407,6 +406,12 @@ async function updateArrivals() {
407
406
  return false;
408
407
  }
409
408
 
409
+ // If vehiclePosition includes route_id, use that to filter
410
+ if (vehiclePosition.vehicle.trip.route_id) {
411
+ return routeIds.includes(vehiclePosition.vehicle.trip.route_id);
412
+ }
413
+
414
+ // Otherwise, fall back to using trip_id to filter
410
415
  return tripIds.includes(vehiclePosition.vehicle.trip.trip_id);
411
416
  });
412
417
 
@@ -692,7 +697,7 @@ function createMap(id) {
692
697
 
693
698
  new mapboxgl.Popup()
694
699
  .setLngLat(feature.geometry.coordinates)
695
- .setHTML(formatStopPopup(feature, stops[feature.properties.stop_id]))
700
+ .setHTML(formatStopPopup(feature, stopData[feature.properties.stop_id]))
696
701
  .addTo(map);
697
702
  });
698
703
 
@@ -13,8 +13,15 @@ include formatting_functions.pug
13
13
 
14
14
  include timetable_menu.pug
15
15
 
16
+ if config.hasGtfsRealtimeAlerts
17
+ .timetable-alerts
18
+ h2 Service Alerts
19
+ .timetable-alerts-list
20
+ .timetable-alert-empty
21
+ | There are no service alerts at this time.
22
+
16
23
  each timetable in timetablePage.consolidatedTimetables
17
- .timetable(id=`timetable_id_${formatHtmlId(timetable.timetable_id)}` data-day-list=timetable.dayList data-direction-name=timetable.direction_name data-timetable-id=timetable.timetable_id data-direction-id=timetable.direction_id data-route-id=timetable.route_ids.join('_'))
24
+ .timetable(id=`timetable_id_${formatHtmlId(timetable.timetable_id)}` data-day-list=timetable.dayList data-direction-name=timetable.direction_name data-timetable-id=`${formatHtmlId(timetable.timetable_id)}` data-direction-id=timetable.direction_id data-route-id=timetable.route_ids.join('_'))
18
25
  if config.showRouteTitle
19
26
  .timetable-label
20
27
  h2= `${timetable.timetable_label} | ${timetable.dayListLong}`
@@ -59,6 +66,7 @@ include formatting_functions.pug
59
66
  if config.showCalendarExceptions && timetable.calendarDates.excludedDates.length
60
67
  .excluded-dates= `${config.serviceNotProvidedOnText}: ${timetable.calendarDates.excludedDates.join(', ')}`
61
68
 
62
- script.
63
- const { geojsons, tripIds, routes, stops, gtfsRealtimeUrls } = !{JSON.stringify(prepareMapData(timetablePage, config))};
64
- createMaps();
69
+ if config.showMap
70
+ script.
71
+ const { routeData, stopData, pageData: { routeIds, tripIds, stopIds, geojsons, gtfsRealtimeUrls } } = !{JSON.stringify(prepareMapData(timetablePage, config))};
72
+ createMaps();
@@ -8,18 +8,19 @@ block extraHeader
8
8
  if config.menuType === 'radio'
9
9
  script(src=`${config.assetPath}js/timetable-menu.js`)
10
10
 
11
+ if (config.hasGtfsRealtime && config.showMap) || config.hasGtfsRealtimeAlerts
12
+ script(src=`${config.assetPath}js/pbf.js`)
13
+ script(src=`${config.assetPath}js/gtfs-realtime.browser.proto.js`)
14
+
11
15
  if config.showMap
12
- if config.hasGtfsRealtime
13
- script(src=`${config.assetPath}js/pbf.js`)
14
- script(src=`${config.assetPath}js/gtfs-realtime.browser.proto.js`)
16
+ link(href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css" rel="stylesheet")
15
17
  script(src="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js")
16
18
  script.
17
19
  mapboxgl.accessToken = '#{config.mapboxAccessToken}';
18
20
  script(src=`${config.assetPath}js/timetable-map.js`)
19
21
 
20
- link(href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css" rel="stylesheet")
22
+ if config.hasGtfsRealtimeAlerts
23
+ script(src=`${config.assetPath}js/timetable-alerts.js`)
21
24
 
22
25
  link(rel="stylesheet" href=`${config.assetPath}css/timetable_styles.css`)
23
- if config.outputFormat === 'pdf'
24
- link(rel="stylesheet" href=`${config.assetPath}css/timetable_pdf_styles.css`)
25
26
 
@@ -1,69 +0,0 @@
1
- body {
2
- -webkit-print-color-adjust: exact;
3
- width: 100%;
4
- margin: 0 auto;
5
- font-size: 0.8rem;
6
- }
7
-
8
- h1,
9
- h2,
10
- h3,
11
- h4,
12
- h5,
13
- p {
14
- page-break-inside: avoid;
15
- orphans: 3;
16
- widows: 3;
17
- }
18
-
19
- img,
20
- table {
21
- max-width: 100%;
22
- }
23
-
24
- .timetable-page .timetable:not(:first-of-type) {
25
- page-break-before: always;
26
- }
27
-
28
- .timetable-page .timetable .table-container {
29
- overflow: auto;
30
- margin-bottom: 0;
31
- }
32
-
33
- .timetable-page .timetable table > thead > tr > th,
34
- .timetable-page .timetable table > tbody > tr > th,
35
- .timetable-page .timetable table > tfoot > tr > th {
36
- padding: 3px 5px;
37
- }
38
-
39
- .timetable-page .timetable table > thead > tr > td,
40
- .timetable-page .timetable table > tbody > tr > td,
41
- .timetable-page .timetable table > tfoot > tr > td {
42
- padding: 1px 5px;
43
- line-height: 1;
44
- }
45
-
46
- .timetable .table-container {
47
- overflow: hidden;
48
- }
49
- .timetable table {
50
- page-break-after: auto;
51
- }
52
- .timetable tr {
53
- page-break-inside: avoid;
54
- page-break-after: auto;
55
- }
56
- .timetable td {
57
- page-break-inside: avoid;
58
- page-break-after: auto;
59
- }
60
- .timetable thead {
61
- display: table-header-group;
62
- }
63
- .timetable tfoot {
64
- display: table-footer-group;
65
- }
66
-
67
- .timetable-page .map-legend {
68
- display: none;
69
- }