├── .npmrc ├── .husky └── pre-commit ├── src ├── index.ts ├── types │ └── global_interfaces.ts ├── bin │ └── transit-departures-widget.ts ├── lib │ ├── log-utils.ts │ ├── transit-departures-widget.ts │ ├── file-utils.ts │ └── utils.ts └── app │ └── index.ts ├── views └── widget │ ├── widget_full.pug │ ├── locales │ ├── en.json │ └── pl.json │ ├── layout.pug │ ├── img │ └── refresh.svg │ ├── widget.pug │ ├── css │ └── transit-departures-widget-styles.css │ └── js │ └── transit-departures-widget.js ├── .gitignore ├── tsup.config.js ├── tsconfig.json ├── config-sample.json ├── .eslintrc.json ├── LICENSE.md ├── package.json ├── CHANGELOG.md ├── docs └── images │ └── transit-departures-widget-logo.svg └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx lint-staged 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './lib/transit-departures-widget.ts' 2 | -------------------------------------------------------------------------------- /views/widget/widget_full.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | block content 3 | include widget.pug 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | html 3 | /config* 4 | !config-sample.json 5 | views/custom 6 | 7 | .git 8 | .DS_Store 9 | .vscode 10 | 11 | # Build files 12 | /dist 13 | -------------------------------------------------------------------------------- /tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | 'src/index.ts', 6 | 'src/bin/transit-departures-widget.ts', 7 | 'src/app/index.ts', 8 | ], 9 | dts: true, 10 | clean: true, 11 | format: ['esm'], 12 | splitting: false, 13 | sourcemap: true, 14 | minify: false, 15 | }) 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "NodeNext", 5 | "moduleResolution": "nodenext", 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "allowImportingTsExtensions": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": ["src/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /config-sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "agency": { 3 | "agency_key": "youragency", 4 | "gtfs_static_url": "https://marintransit.org/data/google_transit.zip", 5 | "gtfs_rt_tripupdates_url": "https://marintransit.net/gtfs-rt/tripupdates" 6 | }, 7 | "beautify": true, 8 | "endDate": "20240331", 9 | "includeCoordinates": false, 10 | "locale": "en", 11 | "noHead": false, 12 | "refreshIntervalSeconds": 20, 13 | "templatePath": "views/widget", 14 | "timeFormat": "12hour", 15 | "startDate": "20240301" 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true 5 | }, 6 | "extends": ["xo", "prettier"], 7 | "parserOptions": { 8 | "ecmaVersion": 12, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "arrow-parens": ["error", "always"], 13 | "camelcase": [ 14 | "error", 15 | { 16 | "properties": "never" 17 | } 18 | ], 19 | "indent": "off", 20 | "object-curly-spacing": ["error", "always"], 21 | "no-unused-vars": [ 22 | "error", 23 | { 24 | "varsIgnorePattern": "^[A-Z]" 25 | } 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /views/widget/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Realtime Departures": "Realtime Departures", 3 | "By Stop": "By Stop", 4 | "By Route": "By Route", 5 | "Search by stop name or stop code": "Search by stop name or stop code", 6 | "all": "all", 7 | "Invalid stop code": "Invalid stop code", 8 | "Get Departures": "Get Departures", 9 | "Choose a route": "Choose a route", 10 | "Choose a direction": "Choose a direction", 11 | "Choose a stop": "Choose a stop", 12 | "Loading": "Loading", 13 | "Unknown Stop": "Unknown Stop", 14 | "As of": "As of", 15 | "Stop Code": "Stop Code", 16 | "min": "min", 17 | "No upcoming departures": "No upcoming departures", 18 | "Unable to fetch departures": "Unable to fetch departures", 19 | "To {{{headsign}}}": "To {{{headsign}}}" 20 | } 21 | -------------------------------------------------------------------------------- /views/widget/locales/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "Realtime Departures": "Przyjazdy w czasie rzeczywistym", 3 | "By Stop": "Wg przystanku", 4 | "By Route": "Wg linii", 5 | "Search by stop name or stop code": "Wyszukaj po nazwie przystanku lub jego identyfikatorze", 6 | "all": "wszystkie", 7 | "Invalid stop code": "Nieprawidłowy identyfikator przystanku", 8 | "Get Departures": "Uzyskaj przyjazdy", 9 | "Choose a route": "Wybierz linię", 10 | "Choose a direction": "Wybierz kierunek", 11 | "Choose a stop": "Wybierz przystanek", 12 | "Loading": "Ładowanie", 13 | "Unknown Stop": "Nieznany przystanek", 14 | "As of": "Aktualizacja", 15 | "Stop Code": "Przystanek", 16 | "min": "min", 17 | "No upcoming departures": "Brak nadchodzących przyjazdów", 18 | "Unable to fetch departures": "Nie można było pobrać przyjazdów", 19 | "To {{{headsign}}}": "Do {{{headsign}}}" 20 | } 21 | -------------------------------------------------------------------------------- /src/types/global_interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | agency: { 3 | agency_key: string 4 | gtfs_static_path?: string 5 | gtfs_static_url?: string 6 | gtfs_rt_tripupdates_url: string 7 | gtfs_rt_tripupdates_headers?: Record 8 | exclude?: string[] 9 | } 10 | assetPath?: string 11 | beautify?: boolean 12 | startDate?: string 13 | endDate?: string 14 | locale?: string 15 | includeCoordinates?: boolean 16 | noHead?: boolean 17 | outputPath?: string 18 | overwriteExistingFiles?: boolean 19 | refreshIntervalSeconds?: number 20 | skipImport?: boolean 21 | sqlitePath?: string 22 | templatePath?: string 23 | timeFormat?: string 24 | verbose?: boolean 25 | logFunction?: (text: string) => void 26 | } 27 | 28 | export type SqlValue = 29 | | undefined 30 | | null 31 | | string 32 | | number 33 | | boolean 34 | | Date 35 | | SqlValue[] 36 | 37 | export type SqlWhere = Record 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Brendan Nee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/widget/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | meta(charset="utf-8") 6 | link(rel="stylesheet" href="https://unpkg.com/bootstrap@5.3.3/dist/css/bootstrap.min.css" crossorigin="anonymous") 7 | link(rel="stylesheet" href="https://unpkg.com/accessible-autocomplete@3.0.0/dist/accessible-autocomplete.min.css" crossorigin="anonymous") 8 | link(rel="stylesheet" href=`${config.assetPath}css/transit-departures-widget-styles.css`) 9 | meta(name="viewport" content="initial-scale=1.0, width=device-width") 10 | 11 | script(src="https://unpkg.com/jquery@3.7.1/dist/jquery.min.js" crossorigin="anonymous") 12 | script(src="https://unpkg.com/lodash@4.17.21/lodash.min.js" crossorigin="anonymous") 13 | script(src="https://unpkg.com/pbf@3.3.0/dist/pbf.js" crossorigin="anonymous") 14 | script(src="https://unpkg.com/gtfs-realtime-pbf-js-module@1.0.0/gtfs-realtime.browser.proto.js" crossorigin="anonymous") 15 | script(src="https://unpkg.com/accessible-autocomplete@3.0.0/dist/accessible-autocomplete.min.js" crossorigin="anonymous") 16 | script(src=`${config.assetPath}js/transit-departures-widget.js`) 17 | 18 | body 19 | block content 20 | -------------------------------------------------------------------------------- /src/bin/transit-departures-widget.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from 'yargs' 4 | import { hideBin } from 'yargs/helpers' 5 | import PrettyError from 'pretty-error' 6 | 7 | import { getConfig } from '../lib/file-utils.ts' 8 | import { formatError } from '../lib/log-utils.ts' 9 | import transitDeparturesWidget from '../index.ts' 10 | 11 | const pe = new PrettyError() 12 | 13 | const argv = yargs(hideBin(process.argv)) 14 | .usage('Usage: $0 --config ./config.json') 15 | .help() 16 | .option('c', { 17 | alias: 'configPath', 18 | describe: 'Path to config file', 19 | default: './config.json', 20 | type: 'string', 21 | }) 22 | .option('s', { 23 | alias: 'skipImport', 24 | describe: 'Don’t import GTFS file.', 25 | type: 'boolean', 26 | }) 27 | .default('skipImport', undefined) 28 | .parseSync() 29 | 30 | const handleError = (error: any) => { 31 | const text = error || 'Unknown Error' 32 | process.stdout.write(`\n${formatError(text)}\n`) 33 | console.error(pe.render(error)) 34 | process.exit(1) 35 | } 36 | 37 | const setupImport = async () => { 38 | const config = await getConfig(argv) 39 | await transitDeparturesWidget(config) 40 | process.exit() 41 | } 42 | 43 | setupImport().catch(handleError) 44 | -------------------------------------------------------------------------------- /views/widget/img/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/log-utils.ts: -------------------------------------------------------------------------------- 1 | import { clearLine, cursorTo } from 'node:readline' 2 | import { noop } from 'lodash-es' 3 | import * as colors from 'yoctocolors' 4 | 5 | import { Config } from '../types/global_interfaces.ts' 6 | 7 | /* 8 | * Returns a log function based on config settings 9 | */ 10 | export function log(config: Config) { 11 | if (config.verbose === false) { 12 | return noop 13 | } 14 | 15 | if (config.logFunction) { 16 | return config.logFunction 17 | } 18 | 19 | return (text: string, overwrite: boolean) => { 20 | if (overwrite === true && process.stdout.isTTY) { 21 | clearLine(process.stdout, 0) 22 | cursorTo(process.stdout, 0) 23 | } else { 24 | process.stdout.write('\n') 25 | } 26 | 27 | process.stdout.write(text) 28 | } 29 | } 30 | 31 | /* 32 | * Returns an warning log function based on config settings 33 | */ 34 | export function logWarning(config: Config) { 35 | if (config.logFunction) { 36 | return config.logFunction 37 | } 38 | 39 | return (text: string) => { 40 | process.stdout.write(`\n${formatWarning(text)}\n`) 41 | } 42 | } 43 | 44 | /* 45 | * Returns an error log function based on config settings 46 | */ 47 | export function logError(config: Config) { 48 | if (config.logFunction) { 49 | return config.logFunction 50 | } 51 | 52 | return (text: string) => { 53 | process.stdout.write(`\n${formatError(text)}\n`) 54 | } 55 | } 56 | 57 | /* 58 | * Format console warning text 59 | */ 60 | export function formatWarning(text: string) { 61 | const warningMessage = `${colors.underline('Warning')}: ${text}` 62 | return colors.yellow(warningMessage) 63 | } 64 | 65 | /* 66 | * Format console error text 67 | */ 68 | export function formatError(error: any) { 69 | const messageText = error instanceof Error ? error.message : error 70 | const errorMessage = `${colors.underline('Error')}: ${messageText.replace( 71 | 'Error: ', 72 | '', 73 | )}` 74 | return colors.red(errorMessage) 75 | } 76 | -------------------------------------------------------------------------------- /views/widget/widget.pug: -------------------------------------------------------------------------------- 1 | #real_time_departures.container.transit-departures-widget 2 | .card.mx-auto.my-5 3 | .card-body 4 | h2= __('Realtime Departures') 5 | .mb-3 6 | .btn-group.btn-group-two(data-label="Lookup Type") 7 | input#departure_type_stop_id.btn-check(type="radio" name="departure_type" value="stop" checked) 8 | label.btn.btn-outline-primary( for="departure_type_stop_id") By Stop 9 | input#departure_type_route.btn-check(type="radio" name="departure_type" value="route") 10 | label.btn.btn-outline-primary( for="departure_type_route") By Route 11 | 12 | form#stop_form 13 | .mb-3 14 | #departure_stop_code_container(data-placeholder=__('Search by stop name or stop code') data-stop-code-all=__('all')) 15 | 16 | .stop-code-invalid= __('Invalid stop code') 17 | .d-grid 18 | button.btn.btn-primary(type="submit")= __('Get Departures') 19 | 20 | form#route_form.hidden-form 21 | .mb-3 22 | select.form-select#departure_route(name="route") 23 | option(value="")= __('Choose a route') 24 | 25 | .mb-3 26 | select.form-select#departure_direction(name="direction" disabled) 27 | option(value="")= __('Choose a direction') 28 | 29 | .mb-3 30 | select.form-select#departure_stop(name="stop" disabled) 31 | option(value="")= __('Choose a stop') 32 | 33 | #loading.loader #{__('Loading')}... 34 | 35 | #departure_results.departure-results 36 | .departure-results-header 37 | .departure-results-stop 38 | .departure-results-stop-unknown= __('Unknown Stop') 39 | button.departure-results-fetchtime(title=__('Refresh')) 40 | span #{__('As of')}  41 | span.departure-results-fetchtime-time 42 | .departure-results-stop-code-container 43 | span #{__('Stop Code')}  44 | span.departure-results-stop-code 45 | .departure-results-container(data-minutes-label=__('min')) 46 | .departure-results-none= __('No upcoming departures') 47 | .departure-results-error= __('Unable to fetch departures') 48 | 49 | script. 50 | (async function() { 51 | const routes = await fetch('data/routes.json').then(res => res.json()) 52 | const stops = await fetch('data/stops.json').then(res => res.json()) 53 | setupTransitDeparturesWidget(routes, stops, { 54 | gtfsRtTripupdatesUrl: '#{config.agency.gtfs_rt_tripupdates_url}', 55 | refreshIntervalSeconds: #{config.refreshIntervalSeconds}, 56 | timeFormat: '#{config.timeFormat}' 57 | }) 58 | })() 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transit-departures-widget", 3 | "description": "Build a realtime transit departures tool from GTFS and GTFS-Realtime.", 4 | "version": "2.5.5", 5 | "keywords": [ 6 | "transit", 7 | "gtfs", 8 | "transportation", 9 | "gtfs-rt", 10 | "gtfs-realtime" 11 | ], 12 | "private": false, 13 | "author": "Brendan Nee ", 14 | "homepage": "https://github.com/BlinkTagInc/transit-departures-widget", 15 | "bugs": { 16 | "url": "https://github.com/BlinkTagInc/transit-departures-widget" 17 | }, 18 | "repository": "git://github.com/BlinkTagInc/transit-departures-widget", 19 | "contributors": [ 20 | "Wojciech Kulesza ", 21 | "eMerzh" 22 | ], 23 | "license": "MIT", 24 | "scripts": { 25 | "build": "tsup", 26 | "prepare": "husky", 27 | "start": "node dist/app" 28 | }, 29 | "bin": { 30 | "transit-departures-widget": "dist/bin/transit-departures-widget.js" 31 | }, 32 | "type": "module", 33 | "main": "./dist/index.js", 34 | "types": "./dist/index.d.ts", 35 | "files": [ 36 | "dist" 37 | ], 38 | "dependencies": { 39 | "express": "^5.1.0", 40 | "gtfs": "^4.17.3", 41 | "i18n": "^0.15.1", 42 | "js-beautify": "^1.15.4", 43 | "lodash-es": "^4.17.21", 44 | "morgan": "^1.10.0", 45 | "pretty-error": "^4.0.0", 46 | "pug": "^3.0.3", 47 | "sanitize-filename": "^1.6.3", 48 | "sqlstring-sqlite": "^0.1.1", 49 | "timer-machine": "^1.1.0", 50 | "toposort": "^2.0.2", 51 | "untildify": "^5.0.0", 52 | "yargs": "^17.7.2", 53 | "yoctocolors": "^2.1.1" 54 | }, 55 | "devDependencies": { 56 | "@types/express": "^5.0.2", 57 | "@types/i18n": "^0.13.12", 58 | "@types/js-beautify": "^1.14.3", 59 | "@types/lodash-es": "^4.17.12", 60 | "@types/morgan": "^1.9.9", 61 | "@types/node": "^22.15.21", 62 | "@types/pug": "^2.0.10", 63 | "@types/timer-machine": "^1.1.3", 64 | "@types/toposort": "^2.0.7", 65 | "@types/yargs": "^17.0.33", 66 | "husky": "^9.1.7", 67 | "lint-staged": "^16.0.0", 68 | "prettier": "^3.5.3", 69 | "tsup": "^8.5.0", 70 | "typescript": "^5.8.3" 71 | }, 72 | "engines": { 73 | "node": ">= 14.15.4" 74 | }, 75 | "release-it": { 76 | "github": { 77 | "release": true 78 | }, 79 | "plugins": { 80 | "@release-it/keep-a-changelog": { 81 | "filename": "CHANGELOG.md" 82 | } 83 | }, 84 | "hooks": { 85 | "after:bump": "npm run build" 86 | } 87 | }, 88 | "prettier": { 89 | "singleQuote": true, 90 | "semi": false 91 | }, 92 | "lint-staged": { 93 | "*.js": "prettier --write", 94 | "*.ts": "prettier --write", 95 | "*.json": "prettier --write" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/lib/transit-departures-widget.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { clone, omit } from 'lodash-es' 3 | import { writeFile } from 'node:fs/promises' 4 | import { importGtfs, openDb } from 'gtfs' 5 | import sanitize from 'sanitize-filename' 6 | import Timer from 'timer-machine' 7 | import untildify from 'untildify' 8 | 9 | import { copyStaticAssets, prepDirectory } from './file-utils.ts' 10 | import { log, logError } from './log-utils.ts' 11 | import { 12 | generateTransitDeparturesWidgetHtml, 13 | generateTransitDeparturesWidgetJson, 14 | setDefaultConfig, 15 | } from './utils.ts' 16 | import { Config } from '../types/global_interfaces.ts' 17 | 18 | /* 19 | * Generate transit departures widget HTML from GTFS. 20 | */ 21 | async function transitDeparturesWidget(initialConfig: Config) { 22 | const config = setDefaultConfig(initialConfig) 23 | 24 | try { 25 | openDb(config) 26 | } catch (error: any) { 27 | if (error?.code === 'SQLITE_CANTOPEN') { 28 | logError(config)( 29 | `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.`, 30 | ) 31 | } 32 | 33 | throw error 34 | } 35 | 36 | if (!config.agency) { 37 | throw new Error('No agency defined in `config.json`') 38 | } 39 | 40 | const timer = new Timer() 41 | const agencyKey = config.agency.agency_key ?? 'unknown' 42 | 43 | const outputPath = config.outputPath 44 | ? untildify(config.outputPath) 45 | : path.join(process.cwd(), 'html', sanitize(agencyKey)) 46 | 47 | timer.start() 48 | 49 | if (!config.skipImport) { 50 | // Import GTFS 51 | const gtfsImportConfig = { 52 | ...clone(omit(config, 'agency')), 53 | agencies: [ 54 | { 55 | agency_key: config.agency.agency_key, 56 | path: config.agency.gtfs_static_path, 57 | url: config.agency.gtfs_static_url, 58 | }, 59 | ], 60 | } 61 | 62 | await importGtfs(gtfsImportConfig) 63 | } 64 | 65 | await prepDirectory(outputPath, config) 66 | 67 | if (config.noHead !== true) { 68 | await copyStaticAssets(config, outputPath) 69 | } 70 | 71 | log(config)(`${agencyKey}: Generating Transit Departures Widget HTML`) 72 | 73 | config.assetPath = '' 74 | 75 | // Generate JSON of routes and stops 76 | const { routes, stops } = generateTransitDeparturesWidgetJson(config) 77 | await writeFile( 78 | path.join(outputPath, 'data', 'routes.json'), 79 | JSON.stringify(routes, null, 2), 80 | ) 81 | await writeFile( 82 | path.join(outputPath, 'data', 'stops.json'), 83 | JSON.stringify(stops, null, 2), 84 | ) 85 | 86 | const html = await generateTransitDeparturesWidgetHtml(config) 87 | await writeFile(path.join(outputPath, 'index.html'), html) 88 | 89 | timer.stop() 90 | 91 | // Print stats 92 | log(config)( 93 | `${agencyKey}: Transit Departures Widget HTML created at ${outputPath}`, 94 | ) 95 | 96 | const seconds = Math.round(timer.time() / 1000) 97 | log(config)(`${agencyKey}: HTML generation required ${seconds} seconds`) 98 | } 99 | 100 | export default transitDeparturesWidget 101 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs' 2 | import { join } from 'node:path' 3 | import yargs from 'yargs' 4 | import { getRoutes, importGtfs, openDb } from 'gtfs' 5 | import { clone, omit } from 'lodash-es' 6 | import untildify from 'untildify' 7 | import express from 'express' 8 | import logger from 'morgan' 9 | 10 | import { 11 | setDefaultConfig, 12 | generateTransitDeparturesWidgetHtml, 13 | generateTransitDeparturesWidgetJson, 14 | } from '../lib/utils.ts' 15 | import { getPathToViewsFolder } from '../lib/file-utils.ts' 16 | 17 | const argv = yargs(process.argv) 18 | .option('c', { 19 | alias: 'configPath', 20 | describe: 'Path to config file', 21 | default: './config.json', 22 | type: 'string', 23 | }) 24 | .parseSync() 25 | 26 | const app = express() 27 | 28 | const configPath = 29 | (argv.configPath as string) || join(process.cwd(), 'config.json') 30 | const selectedConfig = JSON.parse(readFileSync(configPath, 'utf8')) 31 | 32 | const config = setDefaultConfig(selectedConfig) 33 | // Override noHead config option so full HTML pages are generated 34 | config.noHead = false 35 | config.assetPath = '/' 36 | config.logFunction = console.log 37 | 38 | try { 39 | openDb(config) 40 | getRoutes() 41 | } catch (error: any) { 42 | console.log('Importing GTFS') 43 | 44 | try { 45 | // Import GTFS 46 | const gtfsImportConfig = { 47 | ...clone(omit(config, 'agency')), 48 | agencies: [ 49 | { 50 | agency_key: config.agency.agency_key, 51 | path: config.agency.gtfs_static_path, 52 | url: config.agency.gtfs_static_url, 53 | }, 54 | ], 55 | } 56 | 57 | await importGtfs(gtfsImportConfig) 58 | } catch (error: any) { 59 | console.error( 60 | `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists and import GTFS before running this app.`, 61 | ) 62 | throw error 63 | } 64 | } 65 | 66 | /* 67 | * Show the transit departures widget 68 | */ 69 | app.get('/', async (request, response, next) => { 70 | try { 71 | const html = await generateTransitDeparturesWidgetHtml(config) 72 | response.send(html) 73 | } catch (error) { 74 | next(error) 75 | } 76 | }) 77 | 78 | /* 79 | * Provide data 80 | */ 81 | app.get('/data/routes.json', async (request, response, next) => { 82 | try { 83 | const { routes } = await generateTransitDeparturesWidgetJson(config) 84 | response.json(routes) 85 | } catch (error) { 86 | next(error) 87 | } 88 | }) 89 | 90 | app.get('/data/stops.json', async (request, response, next) => { 91 | try { 92 | const { stops } = await generateTransitDeparturesWidgetJson(config) 93 | response.json(stops) 94 | } catch (error) { 95 | next(error) 96 | } 97 | }) 98 | 99 | app.set('views', getPathToViewsFolder(config)) 100 | app.set('view engine', 'pug') 101 | 102 | app.use(logger('dev')) 103 | 104 | // Serve static assets 105 | const staticAssetPath = 106 | config.templatePath === undefined 107 | ? getPathToViewsFolder(config) 108 | : untildify(config.templatePath) 109 | 110 | app.use(express.static(staticAssetPath)) 111 | 112 | // Fallback 404 route 113 | app.use((req, res) => { 114 | res.status(404).send('Not Found') 115 | }) 116 | 117 | // Error handling middleware 118 | app.use( 119 | ( 120 | err: Error, 121 | req: express.Request, 122 | res: express.Response, 123 | next: express.NextFunction, 124 | ) => { 125 | console.error(err.stack) 126 | res.status(500).send('Something broke!') 127 | }, 128 | ) 129 | 130 | const startServer = async (port: number): Promise => { 131 | try { 132 | await new Promise((resolve, reject) => { 133 | const server = app 134 | .listen(port) 135 | .once('listening', () => { 136 | console.log(`Express server listening on port ${port}`) 137 | resolve() 138 | }) 139 | .once('error', (err: NodeJS.ErrnoException) => { 140 | if (err.code === 'EADDRINUSE') { 141 | console.log(`Port ${port} is in use, trying ${port + 1}`) 142 | server.close() 143 | resolve(startServer(port + 1)) 144 | } else { 145 | reject(err) 146 | } 147 | }) 148 | }) 149 | } catch (err) { 150 | console.error('Failed to start server:', err) 151 | process.exit(1) 152 | } 153 | } 154 | 155 | const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000 156 | startServer(port) 157 | -------------------------------------------------------------------------------- /src/lib/file-utils.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join, resolve } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { access, cp, mkdir, readdir, readFile, rm } from 'node:fs/promises' 4 | import beautify from 'js-beautify' 5 | import pug from 'pug' 6 | import untildify from 'untildify' 7 | 8 | import { Config } from '../types/global_interfaces.ts' 9 | 10 | /* 11 | * Attempt to parse the specified config JSON file. 12 | */ 13 | export async function getConfig(argv) { 14 | try { 15 | const data = await readFile( 16 | resolve(untildify(argv.configPath)), 17 | 'utf8', 18 | ).catch((error) => { 19 | console.error( 20 | new Error( 21 | `Cannot find configuration file at \`${argv.configPath}\`. Use config-sample.json as a starting point, pass --configPath option`, 22 | ), 23 | ) 24 | throw error 25 | }) 26 | const config = JSON.parse(data) 27 | 28 | if (argv.skipImport === true) { 29 | config.skipImport = argv.skipImport 30 | } 31 | 32 | return config 33 | } catch (error) { 34 | console.error( 35 | new Error( 36 | `Cannot parse configuration file at \`${argv.configPath}\`. Check to ensure that it is valid JSON.`, 37 | ), 38 | ) 39 | throw error 40 | } 41 | } 42 | 43 | /* 44 | * Get the full path to the views folder. 45 | */ 46 | export function getPathToViewsFolder(config: Config) { 47 | if (config.templatePath) { 48 | return untildify(config.templatePath) 49 | } 50 | 51 | const __dirname = dirname(fileURLToPath(import.meta.url)) 52 | 53 | // Dynamically calculate the path to the views directory 54 | let viewsFolderPath 55 | if (__dirname.endsWith('/dist/bin') || __dirname.endsWith('/dist/app')) { 56 | // When the file is in 'dist/bin' or 'dist/app' 57 | viewsFolderPath = resolve(__dirname, '../../views/widget') 58 | } else if (__dirname.endsWith('/dist')) { 59 | // When the file is in 'dist' 60 | viewsFolderPath = resolve(__dirname, '../views/widget') 61 | } else { 62 | // In case it's neither, fallback to project root 63 | viewsFolderPath = resolve(__dirname, 'views/widget') 64 | } 65 | 66 | return viewsFolderPath 67 | } 68 | 69 | /* 70 | * Get the full path of a template file. 71 | */ 72 | function getPathToTemplateFile(templateFileName: string, config: Config) { 73 | const fullTemplateFileName = 74 | config.noHead !== true 75 | ? `${templateFileName}_full.pug` 76 | : `${templateFileName}.pug` 77 | 78 | return join(getPathToViewsFolder(config), fullTemplateFileName) 79 | } 80 | 81 | /* 82 | * Prepare the outputPath directory for writing timetable files. 83 | */ 84 | export async function prepDirectory(outputPath: string, config: Config) { 85 | // Check if outputPath exists 86 | try { 87 | await access(outputPath) 88 | } catch (error: any) { 89 | try { 90 | await mkdir(outputPath, { recursive: true }) 91 | await mkdir(join(outputPath, 'data')) 92 | } catch (error: any) { 93 | if (error?.code === 'ENOENT') { 94 | throw new Error( 95 | `Unable to write to ${outputPath}. Try running this command from a writable directory.`, 96 | ) 97 | } 98 | 99 | throw error 100 | } 101 | } 102 | 103 | // Check if outputPath is empty 104 | const files = await readdir(outputPath) 105 | if (config.overwriteExistingFiles === false && files.length > 0) { 106 | throw new Error( 107 | `Output directory ${outputPath} is not empty. Please specify an empty directory.`, 108 | ) 109 | } 110 | 111 | // Delete all files in outputPath if `overwriteExistingFiles` is true 112 | if (config.overwriteExistingFiles === true) { 113 | await rm(join(outputPath, '*'), { recursive: true, force: true }) 114 | } 115 | } 116 | 117 | /* 118 | * Copy needed CSS and JS to export path. 119 | */ 120 | export async function copyStaticAssets(config: Config, outputPath: string) { 121 | const viewsFolderPath = getPathToViewsFolder(config) 122 | 123 | const foldersToCopy = ['css', 'js', 'img'] 124 | 125 | for (const folder of foldersToCopy) { 126 | if ( 127 | await access(join(viewsFolderPath, folder)) 128 | .then(() => true) 129 | .catch(() => false) 130 | ) { 131 | await cp(join(viewsFolderPath, folder), join(outputPath, folder), { 132 | recursive: true, 133 | }) 134 | } 135 | } 136 | } 137 | 138 | /* 139 | * Render the HTML based on the config. 140 | */ 141 | export async function renderFile( 142 | templateFileName: string, 143 | templateVars: any, 144 | config: Config, 145 | ) { 146 | const templatePath = getPathToTemplateFile(templateFileName, config) 147 | const html = await pug.renderFile(templatePath, templateVars) 148 | 149 | // Beautify HTML if setting is set 150 | if (config.beautify === true) { 151 | return beautify.html_beautify(html, { indent_size: 2 }) 152 | } 153 | 154 | return html 155 | } 156 | -------------------------------------------------------------------------------- /views/widget/css/transit-departures-widget-styles.css: -------------------------------------------------------------------------------- 1 | /* General Styles */ 2 | 3 | body { 4 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 5 | font-size: 14px; 6 | line-height: 1.4; 7 | color: #333333; 8 | background-color: #ffffff; 9 | } 10 | 11 | .transit-departures-widget { 12 | max-width: 600px; 13 | } 14 | 15 | .transit-departures-widget .hidden-form { 16 | display: none; 17 | } 18 | 19 | .transit-departures-widget .stop-code-invalid { 20 | display: none; 21 | color: #821515; 22 | padding-bottom: 10px; 23 | } 24 | 25 | .transit-departures-widget .departure-results { 26 | display: none; 27 | margin-top: 25px; 28 | } 29 | 30 | .transit-departures-widget .departure-results-none { 31 | display: none; 32 | margin-top: 10px; 33 | } 34 | 35 | .transit-departures-widget .departure-results-error { 36 | display: none; 37 | margin-top: 10px; 38 | color: #821515; 39 | } 40 | 41 | .transit-departures-widget .departure-results-stop, 42 | .transit-departures-widget .departure-results-stop-unknown { 43 | font-size: 18px; 44 | font-weight: 500; 45 | line-height: 1; 46 | margin-bottom: 3px; 47 | } 48 | 49 | .transit-departures-widget .departure-result { 50 | background-color: #eee; 51 | display: flex; 52 | align-items: center; 53 | justify-content: space-between; 54 | margin-top: 8px; 55 | } 56 | 57 | .transit-departures-widget .departure-results-header { 58 | display: flex; 59 | justify-content: space-between; 60 | align-items: flex-end; 61 | } 62 | 63 | .transit-departures-widget .departure-results-fetchtime { 64 | flex-shrink: 0; 65 | font-size: 12px; 66 | margin-left: 8px; 67 | padding: 0 0 0 15px; 68 | border: none; 69 | background-color: transparent; 70 | background-image: url('../img/refresh.svg'); 71 | background-repeat: no-repeat; 72 | background-size: 12px 12px; 73 | background-position-y: 1px; 74 | } 75 | 76 | .transit-departures-widget .departure-results-fetchtime:hover { 77 | text-decoration: underline; 78 | } 79 | 80 | .transit-departures-widget .departure-result-route-name { 81 | display: flex; 82 | align-items: center; 83 | line-height: 1; 84 | } 85 | 86 | .transit-departures-widget .departure-result-route-direction { 87 | font-size: 22px; 88 | margin-top: 2px; 89 | } 90 | 91 | .transit-departures-widget .departure-result-route-circle { 92 | margin: 5px 9px 5px; 93 | width: 30px; 94 | height: 30px; 95 | border-radius: 50%; 96 | line-height: 30px; 97 | text-align: center; 98 | flex-shrink: 0; 99 | overflow: hidden; 100 | } 101 | 102 | .transit-departures-widget .departure-result-times { 103 | display: flex; 104 | align-items: stretch; 105 | flex-shrink: 0; 106 | align-self: stretch; 107 | } 108 | 109 | .transit-departures-widget .departure-result-time-container { 110 | padding: 0 5px; 111 | width: 76px; 112 | border-left: 1px solid #ddd; 113 | align-self: stretch; 114 | display: flex; 115 | align-items: center; 116 | justify-content: center; 117 | flex-shrink: 0; 118 | flex-grow: 0; 119 | } 120 | 121 | .transit-departures-widget .departure-result-time { 122 | font-size: 24px; 123 | } 124 | 125 | .transit-departures-widget .departure-result-time-label { 126 | font-size: 14px; 127 | padding-left: 2px; 128 | } 129 | 130 | .transit-departures-widget .loader, 131 | .transit-departures-widget .loader:after { 132 | border-radius: 50%; 133 | width: 10em; 134 | height: 10em; 135 | } 136 | 137 | .transit-departures-widget .loader { 138 | margin: 20px auto; 139 | font-size: 10px; 140 | position: relative; 141 | text-indent: -9999em; 142 | border-top: 1.1em solid rgba(79, 79, 79, 0.2); 143 | border-right: 1.1em solid rgba(79, 79, 79, 0.2); 144 | border-bottom: 1.1em solid rgba(79, 79, 79, 0.2); 145 | border-left: 1.1em solid #4f4f4f; 146 | -webkit-transform: translateZ(0); 147 | -ms-transform: translateZ(0); 148 | transform: translateZ(0); 149 | -webkit-animation: transitDeparturesWidgetLoader 1.1s infinite linear; 150 | animation: transitDeparturesWidgetLoader 1.1s infinite linear; 151 | display: none; 152 | } 153 | 154 | @-webkit-keyframes transitDeparturesWidgetLoader { 155 | 0% { 156 | -webkit-transform: rotate(0deg); 157 | transform: rotate(0deg); 158 | } 159 | 100% { 160 | -webkit-transform: rotate(360deg); 161 | transform: rotate(360deg); 162 | } 163 | } 164 | 165 | @keyframes transitDeparturesWidgetLoader { 166 | 0% { 167 | -webkit-transform: rotate(0deg); 168 | transform: rotate(0deg); 169 | } 170 | 100% { 171 | -webkit-transform: rotate(360deg); 172 | transform: rotate(360deg); 173 | } 174 | } 175 | 176 | .autocomplete { 177 | background: white; 178 | z-index: 1000; 179 | overflow: auto; 180 | box-sizing: border-box; 181 | border: 1px solid rgba(0, 0, 0, 0.125); 182 | } 183 | 184 | .autocomplete * { 185 | font: inherit; 186 | } 187 | 188 | .autocomplete > div { 189 | padding: 4px 4px; 190 | } 191 | 192 | .autocomplete .group { 193 | background: #eee; 194 | } 195 | 196 | .autocomplete > div:hover:not(.group), 197 | .autocomplete > div.selected { 198 | background: #007bff; 199 | color: #fff; 200 | font-weight: bold; 201 | cursor: pointer; 202 | } 203 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.5.5] - 2025-05-21 9 | 10 | ## Updated 11 | - Dependency Updates 12 | - Allow using in-memory database in demo app 13 | 14 | ## [2.5.4] - 2025-02-25 15 | 16 | ## Updated 17 | - Dependency Updates 18 | 19 | ## [2.5.3] - 2025-01-28 20 | 21 | ## Fixed 22 | - Issue with i18n library 23 | 24 | ## Updated 25 | - Dependency Updates 26 | 27 | ## [2.5.2] - 2024-12-10 28 | 29 | ## Updated 30 | - Dependency Updates 31 | 32 | ## [2.5.1] - 2024-11-18 33 | 34 | ## Updated 35 | - Dependency Updates 36 | 37 | ## [2.5.0] - 2024-11-14 38 | 39 | ## Fixed 40 | - Paths to views folder 41 | - Moved assets to views folder 42 | 43 | ## [2.4.4] - 2024-11-14 44 | 45 | ## Updated 46 | - Improvements to export path and logging 47 | - Dependency Updates 48 | 49 | ## [2.4.3] - 2024-10-01 50 | 51 | ## Updated 52 | - Dependency Updates 53 | - Use overlay mode in autocomplete 54 | 55 | ## [2.4.2] - 2024-07-10 56 | 57 | ## Added 58 | - `includeCoordinates` config option 59 | 60 | ## [2.4.1] - 2024-07-04 61 | 62 | ## Updated 63 | - Frontend dependency updates 64 | - Update to bootstrap 5 65 | - Typescript 66 | 67 | ## [2.4.0] - 2024-07-03 68 | 69 | ## Updated 70 | - Store routes and stops in separate JSON file 71 | - Dependency updates 72 | 73 | ## Fixed 74 | - Copy img folder 75 | 76 | ## [2.3.0] - 2024-04-22 77 | ## Fixed 78 | 79 | - Better removing of last stoptime of trip 80 | 81 | ## [2.2.1] - 2024-04-08 82 | 83 | ## Fixed 84 | - allow query param in config gtfsRtTripupdatesUrl 85 | 86 | ## Updated 87 | - Dependency updates 88 | 89 | ## [2.2.0] - 2024-03-07 90 | 91 | ## Added 92 | 93 | - startDate and endDate config params 94 | 95 | ## Updated 96 | 97 | - Dependency updates 98 | 99 | ## [2.1.1] - 2023-12-04 100 | 101 | ## Fixed 102 | 103 | - Fix for routes with no directions 104 | 105 | ## Updated 106 | 107 | - Dependency updates 108 | 109 | ## [2.1.0] - 2023-09-13 110 | 111 | ## Changed 112 | 113 | - Remove last stop of each route 114 | - Improved autocomplete sorting 115 | - Use stop_code as value in autocomplete 116 | - Populate route dropdown on page load 117 | 118 | ## Fixed 119 | 120 | - Fix for grouping child stops 121 | - Handle case with no departures 122 | 123 | ## Updated 124 | 125 | - Dependency updates 126 | 127 | ## [2.0.0] - 2023-09-14 128 | 129 | ## Changed 130 | 131 | - Renamed to transit-departures-widget 132 | - Renamed all styles and functions to use departures instead of arrivals 133 | 134 | ## Fixed 135 | 136 | - Handle empty direction_id in GTFS 137 | - Hide overflow in route circle 138 | - Better route sorting 139 | - Fix for hidden arrival times after none available 140 | 141 | ## Updated 142 | 143 | - Reword input to "stop" and "Stop code" 144 | - Add support for GTFS without stop codes 145 | - Hide departures more than 1 minute in the past 146 | 147 | ## [1.2.1] - 2023-08-14 148 | 149 | ## Fixed 150 | 151 | - Error in logging functions 152 | 153 | ## Updated 154 | 155 | - Use lint-staged instead of pretty-quick 156 | - Dependency updates 157 | 158 | ## [1.2.0] - 2023-05-22 159 | 160 | ## Fixed 161 | 162 | - Deduplicate stops in a row 163 | 164 | ## Updated 165 | 166 | - Update to node-gtfs v4 167 | - Dependency updates 168 | 169 | ## [1.1.0] - 2022-06-29 170 | 171 | ## Added 172 | 173 | - Added a refresh button 174 | - Updated filter to hide departure times at last stop of each trip 175 | 176 | ## Updated 177 | 178 | - Dependency updates 179 | 180 | ## [1.0.2] - 2022-03-17 181 | 182 | ## Changed 183 | 184 | - Move text out of JS into template 185 | - Add i18n and support for locale translation file 186 | 187 | ## Fixed 188 | 189 | - Fix issue with initial arrivals load 190 | - Fix for routes with no colors defined 191 | - Fix HTML escape for headsign in dropdown 192 | 193 | ## Updated 194 | 195 | - Dependency updates 196 | 197 | ## [1.0.1] - 2021-10-17 198 | 199 | ## Updated 200 | 201 | - node-gtfs library 202 | 203 | ## Added 204 | 205 | - Husky and Prettier 206 | 207 | ## [1.0.0] - 2021-10-15 208 | 209 | ### Breaking Changes 210 | 211 | - Converted to ES6 Module 212 | 213 | ### Added 214 | 215 | - Support for other languages using languageCode config variable 216 | - Polish translations 217 | - Support for 24-hour time using timeFormat config variable 218 | 219 | ### Updated 220 | 221 | - Dependency updates 222 | - Support for release-it 223 | 224 | ## [0.1.4] - 2021-04-20 225 | 226 | ### Updated 227 | 228 | - Dependency updates 229 | - Readme updates 230 | 231 | ## [0.1.3] - 2021-01-12 232 | 233 | ### Fixed 234 | 235 | - Fallback for cyclic routes when finding stop order 236 | 237 | ### Updated 238 | 239 | - Dependency updates 240 | 241 | ## [0.1.2] - 2020-11-13 242 | 243 | ### Updated 244 | 245 | - default to lookup by stop id 246 | - better direction names 247 | 248 | ## [0.1.1] - 2020-11-10 249 | 250 | ### Added 251 | 252 | - beautify option 253 | - templatePath option 254 | - noHead option 255 | 256 | ## [0.1.0] - 2020-11-10 257 | 258 | ### Added 259 | 260 | - Initial Release 261 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { openDb, getDirections, getRoutes, getStops, getTrips } from 'gtfs' 3 | import { groupBy, last, maxBy, size, sortBy, uniqBy } from 'lodash-es' 4 | import { getPathToViewsFolder, renderFile } from './file-utils.ts' 5 | import sqlString from 'sqlstring-sqlite' 6 | import toposort from 'toposort' 7 | import { I18n } from 'i18n' 8 | 9 | import { Config, SqlWhere, SqlValue } from '../types/global_interfaces.ts' 10 | import { logWarning } from './log-utils.ts' 11 | 12 | /* 13 | * Get calendars for a specified date range 14 | */ 15 | const getCalendarsForDateRange = (config: Config) => { 16 | const db = openDb(config) 17 | let whereClause = '' 18 | const whereClauses = [] 19 | 20 | if (config.endDate) { 21 | whereClauses.push(`start_date <= ${sqlString.escape(config.endDate)}`) 22 | } 23 | 24 | if (config.startDate) { 25 | whereClauses.push(`end_date >= ${sqlString.escape(config.startDate)}`) 26 | } 27 | 28 | if (whereClauses.length > 0) { 29 | whereClause = `WHERE ${whereClauses.join(' AND ')}` 30 | } 31 | 32 | return db.prepare(`SELECT * FROM calendar ${whereClause}`).all() 33 | } 34 | 35 | /* 36 | * Format a route name. 37 | */ 38 | function formatRouteName(route) { 39 | let routeName = '' 40 | 41 | if (route.route_short_name !== null) { 42 | routeName += route.route_short_name 43 | } 44 | 45 | if (route.route_short_name !== null && route.route_long_name !== null) { 46 | routeName += ' - ' 47 | } 48 | 49 | if (route.route_long_name !== null) { 50 | routeName += route.route_long_name 51 | } 52 | 53 | return routeName 54 | } 55 | 56 | /* 57 | * Get directions for a route 58 | */ 59 | function getDirectionsForRoute( 60 | route: Record, 61 | config: Config & { __: I18n['__'] }, 62 | ) { 63 | const db = openDb(config) 64 | 65 | // Lookup direction names from non-standard directions.txt file 66 | const directions = getDirections({ route_id: route.route_id }, [ 67 | 'direction_id', 68 | 'direction', 69 | ]) 70 | 71 | const calendars = getCalendarsForDateRange(config) 72 | 73 | // Else use the most common headsigns as directions from trips.txt file 74 | if (directions.length === 0) { 75 | const headsigns = db 76 | .prepare( 77 | `SELECT direction_id, trip_headsign, count(*) AS count FROM trips WHERE route_id = ? AND service_id IN (${calendars 78 | .map((calendar: Record) => `'${calendar.service_id}'`) 79 | .join(', ')}) GROUP BY direction_id, trip_headsign`, 80 | ) 81 | .all(route.route_id) 82 | 83 | for (const group of Object.values(groupBy(headsigns, 'direction_id'))) { 84 | const mostCommonHeadsign = maxBy(group, 'count') 85 | directions.push({ 86 | direction_id: mostCommonHeadsign.direction_id, 87 | direction: config.__('To {{{headsign}}}', { 88 | headsign: mostCommonHeadsign.trip_headsign, 89 | }), 90 | }) 91 | } 92 | } 93 | 94 | return directions 95 | } 96 | 97 | /* 98 | * Sort an array of stoptimes by stop_sequence using a directed graph 99 | */ 100 | function sortStopIdsBySequence(stoptimes: Record[]) { 101 | const stoptimesGroupedByTrip = groupBy(stoptimes, 'trip_id') 102 | 103 | // First, try using a directed graph to determine stop order. 104 | try { 105 | const stopGraph = [] 106 | 107 | for (const tripStoptimes of Object.values(stoptimesGroupedByTrip)) { 108 | const sortedStopIds = sortBy(tripStoptimes, 'stop_sequence').map( 109 | (stoptime) => stoptime.stop_id, 110 | ) 111 | 112 | for (const [index, stopId] of sortedStopIds.entries()) { 113 | if (index === sortedStopIds.length - 1) { 114 | continue 115 | } 116 | 117 | stopGraph.push([stopId, sortedStopIds[index + 1]]) 118 | } 119 | } 120 | 121 | return toposort( 122 | stopGraph as unknown as readonly [string, string | undefined][], 123 | ) 124 | } catch { 125 | // Ignore errors and move to next strategy. 126 | } 127 | 128 | // Finally, fall back to using the stop order from the trip with the most stoptimes. 129 | const longestTripStoptimes = maxBy( 130 | Object.values(stoptimesGroupedByTrip), 131 | (stoptimes) => size(stoptimes), 132 | ) 133 | 134 | return longestTripStoptimes.map((stoptime) => stoptime.stop_id) 135 | } 136 | 137 | /* 138 | * Get stops in order for a route and direction 139 | */ 140 | function getStopsForDirection(route, direction, config: Config) { 141 | const db = openDb(config) 142 | const calendars = getCalendarsForDateRange(config) 143 | const whereClause = formatWhereClauses({ 144 | direction_id: direction.direction_id, 145 | route_id: route.route_id, 146 | service_id: calendars.map((calendar) => calendar.service_id), 147 | }) 148 | const stoptimes = db 149 | .prepare( 150 | `SELECT stop_id, stop_sequence, trip_id FROM stop_times WHERE trip_id IN (SELECT trip_id FROM trips ${whereClause}) ORDER BY stop_sequence ASC`, 151 | ) 152 | .all() 153 | 154 | const sortedStopIds = sortStopIdsBySequence(stoptimes) 155 | 156 | const deduplicatedStopIds = sortedStopIds.reduce( 157 | (memo: string[], stopId: string) => { 158 | // Remove duplicated stop_ids in a row 159 | if (last(memo) !== stopId) { 160 | memo.push(stopId) 161 | } 162 | 163 | return memo 164 | }, 165 | [], 166 | ) 167 | 168 | // Remove last stop of route since boarding is not allowed 169 | deduplicatedStopIds.pop() 170 | 171 | // Fetch stop details 172 | 173 | const stopFields = ['stop_id', 'stop_name', 'stop_code', 'parent_station'] 174 | 175 | if (config.includeCoordinates) { 176 | stopFields.push('stop_lat', 'stop_lon') 177 | } 178 | 179 | const stops = getStops({ stop_id: deduplicatedStopIds }, stopFields) 180 | 181 | return deduplicatedStopIds.map((stopId: string) => 182 | stops.find((stop) => stop.stop_id === stopId), 183 | ) 184 | } 185 | 186 | /* 187 | * Generate HTML for transit departures widget. 188 | */ 189 | export function generateTransitDeparturesWidgetHtml(config: Config) { 190 | const templateVars = { 191 | config, 192 | __: config.__, 193 | } 194 | return renderFile('widget', templateVars, config) 195 | } 196 | 197 | /* 198 | * Generate JSON of routes and stops for transit departures widget. 199 | */ 200 | export function generateTransitDeparturesWidgetJson(config: Config) { 201 | const routes = getRoutes() 202 | const stops = [] 203 | const filteredRoutes = [] 204 | const calendars = getCalendarsForDateRange(config) 205 | 206 | for (const route of routes) { 207 | route.route_full_name = formatRouteName(route) 208 | 209 | const directions = getDirectionsForRoute(route, config) 210 | 211 | // Filter out routes with no directions 212 | if (directions.length === 0) { 213 | logWarning(config)( 214 | `route_id ${route.route_id} has no directions - skipping`, 215 | ) 216 | continue 217 | } 218 | 219 | for (const direction of directions) { 220 | const directionStops = getStopsForDirection(route, direction, config) 221 | stops.push(...directionStops) 222 | direction.stopIds = directionStops.map((stop) => stop?.stop_id) 223 | 224 | const trips = getTrips( 225 | { 226 | route_id: route.route_id, 227 | direction_id: direction.direction_id, 228 | service_id: calendars.map( 229 | (calendar: Record) => calendar.service_id, 230 | ), 231 | }, 232 | ['trip_id'], 233 | ) 234 | direction.tripIds = trips.map((trip) => trip.trip_id) 235 | } 236 | 237 | route.directions = directions 238 | filteredRoutes.push(route) 239 | } 240 | 241 | // Sort routes twice to handle integers with alphabetical characters, such as ['14', '14L', '14X'] 242 | const sortedRoutes = sortBy( 243 | sortBy(filteredRoutes, (route) => route.route_short_name?.toLowerCase()), 244 | (route) => Number.parseInt(route.route_short_name, 10), 245 | ) 246 | 247 | // Get Parent Station Stops 248 | const parentStationIds = new Set(stops.map((stop) => stop?.parent_station)) 249 | 250 | const parentStationStops = getStops( 251 | { stop_id: Array.from(parentStationIds) }, 252 | ['stop_id', 'stop_name', 'stop_code', 'parent_station'], 253 | ) 254 | 255 | stops.push( 256 | ...parentStationStops.map((stop) => { 257 | stop.is_parent_station = true 258 | return stop 259 | }), 260 | ) 261 | 262 | // Sort unique list of stops 263 | const sortedStops = sortBy(uniqBy(stops, 'stop_id'), 'stop_name') 264 | 265 | return { 266 | routes: removeNulls(sortedRoutes), 267 | stops: removeNulls(sortedStops), 268 | } 269 | } 270 | 271 | /* 272 | * Remove null values from array or object 273 | */ 274 | function removeNulls(data: any): any { 275 | if (Array.isArray(data)) { 276 | return data 277 | .map(removeNulls) 278 | .filter((item) => item !== null && item !== undefined) 279 | } else if (typeof data === 'object' && data !== null) { 280 | return Object.entries(data).reduce((acc, [key, value]) => { 281 | const cleanedValue = removeNulls(value) 282 | if (cleanedValue !== null && cleanedValue !== undefined) { 283 | acc[key] = cleanedValue 284 | } 285 | return acc 286 | }, {}) 287 | } else { 288 | return data 289 | } 290 | } 291 | 292 | /* 293 | * Initialize configuration with defaults. 294 | */ 295 | export function setDefaultConfig(initialConfig: Config) { 296 | const defaults = { 297 | beautify: false, 298 | noHead: false, 299 | refreshIntervalSeconds: 20, 300 | skipImport: false, 301 | timeFormat: '12hour', 302 | includeCoordinates: false, 303 | overwriteExistingFiles: true, 304 | verbose: true, 305 | } 306 | 307 | const config = Object.assign(defaults, initialConfig) 308 | const viewsFolderPath = getPathToViewsFolder(config) 309 | const i18n = new I18n({ 310 | directory: join(viewsFolderPath, 'locales'), 311 | defaultLocale: config.locale, 312 | updateFiles: false, 313 | }) 314 | const configWithI18n = Object.assign(config, { 315 | __: i18n.__, 316 | }) 317 | return configWithI18n 318 | } 319 | 320 | export function formatWhereClause( 321 | key: string, 322 | value: null | SqlValue | SqlValue[], 323 | ) { 324 | if (Array.isArray(value)) { 325 | let whereClause = `${sqlString.escapeId(key)} IN (${value 326 | .filter((v) => v !== null) 327 | .map((v) => sqlString.escape(v)) 328 | .join(', ')})` 329 | 330 | if (value.includes(null)) { 331 | whereClause = `(${whereClause} OR ${sqlString.escapeId(key)} IS NULL)` 332 | } 333 | 334 | return whereClause 335 | } 336 | 337 | if (value === null) { 338 | return `${sqlString.escapeId(key)} IS NULL` 339 | } 340 | 341 | return `${sqlString.escapeId(key)} = ${sqlString.escape(value)}` 342 | } 343 | 344 | export function formatWhereClauses(query: SqlWhere) { 345 | if (Object.keys(query).length === 0) { 346 | return '' 347 | } 348 | 349 | const whereClauses = Object.entries(query).map(([key, value]) => 350 | formatWhereClause(key, value), 351 | ) 352 | return `WHERE ${whereClauses.join(' AND ')}` 353 | } 354 | -------------------------------------------------------------------------------- /docs/images/transit-departures-widget-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | ➡️ 3 | Command Line Usage | 4 | Configuration 5 | ⬅️ 6 |

