├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config-sample-full.json ├── config-sample.json ├── docker ├── Dockerfile ├── README.md └── docker-compose.yml ├── examples ├── stop_attributes.txt ├── timetable_notes.txt ├── timetable_notes_references.txt ├── timetable_pages.txt ├── timetable_stop_order.txt └── timetables.txt ├── package.json ├── src ├── app │ └── index.ts ├── bin │ └── gtfs-to-html.ts ├── index.ts ├── lib │ ├── file-utils.ts │ ├── formatters.ts │ ├── geojson-utils.ts │ ├── gtfs-to-html.ts │ ├── log-utils.ts │ ├── template-functions.ts │ ├── time-utils.ts │ └── utils.ts └── types │ └── global_interfaces.ts ├── tsconfig.json ├── tsup.config.ts ├── views └── default │ ├── css │ ├── overview_styles.css │ └── timetable_styles.css │ ├── formatting_functions.pug │ ├── js │ ├── system-map.js │ ├── timetable-alerts.js │ ├── timetable-map.js │ └── timetable-menu.js │ ├── layout.pug │ ├── overview.pug │ ├── overview_full.pug │ ├── timetable_continuation_as.pug │ ├── timetable_continuation_from.pug │ ├── timetable_horizontal.pug │ ├── timetable_hourly.pug │ ├── timetable_map.pug │ ├── timetable_menu.pug │ ├── timetable_note_symbol.pug │ ├── timetable_stop_name.pug │ ├── timetable_stoptime.pug │ ├── timetable_vertical.pug │ ├── timetablepage.pug │ └── timetablepage_full.pug └── www ├── .gitignore ├── .npmrc ├── README.md ├── babel.config.js ├── blog ├── 2020-07-07-New-Documentation.md ├── 2020-08-20-Version-1.0.0.md ├── 2021-11-06-CSV-Export.md └── 2024-09-09-New-GTFS-to-HTML-As-A-Service.md ├── docs ├── additional-files.md ├── configuration.md ├── current-usage.md ├── custom-templates.md ├── introduction.md ├── logging-sql-queries.md ├── previewing-html-output.md ├── processing-large-gtfs.md ├── quick-start.md ├── related-libraries.md ├── reviewing-changes.md ├── stop-attributes.md ├── support.md ├── timetable-notes-references.md ├── timetable-notes.md ├── timetable-pages.md ├── timetable-stop-order.md └── timetables.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src ├── css │ └── custom.css └── pages │ ├── index.js │ └── styles.module.css └── static ├── .nojekyll └── img ├── favicon.ico ├── gtfs-to-html-logo.svg ├── overview-example.jpg ├── timetable-example.jpg ├── undraw_happy_music.svg ├── undraw_proud_coder.svg └── undraw_spreadsheets.svg /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | .DS_Store 3 | .vscode 4 | 5 | # dependencies 6 | node_modules 7 | 8 | # output 9 | html 10 | 11 | # custom views 12 | views/custom 13 | 14 | # config files 15 | config* 16 | !config-sample* 17 | 18 | # docker 19 | docker/config* 20 | 21 | # Build files 22 | /dist -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | ➡️ 3 | Documentation | 4 | Quick Start | 5 | Configuration | 6 | Questions and Support 7 | ⬅️ 8 |

9 | GTFS-to-HTML 10 |

11 | 12 | 13 | 14 |

15 | Create human-readable, user-friendly transit timetables in HTML, PDF or CSV format directly from GTFS. 16 |

17 | NPM 18 |

