transit-departures-widget 2.5.4 → 2.6.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/README.md +92 -17
- package/dist/app/index.js +270 -93
- package/dist/app/index.js.map +1 -1
- package/dist/bin/transit-departures-widget.js +247 -105
- package/dist/bin/transit-departures-widget.js.map +1 -1
- package/dist/frontend_libraries/accessible-autocomplete.min.css +3 -0
- package/dist/frontend_libraries/accessible-autocomplete.min.js +2 -0
- package/dist/frontend_libraries/gtfs-realtime.browser.proto.js +0 -0
- package/dist/frontend_libraries/pbf.js +1 -0
- package/dist/index.js +246 -104
- package/dist/index.js.map +1 -1
- package/package.json +24 -20
package/README.md
CHANGED
|
@@ -54,9 +54,38 @@ An demo of the widget is available at https://transit-departures-widget.blinktag
|
|
|
54
54
|
|
|
55
55
|
The following transit agencies use `transit-departures-widget` on their websites:
|
|
56
56
|
|
|
57
|
+
- [County Connection](https://countyconnection.com)
|
|
58
|
+
- [Kings Area Regional Transit](https://kartbus.org)
|
|
57
59
|
- [Marin Transit](https://marintransit.org/)
|
|
60
|
+
- [Mountain View Community Shuttle](https://mvcommunityshuttle.com)
|
|
58
61
|
- [MVgo](https://mvgo.org/)
|
|
59
62
|
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
### CLI
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install transit-departures-widget -g
|
|
69
|
+
transit-departures-widget --configPath ./config-sample.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Outputs are written to `html/<agency_key>/`:
|
|
73
|
+
|
|
74
|
+
- `index.html` (full page unless `noHead: true`)
|
|
75
|
+
- `data/routes.json`
|
|
76
|
+
- `data/stops.json`
|
|
77
|
+
- `css/`, `js/` (when `noHead` is false)
|
|
78
|
+
|
|
79
|
+
### Programmatic
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import transitDeparturesWidget from 'transit-departures-widget'
|
|
83
|
+
import config from './config.json' assert { type: 'json' }
|
|
84
|
+
|
|
85
|
+
await transitDeparturesWidget(config)
|
|
86
|
+
// outputs to html/<agency_key>/ by default
|
|
87
|
+
```
|
|
88
|
+
|
|
60
89
|
## Command Line Usage
|
|
61
90
|
|
|
62
91
|
The `transit-departures-widget` command-line utility will download the GTFS file specified in `config.js` and then build the transit departures widget and save the HTML, CSS and JS in `html/:agency_key`.
|
|
@@ -89,20 +118,22 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
|
|
|
89
118
|
|
|
90
119
|
cp config-sample.json config.json
|
|
91
120
|
|
|
92
|
-
| option | type |
|
|
93
|
-
| --------------------------------------------------- | ------- |
|
|
94
|
-
| [`agency`](#agency) | object |
|
|
95
|
-
| [`
|
|
96
|
-
| [`
|
|
97
|
-
| [`
|
|
98
|
-
| [`
|
|
99
|
-
| [`
|
|
100
|
-
| [`
|
|
101
|
-
| [`
|
|
102
|
-
| [`
|
|
103
|
-
| [`
|
|
104
|
-
| [`
|
|
105
|
-
| [`
|
|
121
|
+
| option | type | default | notes |
|
|
122
|
+
| --------------------------------------------------- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| [`agency`](#agency) | object | — | Required: GTFS static (url or path) and GTFS-RT trip updates URL. |
|
|
124
|
+
| [`assetPath`](#assetpath) | string | `''` | Prefix for assets in HTML; set when hosting assets elsewhere. |
|
|
125
|
+
| [`beautify`](#beautify) | boolean | `false` | Pretty-print HTML output. |
|
|
126
|
+
| [`endDate`](#enddate) | string | all svc | YYYYMMDD calendar filter upper bound. |
|
|
127
|
+
| [`includeCoordinates`](#includecoordinates) | boolean | `false` | Include stop lat/lon in `stops.json`. |
|
|
128
|
+
| [`locale`](#locale) | string | `en` | UI language code. |
|
|
129
|
+
| [`noHead`](#nohead) | boolean | `false` | If true, omit `<html>/<head>/<body>`; only widget markup is output. |
|
|
130
|
+
| [`outputPath`](#outputpath) | string | `./html/<agency_key>` | Where to write generated files. |
|
|
131
|
+
| [`refreshIntervalSeconds`](#refreshIntervalSeconds) | integer | `20` | Autorefresh interval on the widget page. |
|
|
132
|
+
| [`skipImport`](#skipimport) | boolean | `false` | Skip GTFS import if DB already populated (`sqlitePath` recommended). |
|
|
133
|
+
| [`sqlitePath`](#sqlitepath) | string | in-memory | Path to SQLite DB file; enables reusing imports across runs. |
|
|
134
|
+
| [`startDate`](#startdate) | string | all svc | YYYYMMDD calendar filter lower bound. |
|
|
135
|
+
| [`templatePath`](#templatepath) | string | built-in | Custom templates folder (expects `widget.pug` and `widget_full.pug`). |
|
|
136
|
+
| [`timeFormat`](#timeFormat) | string | `12hour` | `12hour` or `24hour` time display. |
|
|
106
137
|
|
|
107
138
|
### agency
|
|
108
139
|
|
|
@@ -114,7 +145,7 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
|
|
|
114
145
|
|
|
115
146
|
`gtfs_static_path` is the local path to an agency's static GTFS on your local machine. Either `gtfs_static_url` or `gtfs_static_path` is required.
|
|
116
147
|
|
|
117
|
-
`gtfs_rt_tripupdates_url` is the URL of an agency's GTFS-RT trip updates. Note that the GTFS-RT URL must support [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) in order for the widget to work.
|
|
148
|
+
`gtfs_rt_tripupdates_url` is the URL of an agency's GTFS-RT trip updates. Note that the GTFS-RT URL must support [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) in order for the widget to work. You may need to set up a proxy that adds CORS headers to your GTFS-RT URLS. [GTFS Realtime Proxy](https://github.com/BlinkTagInc/gtfs-realtime-proxy) is an open-source tool that you could use for adding CORS headers.
|
|
118
149
|
|
|
119
150
|
- Specify a download URL for static GTFS:
|
|
120
151
|
|
|
@@ -192,6 +223,24 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
|
|
|
192
223
|
"noHead": false
|
|
193
224
|
```
|
|
194
225
|
|
|
226
|
+
If `noHead` is `true`, you’ll embed the widget into an existing HTML page. See the examples below for including the generated assets.
|
|
227
|
+
|
|
228
|
+
### assetPath
|
|
229
|
+
|
|
230
|
+
{String} Prefix to use when linking to generated assets (`css`, `js`). Useful if you host assets on a CDN or a different path from the HTML file.
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
"assetPath": "/static/widget/"
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### outputPath
|
|
237
|
+
|
|
238
|
+
{String} Path where generated files are written. Defaults to `./html/<agency_key>`.
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
"outputPath": "/var/www/widget-output"
|
|
242
|
+
```
|
|
243
|
+
|
|
195
244
|
### refreshIntervalSeconds
|
|
196
245
|
|
|
197
246
|
{Integer} How often the widget should refresh departure data in seconds. Optional, defaults to 20 seconds.
|
|
@@ -248,14 +297,40 @@ After an initial run of `transit-departures-widget`, the GTFS data will be downl
|
|
|
248
297
|
|
|
249
298
|
You can view an individual route HTML on demand by running the included Express app:
|
|
250
299
|
|
|
251
|
-
|
|
300
|
+
npm start
|
|
252
301
|
|
|
253
302
|
By default, `transit-departures-widget` will look for a `config.json` file in the project root. To specify a different path for the configuration file:
|
|
254
303
|
|
|
255
|
-
|
|
304
|
+
npm start -- --configPath /path/to/your/custom-config.json
|
|
256
305
|
|
|
257
306
|
Once running, you can view the HTML in your browser at [localhost:3000](http://localhost:3000)
|
|
258
307
|
|
|
308
|
+
## Embedding examples
|
|
309
|
+
|
|
310
|
+
### Default (with head/footer)
|
|
311
|
+
|
|
312
|
+
If `noHead` is `false` (default), `index.html` is a complete HTML page with linked assets in `css/` and `js/` under the output directory. You can host that folder as-is (e.g., serve `html/<agency_key>/` from your web server root).
|
|
313
|
+
|
|
314
|
+
### Headless embed (`noHead: true`)
|
|
315
|
+
|
|
316
|
+
When `noHead` is `true`, you get only the widget markup. Include the generated CSS/JS and mount it in your page:
|
|
317
|
+
|
|
318
|
+
```html
|
|
319
|
+
<!doctype html>
|
|
320
|
+
<html>
|
|
321
|
+
<head>
|
|
322
|
+
<link rel="stylesheet" href="/path/to/css/transit-departures-widget-styles.css" />
|
|
323
|
+
</head>
|
|
324
|
+
<body>
|
|
325
|
+
<div id="tdw-app"></div>
|
|
326
|
+
<script src="/path/to/js/transit-departures-widget.js"></script>
|
|
327
|
+
<script>
|
|
328
|
+
// Transit Departures Widget initializes itself on load using data/routes.json and data/stops.json
|
|
329
|
+
</script>
|
|
330
|
+
</body>
|
|
331
|
+
</html>
|
|
332
|
+
```
|
|
333
|
+
|
|
259
334
|
## Notes
|
|
260
335
|
|
|
261
336
|
`transit-departures-widget` uses the [`node-gtfs`](https://github.com/blinktaginc/node-gtfs) library to handle importing and querying GTFS data.
|
package/dist/app/index.js
CHANGED
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
// src/app/index.ts
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
3
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4
|
+
import { readFileSync } from "fs";
|
|
4
5
|
import yargs from "yargs";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
6
|
+
import { hideBin } from "yargs/helpers";
|
|
7
|
+
import { openDb as openDb2, importGtfs } from "gtfs";
|
|
7
8
|
import express from "express";
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
// src/lib/utils.ts
|
|
11
|
-
import { join as join2 } from "path";
|
|
12
|
-
import { openDb, getDirections, getRoutes, getStops, getTrips } from "gtfs";
|
|
13
|
-
import { groupBy, last, maxBy, size, sortBy, uniqBy } from "lodash-es";
|
|
9
|
+
import { clone, omit } from "lodash-es";
|
|
10
|
+
import untildify2 from "untildify";
|
|
14
11
|
|
|
15
12
|
// src/lib/file-utils.ts
|
|
16
|
-
import { dirname, join, resolve } from "
|
|
17
|
-
import { fileURLToPath } from "
|
|
13
|
+
import { dirname, join, resolve } from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import {
|
|
16
|
+
access,
|
|
17
|
+
cp,
|
|
18
|
+
copyFile,
|
|
19
|
+
mkdir,
|
|
20
|
+
readdir,
|
|
21
|
+
readFile,
|
|
22
|
+
rm
|
|
23
|
+
} from "fs/promises";
|
|
18
24
|
import beautify from "js-beautify";
|
|
19
25
|
import pug from "pug";
|
|
20
26
|
import untildify from "untildify";
|
|
21
|
-
function
|
|
22
|
-
if (config2.templatePath) {
|
|
23
|
-
return untildify(config2.templatePath);
|
|
24
|
-
}
|
|
27
|
+
function getPathToThisModuleFolder() {
|
|
25
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
-
let
|
|
29
|
+
let distFolderPath;
|
|
27
30
|
if (__dirname.endsWith("/dist/bin") || __dirname.endsWith("/dist/app")) {
|
|
28
|
-
|
|
31
|
+
distFolderPath = resolve(__dirname, "../../");
|
|
29
32
|
} else if (__dirname.endsWith("/dist")) {
|
|
30
|
-
|
|
33
|
+
distFolderPath = resolve(__dirname, "../");
|
|
31
34
|
} else {
|
|
32
|
-
|
|
35
|
+
distFolderPath = resolve(__dirname, "../../");
|
|
36
|
+
}
|
|
37
|
+
return distFolderPath;
|
|
38
|
+
}
|
|
39
|
+
function getPathToViewsFolder(config2) {
|
|
40
|
+
if (config2.templatePath) {
|
|
41
|
+
return untildify(config2.templatePath);
|
|
33
42
|
}
|
|
34
|
-
return
|
|
43
|
+
return join(getPathToThisModuleFolder(), "views/default");
|
|
35
44
|
}
|
|
36
45
|
function getPathToTemplateFile(templateFileName, config2) {
|
|
37
46
|
const fullTemplateFileName = config2.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`;
|
|
@@ -47,14 +56,45 @@ async function renderFile(templateFileName, templateVars, config2) {
|
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
// src/lib/utils.ts
|
|
59
|
+
import { openDb, getDirections, getRoutes, getStops, getTrips } from "gtfs";
|
|
60
|
+
import { groupBy, last, maxBy, size, sortBy, uniqBy } from "lodash-es";
|
|
50
61
|
import sqlString from "sqlstring-sqlite";
|
|
51
62
|
import toposort from "toposort";
|
|
52
|
-
import { I18n } from "i18n";
|
|
53
63
|
|
|
54
|
-
// src/lib/log
|
|
64
|
+
// src/lib/logging/log.ts
|
|
65
|
+
import { clearLine, cursorTo } from "readline";
|
|
55
66
|
import { noop } from "lodash-es";
|
|
56
67
|
import * as colors from "yoctocolors";
|
|
57
|
-
|
|
68
|
+
var formatWarning = (text) => {
|
|
69
|
+
const warningMessage = `${colors.underline("Warning")}: ${text}`;
|
|
70
|
+
return colors.yellow(warningMessage);
|
|
71
|
+
};
|
|
72
|
+
var formatError = (error) => {
|
|
73
|
+
const messageText = error instanceof Error ? error.message : error;
|
|
74
|
+
const errorMessage = `${colors.underline("Error")}: ${messageText.replace(
|
|
75
|
+
"Error: ",
|
|
76
|
+
""
|
|
77
|
+
)}`;
|
|
78
|
+
return colors.red(errorMessage);
|
|
79
|
+
};
|
|
80
|
+
var logInfo = (config2) => {
|
|
81
|
+
if (config2.verbose === false) {
|
|
82
|
+
return noop;
|
|
83
|
+
}
|
|
84
|
+
if (config2.logFunction) {
|
|
85
|
+
return config2.logFunction;
|
|
86
|
+
}
|
|
87
|
+
return (text, overwrite) => {
|
|
88
|
+
if (overwrite === true && process.stdout.isTTY) {
|
|
89
|
+
clearLine(process.stdout, 0);
|
|
90
|
+
cursorTo(process.stdout, 0);
|
|
91
|
+
} else {
|
|
92
|
+
process.stdout.write("\n");
|
|
93
|
+
}
|
|
94
|
+
process.stdout.write(text);
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
var logWarn = (config2) => {
|
|
58
98
|
if (config2.logFunction) {
|
|
59
99
|
return config2.logFunction;
|
|
60
100
|
}
|
|
@@ -63,10 +103,59 @@ function logWarning(config2) {
|
|
|
63
103
|
${formatWarning(text)}
|
|
64
104
|
`);
|
|
65
105
|
};
|
|
106
|
+
};
|
|
107
|
+
var logError = (config2) => {
|
|
108
|
+
if (config2.logFunction) {
|
|
109
|
+
return config2.logFunction;
|
|
110
|
+
}
|
|
111
|
+
return (text) => {
|
|
112
|
+
process.stdout.write(`
|
|
113
|
+
${formatError(text)}
|
|
114
|
+
`);
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
function createLogger(config2) {
|
|
118
|
+
return {
|
|
119
|
+
info: logInfo(config2),
|
|
120
|
+
warn: logWarn(config2),
|
|
121
|
+
error: logError(config2)
|
|
122
|
+
};
|
|
66
123
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
124
|
+
|
|
125
|
+
// src/lib/logging/messages.ts
|
|
126
|
+
var messages = {
|
|
127
|
+
noActiveCalendarsGlobal: "No active calendars found for the configured date range - returning empty routes and stops",
|
|
128
|
+
noActiveCalendarsForRoute: (routeId) => `route_id ${routeId} has no active calendars in range - skipping directions`,
|
|
129
|
+
noActiveCalendarsForDirection: (routeId, directionId) => `route_id ${routeId} direction ${directionId} has no active calendars in range - skipping stops`,
|
|
130
|
+
routeHasNoDirections: (routeId) => `route_id ${routeId} has no directions - skipping`,
|
|
131
|
+
stopNotFound: (routeId, directionId, stopId) => `stop_id ${stopId} for route ${routeId} direction ${directionId} not found - dropping`
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// src/lib/config/defaults.ts
|
|
135
|
+
import { join as join2 } from "path";
|
|
136
|
+
import { I18n } from "i18n";
|
|
137
|
+
function setDefaultConfig(initialConfig) {
|
|
138
|
+
const defaults = {
|
|
139
|
+
beautify: false,
|
|
140
|
+
noHead: false,
|
|
141
|
+
refreshIntervalSeconds: 20,
|
|
142
|
+
skipImport: false,
|
|
143
|
+
timeFormat: "12hour",
|
|
144
|
+
includeCoordinates: false,
|
|
145
|
+
overwriteExistingFiles: true,
|
|
146
|
+
verbose: true
|
|
147
|
+
};
|
|
148
|
+
const config2 = Object.assign(defaults, initialConfig);
|
|
149
|
+
const viewsFolderPath = getPathToViewsFolder(config2);
|
|
150
|
+
const i18n = new I18n({
|
|
151
|
+
directory: join2(viewsFolderPath, "locales"),
|
|
152
|
+
defaultLocale: config2.locale,
|
|
153
|
+
updateFiles: false
|
|
154
|
+
});
|
|
155
|
+
const configWithI18n = Object.assign(config2, {
|
|
156
|
+
__: i18n.__
|
|
157
|
+
});
|
|
158
|
+
return configWithI18n;
|
|
70
159
|
}
|
|
71
160
|
|
|
72
161
|
// src/lib/utils.ts
|
|
@@ -99,12 +188,20 @@ function formatRouteName(route) {
|
|
|
99
188
|
return routeName;
|
|
100
189
|
}
|
|
101
190
|
function getDirectionsForRoute(route, config2) {
|
|
191
|
+
const logger = createLogger(config2);
|
|
102
192
|
const db = openDb(config2);
|
|
103
193
|
const directions = getDirections({ route_id: route.route_id }, [
|
|
104
194
|
"direction_id",
|
|
105
195
|
"direction"
|
|
106
|
-
])
|
|
196
|
+
]).filter((direction) => direction.direction_id !== void 0).map((direction) => ({
|
|
197
|
+
direction_id: direction.direction_id,
|
|
198
|
+
direction: direction.direction
|
|
199
|
+
}));
|
|
107
200
|
const calendars = getCalendarsForDateRange(config2);
|
|
201
|
+
if (calendars.length === 0) {
|
|
202
|
+
logger.warn(messages.noActiveCalendarsForRoute(route.route_id));
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
108
205
|
if (directions.length === 0) {
|
|
109
206
|
const headsigns = db.prepare(
|
|
110
207
|
`SELECT direction_id, trip_headsign, count(*) AS count FROM trips WHERE route_id = ? AND service_id IN (${calendars.map((calendar) => `'${calendar.service_id}'`).join(", ")}) GROUP BY direction_id, trip_headsign`
|
|
@@ -145,11 +242,24 @@ function sortStopIdsBySequence(stoptimes) {
|
|
|
145
242
|
Object.values(stoptimesGroupedByTrip),
|
|
146
243
|
(stoptimes2) => size(stoptimes2)
|
|
147
244
|
);
|
|
245
|
+
if (!longestTripStoptimes) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
148
248
|
return longestTripStoptimes.map((stoptime) => stoptime.stop_id);
|
|
149
249
|
}
|
|
150
|
-
function getStopsForDirection(route, direction, config2) {
|
|
250
|
+
function getStopsForDirection(route, direction, config2, stopCache) {
|
|
251
|
+
const logger = createLogger(config2);
|
|
151
252
|
const db = openDb(config2);
|
|
152
253
|
const calendars = getCalendarsForDateRange(config2);
|
|
254
|
+
if (calendars.length === 0) {
|
|
255
|
+
logger.warn(
|
|
256
|
+
messages.noActiveCalendarsForDirection(
|
|
257
|
+
route.route_id,
|
|
258
|
+
direction.direction_id
|
|
259
|
+
)
|
|
260
|
+
);
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
153
263
|
const whereClause = formatWhereClauses({
|
|
154
264
|
direction_id: direction.direction_id,
|
|
155
265
|
route_id: route.route_id,
|
|
@@ -169,14 +279,34 @@ function getStopsForDirection(route, direction, config2) {
|
|
|
169
279
|
[]
|
|
170
280
|
);
|
|
171
281
|
deduplicatedStopIds.pop();
|
|
172
|
-
const stopFields = [
|
|
282
|
+
const stopFields = [
|
|
283
|
+
"stop_id",
|
|
284
|
+
"stop_name",
|
|
285
|
+
"stop_code",
|
|
286
|
+
"parent_station"
|
|
287
|
+
];
|
|
173
288
|
if (config2.includeCoordinates) {
|
|
174
289
|
stopFields.push("stop_lat", "stop_lon");
|
|
175
290
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
291
|
+
const missingStopIds = stopCache ? deduplicatedStopIds.filter((stopId) => !stopCache.has(stopId)) : deduplicatedStopIds;
|
|
292
|
+
const fetchedStops = missingStopIds.length ? getStops(
|
|
293
|
+
{ stop_id: missingStopIds },
|
|
294
|
+
stopFields
|
|
295
|
+
) : [];
|
|
296
|
+
if (stopCache) {
|
|
297
|
+
for (const stop of fetchedStops) {
|
|
298
|
+
stopCache.set(stop.stop_id, stop);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return deduplicatedStopIds.map((stopId) => {
|
|
302
|
+
const stop = stopCache?.get(stopId) ?? fetchedStops.find((candidate) => candidate.stop_id === stopId);
|
|
303
|
+
if (!stop) {
|
|
304
|
+
logger.warn(
|
|
305
|
+
messages.stopNotFound(route.route_id, direction.direction_id, stopId)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return stop;
|
|
309
|
+
}).filter(Boolean);
|
|
180
310
|
}
|
|
181
311
|
function generateTransitDeparturesWidgetHtml(config2) {
|
|
182
312
|
const templateVars = {
|
|
@@ -186,23 +316,37 @@ function generateTransitDeparturesWidgetHtml(config2) {
|
|
|
186
316
|
return renderFile("widget", templateVars, config2);
|
|
187
317
|
}
|
|
188
318
|
function generateTransitDeparturesWidgetJson(config2) {
|
|
319
|
+
const logger = createLogger(config2);
|
|
320
|
+
const calendars = getCalendarsForDateRange(config2);
|
|
321
|
+
if (calendars.length === 0) {
|
|
322
|
+
logger.warn(messages.noActiveCalendarsGlobal);
|
|
323
|
+
return { routes: [], stops: [] };
|
|
324
|
+
}
|
|
189
325
|
const routes = getRoutes();
|
|
190
326
|
const stops = [];
|
|
191
327
|
const filteredRoutes = [];
|
|
192
|
-
const
|
|
328
|
+
const stopCache = /* @__PURE__ */ new Map();
|
|
193
329
|
for (const route of routes) {
|
|
194
|
-
|
|
195
|
-
|
|
330
|
+
const routeWithFullName = {
|
|
331
|
+
...route,
|
|
332
|
+
route_full_name: formatRouteName(route)
|
|
333
|
+
};
|
|
334
|
+
const directions = getDirectionsForRoute(routeWithFullName, config2);
|
|
196
335
|
if (directions.length === 0) {
|
|
197
|
-
|
|
198
|
-
`route_id ${route.route_id} has no directions - skipping`
|
|
199
|
-
);
|
|
336
|
+
logger.warn(messages.routeHasNoDirections(route.route_id));
|
|
200
337
|
continue;
|
|
201
338
|
}
|
|
202
|
-
|
|
203
|
-
const directionStops = getStopsForDirection(
|
|
339
|
+
const directionsWithData = directions.map((direction) => {
|
|
340
|
+
const directionStops = getStopsForDirection(
|
|
341
|
+
routeWithFullName,
|
|
342
|
+
direction,
|
|
343
|
+
config2,
|
|
344
|
+
stopCache
|
|
345
|
+
);
|
|
346
|
+
if (directionStops.length === 0) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
204
349
|
stops.push(...directionStops);
|
|
205
|
-
direction.stopIds = directionStops.map((stop) => stop?.stop_id);
|
|
206
350
|
const trips = getTrips(
|
|
207
351
|
{
|
|
208
352
|
route_id: route.route_id,
|
|
@@ -213,25 +357,49 @@ function generateTransitDeparturesWidgetJson(config2) {
|
|
|
213
357
|
},
|
|
214
358
|
["trip_id"]
|
|
215
359
|
);
|
|
216
|
-
|
|
360
|
+
return {
|
|
361
|
+
...direction,
|
|
362
|
+
stopIds: directionStops.map((stop) => stop.stop_id),
|
|
363
|
+
tripIds: trips.map((trip) => trip.trip_id)
|
|
364
|
+
};
|
|
365
|
+
}).filter(Boolean);
|
|
366
|
+
if (directionsWithData.length === 0) {
|
|
367
|
+
continue;
|
|
217
368
|
}
|
|
218
|
-
|
|
219
|
-
|
|
369
|
+
filteredRoutes.push({
|
|
370
|
+
...routeWithFullName,
|
|
371
|
+
directions: directionsWithData
|
|
372
|
+
});
|
|
220
373
|
}
|
|
221
|
-
const sortedRoutes =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
374
|
+
const sortedRoutes = [...filteredRoutes].sort((a, b) => {
|
|
375
|
+
const aShort = a.route_short_name ?? "";
|
|
376
|
+
const bShort = b.route_short_name ?? "";
|
|
377
|
+
const aNum = Number.parseInt(aShort, 10);
|
|
378
|
+
const bNum = Number.parseInt(bShort, 10);
|
|
379
|
+
if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && aNum !== bNum) {
|
|
380
|
+
return aNum - bNum;
|
|
381
|
+
}
|
|
382
|
+
if (Number.isNaN(aNum) && !Number.isNaN(bNum)) {
|
|
383
|
+
return 1;
|
|
384
|
+
}
|
|
385
|
+
if (!Number.isNaN(aNum) && Number.isNaN(bNum)) {
|
|
386
|
+
return -1;
|
|
387
|
+
}
|
|
388
|
+
return aShort.localeCompare(bShort, void 0, {
|
|
389
|
+
numeric: true,
|
|
390
|
+
sensitivity: "base"
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
const parentStationIds = new Set(stops.map((stop) => stop.parent_station));
|
|
226
394
|
const parentStationStops = getStops(
|
|
227
395
|
{ stop_id: Array.from(parentStationIds) },
|
|
228
396
|
["stop_id", "stop_name", "stop_code", "parent_station"]
|
|
229
397
|
);
|
|
230
398
|
stops.push(
|
|
231
|
-
...parentStationStops.map((stop) => {
|
|
232
|
-
stop
|
|
233
|
-
|
|
234
|
-
})
|
|
399
|
+
...parentStationStops.map((stop) => ({
|
|
400
|
+
...stop,
|
|
401
|
+
is_parent_station: true
|
|
402
|
+
}))
|
|
235
403
|
);
|
|
236
404
|
const sortedStops = sortBy(uniqBy(stops, "stop_id"), "stop_name");
|
|
237
405
|
return {
|
|
@@ -242,41 +410,21 @@ function generateTransitDeparturesWidgetJson(config2) {
|
|
|
242
410
|
function removeNulls(data) {
|
|
243
411
|
if (Array.isArray(data)) {
|
|
244
412
|
return data.map(removeNulls).filter((item) => item !== null && item !== void 0);
|
|
245
|
-
} else if (typeof data === "object" && data
|
|
246
|
-
return Object.entries(data).reduce(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
413
|
+
} else if (data !== null && typeof data === "object" && Object.getPrototypeOf(data) === Object.prototype) {
|
|
414
|
+
return Object.entries(data).reduce(
|
|
415
|
+
(acc, [key, value]) => {
|
|
416
|
+
const cleanedValue = removeNulls(value);
|
|
417
|
+
if (cleanedValue !== null && cleanedValue !== void 0) {
|
|
418
|
+
acc[key] = cleanedValue;
|
|
419
|
+
}
|
|
420
|
+
return acc;
|
|
421
|
+
},
|
|
422
|
+
{}
|
|
423
|
+
);
|
|
253
424
|
} else {
|
|
254
425
|
return data;
|
|
255
426
|
}
|
|
256
427
|
}
|
|
257
|
-
function setDefaultConfig(initialConfig) {
|
|
258
|
-
const defaults = {
|
|
259
|
-
beautify: false,
|
|
260
|
-
noHead: false,
|
|
261
|
-
refreshIntervalSeconds: 20,
|
|
262
|
-
skipImport: false,
|
|
263
|
-
timeFormat: "12hour",
|
|
264
|
-
includeCoordinates: false,
|
|
265
|
-
overwriteExistingFiles: true,
|
|
266
|
-
verbose: true
|
|
267
|
-
};
|
|
268
|
-
const config2 = Object.assign(defaults, initialConfig);
|
|
269
|
-
const viewsFolderPath = getPathToViewsFolder(config2);
|
|
270
|
-
const i18n = new I18n({
|
|
271
|
-
directory: join2(viewsFolderPath, "locales"),
|
|
272
|
-
defaultLocale: config2.locale,
|
|
273
|
-
updateFiles: false
|
|
274
|
-
});
|
|
275
|
-
const configWithI18n = Object.assign(config2, {
|
|
276
|
-
__: i18n.__
|
|
277
|
-
});
|
|
278
|
-
return configWithI18n;
|
|
279
|
-
}
|
|
280
428
|
function formatWhereClause(key, value) {
|
|
281
429
|
if (Array.isArray(value)) {
|
|
282
430
|
let whereClause = `${sqlString.escapeId(key)} IN (${value.filter((v) => v !== null).map((v) => sqlString.escape(v)).join(", ")})`;
|
|
@@ -301,7 +449,7 @@ function formatWhereClauses(query) {
|
|
|
301
449
|
}
|
|
302
450
|
|
|
303
451
|
// src/app/index.ts
|
|
304
|
-
var argv = yargs(process.argv).option("c", {
|
|
452
|
+
var argv = yargs(hideBin(process.argv)).option("c", {
|
|
305
453
|
alias: "configPath",
|
|
306
454
|
describe: "Path to config file",
|
|
307
455
|
default: "./config.json",
|
|
@@ -316,12 +464,46 @@ config.assetPath = "/";
|
|
|
316
464
|
config.logFunction = console.log;
|
|
317
465
|
try {
|
|
318
466
|
openDb2(config);
|
|
467
|
+
const gtfsPath = config.agency.gtfs_static_path;
|
|
468
|
+
const gtfsUrl = config.agency.gtfs_static_url;
|
|
469
|
+
if (!gtfsPath && !gtfsUrl) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
"Missing GTFS source. Set `agency.gtfs_static_path` or `agency.gtfs_static_url` in config.json."
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const agencyImportConfig = gtfsPath ? { path: gtfsPath } : { url: gtfsUrl };
|
|
475
|
+
const gtfsImportConfig = {
|
|
476
|
+
...clone(omit(config, "agency")),
|
|
477
|
+
agencies: [agencyImportConfig]
|
|
478
|
+
};
|
|
479
|
+
await importGtfs(gtfsImportConfig);
|
|
319
480
|
} catch (error) {
|
|
320
481
|
console.error(
|
|
321
|
-
`Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists and import GTFS before running this app.`
|
|
482
|
+
`Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists and run gtfs-to-html to import GTFS before running this app.`
|
|
322
483
|
);
|
|
323
484
|
throw error;
|
|
324
485
|
}
|
|
486
|
+
app.set("views", getPathToViewsFolder(config));
|
|
487
|
+
app.set("view engine", "pug");
|
|
488
|
+
app.use((req, res, next) => {
|
|
489
|
+
console.log(`${req.method} ${req.url}`);
|
|
490
|
+
next();
|
|
491
|
+
});
|
|
492
|
+
var staticAssetPath = config.templatePath === void 0 ? getPathToViewsFolder(config) : untildify2(config.templatePath);
|
|
493
|
+
app.use(express.static(staticAssetPath));
|
|
494
|
+
var frontendLibraryPaths = [
|
|
495
|
+
{ route: "/js", package: "pbf", subPath: "dist" },
|
|
496
|
+
{ route: "/js", package: "gtfs-realtime-pbf-js-module", subPath: "" },
|
|
497
|
+
{ route: "/js", package: "accessible-autocomplete", subPath: "" },
|
|
498
|
+
{ route: "/css", package: "accessible-autocomplete", subPath: "" }
|
|
499
|
+
];
|
|
500
|
+
var resolvePackagePath = (packageName, subPath) => {
|
|
501
|
+
const packagePath = dirname2(fileURLToPath2(import.meta.resolve(packageName)));
|
|
502
|
+
return subPath ? join3(packagePath, subPath) : packagePath;
|
|
503
|
+
};
|
|
504
|
+
for (const { route, package: pkg, subPath } of frontendLibraryPaths) {
|
|
505
|
+
app.use(route, express.static(resolvePackagePath(pkg, subPath)));
|
|
506
|
+
}
|
|
325
507
|
app.get("/", async (request, response, next) => {
|
|
326
508
|
try {
|
|
327
509
|
const html = await generateTransitDeparturesWidgetHtml(config);
|
|
@@ -346,11 +528,6 @@ app.get("/data/stops.json", async (request, response, next) => {
|
|
|
346
528
|
next(error);
|
|
347
529
|
}
|
|
348
530
|
});
|
|
349
|
-
app.set("views", getPathToViewsFolder(config));
|
|
350
|
-
app.set("view engine", "pug");
|
|
351
|
-
app.use(logger("dev"));
|
|
352
|
-
var staticAssetPath = config.templatePath === void 0 ? getPathToViewsFolder(config) : untildify2(config.templatePath);
|
|
353
|
-
app.use(express.static(staticAssetPath));
|
|
354
531
|
app.use((req, res) => {
|
|
355
532
|
res.status(404).send("Not Found");
|
|
356
533
|
});
|