7 | Transit Departures Widget 8 |

9 | 10 | 11 | 12 |

13 | Build a realtime transit departures lookup widget from GTFS and GTFS-Realtime. 14 |

15 | NPM 16 |

17 | 18 |
19 | 20 | Transit Departures Widget generates a user-friendly transit realtime departures widget in HTML format directly from [GTFS and GTFS-RT transit data](http://gtfs.org/). Most transit agencies have schedule data in GTFS format and many publish realtime departures using GTFS-Realtime. This project generates HTML, JS and CSS for use on a transit agency website to allow users to see when the next vehicle is departing from a specific stop. 21 | 22 | transit-departures-widget1 23 |

Lookup by route, direction and stop

24 | 25 | transit-departures-widget2 26 |

Lookup by stop name

27 | 28 | transit-departures-widget3 29 |

Lookup by stop code

30 | 31 | Users can lookup departures by choosing a route, direction and stop or by entering stop id directly. If a stop code is entered, departures for all routes serving that stop are shown. 32 | 33 | Features: 34 | 35 | - Auto-refreshes departures every 20 seconds. (configurable with the `refreshIntervalSeconds` parameter) 36 | 37 | - Caches departures so looking up additional stops is instantaneous. 38 | 39 | - Typeahead autocomplete of stop names makes it easy to look up stops by name. 40 | 41 | - Appends stop_id to URL to support linking to departures for a specific stop or bookmarking the page. 42 | 43 | - Uses `route_color` and `route_text_color` for a stop circle in results. 44 | 45 | - Fetches GTFS-RT data directly - no server-side code is needed. 46 | 47 | - Supports creation of custom HTML templates for complete control over how the widget is rendered. 48 | 49 | ## Demo 50 | 51 | An demo of the widget is available at https://transit-departures-widget.blinktag.com/. Note that this demo will only return departures during hours where vehicles for the demo agency is operating, roughly 7 AM to 10 PM Pacific time. 52 | 53 | ## Current Usage 54 | 55 | The following transit agencies use `transit-departures-widget` on their websites: 56 | 57 | - [County Connection](https://countyconnection.com) 58 | - [Kings Area Regional Transit](https://kartbus.org) 59 | - [Marin Transit](https://marintransit.org/) 60 | - [Mountain View Community Shuttle](https://mvcommunityshuttle.com) 61 | - [MVgo](https://mvgo.org/) 62 | 63 | ## Command Line Usage 64 | 65 | 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`. 66 | 67 | If you would like to use this library as a command-line utility, you can install it globally directly from [npm](https://npmjs.org): 68 | 69 | npm install transit-departures-widget -g 70 | 71 | Then you can run `transit-departures-widget`. 72 | 73 | transit-departures-widget 74 | 75 | ### Command-line options 76 | 77 | `configPath` 78 | 79 | Allows specifying a path to a configuration json file. By default, `transit-departures-widget` will look for a `config.json` file in the directory it is being run from. 80 | 81 | transit-departures-widget --configPath /path/to/your/custom-config.json 82 | 83 | `skipImport` 84 | 85 | Skips importing GTFS into SQLite. Useful if you are rerunning with an unchanged GTFS file. If you use this option and the GTFS file hasn't been imported, you'll get an error. 86 | 87 | transit-departures-widget --skipImport 88 | 89 | ## Configuration 90 | 91 | Copy `config-sample.json` to `config.json` and then add your projects configuration to `config.json`. 92 | 93 | cp config-sample.json config.json 94 | 95 | | option | type | description | 96 | | --------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------- | 97 | | [`agency`](#agency) | object | Information about the GTFS and GTFS-RT to be used. | 98 | | [`beautify`](#beautify) | boolean | Whether or not to beautify the HTML output. | 99 | | [`endDate`](#enddate) | string | A date in YYYYMMDD format to use to filter calendar.txt service. Optional, defaults to using all service in specified GTFS. | 100 | | [`includeCoordinates`](#includecoordinates) | boolean | Whether or not to include stop coordinates in JSON output. | 101 | | [`locale`](#locale) | string | The 2-letter code of the language to use for the interface. | 102 | | [`noHead`](#nohead) | boolean | Whether or not to skip the header and footer of the HTML document. | 103 | | [`refreshIntervalSeconds`](#refreshIntervalSeconds) | integer | How often the widget should refresh departure data in seconds. Optional, defaults to 20 seconds. | 104 | | [`skipImport`](#skipimport) | boolean | Whether or not to skip importing GTFS data into SQLite. | 105 | | [`sqlitePath`](#sqlitepath) | string | A path to an SQLite database. Optional, defaults to using an in-memory database. | 106 | | [`startDate`](#startdate) | string | A date in YYYYMMDD format to use to filter calendar.txt service. Optional, defaults to using all service in specified GTFS. | 107 | | [`templatePath`](#templatepath) | string | Path to custom pug template for rendering widget. | 108 | | [`timeFormat`](#timeFormat) | string | The format (12hour or 24hour) for the "as of" display. | 109 | 110 | ### agency 111 | 112 | {Object} Specify the GTFS file to be imported in an `agency` object. Static GTFS files can be imported via a `url` or a local `path`. 113 | 114 | `agency_key` is a short name you create that is specific to that GTFS file. 115 | 116 | `gtfs_static_url` is the URL of an agency's static GTFS. Either `gtfs_static_url` or `gtfs_static_path` is required. 117 | 118 | `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. 119 | 120 | `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. 121 | 122 | - Specify a download URL for static GTFS: 123 | 124 | ``` 125 | { 126 | "agency": { 127 | "agency_key": "marintransit", 128 | "gtfs_static_url": "https://marintransit.org/data/google_transit.zip", 129 | "gtfs_rt_tripupdates_url": "https://marintransit.net/gtfs-rt/tripupdates" 130 | } 131 | } 132 | ``` 133 | 134 | - Specify a path to a zipped GTFS file: 135 | 136 | ``` 137 | { 138 | "agency": { 139 | "agency_key": "marintransit", 140 | "gtfs_static_path": "/path/to/the/gtfs.zip", 141 | "gtfs_rt_tripupdates_url": "https://marintransit.net/gtfs-rt/tripupdates" 142 | } 143 | } 144 | ``` 145 | 146 | - Specify a path to an unzipped GTFS file: 147 | 148 | ``` 149 | { 150 | "agency": { 151 | "agency_key": "marintransit", 152 | "gtfs_static_path": "/path/to/the/unzipped/gtfs", 153 | "gtfs_rt_tripupdates_url": "https://marintransit.net/gtfs-rt/tripupdates" 154 | } 155 | } 156 | ``` 157 | 158 | ### beautify 159 | 160 | {Boolean} Whether or not to beautify the HTML output. Defaults to `false`. 161 | 162 | ``` 163 | "beautify": false 164 | ``` 165 | 166 | ### endDate 167 | 168 | {String} A date in YYYYMMDD format to use to filter service_ids in calendar.txt. Useful in combination with `startDate` configuration option. Optional, if not specified, all services in GTFS will be used. 169 | 170 | ``` 171 | "endDate": "20240401" 172 | ``` 173 | 174 | ### includeCoordinates 175 | 176 | {Boolean} Whether or not to include stop coordinates in the stops.json output. Can be useful if you need to customize the output to show stops on a map or filter by location. Defaults to `false`. 177 | 178 | ``` 179 | "includeCoordinates": false 180 | ``` 181 | 182 | ### locale 183 | 184 | {String} The 2-letter language code of the language to use for the interface. Current languages supported are Polish (`pl`) and English (`en`). Pull Requests welcome for translations to other languages. Defaults to `en` (English). 185 | 186 | ``` 187 | "locale": "en" 188 | ``` 189 | 190 | ### noHead 191 | 192 | {Boolean} Whether or not to skip the HTML head and footer when generating the HTML for the widget. This is useful for creating embeddable HTML without ``, `` or `` tags. Defaults to `false`. 193 | 194 | ``` 195 | "noHead": false 196 | ``` 197 | 198 | ### refreshIntervalSeconds 199 | 200 | {Integer} How often the widget should refresh departure data in seconds. Optional, defaults to 20 seconds. 201 | 202 | ``` 203 | "refreshIntervalSeconds": 30 204 | ``` 205 | 206 | ### skipImport 207 | 208 | {Boolean} Whether or not to skip importing from GTFS into SQLite. Useful for re-running the script if the GTFS data has not changed. If you use this option and the GTFS file hasn't been imported or you don't have an `sqlitePath` to a non-in-memory database specified, you'll get an error. Defaults to `false`. 209 | 210 | ``` 211 | "skipImport": false 212 | ``` 213 | 214 | ### startDate 215 | 216 | {String} A date in YYYYMMDD format to use to filter service_ids in calendar.txt. Useful in combination with `endDate` configuration option. Optional, if not specified, all services in GTFS will be used. 217 | 218 | ``` 219 | "startDate": "20240301" 220 | ``` 221 | 222 | ### sqlitePath 223 | 224 | {String} A path to an SQLite database. Optional, defaults to using an in-memory database. 225 | 226 | ``` 227 | "sqlitePath": "/tmp/gtfs" 228 | ``` 229 | 230 | ### templatePath 231 | 232 | {String} Path to a folder containing (pug)[https://pugjs.org/] template for rendering the widget widget. This is optional. Defaults to using the templates provided in `views/widget`. All files within the `/views/custom` folder will be .gitignored, so you can copy the `views/widget` folder to `views/custom/myagency` and make any modifications needed. Any custom views folder should contain pug templates called `widget.pug` and `widget_full.pug`. 233 | 234 | ``` 235 | "templatePath": "views/custom/my-agency/" 236 | ``` 237 | 238 | ### timeFormat 239 | 240 | {String} The format (`12hour` or `24hour`) for the "as of" display. Defaults to `12hour`. 241 | 242 | ``` 243 | "timeFormat": "12hour" 244 | ``` 245 | 246 | ## Previewing HTML output 247 | 248 | It can be useful to run the example Express application included in the `app` folder as a way to quickly preview all routes or see changes you are making to custom template. 249 | 250 | After an initial run of `transit-departures-widget`, the GTFS data will be downloaded and loaded into SQLite. 251 | 252 | You can view an individual route HTML on demand by running the included Express app: 253 | 254 | npm start 255 | 256 | 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: 257 | 258 | npm start -- --configPath /path/to/your/custom-config.json 259 | 260 | Once running, you can view the HTML in your browser at [localhost:3000](http://localhost:3000) 261 | 262 | ## Notes 263 | 264 | `transit-departures-widget` uses the [`node-gtfs`](https://github.com/blinktaginc/node-gtfs) library to handle importing and querying GTFS data. 265 | 266 | ## Contributing 267 | 268 | Pull requests are welcome, as is feedback and [reporting issues](https://github.com/blinktaginc/transit-departures-widget/issues). 269 | -------------------------------------------------------------------------------- /views/widget/js/transit-departures-widget.js: -------------------------------------------------------------------------------- 1 | /* global window, $, jQuery, _, Pbf, FeedMessage, alert, accessibleAutocomplete, */ 2 | /* eslint no-var: "off", no-unused-vars: "off", no-alert: "off" */ 3 | 4 | function setupTransitDeparturesWidget(routes, stops, config) { 5 | let departuresResponse 6 | let departuresTimeout 7 | let initialStop 8 | let selectedParameters 9 | let url = new URL(config.gtfsRtTripupdatesUrl) 10 | 11 | function updateUrlWithStop(stop) { 12 | const url = new URL(window.location.origin + window.location.pathname) 13 | url.searchParams.append('stop', stop.stop_code || stop.stop_id) 14 | 15 | window.history.pushState(null, null, url) 16 | } 17 | 18 | async function fetchTripUpdates() { 19 | const response = await fetch(url) 20 | if (response.ok) { 21 | const bufferResponse = await response.arrayBuffer() 22 | const pbf = new Pbf(new Uint8Array(bufferResponse)) 23 | const object = FeedMessage.read(pbf) 24 | 25 | return object.entity 26 | } 27 | 28 | throw new Error(response.status) 29 | } 30 | 31 | function formatMinutes(seconds) { 32 | if (seconds < 60) { 33 | return '<1' 34 | } 35 | 36 | return Math.floor(seconds / 60) 37 | } 38 | 39 | function formatDirectionId(directionId) { 40 | if (directionId === null || directionId === undefined) { 41 | return '0' 42 | } 43 | 44 | return directionId?.toString() 45 | } 46 | 47 | function timeStamp() { 48 | const now = new Date() 49 | let hours = now.getHours() 50 | let minutes = now.getMinutes() 51 | 52 | if (minutes < 10) { 53 | minutes = '0' + minutes 54 | } 55 | 56 | if (config.timeFormat === '24hour') { 57 | return `${hours}:${minutes}` 58 | } 59 | 60 | const suffix = hours < 12 ? 'AM' : 'PM' 61 | hours = hours < 12 ? hours : hours - 12 62 | hours = hours || 12 63 | 64 | return `${hours}:${minutes} ${suffix}` 65 | } 66 | 67 | jQuery(($) => { 68 | // Populate dropdown with all routes 69 | $('#departure_route').append( 70 | routes.map((route) => { 71 | return $('