19 | 20 |
21 | 22 | See [gtfstohtml.com](https://gtfstohtml.com) for full documentation. 23 | 24 | Most transit agencies have schedule data in [GTFS ](https://developers.google.com/transit/gtfs/) format but need to show each route's schedule to users on a website. GTFS-to-HTML automates the process of creating nicely formatted HTML timetables for inclusion on a transit agency website. This makes it easy to keep timetables up to date and accurate when schedule changes happen and reduces the likelihood of errors. 25 | 26 | 27 | 28 | ## Features 29 | 30 | ### Configurable and customizable 31 | 32 | `gtfs-to-html` has many options that configure how timetables are presented. It also allows using a completely custom template which makes it easy to build chunks of HTML that will fit perfectly into any website using any HTML structure and classes that you'd like. Or, create printable PDF versions or CSV exports of timetables using the `outputFormat` config option. 33 | 34 | ### Accessibility for all 35 | 36 | `gtfs-to-html` properly formats timetables to ensure they are screen-reader accessible and WCAG 2.0 compliant. 37 | 38 | ### Mobile responsiveness built in 39 | 40 | Built-in styling makes `gtfs-to-html` timetables ready to size and scroll easily on mobile phones and tablets. 41 | 42 | ### Schedule changes? A cinch. 43 | 44 | By generating future timetables and including dates in table metadata, your timetables can appear in advance of a schedule change, and you can validate that your new timetables and GTFS are correct. 45 | 46 | ### Auto-generated maps 47 | 48 | `gtfs-to-html` can also generate a map for each route that can be included with the schedule page. The map shows all stops for the route and lists all routes that serve each stop. Maps can also show realtime vehicle locations and predicted arrival times from GTFS-realtime data. 49 | 50 | Note: If you only want maps of GTFS data, use the [gtfs-to-geojson](https://github.com/blinktaginc/gtfs-to-geojson) package instead and skip making timetables entirely. If offers many different formats of GeoJSON for routes and stops. 51 | 52 | `gtfs-to-html` uses the [`node-gtfs`](https://github.com/blinktaginc/node-gtfs) library to handle importing and querying GTFS data. 53 | 54 | ## GTFS-to-HTML on the web 55 | 56 | You can now use `gtfs-to-html` without actually downloading any code or doing any configuration. [run.gtfstohtml.com](https://run.gtfstohtml.com) provides a web based interface for finding GTFS feeds for agencies, setting configuration and then generates a previewable and downloadable set of timetables. 57 | 58 | ## Current Usage 59 | 60 | Many transit agencies use `gtfs-to-html` to generate the schedule pages used on their websites, including: 61 | 62 | - [Advance Transit (Vermont)](https://advancetransit.com) 63 | - [Basin Transit (Morongo Basin, California)](https://basin-transit.com/) 64 | - [Brockton Area Transit Authority](https://ridebat.com) 65 | - [BusWay – CIRA (Aveiro, Portugal)](https://busway-cira.pt) 66 | - [Capital Transit (Helena, Montana)](http://www.ridethecapitalt.org) 67 | - [Capital Transit (Juneau, Alaska)](https://juneaucapitaltransit.org) 68 | - [Central Transit (Ellensburg, Washington)](https://centraltransit.org) 69 | - [County Connection (Contra Costa County, California)](https://countyconnection.com) 70 | - [El Dorado Transit](http://eldoradotransit.com) 71 | - [Greater Attleboro-Taunton Regional Transit Authority](https://www.gatra.org) 72 | - [Humboldt Transit Authority](http://hta.org) 73 | - [Kings Area Rural Transit (KART)](https://www.kartbus.org) 74 | - [Lowell Regional Transit Authority](https://lrta.com) 75 | - [Madera County Connection](http://mcctransit.com) 76 | - [Marin Transit](https://marintransit.org) 77 | - [Morongo Basin Transit Authority](https://mbtabus.com) 78 | - [Mountain Transit](http://mountaintransit.org) 79 | - [Mountain View Community Shuttle](http://mvcommunityshuttle.com) 80 | - [MVgo (Mountain View, CA)](https://mvgo.org) 81 | - [NW Connector (Oregon)](http://www.nworegontransit.org) 82 | - [Palo Verde Valley Transit Agency](http://pvvta.com) 83 | - [Petaluma Transit](http://transit.cityofpetaluma.net) 84 | - [Rogue Valley Transportation District (Medford, OR)](https://rvtd.org) 85 | - [rabbittransit (York and Adams County, PA)](https://www.rabbittransit.org) 86 | - [RTC Washoe (Reno, NV)](https://www.rtcwashoe.com) 87 | - [Santa Barbara Metropolitan Transit District](https://sbmtd.gov) 88 | - [Sonoma County Transit](http://sctransit.com) 89 | - [Tahoe Transportation District](https://www.tahoetransportation.org) 90 | - [Tahoe Truckee Area Regional Transit](https://tahoetruckeetransit.com) 91 | - [Transcollines](https://transcollines.ca) 92 | - [Tulare County Area Transit](https://ridetcat.org) 93 | - [Victor Valley Transit](https://vvta.org) 94 | - [Worcester Regional Transit Authority](https://therta.com) 95 | 96 | Are you using `gtfs-to-html`? Let us know via email [gtfs@blinktag.com](mailto:gtfs@blinktag.com) or via opening a github issue or pull request if your agency is using this library. 97 | 98 | `gtfs-to-html` is used as an integral part of [`transit-custom-posts`](https://trilliumtransit.github.io/transit-custom-posts/) - a GTFS-optimized Wordpress plugin for transit websites. 99 | 100 | 101 | 102 | 103 | 104 | ## Installation, Configuration and Usage documentation 105 | 106 | [See GTFS-to-HTML Documentation](https://gtfstohtml.com) 107 | 108 | ## Changelog 109 | 110 | [See Changelog](https://github.com/blinktaginc/gtfs-to-html/blob/master/CHANGELOG.md) 111 | 112 | ## Contributing 113 | 114 | Pull requests are welcome, as well as [feedback and reporting issues](https://github.com/blinktaginc/gtfs-to-html/issues). 115 | 116 | ## Tests 117 | 118 | npm test 119 | -------------------------------------------------------------------------------- /config-sample-full.json: -------------------------------------------------------------------------------- 1 | { 2 | "agencies": [ 3 | { 4 | "agencyKey": "marintransit", 5 | "url": "https://marintransit.org/data/google_transit.zip", 6 | "realtimeAlerts": { 7 | "url": "https://api.marintransit.org/alerts" 8 | }, 9 | "realtimeTripUpdates": { 10 | "url": "https://api.marintransit.org/tripupdates" 11 | }, 12 | "realtimeVehiclePositions": { 13 | "url": "https://api.marintransit.org/vehiclepositions" 14 | } 15 | } 16 | ], 17 | "sqlitePath": "/tmp/gtfs", 18 | "allowEmptyTimetables": false, 19 | "beautify": false, 20 | "coordinatePrecision": 5, 21 | "dateFormat": "MMM D, YYYY", 22 | "daysShortStrings": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], 23 | "daysStrings": [ 24 | "Monday", 25 | "Tuesday", 26 | "Wednesday", 27 | "Thursday", 28 | "Friday", 29 | "Saturday", 30 | "Sunday" 31 | ], 32 | "defaultOrientation": "vertical", 33 | "effectiveDate": "July 8, 2016", 34 | "interpolatedStopSymbol": "•", 35 | "interpolatedStopText": "Estimated time of arrival", 36 | "linkStopUrls": false, 37 | "mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty", 38 | "menuType": "jump", 39 | "noDropoffSymbol": "‡", 40 | "noDropoffText": "No drop off available", 41 | "noHead": false, 42 | "noPickupSymbol": "**", 43 | "noPickupText": "No pickup available", 44 | "noServiceSymbol": "—", 45 | "noServiceText": "No service at this stop", 46 | "outputFormat": "html", 47 | "overwriteExistingFiles": true, 48 | "outputPath": "custom/output/path", 49 | "requestDropoffSymbol": "†", 50 | "requestDropoffText": "Must request drop off", 51 | "requestPickupSymbol": "***", 52 | "requestPickupText": "Request stop - call for pickup", 53 | "serviceNotProvidedOnText": "Service not provided on", 54 | "serviceProvidedOnText": "Service provided on", 55 | "showArrivalOnDifference": 0.2, 56 | "showCalendarExceptions": true, 57 | "showMap": true, 58 | "showOnlyTimepoint": true, 59 | "showRouteTitle": true, 60 | "showStopCity": false, 61 | "showStopDescription": false, 62 | "showStoptimesForRequestStops": true, 63 | "skipImport": false, 64 | "sortingAlgorithm": "common", 65 | "templatePath": "views/default", 66 | "timeFormat": "h:mma", 67 | "useParentStation": true, 68 | "verbose": true, 69 | "zipOutput": false 70 | } 71 | -------------------------------------------------------------------------------- /config-sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "agencies": [ 3 | { 4 | "agencyKey": "marintransit", 5 | "url": "https://marintransit.org/data/google_transit.zip", 6 | "realtimeAlerts": { 7 | "url": "https://api.marintransit.org/alerts" 8 | }, 9 | "realtimeTripUpdates": { 10 | "url": "https://api.marintransit.org/tripupdates" 11 | }, 12 | "realtimeVehiclePositions": { 13 | "url": "https://api.marintransit.org/vehiclepositions" 14 | } 15 | } 16 | ], 17 | "linkStopUrls": true, 18 | "menuType": "radio", 19 | "noHead": false, 20 | "outputFormat": "html", 21 | "showMap": true, 22 | "showOnlyTimepoint": true, 23 | "useParentStation": true, 24 | "verbose": true 25 | } 26 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM node:20 3 | 4 | RUN apt update 5 | RUN apt install -y chromium 6 | 7 | RUN cd ~/ 8 | COPY config.json ./ 9 | 10 | ENV NPM_CONFIG_PREFIX=/home/node/.npm-global 11 | ENV PATH=$PATH:/home/node/.npm-global/bin 12 | RUN npm install -g gtfs-to-html 13 | 14 | CMD [ "gtfs-to-html" ] -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker area 🐳 2 | 3 | Use the files contained in this folder to run GTFS-to-HTML with `docker` (or `docker-compose`). 4 | 5 | Read more in the documentation. -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | gtfs-to-html: 5 | image: gtfs-to-html 6 | build: 7 | context: . 8 | volumes: 9 | - ./html:/html 10 | - ./config.json:/config.json -------------------------------------------------------------------------------- /examples/stop_attributes.txt: -------------------------------------------------------------------------------- 1 | stop_id,stop_city 2 | 1001,"Fresno, CA" 3 | 1002,"Fresno, CA" 4 | 1003,"Hanford, CA" 5 | 1004,"Hanford, CA" 6 | 1005,"Lemoore, CA" 7 | -------------------------------------------------------------------------------- /examples/timetable_notes.txt: -------------------------------------------------------------------------------- 1 | note_id,symbol,note 2 | 1,,"No service during baseball games" 3 | 2,,"No express service during a full moon" 4 | 3,,"Trip is cancelled if drawbridge is up" 5 | 4,,"This stop is sometimes underwater" 6 | 5,,"Driver will only stop if prearranged by fax" 7 | 6,§,"Vehicle can arrive early if leap second is added during trip and *will not wait*" 8 | 7,,"[See list of holidays](http://transitagency.org/holidays)" 9 | -------------------------------------------------------------------------------- /examples/timetable_notes_references.txt: -------------------------------------------------------------------------------- 1 | note_id,timetable_id,route_id,trip_id,stop_id,stop_sequence,show_on_stoptime 2 | 1,131,,,,, 3 | 2,,17,,,, 4 | 3,,,17010,,,1 5 | 4,,,,254514,, 6 | 5,,,,254514,11, 7 | 6,,,17010,235269,, 8 | 7,131,,,,, 9 | -------------------------------------------------------------------------------- /examples/timetable_pages.txt: -------------------------------------------------------------------------------- 1 | timetable_page_id,timetable_page_label,filename 2 | 1,"Cloverdale, Healdsburg, Windsor, Santa Rosa","60.html" 3 | 2,"Sebastopol, Rohnert Park, Cotati","26.html" 4 | -------------------------------------------------------------------------------- /examples/timetable_stop_order.txt: -------------------------------------------------------------------------------- 1 | timetable_id,stop_id,stop_sequence 2 | 1,757717,0 3 | 1,757722,1 4 | 1,757728,2 5 | 1,757668,3 6 | 1,757751,4 7 | 1,757683,5 8 | 1,757689,6 9 | 1,757691,7 10 | 1,757692,8 11 | 1,757700,9 12 | 1,757703,10 13 | 1,757068,11 14 | 1,757074,12 15 | 1,757080,13 16 | 1,757277,14 17 | -------------------------------------------------------------------------------- /examples/timetables.txt: -------------------------------------------------------------------------------- 1 | timetable_id,route_id,direction_id,start_date,end_date,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_time,end_time,include_exceptions,timetable_label,service_notes,orientation,timetable_page_id,timetable_sequence,direction_name,show_trip_continuation 2 | 0,2034,0,20150101,20151122,1,1,1,1,1,1,0,00:00:00,13:00:00,0,101 Northbound,Mon-Sat AM,horizontal,1,0,Northbound,0 3 | 1,2034,0,20150101,20151122,1,1,1,1,1,1,0,13:00:00,24:00:00,0,101 Northbound,Mon-Sat PM,horizontal,1,1,Northbound,0 4 | 2,2035,1,20150819,20151122,1,1,0,1,1,0,0,,,0,101T Northbound,"Mon,Tue,Thur,Fri",horizontal,1,0,Northbound,0 5 | 3,2035,0,20150819,20151122,0,0,1,0,0,0,0,,,0,101T Southbound,Wednesday,horizontal,1,0,Southbound,0 6 | 4,2036,1,20150101,20151122,0,0,0,0,0,0,1,,,0,102 Eastbound,Sunday,horizontal,1,0,Eastbound,0 7 | 5,2036,0,20150101,20151122,1,1,1,1,1,0,0,,,0,102 Westbound,Mon-Fri,horizontal,1,0,Westbound,0 8 | 6,2036,1,20150101,20151122,0,0,0,0,0,1,0,,,0,102 Eastbound,Saturday,horizontal,1,0,Eastbound,0 9 | 7,2037,0,20150101,20151122,1,1,1,1,1,0,0,,,0,103 Westbound,Mon-Fri,horizontal,1,0,Westbound,0 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gtfs-to-html", 3 | "version": "2.10.13", 4 | "private": false, 5 | "description": "Build human readable transit timetables as HTML, PDF or CSV from GTFS", 6 | "keywords": [ 7 | "transit", 8 | "gtfs", 9 | "gtfs-realtime", 10 | "transportation", 11 | "timetables" 12 | ], 13 | "homepage": "https://gtfstohtml.com", 14 | "bugs": { 15 | "url": "https://github.com/blinktaginc/gtfs-to-html/issues" 16 | }, 17 | "repository": "git://github.com/blinktaginc/gtfs-to-html", 18 | "license": "MIT", 19 | "author": "Brendan Nee ", 20 | "contributors": [ 21 | "Evan Siroky ", 22 | "Nathan Selikoff", 23 | "Aaron Antrim ", 24 | "Thomas Craig ", 25 | "Holly Kvalheim", 26 | "Pawajoro", 27 | "Andrea Mignone", 28 | "Evo Stamatov" 29 | ], 30 | "type": "module", 31 | "main": "./dist/index.js", 32 | "types": "./dist/index.d.ts", 33 | "files": [ 34 | "dist", 35 | "docker", 36 | "examples", 37 | "views/default", 38 | "config-sample.json" 39 | ], 40 | "bin": { 41 | "gtfs-to-html": "dist/bin/gtfs-to-html.js" 42 | }, 43 | "scripts": { 44 | "build": "tsup", 45 | "start": "node ./dist/app", 46 | "prepare": "husky" 47 | }, 48 | "dependencies": { 49 | "@turf/helpers": "^7.2.0", 50 | "@turf/simplify": "^7.2.0", 51 | "anchorme": "^3.0.8", 52 | "archiver": "^7.0.1", 53 | "cli-table": "^0.3.11", 54 | "csv-stringify": "^6.5.2", 55 | "express": "^5.1.0", 56 | "gtfs": "^4.17.4", 57 | "gtfs-realtime-pbf-js-module": "^1.0.0", 58 | "js-beautify": "^1.15.4", 59 | "lodash-es": "^4.17.21", 60 | "marked": "^15.0.12", 61 | "moment": "^2.30.1", 62 | "pbf": "^4.0.1", 63 | "pretty-error": "^4.0.0", 64 | "pug": "^3.0.3", 65 | "puppeteer": "^24.9.0", 66 | "sanitize-filename": "^1.6.3", 67 | "sanitize-html": "^2.17.0", 68 | "sqlstring": "^2.3.3", 69 | "timer-machine": "^1.1.0", 70 | "toposort": "^2.0.2", 71 | "untildify": "^5.0.0", 72 | "yargs": "^18.0.0", 73 | "yoctocolors": "^2.1.1" 74 | }, 75 | "devDependencies": { 76 | "@types/archiver": "^6.0.3", 77 | "@types/express": "^5.0.2", 78 | "@types/insane": "^1.0.0", 79 | "@types/js-beautify": "^1.14.3", 80 | "@types/lodash-es": "^4.17.12", 81 | "@types/morgan": "^1.9.9", 82 | "@types/node": "^22.15.24", 83 | "@types/pug": "^2.0.10", 84 | "@types/sanitize-html": "^2.16.0", 85 | "@types/timer-machine": "^1.1.3", 86 | "@types/yargs": "^17.0.33", 87 | "husky": "^9.1.7", 88 | "lint-staged": "^16.1.0", 89 | "prettier": "^3.5.3", 90 | "tsup": "^8.5.0", 91 | "typescript": "^5.8.3" 92 | }, 93 | "engines": { 94 | "node": ">= 20.11.0" 95 | }, 96 | "release-it": { 97 | "github": { 98 | "release": true 99 | }, 100 | "plugins": { 101 | "@release-it/keep-a-changelog": { 102 | "filename": "CHANGELOG.md" 103 | } 104 | }, 105 | "hooks": { 106 | "after:bump": "npm run build" 107 | } 108 | }, 109 | "prettier": { 110 | "singleQuote": true 111 | }, 112 | "lint-staged": { 113 | "*.js": "prettier --write", 114 | "*.ts": "prettier --write", 115 | "*.json": "prettier --write" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { readFileSync } from 'node:fs'; 4 | import yargs from 'yargs'; 5 | import { hideBin } from 'yargs/helpers'; 6 | import { openDb } from 'gtfs'; 7 | import express from 'express'; 8 | import untildify from 'untildify'; 9 | 10 | import { formatTimetableLabel } from '../lib/formatters.js'; 11 | import { getPathToViewsFolder } from '../lib/file-utils.js'; 12 | import { 13 | setDefaultConfig, 14 | getTimetablePagesForAgency, 15 | getFormattedTimetablePage, 16 | generateOverviewHTML, 17 | generateTimetableHTML, 18 | } from '../lib/utils.js'; 19 | 20 | const argv = yargs(hideBin(process.argv)) 21 | .option('c', { 22 | alias: 'configPath', 23 | describe: 'Path to config file', 24 | default: './config.json', 25 | type: 'string', 26 | }) 27 | .parseSync(); 28 | 29 | const app = express(); 30 | 31 | const configPath = 32 | (argv.configPath as string) || join(process.cwd(), 'config.json'); 33 | const selectedConfig = JSON.parse(readFileSync(configPath, 'utf8')); 34 | 35 | const config = setDefaultConfig(selectedConfig); 36 | // Override noHead config option so full HTML pages are generated 37 | config.noHead = false; 38 | config.assetPath = '/'; 39 | config.logFunction = console.log; 40 | 41 | try { 42 | openDb(config); 43 | } catch (error: any) { 44 | console.error( 45 | `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.`, 46 | ); 47 | throw error; 48 | } 49 | 50 | app.set('views', getPathToViewsFolder(config)); 51 | app.set('view engine', 'pug'); 52 | 53 | // Logging middleware 54 | app.use((req, res, next) => { 55 | console.log(`${req.method} ${req.url}`); 56 | next(); 57 | }); 58 | 59 | // Serve static assets 60 | const staticAssetPath = 61 | config.templatePath === undefined 62 | ? getPathToViewsFolder(config) 63 | : untildify(config.templatePath); 64 | 65 | app.use(express.static(staticAssetPath)); 66 | app.use( 67 | '/js', 68 | express.static( 69 | join(dirname(fileURLToPath(import.meta.resolve('pbf'))), 'dist'), 70 | ), 71 | ); 72 | app.use( 73 | '/js', 74 | express.static( 75 | dirname(fileURLToPath(import.meta.resolve('gtfs-realtime-pbf-js-module'))), 76 | ), 77 | ); 78 | app.use( 79 | '/js', 80 | express.static( 81 | join( 82 | dirname(fileURLToPath(import.meta.resolve('anchorme'))), 83 | '../../dist/browser', 84 | ), 85 | ), 86 | ); 87 | 88 | // Show all timetable pages 89 | app.get('/', async (req, res, next) => { 90 | try { 91 | const timetablePages = []; 92 | const timetablePageIds = getTimetablePagesForAgency(config).map( 93 | (timetablePage) => timetablePage.timetable_page_id, 94 | ); 95 | 96 | for (const timetablePageId of timetablePageIds) { 97 | const timetablePage = await getFormattedTimetablePage( 98 | timetablePageId, 99 | config, 100 | ); 101 | 102 | if ( 103 | !timetablePage.consolidatedTimetables || 104 | timetablePage.consolidatedTimetables.length === 0 105 | ) { 106 | console.error( 107 | `No timetables found for timetable_page_id=${timetablePage.timetable_page_id}`, 108 | ); 109 | continue; 110 | } 111 | 112 | timetablePage.relativePath = `/timetables/${timetablePage.timetable_page_id}`; 113 | for (const timetable of timetablePage.consolidatedTimetables) { 114 | timetable.timetable_label = formatTimetableLabel(timetable); 115 | } 116 | 117 | timetablePages.push(timetablePage); 118 | } 119 | 120 | const html = await generateOverviewHTML(timetablePages, config); 121 | res.send(html); 122 | } catch (error) { 123 | next(error); 124 | } 125 | }); 126 | 127 | // Show a specific timetable page 128 | app.get('/timetables/:timetablePageId', async (req, res, next) => { 129 | const { timetablePageId } = req.params; 130 | 131 | if (!timetablePageId) { 132 | res.status(400).send('No timetablePageId provided'); 133 | return; 134 | } 135 | 136 | try { 137 | const timetablePage = await getFormattedTimetablePage( 138 | timetablePageId, 139 | config, 140 | ); 141 | 142 | if ( 143 | !timetablePage || 144 | !timetablePage.consolidatedTimetables || 145 | timetablePage.consolidatedTimetables.length === 0 146 | ) { 147 | res.status(404).send('Timetable page not found'); 148 | return; 149 | } 150 | 151 | const html = await generateTimetableHTML(timetablePage, config); 152 | res.send(html); 153 | } catch (error: any) { 154 | if (error?.message.startsWith('No timetable found')) { 155 | res.status(404).send('Timetable page not found'); 156 | return; 157 | } 158 | 159 | next(error); 160 | } 161 | }); 162 | 163 | // Fallback 404 route 164 | app.use((req, res) => { 165 | res.status(404).send('Not Found'); 166 | }); 167 | 168 | // Error handling middleware 169 | app.use( 170 | ( 171 | err: Error, 172 | req: express.Request, 173 | res: express.Response, 174 | next: express.NextFunction, 175 | ) => { 176 | console.error(err.stack); 177 | res.status(500).send('Something broke!'); 178 | }, 179 | ); 180 | 181 | const startServer = async (port: number): Promise => { 182 | try { 183 | await new Promise((resolve, reject) => { 184 | const server = app 185 | .listen(port) 186 | .once('listening', () => { 187 | console.log(`Express server listening on port ${port}`); 188 | resolve(); 189 | }) 190 | .once('error', (err: NodeJS.ErrnoException) => { 191 | if (err.code === 'EADDRINUSE') { 192 | console.log(`Port ${port} is in use, trying ${port + 1}`); 193 | server.close(); 194 | resolve(startServer(port + 1)); 195 | } else { 196 | reject(err); 197 | } 198 | }); 199 | }); 200 | } catch (err) { 201 | console.error('Failed to start server:', err); 202 | process.exit(1); 203 | } 204 | }; 205 | 206 | const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; 207 | startServer(port); 208 | -------------------------------------------------------------------------------- /src/bin/gtfs-to-html.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.js'; 8 | import { formatError } from '../lib/log-utils.js'; 9 | import gtfsToHtml from '../index.js'; 10 | 11 | const pe = new PrettyError(); 12 | 13 | const { argv } = yargs(hideBin(process.argv)) 14 | .usage('Usage: $0 --configPath ./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 | .option('t', { 29 | alias: 'showOnlyTimepoint', 30 | describe: 'Show only stops with a `timepoint` value in `stops.txt`', 31 | type: 'boolean', 32 | }) 33 | .default('showOnlyTimepoint', undefined); 34 | 35 | const handleError = (error: any) => { 36 | const text = error || 'Unknown Error'; 37 | process.stdout.write(`\n${formatError(text)}\n`); 38 | console.error(pe.render(error)); 39 | process.exit(1); 40 | }; 41 | 42 | const setupImport = async () => { 43 | const config = await getConfig(argv); 44 | await gtfsToHtml(config); 45 | process.exit(); 46 | }; 47 | 48 | setupImport().catch(handleError); 49 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './lib/gtfs-to-html.js'; 2 | -------------------------------------------------------------------------------- /src/lib/file-utils.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join, resolve } from 'node:path'; 2 | import { createWriteStream } from 'node:fs'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { 5 | access, 6 | cp, 7 | copyFile, 8 | mkdir, 9 | readdir, 10 | readFile, 11 | rm, 12 | } from 'node:fs/promises'; 13 | 14 | import * as _ from 'lodash-es'; 15 | import archiver from 'archiver'; 16 | import beautify from 'js-beautify'; 17 | import sanitizeHtml from 'sanitize-html'; 18 | import { renderFile } from 'pug'; 19 | import puppeteer from 'puppeteer'; 20 | import sanitize from 'sanitize-filename'; 21 | import untildify from 'untildify'; 22 | import { marked } from 'marked'; 23 | 24 | import { 25 | isNullOrEmpty, 26 | formatDays, 27 | formatRouteColor, 28 | formatRouteTextColor, 29 | } from './formatters.js'; 30 | import * as templateFunctions from './template-functions.js'; 31 | 32 | import type { Config } from '../types/global_interfaces.js'; 33 | 34 | /* 35 | * Attempt to parse the specified config JSON file. 36 | */ 37 | export async function getConfig(argv) { 38 | let data; 39 | let config; 40 | 41 | try { 42 | data = await readFile(resolve(untildify(argv.configPath)), 'utf8'); 43 | } catch (error) { 44 | throw new Error( 45 | `Cannot find configuration file at \`${argv.configPath}\`. Use config-sample.json as a starting point, pass --configPath option`, 46 | ); 47 | } 48 | 49 | try { 50 | config = JSON.parse(data); 51 | } catch (error) { 52 | throw new Error( 53 | `Cannot parse configuration file at \`${argv.configPath}\`. Check to ensure that it is valid JSON.`, 54 | ); 55 | } 56 | 57 | if (argv.skipImport === true) { 58 | config.skipImport = argv.skipImport; 59 | } 60 | 61 | if (argv.showOnlyTimepoint === true) { 62 | config.showOnlyTimepoint = argv.showOnlyTimepoint; 63 | } 64 | 65 | return config; 66 | } 67 | 68 | /* 69 | * Get the full path to the views folder. 70 | */ 71 | export function getPathToViewsFolder(config: Config) { 72 | if (config.templatePath) { 73 | return untildify(config.templatePath); 74 | } 75 | 76 | const __dirname = dirname(fileURLToPath(import.meta.url)); 77 | 78 | // Dynamically calculate the path to the views directory 79 | let viewsFolderPath; 80 | if (__dirname.endsWith('/dist/bin') || __dirname.endsWith('/dist/app')) { 81 | // When the file is in 'dist/bin' or 'dist/app' 82 | viewsFolderPath = resolve(__dirname, '../../views/default'); 83 | } else if (__dirname.endsWith('/dist')) { 84 | // When the file is in 'dist' 85 | viewsFolderPath = resolve(__dirname, '../views/default'); 86 | } else { 87 | // In case it's neither, fallback to project root 88 | viewsFolderPath = resolve(__dirname, '../../views/default'); 89 | } 90 | 91 | return viewsFolderPath; 92 | } 93 | 94 | /* 95 | * Get the full path of a template file. 96 | */ 97 | function getPathToTemplateFile(templateFileName: string, config: Config) { 98 | const fullTemplateFileName = 99 | config.noHead !== true 100 | ? `${templateFileName}_full.pug` 101 | : `${templateFileName}.pug`; 102 | 103 | return join(getPathToViewsFolder(config), fullTemplateFileName); 104 | } 105 | 106 | /* 107 | * Prepare the outputPath directory for writing timetable files. 108 | */ 109 | export async function prepDirectory(outputPath: string, config: Config) { 110 | // Check if outputPath exists 111 | try { 112 | await access(outputPath); 113 | } catch (error: any) { 114 | try { 115 | await mkdir(outputPath, { recursive: true }); 116 | } catch (error: any) { 117 | if (error?.code === 'ENOENT') { 118 | throw new Error( 119 | `Unable to write to ${outputPath}. Try running this command from a writable directory.`, 120 | ); 121 | } 122 | 123 | throw error; 124 | } 125 | } 126 | 127 | // Check if outputPath is empty 128 | const files = await readdir(outputPath); 129 | if (config.overwriteExistingFiles === false && files.length > 0) { 130 | throw new Error( 131 | `Output directory ${outputPath} is not empty. Please specify an empty directory.`, 132 | ); 133 | } 134 | 135 | // Delete all files in outputPath if `overwriteExistingFiles` is true 136 | if (config.overwriteExistingFiles === true) { 137 | await rm(join(outputPath, '*'), { recursive: true, force: true }); 138 | } 139 | } 140 | 141 | /* 142 | * Copy needed CSS and JS to export path. 143 | */ 144 | export async function copyStaticAssets(config: Config, outputPath: string) { 145 | const viewsFolderPath = getPathToViewsFolder(config); 146 | 147 | const foldersToCopy = ['css', 'js', 'img']; 148 | 149 | for (const folder of foldersToCopy) { 150 | if ( 151 | await access(join(viewsFolderPath, folder)) 152 | .then(() => true) 153 | .catch(() => false) 154 | ) { 155 | await cp(join(viewsFolderPath, folder), join(outputPath, folder), { 156 | recursive: true, 157 | }); 158 | } 159 | } 160 | 161 | // Add libraries needed for GTFS-Realtime if present 162 | if ( 163 | config.hasGtfsRealtimeVehiclePositions || 164 | config.hasGtfsRealtimeTripUpdates || 165 | config.hasGtfsRealtimeAlerts 166 | ) { 167 | await copyFile( 168 | 'node_modules/pbf/dist/pbf.js', 169 | join(outputPath, 'js/pbf.js'), 170 | ); 171 | await copyFile( 172 | 'node_modules/gtfs-realtime-pbf-js-module/gtfs-realtime.browser.proto.js', 173 | join(outputPath, 'js/gtfs-realtime.browser.proto.js'), 174 | ); 175 | } 176 | 177 | if (config.hasGtfsRealtimeAlerts) { 178 | await copyFile( 179 | 'node_modules/anchorme/dist/browser/anchorme.min.js', 180 | join(outputPath, 'js//anchorme.min.js'), 181 | ); 182 | } 183 | } 184 | 185 | /* 186 | * Zips the content of the specified folder. 187 | */ 188 | export function zipFolder(outputPath) { 189 | const output = createWriteStream(join(outputPath, 'timetables.zip')); 190 | const archive = archiver('zip'); 191 | 192 | return new Promise((resolve, reject) => { 193 | output.on('close', resolve); 194 | archive.on('error', reject); 195 | archive.pipe(output); 196 | archive.glob('**/*.{txt,css,js,png,jpg,jpeg,svg,csv,pdf,html}', { 197 | cwd: outputPath, 198 | }); 199 | archive.finalize(); 200 | }); 201 | } 202 | 203 | /* 204 | * Generate the filename for a given timetable. 205 | */ 206 | export function generateFileName(timetable, config, extension = 'html') { 207 | let filename = timetable.timetable_id; 208 | 209 | for (const route of timetable.routes) { 210 | filename += isNullOrEmpty(route.route_short_name) 211 | ? `_${route.route_long_name.replace(/\s/g, '-')}` 212 | : `_${route.route_short_name.replace(/\s/g, '-')}`; 213 | } 214 | 215 | if (!isNullOrEmpty(timetable.direction_id)) { 216 | filename += `_${timetable.direction_id}`; 217 | } 218 | 219 | filename += `_${formatDays(timetable, config).replace(/\s/g, '')}.${extension}`; 220 | 221 | return sanitize(filename).toLowerCase(); 222 | } 223 | 224 | /* 225 | * Generates the folder name for a timetable page based on the date. 226 | */ 227 | export function generateFolderName(timetablePage) { 228 | // Use first timetable in timetable page for start date and end date 229 | const timetable = timetablePage.consolidatedTimetables[0]; 230 | if (!timetable.start_date || !timetable.end_date) { 231 | return 'timetables'; 232 | } 233 | 234 | return sanitize(`${timetable.start_date}-${timetable.end_date}`); 235 | } 236 | 237 | /* 238 | * Render the HTML for a timetable based on the config. 239 | */ 240 | export async function renderTemplate( 241 | templateFileName: string, 242 | templateVars, 243 | config: Config, 244 | ) { 245 | const templatePath = getPathToTemplateFile(templateFileName, config); 246 | 247 | // Make template functions, lodash and marked available inside pug templates. 248 | const html = await renderFile(templatePath, { 249 | _, 250 | md: (text: string) => sanitizeHtml(marked.parseInline(text) as string), 251 | ...templateFunctions, 252 | formatRouteColor, 253 | formatRouteTextColor, 254 | ...templateVars, 255 | }); 256 | 257 | // Beautify HTML if `beautify` is set in config. 258 | if (config.beautify === true) { 259 | return beautify.html_beautify(html, { 260 | indent_size: 2, 261 | }); 262 | } 263 | 264 | return html; 265 | } 266 | 267 | /* 268 | * Render the PDF for a timetable based on the config. 269 | */ 270 | export async function renderPdf(htmlPath: string) { 271 | const pdfPath = htmlPath.replace(/html$/, 'pdf'); 272 | const browser = await puppeteer.launch(); 273 | const page = await browser.newPage(); 274 | await page.emulateMediaType('print'); 275 | await page.goto(`file://${htmlPath}`, { 276 | waitUntil: 'networkidle0', 277 | }); 278 | await page.pdf({ 279 | path: pdfPath, 280 | }); 281 | 282 | await browser.close(); 283 | } 284 | -------------------------------------------------------------------------------- /src/lib/formatters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | clone, 3 | find, 4 | first, 5 | groupBy, 6 | last, 7 | omit, 8 | sortBy, 9 | zipObject, 10 | } from 'lodash-es'; 11 | import moment from 'moment'; 12 | 13 | import { 14 | fromGTFSTime, 15 | minutesAfterMidnight, 16 | calendarToCalendarCode, 17 | secondsAfterMidnight, 18 | toGTFSTime, 19 | updateTimeByOffset, 20 | } from './time-utils.js'; 21 | import { isTimepoint } from './utils.js'; 22 | 23 | /* 24 | * Replace all instances in a string with items from an object. 25 | */ 26 | function replaceAll(string, mapObject) { 27 | const re = new RegExp(Object.keys(mapObject).join('|'), 'gi'); 28 | return string.replace(re, (matched) => mapObject[matched]); 29 | } 30 | 31 | /* 32 | * Determine if value is null or empty string. 33 | */ 34 | export function isNullOrEmpty(value) { 35 | return value === null || value === ''; 36 | } 37 | 38 | /* 39 | * Format a date for display. 40 | */ 41 | export function formatDate(date, dateFormat) { 42 | if (date.holiday_name) { 43 | return date.holiday_name; 44 | } 45 | 46 | return moment(date.date, 'YYYYMMDD').format(dateFormat); 47 | } 48 | 49 | /* 50 | * Convert time to seconds. 51 | */ 52 | export function timeToSeconds(time) { 53 | return moment.duration(time).asSeconds(); 54 | } 55 | 56 | /* 57 | * Format a single stoptime. 58 | */ 59 | /* eslint-disable complexity */ 60 | function formatStopTime(stoptime, timetable, config) { 61 | stoptime.classes = []; 62 | 63 | if (stoptime.type === 'arrival' && stoptime.arrival_time) { 64 | const arrivalTime = fromGTFSTime(stoptime.arrival_time); 65 | stoptime.formatted_time = arrivalTime.format(config.timeFormat); 66 | stoptime.classes.push(arrivalTime.format('a')); 67 | } else if (stoptime.type === 'departure' && stoptime.departure_time) { 68 | const departureTime = fromGTFSTime(stoptime.departure_time); 69 | stoptime.formatted_time = departureTime.format(config.timeFormat); 70 | stoptime.classes.push(departureTime.format('a')); 71 | } 72 | 73 | if (stoptime.pickup_type === 1) { 74 | stoptime.noPickup = true; 75 | stoptime.classes.push('no-pickup'); 76 | if (timetable.noPickupSymbol !== null) { 77 | timetable.noPickupSymbolUsed = true; 78 | } 79 | } else if (stoptime.pickup_type === 2 || stoptime.pickup_type === 3) { 80 | stoptime.requestPickup = true; 81 | stoptime.classes.push('request-pickup'); 82 | if (timetable.requestPickupSymbol !== null) { 83 | timetable.requestPickupSymbolUsed = true; 84 | } 85 | } 86 | 87 | if (stoptime.drop_off_type === 1) { 88 | stoptime.noDropoff = true; 89 | stoptime.classes.push('no-drop-off'); 90 | if (timetable.noDropoffSymbol !== null) { 91 | timetable.noDropoffSymbolUsed = true; 92 | } 93 | } else if (stoptime.drop_off_type === 2 || stoptime.drop_off_type === 3) { 94 | stoptime.requestDropoff = true; 95 | stoptime.classes.push('request-drop-off'); 96 | if (timetable.requestDropoffSymbol !== null) { 97 | timetable.requestDropoffSymbolUsed = true; 98 | } 99 | } 100 | 101 | if (stoptime.timepoint === 0 || stoptime.departure_time === '') { 102 | stoptime.interpolated = true; 103 | stoptime.classes.push('interpolated'); 104 | if (timetable.interpolatedStopSymbol !== null) { 105 | timetable.interpolatedStopSymbolUsed = true; 106 | } 107 | } 108 | 109 | if ( 110 | stoptime.timepoint === null && 111 | stoptime.departure_time === null && 112 | stoptime.stop_sequence === null 113 | ) { 114 | stoptime.skipped = true; 115 | stoptime.classes.push('skipped'); 116 | if (timetable.noServiceSymbol !== null) { 117 | timetable.noServiceSymbolUsed = true; 118 | } 119 | } 120 | 121 | if (stoptime.timepoint === 1) { 122 | stoptime.classes.push('timepoint'); 123 | } 124 | 125 | return stoptime; 126 | } 127 | /* eslint-enable complexity */ 128 | 129 | /* 130 | * Find hourly times for each stop for hourly schedules. 131 | */ 132 | function filterHourlyTimes(stops) { 133 | // Find all stoptimes within the first 60 minutes. 134 | const firstStopTimes = []; 135 | const firstTripMinutes = minutesAfterMidnight(stops[0].trips[0].arrival_time); 136 | for (const trip of stops[0].trips) { 137 | const minutes = minutesAfterMidnight(trip.arrival_time); 138 | if (minutes >= firstTripMinutes + 60) { 139 | break; 140 | } 141 | 142 | firstStopTimes.push(fromGTFSTime(trip.arrival_time)); 143 | } 144 | 145 | // Sort stoptimes by minutes for first stop. 146 | const firstStopTimesAndIndex = firstStopTimes.map((time, idx) => ({ 147 | idx, 148 | time, 149 | })); 150 | const sortedFirstStopTimesAndIndex = sortBy(firstStopTimesAndIndex, (item) => 151 | Number.parseInt(item.time.format('m'), 10), 152 | ); 153 | 154 | // Filter and arrange stoptimes for all stops based on sort. 155 | return stops.map((stop) => { 156 | stop.hourlyTimes = sortedFirstStopTimesAndIndex.map((item) => 157 | fromGTFSTime(stop.trips[item.idx].arrival_time).format(':mm'), 158 | ); 159 | 160 | return stop; 161 | }); 162 | } 163 | 164 | /* 165 | * Format a calendar's list of days for display using abbreviated day names. 166 | */ 167 | const days = [ 168 | 'monday', 169 | 'tuesday', 170 | 'wednesday', 171 | 'thursday', 172 | 'friday', 173 | 'saturday', 174 | 'sunday', 175 | ]; 176 | export function formatDays(calendar, config) { 177 | const daysShort = config.daysShortStrings; 178 | let daysInARow = 0; 179 | let dayString = ''; 180 | 181 | if (!calendar) { 182 | return ''; 183 | } 184 | 185 | for (let i = 0; i <= 6; i += 1) { 186 | const currentDayOperating = calendar[days[i]] === 1; 187 | const previousDayOperating = i > 0 ? calendar[days[i - 1]] === 1 : false; 188 | const nextDayOperating = i < 6 ? calendar[days[i + 1]] === 1 : false; 189 | 190 | if (currentDayOperating) { 191 | if (dayString.length > 0) { 192 | if (!previousDayOperating) { 193 | dayString += ', '; 194 | } else if (daysInARow === 1) { 195 | dayString += '-'; 196 | } 197 | } 198 | 199 | daysInARow += 1; 200 | 201 | if ( 202 | dayString.length === 0 || 203 | !nextDayOperating || 204 | i === 6 || 205 | !previousDayOperating 206 | ) { 207 | dayString += daysShort[i]; 208 | } 209 | } else { 210 | daysInARow = 0; 211 | } 212 | } 213 | 214 | if (dayString.length === 0) { 215 | dayString = 'No regular service days'; 216 | } 217 | 218 | return dayString; 219 | } 220 | 221 | /* 222 | * Format a list of days for display using full names of days. 223 | */ 224 | export function formatDaysLong(dayList, config) { 225 | const mapObject = zipObject(config.daysShortStrings, config.daysStrings); 226 | 227 | return replaceAll(dayList, mapObject); 228 | } 229 | 230 | /* 231 | * Format a trip. 232 | */ 233 | export function formatTrip(trip, timetable, calendars, config) { 234 | trip.calendar = find(calendars, { 235 | service_id: trip.service_id, 236 | }); 237 | trip.dayList = formatDays(trip.calendar, config); 238 | trip.dayListLong = formatDaysLong(trip.dayList, config); 239 | 240 | if (timetable.routes.length === 1) { 241 | trip.route_short_name = timetable.routes[0].route_short_name; 242 | } else { 243 | const route = timetable.routes.find( 244 | (route) => route.route_id === trip.route_id, 245 | ); 246 | trip.route_short_name = route.route_short_name; 247 | } 248 | 249 | return trip; 250 | } 251 | 252 | /* 253 | * Format a frequency. 254 | */ 255 | export function formatFrequency(frequency, config) { 256 | const startTime = fromGTFSTime(frequency.start_time); 257 | const endTime = fromGTFSTime(frequency.end_time); 258 | const headway = moment.duration(frequency.headway_secs, 'seconds'); 259 | frequency.start_formatted_time = startTime.format(config.timeFormat); 260 | frequency.end_formatted_time = endTime.format(config.timeFormat); 261 | frequency.headway_min = Math.round(headway.asMinutes()); 262 | return frequency; 263 | } 264 | 265 | /* 266 | * Generate a timetable id. 267 | */ 268 | export function formatTimetableId(timetable) { 269 | let timetableId = `${timetable.route_ids.join('_')}|${calendarToCalendarCode( 270 | timetable, 271 | )}`; 272 | if (!isNullOrEmpty(timetable.direction_id)) { 273 | timetableId += `|${timetable.direction_id}`; 274 | } 275 | 276 | return timetableId; 277 | } 278 | 279 | function createEmptyStoptime(stopId, tripId) { 280 | return { 281 | id: null, 282 | trip_id: tripId, 283 | arrival_time: null, 284 | departure_time: null, 285 | stop_id: stopId, 286 | stop_sequence: null, 287 | stop_headsign: null, 288 | pickup_type: null, 289 | drop_off_type: null, 290 | continuous_pickup: null, 291 | continuous_drop_off: null, 292 | shape_dist_traveled: null, 293 | timepoint: null, 294 | }; 295 | } 296 | 297 | /* 298 | * Format stops. 299 | */ 300 | export function formatStops(timetable, config) { 301 | for (const trip of timetable.orderedTrips) { 302 | let stopIndex = -1; 303 | for (const [idx, stoptime] of trip.stoptimes.entries()) { 304 | // Find a stop for the matching `stop_id` greater than the last `stopIndex`. 305 | const stop = find(timetable.stops, (st, idx) => { 306 | if (st.stop_id === stoptime.stop_id && idx > stopIndex) { 307 | stopIndex = idx; 308 | return true; 309 | } 310 | 311 | return false; 312 | }); 313 | 314 | if (!stop) { 315 | continue; 316 | } 317 | 318 | // If first stoptime of the trip, remove drop_off_type information 319 | if (idx === 0) { 320 | stoptime.drop_off_type = 0; 321 | } 322 | 323 | // If last stoptime of the trip, remove pickup_type information 324 | if (idx === trip.stoptimes.length - 1) { 325 | stoptime.pickup_type = 0; 326 | } 327 | 328 | // If showing arrival and departure times as separate columns/rows, add 329 | // trip to the departure stop, unless it is the last stoptime of the trip. 330 | if (stop.type === 'arrival' && idx < trip.stoptimes.length - 1) { 331 | const departureStoptime = clone(stoptime); 332 | departureStoptime.type = 'departure'; 333 | timetable.stops[stopIndex + 1].trips.push( 334 | formatStopTime(departureStoptime, timetable, config), 335 | ); 336 | } 337 | 338 | // Show times if it is an arrival stop and is the first stoptime for the trip. 339 | if (!(stop.type === 'arrival' && idx === 0)) { 340 | stoptime.type = 'arrival'; 341 | stop.trips.push(formatStopTime(stoptime, timetable, config)); 342 | } 343 | } 344 | 345 | // Fill in any missing stoptimes for this trip. 346 | for (const stop of timetable.stops) { 347 | const lastStopTime = last(stop.trips); 348 | if (!lastStopTime || lastStopTime.trip_id !== trip.trip_id) { 349 | stop.trips.push( 350 | formatStopTime( 351 | createEmptyStoptime(stop.stop_id, trip.trip_id), 352 | timetable, 353 | config, 354 | ), 355 | ); 356 | } 357 | } 358 | } 359 | 360 | if (timetable.orientation === 'hourly') { 361 | timetable.stops = filterHourlyTimes(timetable.stops); 362 | } 363 | 364 | for (const stop of timetable.stops) { 365 | stop.is_timepoint = stop.trips.some((stoptime) => isTimepoint(stoptime)); 366 | } 367 | 368 | return timetable.stops; 369 | } 370 | 371 | /* 372 | * Formats a stop name. 373 | */ 374 | export function formatStopName(stop) { 375 | return `${stop.stop_name}${ 376 | stop.type === 'arrival' 377 | ? ' (Arrival)' 378 | : stop.type === 'departure' 379 | ? ' (Departure)' 380 | : '' 381 | }`; 382 | } 383 | 384 | /* 385 | * Formats trip "Continues from". 386 | */ 387 | export function formatTripContinuesFrom(trip) { 388 | return trip.continues_from_route 389 | ? trip.continues_from_route.route.route_short_name 390 | : ''; 391 | } 392 | 393 | /* 394 | * Formats trip "Continues as". 395 | */ 396 | export function formatTripContinuesAs(trip) { 397 | return trip.continues_as_route 398 | ? trip.continues_as_route.route.route_short_name 399 | : ''; 400 | } 401 | 402 | /* 403 | * Change all stoptimes of a trip so the first trip starts at midnight. Useful 404 | * for hourly schedules. 405 | */ 406 | export function resetStoptimesToMidnight(trip) { 407 | const offsetSeconds = secondsAfterMidnight( 408 | first(trip.stoptimes).departure_time, 409 | ); 410 | if (offsetSeconds > 0) { 411 | for (const stoptime of trip.stoptimes) { 412 | stoptime.departure_time = toGTFSTime( 413 | fromGTFSTime(stoptime.departure_time).subtract( 414 | offsetSeconds, 415 | 'seconds', 416 | ), 417 | ); 418 | stoptime.arrival_time = toGTFSTime( 419 | fromGTFSTime(stoptime.arrival_time).subtract(offsetSeconds, 'seconds'), 420 | ); 421 | } 422 | } 423 | 424 | return trip; 425 | } 426 | 427 | /* 428 | * Change all stoptimes of a trip by a specified number of seconds. Useful for 429 | * hourly schedules. 430 | */ 431 | export function updateStoptimesByOffset(trip, offsetSeconds) { 432 | return trip.stoptimes.map((stoptime) => { 433 | delete stoptime._id; 434 | stoptime.departure_time = updateTimeByOffset( 435 | stoptime.departure_time, 436 | offsetSeconds, 437 | ); 438 | stoptime.arrival_time = updateTimeByOffset( 439 | stoptime.arrival_time, 440 | offsetSeconds, 441 | ); 442 | stoptime.trip_id = trip.trip_id; 443 | return stoptime; 444 | }); 445 | } 446 | 447 | /* 448 | * Format a route color as a hex color. 449 | */ 450 | export function formatRouteColor(route) { 451 | // Defaults to #000000 (black) if no color is provided. 452 | return route.route_color ? `#${route.route_color}` : '#000000'; 453 | } 454 | 455 | /* 456 | * Format a route text color as a hex color. 457 | */ 458 | export function formatRouteTextColor(route) { 459 | // Defaults to #FFFFFF (white) if no color is provided. 460 | return route.route_text_color ? `#${route.route_text_color}` : '#FFFFFF'; 461 | } 462 | 463 | /* 464 | * Format a label for a timetable. 465 | */ 466 | export function formatTimetableLabel(timetable) { 467 | if (!isNullOrEmpty(timetable.timetable_label)) { 468 | return timetable.timetable_label; 469 | } 470 | 471 | let timetableLabel = ''; 472 | 473 | if (timetable.routes && timetable.routes.length > 0) { 474 | timetableLabel += 'Route '; 475 | if (!isNullOrEmpty(timetable.routes[0].route_short_name)) { 476 | timetableLabel += timetable.routes[0].route_short_name; 477 | } else if (!isNullOrEmpty(timetable.routes[0].route_long_name)) { 478 | timetableLabel += timetable.routes[0].route_long_name; 479 | } 480 | } 481 | 482 | if (timetable.stops && timetable.stops.length > 0) { 483 | const firstStop = timetable.stops[0].stop_name; 484 | const lastStop = timetable.stops[timetable.stops.length - 1].stop_name; 485 | if (firstStop === lastStop) { 486 | if (!isNullOrEmpty(timetable.routes[0].route_long_name)) { 487 | timetableLabel += ` - ${timetable.routes[0].route_long_name}`; 488 | } 489 | 490 | timetableLabel += ' - Loop'; 491 | } else { 492 | timetableLabel += ` - ${firstStop} to ${lastStop}`; 493 | } 494 | } else if (timetable.direction_name !== null) { 495 | timetableLabel += ` to ${timetable.direction_name}`; 496 | } 497 | 498 | return timetableLabel; 499 | } 500 | 501 | /* 502 | * Format a route name. 503 | */ 504 | export const formatRouteName = (route: Record) => { 505 | if (route.route_long_name === null || route.route_long_name === '') { 506 | return `Route ${route.route_short_name}`; 507 | } 508 | 509 | return route.route_long_name ?? 'Unknown'; 510 | }; 511 | 512 | /* 513 | * Format a list for display. 514 | */ 515 | export const formatListForDisplay = (list: string[]) => { 516 | return new Intl.ListFormat('en-US', { 517 | style: 'long', 518 | type: 'conjunction', 519 | }).format(list); 520 | }; 521 | 522 | /* 523 | * Merge timetables with same `timetable_id`. 524 | */ 525 | export function mergeTimetablesWithSameId(timetables) { 526 | if (timetables.length === 0) { 527 | return []; 528 | } 529 | 530 | const mergedTimetables = groupBy(timetables, 'timetable_id'); 531 | 532 | return Object.values(mergedTimetables).map((timetableGroup) => { 533 | const mergedTimetable = omit(timetableGroup[0], 'route_id'); 534 | 535 | mergedTimetable.route_ids = timetableGroup.map( 536 | (timetable) => timetable.route_id, 537 | ); 538 | 539 | return mergedTimetable; 540 | }); 541 | } 542 | -------------------------------------------------------------------------------- /src/lib/geojson-utils.ts: -------------------------------------------------------------------------------- 1 | import { getShapesAsGeoJSON, getStopsAsGeoJSON } from 'gtfs'; 2 | import { flatMap } from 'lodash-es'; 3 | import simplify from '@turf/simplify'; 4 | import { featureCollection, round } from '@turf/helpers'; 5 | import { logWarning } from './log-utils.js'; 6 | 7 | /* 8 | * Merge any number of geojson objects into one. Only works for `FeatureCollection`. 9 | */ 10 | const mergeGeojson = (...geojsons) => 11 | featureCollection(flatMap(geojsons, (geojson) => geojson.features)); 12 | 13 | /* 14 | * Truncate a geojson coordinates to a specific number of decimal places. 15 | */ 16 | const truncateGeoJSONDecimals = (geojson, config) => { 17 | for (const feature of geojson.features) { 18 | if (feature.geometry.coordinates) { 19 | if (feature.geometry.type.toLowerCase() === 'point') { 20 | feature.geometry.coordinates = feature.geometry.coordinates.map( 21 | (number) => round(number, config.coordinatePrecision), 22 | ); 23 | } else if (feature.geometry.type.toLowerCase() === 'linestring') { 24 | feature.geometry.coordinates = feature.geometry.coordinates.map( 25 | (coordinate) => 26 | coordinate.map((number) => 27 | round(number, config.coordinatePrecision), 28 | ), 29 | ); 30 | } else if (feature.geometry.type.toLowerCase() === 'multilinestring') { 31 | feature.geometry.coordinates = feature.geometry.coordinates.map( 32 | (linestring) => 33 | linestring.map((coordinate) => 34 | coordinate.map((number) => 35 | round(number, config.coordinatePrecision), 36 | ), 37 | ), 38 | ); 39 | } 40 | } 41 | } 42 | 43 | return geojson; 44 | }; 45 | 46 | /* 47 | * Get the geoJSON for a timetable. 48 | */ 49 | export function getTimetableGeoJSON(timetable, config) { 50 | const shapesGeojsons = timetable.route_ids.map((routeId) => 51 | getShapesAsGeoJSON({ 52 | route_id: routeId, 53 | direction_id: timetable.direction_id, 54 | trip_id: timetable.orderedTrips.map((trip) => trip.trip_id), 55 | }), 56 | ); 57 | 58 | const stopsGeojsons = timetable.route_ids.map((routeId) => 59 | getStopsAsGeoJSON({ 60 | route_id: routeId, 61 | direction_id: timetable.direction_id, 62 | trip_id: timetable.orderedTrips.map((trip) => trip.trip_id), 63 | }), 64 | ); 65 | 66 | const geojson = mergeGeojson(...shapesGeojsons, ...stopsGeojsons); 67 | 68 | let simplifiedGeojson; 69 | try { 70 | simplifiedGeojson = simplify(geojson, { 71 | tolerance: 1 / 10 ** config.coordinatePrecision, 72 | highQuality: true, 73 | }); 74 | } catch { 75 | timetable.warnings.push( 76 | `Timetable ${timetable.timetable_id} - Unable to simplify geojson`, 77 | ); 78 | simplifiedGeojson = geojson; 79 | } 80 | 81 | return truncateGeoJSONDecimals(simplifiedGeojson, config); 82 | } 83 | 84 | /* 85 | * Get the geoJSON for an agency (all routes and stops). 86 | */ 87 | export function getAgencyGeoJSON(config) { 88 | const shapesGeojsons = getShapesAsGeoJSON(); 89 | const stopsGeojsons = getStopsAsGeoJSON(); 90 | 91 | const geojson = mergeGeojson(shapesGeojsons, stopsGeojsons); 92 | 93 | let simplifiedGeojson; 94 | try { 95 | simplifiedGeojson = simplify(geojson, { 96 | tolerance: 1 / 10 ** config.coordinatePrecision, 97 | highQuality: true, 98 | }); 99 | } catch { 100 | logWarning(config)('Unable to simplify geojson'); 101 | simplifiedGeojson = geojson; 102 | } 103 | 104 | return truncateGeoJSONDecimals(simplifiedGeojson, config); 105 | } 106 | -------------------------------------------------------------------------------- /src/lib/gtfs-to-html.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { mkdir, writeFile } from 'node:fs/promises'; 3 | 4 | import { openDb, importGtfs } from 'gtfs'; 5 | import sanitize from 'sanitize-filename'; 6 | import Timer from 'timer-machine'; 7 | import untildify from 'untildify'; 8 | 9 | import { 10 | prepDirectory, 11 | copyStaticAssets, 12 | generateFolderName, 13 | renderPdf, 14 | zipFolder, 15 | generateFileName, 16 | } from './file-utils.js'; 17 | import { 18 | progressBar, 19 | generateLogText, 20 | logStats, 21 | logError, 22 | log, 23 | } from './log-utils.js'; 24 | import { 25 | setDefaultConfig, 26 | getTimetablePagesForAgency, 27 | getFormattedTimetablePage, 28 | generateTimetableHTML, 29 | generateTimetableCSV, 30 | generateOverviewHTML, 31 | generateStats, 32 | } from './utils.js'; 33 | 34 | import type { Config } from '../types/global_interfaces.js'; 35 | 36 | /* 37 | * Generate HTML timetables from GTFS. 38 | */ 39 | /* eslint-disable complexity */ 40 | const gtfsToHtml = async (initialConfig: Config) => { 41 | const config = setDefaultConfig(initialConfig); 42 | const timer = new Timer(); 43 | 44 | const agencyKey = config.agencies 45 | .map( 46 | (agency: { agencyKey?: string; agency_key?: string }) => 47 | agency.agencyKey ?? agency.agency_key ?? 'unknown', 48 | ) 49 | .join('-'); 50 | const outputPath = config.outputPath 51 | ? untildify(config.outputPath) 52 | : path.join(process.cwd(), 'html', sanitize(agencyKey)); 53 | 54 | timer.start(); 55 | 56 | await prepDirectory(outputPath, config); 57 | 58 | try { 59 | openDb(config); 60 | } catch (error: any) { 61 | if (error?.code === 'SQLITE_CANTOPEN') { 62 | logError(config)( 63 | `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.`, 64 | ); 65 | } 66 | 67 | throw error; 68 | } 69 | 70 | if (!config.agencies || config.agencies.length === 0) { 71 | throw new Error('No agencies defined in `config.json`'); 72 | } 73 | 74 | if (!config.skipImport) { 75 | await importGtfs(config); 76 | } 77 | 78 | const stats: { 79 | timetables: number; 80 | timetablePages: number; 81 | calendars: number; 82 | routes: number; 83 | trips: number; 84 | stops: number; 85 | warnings: string[]; 86 | [key: string]: number | string[]; 87 | } = { 88 | timetables: 0, 89 | timetablePages: 0, 90 | calendars: 0, 91 | routes: 0, 92 | trips: 0, 93 | stops: 0, 94 | warnings: [], 95 | }; 96 | 97 | const timetablePages = []; 98 | const timetablePageIds = getTimetablePagesForAgency(config).map( 99 | (timetablePage) => timetablePage.timetable_page_id, 100 | ); 101 | 102 | if (config.noHead !== true && ['html', 'pdf'].includes(config.outputFormat)) { 103 | await copyStaticAssets(config, outputPath); 104 | } 105 | 106 | const bar = progressBar( 107 | `${agencyKey}: Generating ${config.outputFormat.toUpperCase()} timetables {bar} {value}/{total}`, 108 | timetablePageIds.length, 109 | config, 110 | ); 111 | 112 | /* eslint-disable no-await-in-loop */ 113 | for (const timetablePageId of timetablePageIds) { 114 | try { 115 | const timetablePage = await getFormattedTimetablePage( 116 | timetablePageId, 117 | config, 118 | ); 119 | 120 | for (const timetable of timetablePage.timetables) { 121 | for (const warning of timetable.warnings) { 122 | stats.warnings.push(warning); 123 | bar?.interrupt(warning); 124 | } 125 | } 126 | 127 | if (timetablePage.consolidatedTimetables.length === 0) { 128 | throw new Error( 129 | `No timetables found for timetable_page_id=${timetablePage.timetable_page_id}`, 130 | ); 131 | } 132 | 133 | stats.timetables += timetablePage.consolidatedTimetables.length; 134 | stats.timetablePages += 1; 135 | 136 | const datePath = generateFolderName(timetablePage); 137 | 138 | // Make directory if it doesn't exist 139 | await mkdir(path.join(outputPath, datePath), { recursive: true }); 140 | config.assetPath = '../'; 141 | 142 | timetablePage.relativePath = path.join( 143 | datePath, 144 | sanitize(timetablePage.filename), 145 | ); 146 | 147 | if (config.outputFormat === 'csv') { 148 | for (const timetable of timetablePage.consolidatedTimetables) { 149 | const csv = await generateTimetableCSV(timetable); 150 | const csvPath = path.join( 151 | outputPath, 152 | datePath, 153 | generateFileName(timetable, config, 'csv'), 154 | ); 155 | await writeFile(csvPath, csv); 156 | } 157 | } else { 158 | const html = await generateTimetableHTML(timetablePage, config); 159 | const htmlPath = path.join( 160 | outputPath, 161 | datePath, 162 | sanitize(timetablePage.filename), 163 | ); 164 | await writeFile(htmlPath, html); 165 | 166 | if (config.outputFormat === 'pdf') { 167 | await renderPdf(htmlPath); 168 | } 169 | } 170 | 171 | timetablePages.push(timetablePage); 172 | const timetableStats = generateStats(timetablePage); 173 | 174 | stats.stops += timetableStats.stops; 175 | stats.routes += timetableStats.routes; 176 | stats.trips += timetableStats.trips; 177 | stats.calendars += timetableStats.calendars; 178 | } catch (error: any) { 179 | stats.warnings.push(error?.message); 180 | bar?.interrupt(error.message); 181 | } 182 | 183 | bar?.increment(); 184 | } 185 | /* eslint-enable no-await-in-loop */ 186 | 187 | if (config.outputFormat === 'html') { 188 | // Generate overview HTML 189 | config.assetPath = ''; 190 | const html = await generateOverviewHTML(timetablePages, config); 191 | await writeFile(path.join(outputPath, 'index.html'), html); 192 | } 193 | 194 | // Generate log.txt 195 | const logText = generateLogText(stats, config); 196 | await writeFile(path.join(outputPath, 'log.txt'), logText); 197 | 198 | // Zip output, if specified 199 | if (config.zipOutput) { 200 | await zipFolder(outputPath); 201 | } 202 | 203 | const fullOutputPath = path.join( 204 | outputPath, 205 | config.zipOutput ? '/timetables.zip' : '', 206 | ); 207 | 208 | // Print stats 209 | log(config)( 210 | `${agencyKey}: ${config.outputFormat.toUpperCase()} timetables created at ${fullOutputPath}`, 211 | ); 212 | 213 | logStats(config)(stats); 214 | 215 | const seconds = Math.round(timer.time() / 1000); 216 | log(config)( 217 | `${agencyKey}: ${config.outputFormat.toUpperCase()} timetable generation required ${seconds} seconds`, 218 | ); 219 | 220 | timer.stop(); 221 | 222 | return fullOutputPath; 223 | }; 224 | /* eslint-enable complexity */ 225 | 226 | export default gtfsToHtml; 227 | -------------------------------------------------------------------------------- /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 | import { getFeedInfo } from 'gtfs'; 5 | import Table from 'cli-table'; 6 | import { Config } from '../types/global_interfaces.ts'; 7 | 8 | /* 9 | * Creates text for a log of output details. 10 | */ 11 | export function generateLogText(outputStats, config: Config) { 12 | const feedInfo = getFeedInfo(); 13 | const feedVersion = 14 | feedInfo.length > 0 && feedInfo[0].feed_version 15 | ? feedInfo[0].feed_version 16 | : 'Unknown'; 17 | 18 | const logText = [ 19 | `Feed Version: ${feedVersion}`, 20 | `GTFS-to-HTML Version: ${config.gtfsToHtmlVersion}`, 21 | `Date Generated: ${new Date().toISOString()}`, 22 | `Timetable Page Count: ${outputStats.timetablePages}`, 23 | `Timetable Count: ${outputStats.timetables}`, 24 | `Calendar Service ID Count: ${outputStats.calendars}`, 25 | `Route Count: ${outputStats.routes}`, 26 | `Trip Count: ${outputStats.trips}`, 27 | `Stop Count: ${outputStats.stops}`, 28 | ]; 29 | 30 | for (const agency of config.agencies) { 31 | if (agency.url) { 32 | logText.push(`Source: ${agency.url}`); 33 | } else if (agency.path) { 34 | logText.push(`Source: ${agency.path}`); 35 | } 36 | } 37 | 38 | if (outputStats.warnings.length > 0) { 39 | logText.push('', 'Warnings:', ...outputStats.warnings); 40 | } 41 | 42 | return logText.join('\n'); 43 | } 44 | 45 | /* 46 | * Returns a log function based on config settings 47 | */ 48 | export function log(config: Config) { 49 | if (config.verbose === false) { 50 | return noop; 51 | } 52 | 53 | if (config.logFunction) { 54 | return config.logFunction; 55 | } 56 | 57 | return (text: string, overwrite: boolean) => { 58 | if (overwrite === true && process.stdout.isTTY) { 59 | clearLine(process.stdout, 0); 60 | cursorTo(process.stdout, 0); 61 | } else { 62 | process.stdout.write('\n'); 63 | } 64 | 65 | process.stdout.write(text); 66 | }; 67 | } 68 | 69 | /* 70 | * Returns an warning log function based on config settings 71 | */ 72 | export function logWarning(config: Config) { 73 | if (config.logFunction) { 74 | return config.logFunction; 75 | } 76 | 77 | return (text: string) => { 78 | process.stdout.write(`\n${formatWarning(text)}\n`); 79 | }; 80 | } 81 | 82 | /* 83 | * Returns an error log function based on config settings 84 | */ 85 | export function logError(config: Config) { 86 | if (config.logFunction) { 87 | return config.logFunction; 88 | } 89 | 90 | return (text: string) => { 91 | process.stdout.write(`\n${formatError(text)}\n`); 92 | }; 93 | } 94 | 95 | /* 96 | * Format console warning text 97 | */ 98 | export function formatWarning(text: string) { 99 | const warningMessage = `${colors.underline('Warning')}: ${text}`; 100 | return colors.yellow(warningMessage); 101 | } 102 | 103 | /* 104 | * Format console error text 105 | */ 106 | export function formatError(error: any) { 107 | const messageText = error instanceof Error ? error.message : error; 108 | const errorMessage = `${colors.underline('Error')}: ${messageText.replace( 109 | 'Error: ', 110 | '', 111 | )}`; 112 | return colors.red(errorMessage); 113 | } 114 | 115 | /* 116 | * Print a table of stats to the console. 117 | */ 118 | export function logStats(config: Config) { 119 | // Hide stats table from custom log functions 120 | if (config.logFunction) { 121 | return noop; 122 | } 123 | 124 | return (stats: any) => { 125 | const table = new Table({ 126 | colWidths: [40, 20], 127 | head: ['Item', 'Count'], 128 | }); 129 | 130 | table.push( 131 | ['📄 Timetable Pages', stats.timetablePages], 132 | ['🕑 Timetables', stats.timetables], 133 | ['📅 Calendar Service IDs', stats.calendars], 134 | ['🔄 Routes', stats.routes], 135 | ['🚍 Trips', stats.trips], 136 | ['🛑 Stops', stats.stops], 137 | ['⛔️ Warnings', stats.warnings.length], 138 | ); 139 | 140 | log(config)(table.toString()); 141 | }; 142 | } 143 | 144 | /* 145 | * Create progress bar text string 146 | */ 147 | const generateProgressBarString = (barTotal, barProgress, size = 40) => { 148 | const line = '-'; 149 | const slider = '='; 150 | if (!barTotal) { 151 | throw new Error('Total value is either not provided or invalid'); 152 | } 153 | 154 | if (!barProgress && barProgress !== 0) { 155 | throw new Error('Current value is either not provided or invalid'); 156 | } 157 | 158 | if (isNaN(barTotal)) { 159 | throw new Error('Total value is not an integer'); 160 | } 161 | 162 | if (isNaN(barProgress)) { 163 | throw new Error('Current value is not an integer'); 164 | } 165 | 166 | if (isNaN(size)) { 167 | throw new Error('Size is not an integer'); 168 | } 169 | 170 | if (barProgress > barTotal) { 171 | return slider.repeat(size + 2); 172 | } 173 | 174 | const percentage = barProgress / barTotal; 175 | const progress = Math.round(size * percentage); 176 | const emptyProgress = size - progress; 177 | const progressText = slider.repeat(progress); 178 | const emptyProgressText = line.repeat(emptyProgress); 179 | return progressText + emptyProgressText; 180 | }; 181 | 182 | /* 183 | * Print a progress bar to the console. 184 | */ 185 | export function progressBar( 186 | formatString: string, 187 | barTotal: number, 188 | config: Config, 189 | ) { 190 | let barProgress = 0; 191 | 192 | if (config.verbose === false) { 193 | return { 194 | increment: noop, 195 | interrupt: noop, 196 | }; 197 | } 198 | 199 | if (barTotal === 0) { 200 | return null; 201 | } 202 | 203 | const renderProgressString = () => 204 | formatString 205 | .replace('{value}', barProgress) 206 | .replace('{total}', barTotal) 207 | .replace('{bar}', generateProgressBarString(barTotal, barProgress)); 208 | 209 | log(config)(renderProgressString(), true); 210 | 211 | return { 212 | interrupt(text: string) { 213 | // Log two lines to avoid overwrite by progress bar 214 | logWarning(config)(text); 215 | log(config)(''); 216 | }, 217 | increment() { 218 | barProgress += 1; 219 | log(config)(renderProgressString(), true); 220 | }, 221 | }; 222 | } 223 | -------------------------------------------------------------------------------- /src/lib/template-functions.ts: -------------------------------------------------------------------------------- 1 | import { every } from 'lodash-es'; 2 | 3 | /* 4 | * Format an id to be used as an HTML attribute. 5 | */ 6 | export function formatHtmlId(id) { 7 | return id.replace(/([^\w[\]{}.:-])\s?/g, ''); 8 | } 9 | 10 | /* 11 | * Discern if a day list should be shown for a specific timetable (if some 12 | * trips happen on different days). 13 | */ 14 | export function timetableHasDifferentDays(timetable) { 15 | return !every(timetable.orderedTrips, (trip, idx) => { 16 | if (idx === 0) { 17 | return true; 18 | } 19 | 20 | return trip.dayList === timetable.orderedTrips[idx - 1].dayList; 21 | }); 22 | } 23 | 24 | /* 25 | * Discern if a day list should be shown for a specific timetable page's menu (if some 26 | * timetables are for different days). 27 | */ 28 | export function timetablePageHasDifferentDays(timetablePage) { 29 | return !every(timetablePage.consolidatedTimetables, (timetable, idx) => { 30 | if (idx === 0) { 31 | return true; 32 | } 33 | 34 | return ( 35 | timetable.dayListLong === 36 | timetablePage.consolidatedTimetables[idx - 1].dayListLong 37 | ); 38 | }); 39 | } 40 | 41 | /* 42 | * Discern if individual timetable labels should be shown (if some 43 | * timetables have different labels). 44 | */ 45 | export function timetablePageHasDifferentLabels(timetablePage) { 46 | return !every(timetablePage.consolidatedTimetables, (timetable, idx) => { 47 | if (idx === 0) { 48 | return true; 49 | } 50 | 51 | return ( 52 | timetable.timetable_label === 53 | timetablePage.consolidatedTimetables[idx - 1].timetable_label 54 | ); 55 | }); 56 | } 57 | 58 | /* 59 | * Discern if a timetable has any notes or notices to display. 60 | */ 61 | export function hasNotesOrNotices(timetable) { 62 | return ( 63 | timetable.requestPickupSymbolUsed || 64 | timetable.noPickupSymbolUsed || 65 | timetable.requestDropoffSymbolUsed || 66 | timetable.noDropoffSymbolUsed || 67 | timetable.noServiceSymbolUsed || 68 | timetable.interpolatedStopSymbolUsed || 69 | timetable.notes.length > 0 70 | ); 71 | } 72 | 73 | /* 74 | * Return an array of all timetable notes that relate to the entire timetable or route. 75 | */ 76 | export function getNotesForTimetableLabel(notes) { 77 | return notes.filter((note) => !note.stop_id && !note.trip_id); 78 | } 79 | 80 | /* 81 | * Return an array of all timetable notes for a specific stop and stop_sequence. 82 | */ 83 | export function getNotesForStop(notes, stop) { 84 | return notes.filter((note) => { 85 | // Don't show if note applies only to a specific trip. 86 | if (note.trip_id) { 87 | return false; 88 | } 89 | 90 | // Don't show if note applies only to a specific stop_sequence that is not found. 91 | if ( 92 | note.stop_sequence && 93 | !stop.trips.some((trip) => trip.stop_sequence === note.stop_sequence) 94 | ) { 95 | return false; 96 | } 97 | 98 | return note.stop_id === stop.stop_id; 99 | }); 100 | } 101 | 102 | /* 103 | * Return an array of all timetable notes for a specific trip. 104 | */ 105 | export function getNotesForTrip(notes, trip) { 106 | return notes.filter((note) => { 107 | // Don't show if note applies only to a specific stop. 108 | if (note.stop_id) { 109 | return false; 110 | } 111 | 112 | return note.trip_id === trip.trip_id; 113 | }); 114 | } 115 | 116 | /* 117 | * Return an array of all timetable notes for a specific stoptime. 118 | */ 119 | export function getNotesForStoptime(notes, stoptime) { 120 | return notes.filter((note) => { 121 | // Show notes that apply to all trips at this stop if `show_on_stoptime` is true. 122 | if ( 123 | !note.trip_id && 124 | note.stop_id === stoptime.stop_id && 125 | note.show_on_stoptime === 1 126 | ) { 127 | return true; 128 | } 129 | 130 | // Show notes that apply to all stops of this trip if `show_on_stoptime` is true. 131 | if ( 132 | !note.stop_id && 133 | note.trip_id === stoptime.trip_id && 134 | note.show_on_stoptime === 1 135 | ) { 136 | return true; 137 | } 138 | 139 | return ( 140 | note.trip_id === stoptime.trip_id && note.stop_id === stoptime.stop_id 141 | ); 142 | }); 143 | } 144 | 145 | /* 146 | * Formats a trip name for HTML timetable. 147 | * Deprecated, use `formatTripName` in formatting_functions.pug instead. 148 | */ 149 | export function formatTripName(trip, index, timetable) { 150 | let tripName; 151 | if (timetable.routes.length > 1) { 152 | tripName = trip.route_short_name; 153 | } else if (timetable.orientation === 'horizontal') { 154 | // Only add this to horizontal timetables. 155 | if (trip.trip_short_name) { 156 | tripName = trip.trip_short_name; 157 | } else { 158 | tripName = `Run #${index + 1}`; 159 | } 160 | } 161 | 162 | if (timetableHasDifferentDays(timetable)) { 163 | tripName += ` ${trip.dayList}`; 164 | } 165 | 166 | return tripName; 167 | } 168 | 169 | /* 170 | * Formats a trip name for CSV export. 171 | */ 172 | export function formatTripNameForCSV(trip, timetable) { 173 | let tripName = ''; 174 | if (timetable.routes.length > 1) { 175 | tripName += `${trip.route_short_name} - `; 176 | } 177 | 178 | if (trip.trip_short_name) { 179 | tripName += trip.trip_short_name; 180 | } else { 181 | tripName += trip.trip_id; 182 | } 183 | 184 | if (trip.trip_headsign) { 185 | tripName += ` - ${trip.trip_headsign}`; 186 | } 187 | 188 | if (timetableHasDifferentDays(timetable)) { 189 | tripName += ` - ${trip.dayList}`; 190 | } 191 | 192 | return tripName; 193 | } 194 | -------------------------------------------------------------------------------- /src/lib/time-utils.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | /* 4 | * Convert a GTFS formatted time string into a moment less than 24 hours. 5 | */ 6 | export function fromGTFSTime(timeString) { 7 | const duration = moment.duration(timeString); 8 | 9 | return moment({ 10 | hour: duration.hours(), 11 | minute: duration.minutes(), 12 | second: duration.seconds(), 13 | }); 14 | } 15 | 16 | /* 17 | * Convert a moment into a GTFS formatted time string. 18 | */ 19 | export function toGTFSTime(time) { 20 | return time.format('HH:mm:ss'); 21 | } 22 | 23 | /* 24 | * Convert a GTFS formatted date string into a moment. 25 | */ 26 | export function fromGTFSDate(gtfsDate) { 27 | return moment(gtfsDate, 'YYYYMMDD'); 28 | } 29 | 30 | /* 31 | * Convert a moment date into a GTFS formatted date string. 32 | */ 33 | export function toGTFSDate(date) { 34 | return moment(date).format('YYYYMMDD'); 35 | } 36 | 37 | /* 38 | * Convert a object of weekdays into a a string containing 1s and 0s. 39 | */ 40 | export function calendarToCalendarCode(c) { 41 | if (c.service_id) { 42 | return c.service_id; 43 | } 44 | 45 | return `${c.monday}${c.tuesday}${c.wednesday}${c.thursday}${c.friday}${c.saturday}${c.sunday}`; 46 | } 47 | 48 | /* 49 | * Convert a string of 1s and 0s representing a weekday to an object. 50 | */ 51 | export function calendarCodeToCalendar(code) { 52 | const days = [ 53 | 'monday', 54 | 'tuesday', 55 | 'wednesday', 56 | 'thursday', 57 | 'friday', 58 | 'saturday', 59 | 'sunday', 60 | ]; 61 | const calendar = {}; 62 | 63 | for (const [index, day] of days.entries()) { 64 | calendar[day] = code[index]; 65 | } 66 | 67 | return calendar; 68 | } 69 | 70 | /* 71 | * Get number of seconds after midnight of a GTFS formatted time string. 72 | */ 73 | export function secondsAfterMidnight(timeString) { 74 | return moment.duration(timeString).asSeconds(); 75 | } 76 | 77 | /* 78 | * Get number of minutes after midnight of a GTFS formatted time string. 79 | */ 80 | export function minutesAfterMidnight(timeString) { 81 | return moment.duration(timeString).asMinutes(); 82 | } 83 | 84 | /* 85 | * Add specified number of seconds to a GTFS formatted time string. 86 | */ 87 | export function updateTimeByOffset(timeString, offsetSeconds) { 88 | const newTime = fromGTFSTime(timeString); 89 | return toGTFSTime(newTime.add(offsetSeconds, 'seconds')); 90 | } 91 | -------------------------------------------------------------------------------- /src/types/global_interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | agencies: { 3 | agencyKey: string; 4 | agency_key?: string; 5 | url?: string; 6 | path?: string; 7 | exclude?: string[]; 8 | }[]; 9 | sqlitePath?: string; 10 | allowEmptyTimetables?: boolean; 11 | beautify?: boolean; 12 | coordinatePrecision?: number; 13 | dateFormat?: string; 14 | daysShortStrings?: string[]; 15 | daysStrings?: string[]; 16 | defaultOrientation?: string; 17 | effectiveDate?: string; 18 | interpolatedStopSymbol?: string; 19 | interpolatedStopText?: string; 20 | linkStopUrls?: boolean; 21 | mapStyleUrl?: string; 22 | menuType?: 'simple' | 'jump' | 'radio'; 23 | noDropoffSymbol?: string; 24 | noDropoffText?: string; 25 | noHead?: boolean; 26 | noPickupSymbol?: string; 27 | noPickupText?: string; 28 | noServiceSymbol?: string; 29 | noServiceText?: string; 30 | outputFormat?: 'html' | 'pdf' | 'csv'; 31 | overwriteExistingFiles?: boolean; 32 | outputPath?: string; 33 | requestDropoffSymbol?: string; 34 | requestDropoffText?: string; 35 | requestPickupSymbol?: string; 36 | requestPickupText?: string; 37 | serviceNotProvidedOnText?: string; 38 | serviceProvidedOnText?: string; 39 | showArrivalOnDifference?: number; 40 | showCalendarExceptions?: boolean; 41 | showMap?: boolean; 42 | showOnlyTimepoint?: boolean; 43 | showRouteTitle?: boolean; 44 | showStopCity?: boolean; 45 | showStopDescription?: boolean; 46 | showStoptimesForRequestStops?: boolean; 47 | skipImport?: boolean; 48 | sortingAlgorithm?: string; 49 | templatePath?: string; 50 | timeFormat?: string; 51 | useParentStation?: boolean; 52 | verbose?: boolean; 53 | zipOutput?: boolean; 54 | logFunction?: (text: string) => void; 55 | } 56 | 57 | export interface Timetable { 58 | timetable_id: string; 59 | route_id: string; 60 | direction_id: number; 61 | start_date?: number; 62 | end_date?: number; 63 | monday: number; 64 | tuesday: number; 65 | wednesday: number; 66 | thursday: number; 67 | friday: number; 68 | saturday: number; 69 | sunday: number; 70 | start_time?: string; 71 | start_timestamp?: number; 72 | end_time?: string; 73 | end_timestamp?: number; 74 | timetable_label?: string; 75 | service_notes?: string; 76 | orientation?: string; 77 | timetable_page_id?: string; 78 | timetable_sequence?: number; 79 | direction_name?: string; 80 | include_exceptions?: number; 81 | show_trip_continuation?: string; 82 | warnings: string[]; 83 | } 84 | 85 | export interface TimetablePage { 86 | timetable_page_id: string; 87 | timetable_page_label?: string; 88 | filename?: string; 89 | timetables: Timetable[]; 90 | routes: Record; 91 | relativePath?: string; 92 | } 93 | -------------------------------------------------------------------------------- /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 | "resolveJsonModule": true, 12 | "strict": true, 13 | "skipLibCheck": true 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts', 'src/bin/gtfs-to-html.ts', 'src/app/index.ts'], 5 | dts: true, 6 | clean: true, 7 | format: ['esm'], 8 | splitting: false, 9 | sourcemap: true, 10 | minify: false, 11 | }); 12 | -------------------------------------------------------------------------------- /views/default/css/overview_styles.css: -------------------------------------------------------------------------------- 1 | /* Base styles */ 2 | body { 3 | color: #666; 4 | font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 5 | 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 6 | font-feature-settings: normal; 7 | line-height: inherit; 8 | margin: 0; 9 | } 10 | 11 | *, 12 | ::before, 13 | ::after { 14 | box-sizing: border-box; 15 | border-width: 0; 16 | border-style: solid; 17 | border-color: #e5e7eb; 18 | } 19 | 20 | h1, 21 | h2, 22 | h3, 23 | h4, 24 | h5, 25 | h6 { 26 | line-height: inherit; 27 | font-weight: inherit; 28 | color: #333; 29 | margin: 0; 30 | } 31 | 32 | a { 33 | text-decoration: none; 34 | } 35 | 36 | a:hover { 37 | text-decoration: underline; 38 | } 39 | 40 | /* Overview styles */ 41 | 42 | .timetable-overview { 43 | height: 100vh; 44 | align-items: stretch; 45 | } 46 | 47 | @media (min-width: 768px) { 48 | .timetable-overview { 49 | display: flex; 50 | } 51 | } 52 | 53 | .timetable-overview h1 { 54 | font-size: 1.5rem; 55 | line-height: 2rem; 56 | margin-left: 1rem; 57 | margin-right: 1rem; 58 | padding-top: 0.75rem; 59 | padding-bottom: 0.75rem; 60 | } 61 | 62 | .timetable-overview .overview-list { 63 | flex: none; 64 | } 65 | 66 | @media (min-width: 768px) { 67 | .timetable-overview .overview-list { 68 | max-width: 24rem; 69 | overflow-y: scroll; 70 | } 71 | } 72 | 73 | .timetable-overview a.timetable-page-link { 74 | display: flex; 75 | align-items: center; 76 | gap: 0.5rem; 77 | border-bottom: 1px solid rgb(226 232 240); 78 | text-decoration: none; 79 | padding: 0.5rem; 80 | } 81 | 82 | .timetable-overview a.timetable-page-link:hover { 83 | background-color: rgb(241 245 249); 84 | text-decoration: none; 85 | } 86 | 87 | .timetable-overview a.timetable-page-link .timetable-page-label { 88 | font-size: 1.25rem; 89 | line-height: 1; 90 | color: rgb(30 41 59); 91 | } 92 | 93 | .timetable-overview .route-color-swatch { 94 | min-width: 29px; 95 | height: 29px; 96 | border-radius: 15px; 97 | display: flex; 98 | align-items: center; 99 | justify-content: center; 100 | font-size: 12px; 101 | letter-spacing: -0.5px; 102 | padding: 0 2px; 103 | flex-shrink: 0; 104 | font-weight: bold; 105 | color: white; 106 | } 107 | 108 | .timetable-overview .route-color-swatch-large { 109 | min-width: 46px; 110 | height: 46px; 111 | border-radius: 23px; 112 | display: flex; 113 | align-items: center; 114 | justify-content: center; 115 | font-size: 20px; 116 | font-weight: bold; 117 | letter-spacing: -1px; 118 | padding: 0 6px; 119 | flex-shrink: 0; 120 | font-weight: bold; 121 | color: white; 122 | } 123 | 124 | .timetable-overview .btn-blue { 125 | color: rgb(255 255 255); 126 | padding: 0.75rem 1.5rem; 127 | background-color: rgb(37 99 235); 128 | border-radius: 0.375rem; 129 | justify-content: center; 130 | align-items: center; 131 | display: inline-flex; 132 | cursor: pointer; 133 | text-decoration: none; 134 | } 135 | 136 | .timetable-overview .btn-blue:hover { 137 | background-color: rgb(29 78 216); 138 | text-decoration: none; 139 | } 140 | 141 | .timetable-overview .btn-sm { 142 | padding: 0.25rem 1rem; 143 | border-radius: 0.25rem; 144 | } 145 | 146 | .timetable-overview .badge-gray { 147 | display: inline-flex; 148 | align-items: center; 149 | justify-content: center; 150 | padding-left: 0.5rem; 151 | padding-right: 0.5rem; 152 | padding-top: 0.25rem; 153 | padding-bottom: 0.25rem; 154 | font-size: 0.75rem; 155 | line-height: 1; 156 | font-weight: 700; 157 | color: rgb(30 41 59); 158 | background-color: rgb(226 232 240); 159 | border-radius: 9999px; 160 | } 161 | 162 | /* Map Styles */ 163 | 164 | .overview-map { 165 | height: 100%; 166 | width: 100%; 167 | } 168 | 169 | .overview-map .maplibregl-popup-content .popup-title { 170 | margin: 0 20px 5px 0; 171 | font-size: 16px; 172 | font-weight: bold; 173 | } 174 | 175 | .overview-map .maplibregl-popup-content .popup-label { 176 | border-bottom: 1px solid #e0e0e0; 177 | padding-top: 0.5rem; 178 | } 179 | 180 | .overview-map .maplibregl-popup-content .route-list { 181 | margin-bottom: 1rem; 182 | margin-top: 0.25rem; 183 | } 184 | 185 | .overview-map .maplibregl-popup-content .map-route-item { 186 | display: flex; 187 | align-items: center; 188 | font-size: 0.75rem; 189 | line-height: 1; 190 | margin-bottom: 0.5rem; 191 | gap: 0.5rem; 192 | } 193 | 194 | .overview-map .maplibregl-popup-content .map-route-item:hover { 195 | text-decoration: none; 196 | } 197 | 198 | .overview-map .maplibregl-popup-content a.map-route-item .underline-hover:hover { 199 | text-decoration: underline; 200 | } 201 | 202 | .overview-map .maplibregl-popup-content .maplibregl-popup-close-button { 203 | padding: 0 5px; 204 | } 205 | -------------------------------------------------------------------------------- /views/default/formatting_functions.pug: -------------------------------------------------------------------------------- 1 | - 2 | function formatFrequencyWarning(frequencies) { 3 | let warning = 'Trip times shown below are an example only. '; 4 | frequencies.forEach((frequency, idx) => { 5 | if (idx === 0) { 6 | warning += 'This route runs every '; 7 | } else { 8 | warning += ' and '; 9 | } 10 | warning += `${frequency.headway_min} minutes between ${frequency.start_formatted_time} and ${frequency.end_formatted_time}`; 11 | }); 12 | warning += '.'; 13 | return warning; 14 | } 15 | 16 | function getAgencyTimetableGroups(timetablePages, agencies) { 17 | const agencyIds = []; 18 | for (const timetablePage of timetablePages) { 19 | agencyIds.push(...timetablePage.agency_ids); 20 | } 21 | 22 | const uniqueAgencyIds = _.uniq(_.compact(agencyIds)); 23 | 24 | if (uniqueAgencyIds.length === 0) { 25 | return [{ 26 | agency: _.first(agencies), 27 | timetablePages 28 | }]; 29 | } 30 | 31 | return _.orderBy(uniqueAgencyIds.map(agencyId => { 32 | return { 33 | agency: agencies.find(agency => agency.agency_id === agencyId) || _.first(agencies), 34 | timetablePages: timetablePages.filter(timetablePage => timetablePage.agency_ids.includes(agencyId)) 35 | }; 36 | }), timetableGroup => timetableGroup.agency.agency_name.toLowerCase()); 37 | } 38 | 39 | function prepareMapData(timetablePage, config) { 40 | const routeData = {} 41 | const stopData = {} 42 | const geojsons = {} 43 | 44 | for (const timetable of timetablePage.consolidatedTimetables) { 45 | const minifiedGeojson = { 46 | type: 'FeatureCollection', 47 | features: [] 48 | } 49 | 50 | for (const feature of timetable.geojson.features) { 51 | if (feature.geometry.type.toLowerCase() === 'point') { 52 | for (const route of feature.properties.routes) { 53 | routeData[route.route_id] = route 54 | } 55 | 56 | stopData[feature.properties.stop_id] = { 57 | stop_id: feature.properties.stop_id, 58 | stop_code: feature.properties.stop_code, 59 | stop_name: feature.properties.stop_name, 60 | parent_station: feature.properties.parent_station, 61 | stop_lat: feature.geometry.coordinates[1], 62 | stop_lon: feature.geometry.coordinates[0], 63 | } 64 | 65 | feature.properties = { 66 | route_ids: feature.properties.routes.map(route => route.route_id), 67 | stop_id: feature.properties.stop_id, 68 | parent_station: feature.properties.parent_station, 69 | } 70 | } else if (feature.geometry.type.toLowerCase() === 'linestring') { 71 | feature.properties = { 72 | route_color: feature.properties.route_color 73 | } 74 | } else if (feature.geometry.type.toLowerCase() === 'multilinestring') { 75 | feature.properties = { 76 | route_color: feature.properties.route_color 77 | } 78 | } 79 | minifiedGeojson.features.push(feature) 80 | } 81 | 82 | geojsons[formatHtmlId(timetable.timetable_id)] = minifiedGeojson 83 | } 84 | 85 | const gtfsRealtimeUrls = {} 86 | 87 | if (config.hasGtfsRealtimeVehiclePositions) { 88 | gtfsRealtimeUrls.realtimeVehiclePositions = config.agencies.find(agency => agency.realtimeVehiclePositions?.url)?.realtimeVehiclePositions 89 | } 90 | 91 | if (config.hasGtfsRealtimeTripUpdates) { 92 | gtfsRealtimeUrls.realtimeTripUpdates = config.agencies.find(agency => agency.realtimeTripUpdates?.url)?.realtimeTripUpdates 93 | } 94 | 95 | if (config.hasGtfsRealtimeAlerts) { 96 | gtfsRealtimeUrls.realtimeAlerts = config.agencies.find(agency => agency.realtimeAlerts?.url)?.realtimeAlerts 97 | } 98 | 99 | return { 100 | gtfsRealtimeUrls, 101 | mapStyleUrl: config.mapStyleUrl, 102 | pageData: { 103 | routeIds: _.uniq(_.flatMap(timetablePage.consolidatedTimetables, timetable => timetable.routes.map(route => route.route_id))), 104 | tripIds: _.uniq(_.flatMap(timetablePage.consolidatedTimetables, timetable => timetable.orderedTrips.map(trip => trip.trip_id))), 105 | stopIds: Object.keys(stopData), 106 | geojsons, 107 | }, 108 | routeData, 109 | stopData, 110 | } 111 | } 112 | 113 | function getRouteColorsAsCss(route) { 114 | if (route && route.route_color) { 115 | return `background: #${route.route_color}; color: #${route.route_text_color ?? 'ffffff'};` 116 | } 117 | 118 | return '' 119 | } 120 | 121 | function formatTripName(trip, index, timetable) { 122 | let tripName; 123 | if (timetable.routes.length > 1) { 124 | tripName = trip.route_short_name; 125 | } else if (timetable.orientation === 'horizontal') { 126 | // Only add this to horizontal timetables. 127 | if (trip.trip_short_name) { 128 | tripName = trip.trip_short_name; 129 | } else { 130 | tripName = `Run #${index + 1}`; 131 | } 132 | } 133 | 134 | if (timetableHasDifferentDays(timetable)) { 135 | tripName += ` ${trip.dayList}`; 136 | } 137 | 138 | return tripName; 139 | } 140 | 141 | function formatListForDisplay(list) { 142 | return new Intl.ListFormat('en-US', { 143 | style: 'long', 144 | type: 'conjunction', 145 | }).format(list); 146 | } 147 | 148 | function sortTimetablePages(timetablePages) { 149 | return _.sortBy(timetablePages, [ 150 | timetablePage => { 151 | // First sort numerically by route_short_name, removing leading non-digits 152 | const firstRoute = timetablePage.consolidatedTimetables?.[0]?.routes?.[0]; 153 | if (!firstRoute?.route_short_name) { 154 | return 0; 155 | } 156 | 157 | return Number.parseInt(firstRoute.route_short_name.replace(/^\D+/g, ''), 10) || 0; 158 | }, 159 | timetablePage => { 160 | // Then sort by route_short_name alphabetically 161 | return timetablePage.consolidatedTimetables?.[0]?.routes?.[0]?.route_short_name ?? ''; 162 | } 163 | ]); 164 | } 165 | -------------------------------------------------------------------------------- /views/default/js/timetable-alerts.js: -------------------------------------------------------------------------------- 1 | /* global jQuery, anchorme, Pbf, stopData, routeData, routeIds, tripIds, stopIds, gtfsRealtimeUrls */ 2 | /* eslint no-var: "off", prefer-arrow-callback: "off", no-unused-vars: "off" */ 3 | 4 | let gtfsRealtimeAlertsInterval; 5 | 6 | async function fetchGtfsRealtime(url, headers) { 7 | if (!url) { 8 | return null; 9 | } 10 | 11 | const response = await fetch(url, { 12 | headers: { ...(headers ?? {}) }, 13 | }); 14 | 15 | if (!response.ok) { 16 | throw new Error(response.status); 17 | } 18 | 19 | const bufferRes = await response.arrayBuffer(); 20 | const pdf = new Pbf(new Uint8Array(bufferRes)); 21 | const obj = FeedMessage.read(pdf); 22 | return obj.entity; 23 | } 24 | 25 | function formatAlertAsHtml( 26 | alert, 27 | affectedRouteIdsInTimetable, 28 | affectedStopsIdsInTimetable, 29 | ) { 30 | const $alert = jQuery('
').addClass('timetable-alert'); 31 | 32 | const $routeList = jQuery('
').addClass('route-list'); 33 | 34 | for (const routeId of affectedRouteIdsInTimetable) { 35 | const route = routeData[routeId]; 36 | 37 | if (!route) { 38 | continue; 39 | } 40 | 41 | jQuery('
') 42 | .addClass('route-color-swatch') 43 | .css('background-color', route.route_color || '#000000') 44 | .css('color', route.route_text_color || '#FFFFFF') 45 | .text(route.route_short_name) 46 | .appendTo($routeList); 47 | } 48 | 49 | const $alertHeader = jQuery('
') 50 | .addClass('alert-header') 51 | .append($routeList) 52 | .append( 53 | jQuery('
') 54 | .addClass('alert-title') 55 | .text(alert.alert.header_text.translation[0].text), 56 | ); 57 | 58 | // Use anchorme to convert URLs to clickable links while using jQuery .text to prevent XSS 59 | const $alertBody = jQuery('
') 60 | .addClass('alert-body') 61 | .append( 62 | anchorme( 63 | jQuery('
') 64 | .text(alert.alert.description_text.translation[0].text) 65 | .html(), 66 | ), 67 | ); 68 | 69 | if (alert.alert.url?.translation?.[0].text) { 70 | jQuery('') 71 | .attr('href', alert.alert.url.translation[0].text) 72 | .addClass('btn-blue btn-sm alert-more-info') 73 | .text('More Info') 74 | .appendTo($alertBody); 75 | } 76 | 77 | if (affectedStopsIdsInTimetable.length > 0) { 78 | const $stopList = jQuery('