gtfs-to-chart 2.0.12 → 2.1.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.1.0] - 2025-06-06
9
+
10
+ ### Added
11
+
12
+ - Support for frequencies.txt
13
+
8
14
  ## [2.0.12] - 2025-02-25
9
15
 
10
16
  ### Updated
package/app/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
-
4
3
  import express from 'express';
5
4
  import logger from 'morgan';
6
5
  import slashes from 'connect-slashes';
@@ -8,61 +7,63 @@ import slashes from 'connect-slashes';
8
7
  import routes from './routes.js';
9
8
 
10
9
  const app = express();
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
 
12
12
  // View engine setup
13
- app.set('views', path.join(fileURLToPath(import.meta.url), '../views'));
13
+ app.set('views', path.join(__dirname, './views'));
14
14
  app.set('view engine', 'pug');
15
15
 
16
+ // Middleware
16
17
  app.use(logger('dev'));
17
- app.use(express.static(path.join(fileURLToPath(import.meta.url), '../../public')));
18
+ app.use(express.static(path.join(__dirname, '../public')));
18
19
  app.use(slashes());
19
20
 
21
+ // Routes
20
22
  app.use('/', routes);
21
23
 
22
24
  // Error handlers
23
-
24
- // 404 error handler
25
25
  app.use((request, response) => {
26
26
  const error = {
27
27
  message: 'Not Found',
28
- status: 404
28
+ status: 404,
29
29
  };
30
30
  response.status(404);
31
31
  if (request.xhr) {
32
- response.send({
32
+ response.json({
33
33
  message: error.message,
34
- error
34
+ error,
35
35
  });
36
36
  } else {
37
37
  response.render('error', {
38
38
  message: error.message,
39
- error
39
+ error,
40
40
  });
41
41
  }
42
42
  });
43
43
 
44
44
  // Development error handler: will print stacktrace
45
45
  if (process.env.NODE_ENV === 'development') {
46
- app.use((error, request, response) => {
46
+ app.use((error, request, response, next) => {
47
47
  response.status(error.status || 500);
48
48
  response.render('error', {
49
49
  message: error.message,
50
- error
50
+ error,
51
51
  });
52
52
  });
53
53
  }
54
54
 
55
55
  // Production error handler: no stacktraces leaked to user
56
- app.use((error, request, response) => {
56
+ app.use((error, request, response, next) => {
57
57
  response.status(error.status || 500);
58
58
  response.render('error', {
59
59
  message: error.message,
60
- error: {}
60
+ error: {},
61
61
  });
62
62
  });
63
63
 
64
- app.set('port', process.env.PORT || 3000);
64
+ const port = process.env.PORT || 3000;
65
+ app.set('port', port);
65
66
 
66
- const server = app.listen(app.get('port'), () => {
67
+ const server = app.listen(port, () => {
67
68
  console.log(`Express server listening on port ${server.address().port}`);
68
69
  });
@@ -5,5 +5,6 @@
5
5
  "url": "http://www.bart.gov/dev/schedules/google_transit.zip"
6
6
  }
7
7
  ],
8
+ "chartDate": "20250606",
8
9
  "sqlitePath": "/tmp/gtfs"
9
10
  }
package/lib/utils.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import path from 'node:path';
2
2
  import { readFileSync } from 'node:fs';
3
3
 
4
- import { max, map, maxBy, size, every, uniq, groupBy, first, sortBy } from 'lodash-es';
4
+ import {
5
+ max,
6
+ map,
7
+ maxBy,
8
+ size,
9
+ every,
10
+ uniq,
11
+ groupBy,
12
+ first,
13
+ sortBy,
14
+ } from 'lodash-es';
5
15
  import { getStops, openDb, getStoptimes, getAgencies, getRoutes } from 'gtfs';
6
16
  import sanitize from 'sanitize-filename';
7
17
  import moment from 'moment';
@@ -10,7 +20,29 @@ import sqlString from 'sqlstring';
10
20
  import { renderTemplate } from './file-utils.js';
11
21
  import { formatRouteName } from './formatters.js';
12
22
 
13
- const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
23
+ const { version } = JSON.parse(
24
+ readFileSync(new URL('../package.json', import.meta.url)),
25
+ );
26
+
27
+ /*
28
+ * Convert a GTFS formatted time string into a moment less than 24 hours.
29
+ */
30
+ export function fromGTFSTime(timeString) {
31
+ const duration = moment.duration(timeString);
32
+
33
+ return moment({
34
+ hour: duration.hours(),
35
+ minute: duration.minutes(),
36
+ second: duration.seconds(),
37
+ });
38
+ }
39
+
40
+ /*
41
+ * Convert a moment into a GTFS formatted time string.
42
+ */
43
+ export function toGTFSTime(time) {
44
+ return time.format('HH:mm:ss');
45
+ }
14
46
 
15
47
  /*
16
48
  * Calculate the distance between two coordinates.
@@ -20,17 +52,19 @@ function calculateDistanceMi(lat1, lon1, lat2, lon2) {
20
52
  return 0;
21
53
  }
22
54
 
23
- const radlat1 = Math.PI * lat1 / 180;
24
- const radlat2 = Math.PI * lat2 / 180;
55
+ const radlat1 = (Math.PI * lat1) / 180;
56
+ const radlat2 = (Math.PI * lat2) / 180;
25
57
  const theta = lon1 - lon2;
26
- const radtheta = Math.PI * theta / 180;
27
- let dist = (Math.sin(radlat1) * Math.sin(radlat2)) + (Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta));
58
+ const radtheta = (Math.PI * theta) / 180;
59
+ let dist =
60
+ Math.sin(radlat1) * Math.sin(radlat2) +
61
+ Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta);
28
62
  if (dist > 1) {
29
63
  dist = 1;
30
64
  }
31
65
 
32
66
  dist = Math.acos(dist);
33
- dist = dist * 180 / Math.PI;
67
+ dist = (dist * 180) / Math.PI;
34
68
  dist = dist * 60 * 1.1515;
35
69
  return dist;
36
70
  }
@@ -42,7 +76,9 @@ const reverseStationDistances = (stations, oppositeDirectionDistance) => {
42
76
  const tripDistance = max(map(stations, 'distance'));
43
77
  for (const station of stations) {
44
78
  // Scale distances to match opposite direction total distance
45
- station.distance = (tripDistance - station.distance) * oppositeDirectionDistance / tripDistance;
79
+ station.distance =
80
+ ((tripDistance - station.distance) * oppositeDirectionDistance) /
81
+ tripDistance;
46
82
  }
47
83
  };
48
84
 
@@ -65,7 +101,7 @@ const isTimepoint = (stoptime) => {
65
101
  const getStationsFromTrip = (trip) => {
66
102
  const stops = trip.stoptimes.map((stoptime) => {
67
103
  const stops = getStops({
68
- stop_id: stoptime.stop_id
104
+ stop_id: stoptime.stop_id,
69
105
  });
70
106
 
71
107
  if (stops.length === 0) {
@@ -78,20 +114,29 @@ const getStationsFromTrip = (trip) => {
78
114
  let previousStationCoordinates;
79
115
  return trip.stoptimes.map((stoptime, index) => {
80
116
  const stop = stops[index];
81
- const hasShapeDistance = every(trip.stoptimes, (stoptime) => stoptime.shape_dist_traveled !== null);
117
+ const hasShapeDistance = every(
118
+ trip.stoptimes,
119
+ (stoptime) => stoptime.shape_dist_traveled !== null,
120
+ );
82
121
 
83
122
  if (!hasShapeDistance) {
84
123
  if (index === 0) {
85
124
  stoptime.shape_dist_traveled = 0;
86
125
  } else {
87
126
  const previousStopTime = trip.stoptimes[index - 1];
88
- const distanceFromPreviousStation = calculateDistanceMi(stop.stop_lat, stop.stop_lon, previousStationCoordinates.stop_lat, previousStationCoordinates.stop_lon);
89
- stoptime.shape_dist_traveled = previousStopTime.shape_dist_traveled + distanceFromPreviousStation;
127
+ const distanceFromPreviousStation = calculateDistanceMi(
128
+ stop.stop_lat,
129
+ stop.stop_lon,
130
+ previousStationCoordinates.stop_lat,
131
+ previousStationCoordinates.stop_lon,
132
+ );
133
+ stoptime.shape_dist_traveled =
134
+ previousStopTime.shape_dist_traveled + distanceFromPreviousStation;
90
135
  }
91
136
 
92
137
  previousStationCoordinates = {
93
138
  stop_lat: stop.stop_lat,
94
- stop_lon: stop.stop_lon
139
+ stop_lon: stop.stop_lon,
95
140
  };
96
141
  }
97
142
 
@@ -99,7 +144,7 @@ const getStationsFromTrip = (trip) => {
99
144
  stop_id: stop.stop_id,
100
145
  name: stop.stop_name,
101
146
  distance: stoptime.shape_dist_traveled,
102
- direction_id: trip.direction_id
147
+ direction_id: trip.direction_id,
103
148
  };
104
149
  });
105
150
  };
@@ -110,20 +155,32 @@ const getStationsFromTrip = (trip) => {
110
155
  const getDataforChart = (config, routeId) => {
111
156
  const db = openDb(config);
112
157
  const notes = [];
113
- const dayOfWeek = moment(config.chartDate, 'YYYYMMDD').format('dddd').toLowerCase();
114
- const calendars = db.prepare(`SELECT DISTINCT service_id FROM calendar WHERE start_date <= $date AND end_date >= $date AND ${sqlString.escapeId(dayOfWeek)} = 1`).all({ date: config.chartDate });
158
+ const dayOfWeek = moment(config.chartDate, 'YYYYMMDD')
159
+ .format('dddd')
160
+ .toLowerCase();
161
+ const calendars = db
162
+ .prepare(
163
+ `SELECT DISTINCT service_id FROM calendar WHERE start_date <= $date AND end_date >= $date AND ${sqlString.escapeId(dayOfWeek)} = 1`,
164
+ )
165
+ .all({ date: config.chartDate });
115
166
 
116
167
  if (calendars.length === 0) {
117
- throw new Error(`No calendars found for route ${routeId} on ${moment(config.chartDate, 'YYYYMMDD').format('MMM D, YYYY')}`);
168
+ throw new Error(
169
+ `No calendars found for route ${routeId} on ${moment(config.chartDate, 'YYYYMMDD').format('MMM D, YYYY')}. Try changing the chartDate in your config.json file to a date that has service.`,
170
+ );
118
171
  }
119
172
 
120
173
  const serviceIds = calendars.map((calendar) => calendar.service_id);
121
- const trips = db.prepare(`SELECT service_id, trip_id, trip_headsign, direction_id, shape_id FROM trips where route_id = ? AND service_id IN (${serviceIds.map(() => '?').join(', ')})`).all(
122
- routeId,
123
- ...serviceIds);
174
+ let trips = db
175
+ .prepare(
176
+ `SELECT service_id, trip_id, trip_headsign, direction_id, shape_id FROM trips where route_id = ? AND service_id IN (${serviceIds.map(() => '?').join(', ')})`,
177
+ )
178
+ .all(routeId, ...serviceIds);
124
179
 
125
180
  if (trips.length === 0) {
126
- throw new Error(`No trips found for route ${routeId} on ${moment(config.chartDate, 'YYYYMMDD').format('MMM D, YYYY')}`);
181
+ throw new Error(
182
+ `No trips found for route ${routeId} on ${moment(config.chartDate, 'YYYYMMDD').format('MMM D, YYYY')}`,
183
+ );
127
184
  }
128
185
 
129
186
  const shapeIds = uniq(map(trips, 'shape_id'));
@@ -135,23 +192,83 @@ const getDataforChart = (config, routeId) => {
135
192
  for (const trip of trips) {
136
193
  const stoptimes = getStoptimes(
137
194
  {
138
- trip_id: trip.trip_id
195
+ trip_id: trip.trip_id,
139
196
  },
140
197
  [
141
198
  'arrival_time',
142
199
  'departure_time',
143
200
  'stop_id',
144
201
  'shape_dist_traveled',
145
- 'timepoint'
202
+ 'timepoint',
146
203
  ],
147
- [
148
- ['stop_sequence', 'ASC']
149
- ]
204
+ [['stop_sequence', 'ASC']],
150
205
  );
151
206
 
152
207
  trip.stoptimes = stoptimes.filter((stoptime) => isTimepoint(stoptime));
153
208
  }
154
209
 
210
+ const frequencies = db
211
+ .prepare(
212
+ `SELECT * FROM frequencies WHERE trip_id IN (${trips.map((trip) => '?').join(', ')})`,
213
+ )
214
+ .all(...trips.map((trip) => trip.trip_id));
215
+
216
+ // Create trips from frequencies.txt
217
+ if (frequencies.length > 0) {
218
+ for (const frequency of frequencies) {
219
+ const exampleTrip = trips.find(
220
+ (trip) => trip.trip_id === frequency.trip_id,
221
+ );
222
+ if (!exampleTrip) {
223
+ console.log(`No example trip found for frequency ${frequency.trip_id}`);
224
+ continue;
225
+ }
226
+
227
+ const stoptimesOffsets = exampleTrip.stoptimes.map((stoptime, index) => ({
228
+ departure_offset: fromGTFSTime(stoptime.departure_time).diff(
229
+ fromGTFSTime(exampleTrip.stoptimes[0].departure_time),
230
+ 'seconds',
231
+ ),
232
+ arrival_offset: fromGTFSTime(stoptime.arrival_time).diff(
233
+ fromGTFSTime(exampleTrip.stoptimes[0].arrival_time),
234
+ 'seconds',
235
+ ),
236
+ }));
237
+
238
+ for (
239
+ let offset = 0;
240
+ fromGTFSTime(frequency.start_time)
241
+ .add(offset, 'seconds')
242
+ .isBefore(fromGTFSTime(frequency.end_time));
243
+ offset += frequency.headway_secs
244
+ ) {
245
+ trips.push({
246
+ ...exampleTrip,
247
+ trip_id: `${exampleTrip.trip_id}_${toGTFSTime(fromGTFSTime(frequency.start_time).add(offset, 'seconds'))}`,
248
+ stoptimes: exampleTrip.stoptimes.map((stoptime, index) => ({
249
+ ...stoptime,
250
+ arrival_time: toGTFSTime(
251
+ fromGTFSTime(frequency.start_time)
252
+ .add(offset, 'seconds')
253
+ .add(stoptimesOffsets[index].arrival_offset, 'seconds'),
254
+ ),
255
+ departure_time: toGTFSTime(
256
+ fromGTFSTime(frequency.start_time)
257
+ .add(offset, 'seconds')
258
+ .add(stoptimesOffsets[index].departure_offset, 'seconds'),
259
+ ),
260
+ })),
261
+ });
262
+ }
263
+ }
264
+
265
+ // Remove the example trips from the trips array
266
+ trips = trips.filter(
267
+ (trip) =>
268
+ !frequencies.some((frequency) => frequency.trip_id === trip.trip_id),
269
+ );
270
+ }
271
+
155
272
  const longestTrip = findLongestTrip(trips);
156
273
  let stations = getStationsFromTrip(longestTrip);
157
274
  const tripDistance = max(map(stations, 'distance'));
@@ -160,15 +277,22 @@ const getDataforChart = (config, routeId) => {
160
277
  // If there are two directions, get stops in other direction
161
278
  if (size(directionGroups) > 1) {
162
279
  const oppositeDirection = longestTrip.direction_id === 1 ? '0' : '1';
163
- const longestTripOppositeDirection = findLongestTrip(directionGroups[oppositeDirection]);
164
- const stationsOppositeDirection = getStationsFromTrip(longestTripOppositeDirection);
280
+ const longestTripOppositeDirection = findLongestTrip(
281
+ directionGroups[oppositeDirection],
282
+ );
283
+ const stationsOppositeDirection = getStationsFromTrip(
284
+ longestTripOppositeDirection,
285
+ );
165
286
 
166
287
  reverseStationDistances(stationsOppositeDirection, tripDistance);
167
288
 
168
289
  stations = [...stations, ...stationsOppositeDirection];
169
290
  }
170
291
 
171
- const hasShapeDistance = every(longestTrip.stoptimes, (stoptime) => stoptime.shape_dist_traveled !== null);
292
+ const hasShapeDistance = every(
293
+ longestTrip.stoptimes,
294
+ (stoptime) => stoptime.shape_dist_traveled !== null,
295
+ );
172
296
  if (!hasShapeDistance) {
173
297
  notes.push('Distance between stops calculated assuming a straight line.');
174
298
  }
@@ -176,7 +300,7 @@ const getDataforChart = (config, routeId) => {
176
300
  return {
177
301
  trips,
178
302
  stations,
179
- notes
303
+ notes,
180
304
  };
181
305
  };
182
306
 
@@ -188,7 +312,7 @@ export function setDefaultConfig(initialConfig) {
188
312
  beautify: false,
189
313
  gtfsToChartVersion: version,
190
314
  chartDate: moment().format('YYYYMMDD'),
191
- skipImport: false
315
+ skipImport: false,
192
316
  };
193
317
 
194
318
  return { ...defaults, ...initialConfig };
@@ -206,13 +330,15 @@ export async function generateOverviewHTML(config, routes) {
206
330
  const agency = first(agencies);
207
331
 
208
332
  for (const route of routes) {
209
- route.relativePath = config.isLocal ? path.join('charts', sanitize(route.route_id)) : path.join('charts', sanitize(`${formatRouteName(route)}.html`));
333
+ route.relativePath = config.isLocal
334
+ ? path.join('charts', sanitize(route.route_id))
335
+ : path.join('charts', sanitize(`${formatRouteName(route)}.html`));
210
336
  }
211
337
 
212
338
  const templateVars = {
213
339
  agency,
214
340
  config,
215
- routes: sortBy(routes, (r) => Number.parseInt(r.route_short_name, 10))
341
+ routes: sortBy(routes, (r) => Number.parseInt(r.route_short_name, 10)),
216
342
  };
217
343
  return renderTemplate('overview_page', templateVars, config);
218
344
  }
@@ -222,7 +348,7 @@ export async function generateOverviewHTML(config, routes) {
222
348
  */
223
349
  export async function generateChartHTML(config, routeId) {
224
350
  const routes = getRoutes({
225
- route_id: routeId
351
+ route_id: routeId,
226
352
  });
227
353
 
228
354
  if (routes.length === 0) {
@@ -231,10 +357,14 @@ export async function generateChartHTML(config, routeId) {
231
357
 
232
358
  const chartData = getDataforChart(config, routeId);
233
359
 
234
- return renderTemplate('chart_page', {
235
- route: routes[0],
236
- chartData,
360
+ return renderTemplate(
361
+ 'chart_page',
362
+ {
363
+ route: routes[0],
364
+ chartData,
365
+ config,
366
+ moment,
367
+ },
237
368
  config,
238
- moment
239
- }, config);
369
+ );
240
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtfs-to-chart",
3
- "version": "2.0.12",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "description": "Generate stringline charts of a transit routes from GTFS",
6
6
  "keywords": [
@@ -32,9 +32,9 @@
32
32
  "better-copy": "^1.0.4",
33
33
  "chalk": "^5.4.1",
34
34
  "connect-slashes": "^1.4.0",
35
- "express": "^4.21.2",
36
- "gtfs": "^4.15.15",
37
- "js-beautify": "^1.15.3",
35
+ "express": "^5.1.0",
36
+ "gtfs": "^4.17.4",
37
+ "js-beautify": "^1.15.4",
38
38
  "lodash-es": "^4.17.21",
39
39
  "moment": "^2.30.1",
40
40
  "morgan": "^1.10.0",
@@ -45,12 +45,12 @@
45
45
  "sqlstring": "^2.3.3",
46
46
  "timer-machine": "^1.1.0",
47
47
  "untildify": "^5.0.0",
48
- "yargs": "^17.7.2"
48
+ "yargs": "^18.0.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "husky": "^9.1.7",
52
- "lint-staged": "^15.4.3",
53
- "prettier": "^3.5.2"
52
+ "lint-staged": "^16.1.0",
53
+ "prettier": "^3.5.3"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">= 20.11.0"