UNPKG

gtfs-to-html

Version:

Build human readable transit timetables as HTML, PDF or CSV from GTFS

1,598 lines (1,587 loc) 85.1 kB
var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/lib/gtfs-to-html.ts import path from "path"; import { mkdir as mkdir2, writeFile } from "fs/promises"; import { openDb as openDb2, importGtfs } from "gtfs"; import sanitize2 from "sanitize-filename"; // src/lib/file-utils.ts import { dirname, join, resolve } from "path"; import cssEscape from "css.escape"; import { createWriteStream } from "fs"; import { fileURLToPath } from "url"; import { access, cp, copyFile, mkdir, readdir, readFile, rm } from "fs/promises"; import { homedir } from "os"; import * as _ from "lodash-es"; import { uniqBy as uniqBy2 } from "lodash-es"; import archiver from "archiver"; import beautify from "js-beautify"; import sanitizeHtml from "sanitize-html"; import { renderFile } from "pug"; import puppeteer from "puppeteer"; import sanitize from "sanitize-filename"; import { marked } from "marked"; // src/lib/formatters.ts import { clone, find as find2, first as first2, groupBy as groupBy2, last as last2, omit, sortBy as sortBy2, zipObject } from "lodash-es"; import moment3 from "moment"; // src/lib/time-utils.ts import moment from "moment"; function fromGTFSTime(timeString) { const duration = moment.duration(timeString); return moment({ hour: duration.hours(), minute: duration.minutes(), second: duration.seconds() }); } function toGTFSTime(time) { return time.format("HH:mm:ss"); } function calendarToCalendarCode(calendar) { if (Object.values(calendar).every((value) => value === null)) { return ""; } return `${calendar.monday ?? "0"}${calendar.tuesday ?? "0"}${calendar.wednesday ?? "0"}${calendar.thursday ?? "0"}${calendar.friday ?? "0"}${calendar.saturday ?? "0"}${calendar.sunday ?? "0"}`; } function calendarCodeToCalendar(code) { const days2 = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ]; const calendar = {}; for (const [index, day] of days2.entries()) { calendar[day] = code[index]; } return calendar; } function calendarToDateList(calendar, startDate, endDate) { if (!startDate || !endDate) { return []; } const activeWeekdays = [ calendar.monday === 1 ? 1 : null, calendar.tuesday === 1 ? 2 : null, calendar.wednesday === 1 ? 3 : null, calendar.thursday === 1 ? 4 : null, calendar.friday === 1 ? 5 : null, calendar.saturday === 1 ? 6 : null, calendar.sunday === 1 ? 7 : null ].filter((weekday) => weekday !== null); if (activeWeekdays.length === 0) { return []; } const activeWeekdaySet = new Set(activeWeekdays); const dates = /* @__PURE__ */ new Set(); const date = moment(startDate.toString(), "YYYYMMDD"); const endDateMoment = moment(endDate.toString(), "YYYYMMDD"); while (date.isSameOrBefore(endDateMoment)) { const isoWeekday = date.isoWeekday(); if (activeWeekdaySet.has(isoWeekday)) { dates.add(parseInt(date.format("YYYYMMDD"), 10)); } date.add(1, "day"); } return Array.from(dates); } function secondsAfterMidnight(timeString) { return moment.duration(timeString).asSeconds(); } function minutesAfterMidnight(timeString) { return moment.duration(timeString).asMinutes(); } function updateTimeByOffset(timeString, offsetSeconds) { const newTime = fromGTFSTime(timeString); return toGTFSTime(newTime.add(offsetSeconds, "seconds")); } // src/lib/utils.ts import { cloneDeep, compact, countBy, difference, entries, every as every2, find, findLast, first, flatMap, flow, groupBy, head, last, maxBy, orderBy, partialRight, reduce, size, some, sortBy, uniq, uniqBy, zip } from "lodash-es"; import { getCalendarDates, getTrips, getTimetableNotesReferences, getTimetableNotes, getRoutes, getCalendars, getTimetableStopOrders, getStops, getStopAttributes, getStoptimes, getFrequencies, getTimetables, getTimetablePages, getAgencies as getAgencies2, openDb } from "gtfs"; import { stringify } from "csv-stringify"; import moment2 from "moment"; import sqlString from "sqlstring"; import toposort from "toposort"; // src/lib/geojson-utils.ts import { getShapesAsGeoJSON, getStopsAsGeoJSON } from "gtfs"; import simplify from "@turf/simplify"; import { featureCollection, round } from "@turf/helpers"; // src/lib/log-utils.ts import { clearLine, cursorTo } from "readline"; import { noop } from "lodash-es"; import * as colors from "yoctocolors"; import { getAgencies, getFeedInfo } from "gtfs"; import Table from "cli-table"; function generateLogText(outputStats, config) { const feedInfo = getFeedInfo(); const agencies = getAgencies(); const feedVersion = feedInfo.length > 0 && feedInfo[0].feed_version ? feedInfo[0].feed_version : "Unknown"; const logText = [ `Agencies: ${agencies.map((agency) => agency.agency_name).join(", ")}`, `Feed Version: ${feedVersion}`, `GTFS-to-HTML Version: ${config.gtfsToHtmlVersion}`, `Date Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`, `Timetable Page Count: ${outputStats.timetablePages}`, `Timetable Count: ${outputStats.timetables}`, `Calendar Service ID Count: ${outputStats.calendars}`, `Route Count: ${outputStats.routes}`, `Trip Count: ${outputStats.trips}`, `Stop Count: ${outputStats.stops}` ]; for (const agency of config.agencies) { if (agency.url) { logText.push(`Source: ${agency.url}`); } else if (agency.path) { logText.push(`Source: ${agency.path}`); } } if (outputStats.warnings.length > 0) { logText.push("", "Warnings:", ...outputStats.warnings); } return logText.join("\n"); } function log(config) { if (config.verbose === false) { return noop; } if (config.logFunction) { return config.logFunction; } return (text, overwrite) => { if (overwrite === true && process.stdout.isTTY) { clearLine(process.stdout, 0); cursorTo(process.stdout, 0); } else { process.stdout.write("\n"); } process.stdout.write(text); }; } function logWarning(config) { if (config.logFunction) { return config.logFunction; } return (text) => { process.stdout.write(` ${formatWarning(text)} `); }; } function logError(config) { if (config.logFunction) { return config.logFunction; } return (text) => { process.stdout.write(` ${formatError(text)} `); }; } function formatWarning(text) { const warningMessage = `${colors.underline("Warning")}: ${text}`; return colors.yellow(warningMessage); } function formatError(error) { const messageText = error instanceof Error ? error.message : error; const errorMessage = `${colors.underline("Error")}: ${messageText.replace( "Error: ", "" )}`; return colors.red(errorMessage); } function logStats(config) { if (config.logFunction) { return noop; } return (stats) => { const table = new Table({ colWidths: [40, 20], head: ["Item", "Count"] }); table.push( ["\u{1F4C4} Timetable Pages", stats.timetablePages], ["\u{1F551} Timetables", stats.timetables], ["\u{1F4C5} Calendar Service IDs", stats.calendars], ["\u{1F504} Routes", stats.routes], ["\u{1F68D} Trips", stats.trips], ["\u{1F6D1} Stops", stats.stops], ["\u26D4\uFE0F Warnings", stats.warnings.length] ); log(config)(table.toString()); }; } var generateProgressBarString = (barTotal, barProgress, size2 = 40) => { const line = "-"; const slider = "="; if (!barTotal) { throw new Error("Total value is either not provided or invalid"); } if (!barProgress && barProgress !== 0) { throw new Error("Current value is either not provided or invalid"); } if (isNaN(barTotal)) { throw new Error("Total value is not an integer"); } if (isNaN(barProgress)) { throw new Error("Current value is not an integer"); } if (isNaN(size2)) { throw new Error("Size is not an integer"); } if (barProgress > barTotal) { return slider.repeat(size2 + 2); } const percentage = barProgress / barTotal; const progress = Math.round(size2 * percentage); const emptyProgress = size2 - progress; const progressText = slider.repeat(progress); const emptyProgressText = line.repeat(emptyProgress); return progressText + emptyProgressText; }; function progressBar(formatString, barTotal, config) { let barProgress = 0; if (config.verbose === false) { return { increment: noop, interrupt: noop }; } if (barTotal === 0) { return null; } const renderProgressString = () => formatString.replace("{value}", barProgress).replace("{total}", barTotal).replace("{bar}", generateProgressBarString(barTotal, barProgress)); log(config)(renderProgressString(), true); return { interrupt(text) { logWarning(config)(text); log(config)(""); }, increment() { barProgress += 1; log(config)(renderProgressString(), true); } }; } // src/lib/geojson-utils.ts var mergeGeojson = (...geojsons) => featureCollection(geojsons.flatMap((geojson) => geojson.features)); var truncateGeoJSONDecimals = (geojson, config) => { for (const feature of geojson.features) { if (feature.geometry.coordinates) { if (feature.geometry.type.toLowerCase() === "point") { feature.geometry.coordinates = feature.geometry.coordinates.map( (number) => round(number, config.coordinatePrecision) ); } else if (feature.geometry.type.toLowerCase() === "linestring") { feature.geometry.coordinates = feature.geometry.coordinates.map( (coordinate) => coordinate.map( (number) => round(number, config.coordinatePrecision) ) ); } else if (feature.geometry.type.toLowerCase() === "multilinestring") { feature.geometry.coordinates = feature.geometry.coordinates.map( (linestring) => linestring.map( (coordinate) => coordinate.map( (number) => round(number, config.coordinatePrecision) ) ) ); } } } return geojson; }; function getTimetableGeoJSON(timetable, config) { const shapesGeojsons = timetable.route_ids.map( (routeId) => getShapesAsGeoJSON({ route_id: routeId, direction_id: timetable.direction_id, trip_id: timetable.orderedTrips.map((trip) => trip.trip_id) }) ); const stopsGeojsons = timetable.route_ids.map( (routeId) => getStopsAsGeoJSON({ route_id: routeId, direction_id: timetable.direction_id, trip_id: timetable.orderedTrips.map((trip) => trip.trip_id) }) ); const geojson = mergeGeojson(...shapesGeojsons, ...stopsGeojsons); let simplifiedGeojson; try { simplifiedGeojson = simplify(geojson, { tolerance: 1 / 10 ** config.coordinatePrecision, highQuality: true }); } catch { timetable.warnings.push( `Timetable ${timetable.timetable_id} - Unable to simplify geojson` ); simplifiedGeojson = geojson; } return truncateGeoJSONDecimals(simplifiedGeojson, config); } function getAgencyGeoJSON(config) { const shapesGeojsons = getShapesAsGeoJSON(); const stopsGeojsons = getStopsAsGeoJSON(); const geojson = mergeGeojson(shapesGeojsons, stopsGeojsons); let simplifiedGeojson; try { simplifiedGeojson = simplify(geojson, { tolerance: 1 / 10 ** config.coordinatePrecision, highQuality: true }); } catch { logWarning(config)("Unable to simplify geojson"); simplifiedGeojson = geojson; } return truncateGeoJSONDecimals(simplifiedGeojson, config); } // src/lib/template-functions.ts var template_functions_exports = {}; __export(template_functions_exports, { formatTripName: () => formatTripName, formatTripNameForCSV: () => formatTripNameForCSV, getNotesForStop: () => getNotesForStop, getNotesForStoptime: () => getNotesForStoptime, getNotesForTimetableLabel: () => getNotesForTimetableLabel, getNotesForTrip: () => getNotesForTrip, hasNotesOrNotices: () => hasNotesOrNotices, timetableHasDifferentDays: () => timetableHasDifferentDays, timetablePageHasDifferentDays: () => timetablePageHasDifferentDays, timetablePageHasDifferentLabels: () => timetablePageHasDifferentLabels }); import { every } from "lodash-es"; function timetableHasDifferentDays(timetable) { return !every(timetable.orderedTrips, (trip, idx) => { if (idx === 0) { return true; } return trip.dayList === timetable.orderedTrips[idx - 1].dayList; }); } function timetablePageHasDifferentDays(timetablePage) { return !every(timetablePage.consolidatedTimetables, (timetable, idx) => { if (idx === 0) { return true; } return timetable.dayListLong === timetablePage.consolidatedTimetables[idx - 1].dayListLong; }); } function timetablePageHasDifferentLabels(timetablePage) { return !every(timetablePage.consolidatedTimetables, (timetable, idx) => { if (idx === 0) { return true; } return timetable.timetable_label === timetablePage.consolidatedTimetables[idx - 1].timetable_label; }); } function hasNotesOrNotices(timetable) { return timetable.requestPickupSymbolUsed || timetable.noPickupSymbolUsed || timetable.requestDropoffSymbolUsed || timetable.noDropoffSymbolUsed || timetable.noServiceSymbolUsed || timetable.interpolatedStopSymbolUsed || timetable.notes.length > 0; } function getNotesForTimetableLabel(notes) { return notes.filter((note) => !note.stop_id && !note.trip_id); } function getNotesForStop(notes, stop) { return notes.filter((note) => { if (note.trip_id) { return false; } if (note.stop_sequence && !stop.trips.some((trip) => trip.stop_sequence === note.stop_sequence)) { return false; } return note.stop_id === stop.stop_id; }); } function getNotesForTrip(notes, trip) { return notes.filter((note) => { if (note.stop_id) { return false; } return note.trip_id === trip.trip_id; }); } function getNotesForStoptime(notes, stoptime) { return notes.filter((note) => { if (!note.trip_id && note.stop_id === stoptime.stop_id && note.show_on_stoptime === 1) { return true; } if (!note.stop_id && note.trip_id === stoptime.trip_id && note.show_on_stoptime === 1) { return true; } return note.trip_id === stoptime.trip_id && note.stop_id === stoptime.stop_id; }); } function formatTripName(trip, index, timetable) { let tripName; if (timetable.routes.length > 1) { tripName = trip.route_short_name; } else if (timetable.orientation === "horizontal") { if (trip.trip_short_name) { tripName = trip.trip_short_name; } else { tripName = `Run #${index + 1}`; } } if (timetableHasDifferentDays(timetable)) { tripName += ` ${trip.dayList}`; } return tripName; } function formatTripNameForCSV(trip, timetable) { let tripName = ""; if (timetable.routes.length > 1) { tripName += `${trip.route_short_name} - `; } if (trip.trip_short_name) { tripName += trip.trip_short_name; } else { tripName += trip.trip_id; } if (trip.trip_headsign) { tripName += ` - ${trip.trip_headsign}`; } if (timetableHasDifferentDays(timetable)) { tripName += ` - ${trip.dayList}`; } return tripName; } // package.json var package_default = { name: "gtfs-to-html", version: "2.12.3", private: false, description: "Build human readable transit timetables as HTML, PDF or CSV from GTFS", keywords: [ "transit", "gtfs", "gtfs-realtime", "transportation", "timetables" ], homepage: "https://gtfstohtml.com", bugs: { url: "https://github.com/blinktaginc/gtfs-to-html/issues" }, repository: "git://github.com/blinktaginc/gtfs-to-html", license: "MIT", author: "Brendan Nee <brendan@blinktag.com>", contributors: [ "Evan Siroky <evan.siroky@yahoo.com>", "Nathan Selikoff", "Aaron Antrim <aaron@trilliumtransit.com>", "Thomas Craig <thomas@trilliumtransit.com>", "Holly Kvalheim", "Pawajoro", "Andrea Mignone", "Evo Stamatov", "Sebastian Knopf" ], type: "module", main: "./dist/index.js", types: "./dist/index.d.ts", files: [ "dist", "docker", "examples", "scripts", "views/default", "config-sample.json" ], bin: { "gtfs-to-html": "dist/bin/gtfs-to-html.js" }, scripts: { build: "tsup", postbuild: "node scripts/postinstall.js", start: "node ./dist/app", prepare: "husky && npm run build", postinstall: "node scripts/postinstall.js" }, dependencies: { "@maplibre/maplibre-gl-geocoder": "^1.9.1", "@turf/helpers": "^7.3.1", "@turf/simplify": "^7.3.1", anchorme: "^3.0.8", archiver: "^7.0.1", "cli-table": "^0.3.11", "css.escape": "^1.5.1", "csv-stringify": "^6.6.0", express: "^5.2.1", gtfs: "^4.18.2", "gtfs-realtime-pbf-js-module": "^1.0.0", "js-beautify": "^1.15.4", "lodash-es": "^4.17.21", "maplibre-gl": "^5.14.0", marked: "^17.0.1", moment: "^2.30.1", pbf: "^4.0.1", "pretty-error": "^4.0.0", pug: "^3.0.3", puppeteer: "^24.32.0", "sanitize-filename": "^1.6.3", "sanitize-html": "^2.17.0", sqlstring: "^2.3.3", toposort: "^2.0.2", yargs: "^18.0.0", yoctocolors: "^2.1.2" }, devDependencies: { "@types/archiver": "^7.0.0", "@types/cli-table": "^0.3.4", "@types/express": "^5.0.6", "@types/insane": "^1.0.0", "@types/js-beautify": "^1.14.3", "@types/lodash-es": "^4.17.12", "@types/morgan": "^1.9.10", "@types/node": "^24", "@types/pug": "^2.0.10", "@types/sanitize-html": "^2.16.0", "@types/sqlstring": "^2.3.2", "@types/toposort": "^2.0.7", "@types/yargs": "^17.0.35", husky: "^9.1.7", "lint-staged": "^16.2.7", prettier: "^3.7.4", tsup: "^8.5.1", typescript: "^5.9.3" }, engines: { node: ">= 22" }, "release-it": { github: { release: true }, plugins: { "@release-it/keep-a-changelog": { filename: "CHANGELOG.md" } }, hooks: { "after:bump": "npm run build" } }, prettier: { singleQuote: true }, "lint-staged": { "*.js": "prettier --write", "*.ts": "prettier --write", "*.json": "prettier --write" } }; // src/lib/utils.ts var { version } = package_default; var isTimepoint = (stoptime) => { if (isNullOrEmpty(stoptime.timepoint)) { return !isNullOrEmpty(stoptime.arrival_time) && !isNullOrEmpty(stoptime.departure_time); } return stoptime.timepoint === 1; }; var getLongestTripStoptimes = (trips, config) => { const filteredTripStoptimes = trips.map( (trip) => trip.stoptimes.filter((stoptime) => { if (config.showOnlyTimepoint === true) { return isTimepoint(stoptime); } return true; }) ); return maxBy(filteredTripStoptimes, (stoptimes) => size(stoptimes)); }; var findCommonStopId = (trips, config) => { const longestTripStoptimes = getLongestTripStoptimes(trips, config); if (!longestTripStoptimes) { return null; } const commonStoptime = longestTripStoptimes.find((stoptime, idx) => { if (idx === 0 && stoptime.stop_id === last(longestTripStoptimes)?.stop_id) { return false; } if (isNullOrEmpty(stoptime.arrival_time)) { return false; } return every2( trips, (trip) => trip.stoptimes.find( (tripStoptime) => tripStoptime.stop_id === stoptime.stop_id && tripStoptime.arrival_time !== null ) ); }); return commonStoptime ? commonStoptime.stop_id : null; }; var deduplicateTrips = (trips) => { if (trips.length <= 1) { return trips; } const uniqueTrips = /* @__PURE__ */ new Map(); for (const trip of trips) { const tripSignature = trip.stoptimes.map( (stoptime) => `${stoptime.stop_id}|${stoptime.departure_time}|${stoptime.arrival_time}` ).join("|"); if (!uniqueTrips.has(tripSignature)) { uniqueTrips.set(tripSignature, trip); } } return Array.from(uniqueTrips.values()); }; var sortTrips = (trips, config) => { let sortedTrips; let commonStopId; if (config.sortingAlgorithm === "common") { commonStopId = findCommonStopId(trips, config); if (commonStopId) { sortedTrips = sortTripsByStoptimeAtStop(trips, commonStopId); } else { sortedTrips = sortTrips(trips, { ...config, sortingAlgorithm: "beginning" }); } } else if (config.sortingAlgorithm === "beginning") { for (const trip of trips) { if (trip.stoptimes.length === 0) { continue; } trip.firstStoptime = timeToSeconds(trip.stoptimes[0].departure_time); trip.lastStoptime = timeToSeconds( trip.stoptimes[trip.stoptimes.length - 1].departure_time ); } sortedTrips = sortBy(trips, ["firstStoptime", "lastStoptime"]); } else if (config.sortingAlgorithm === "end") { for (const trip of trips) { if (trip.stoptimes.length === 0) { continue; } trip.firstStoptime = timeToSeconds(trip.stoptimes[0].departure_time); trip.lastStoptime = timeToSeconds( trip.stoptimes[trip.stoptimes.length - 1].departure_time ); } sortedTrips = sortBy(trips, ["lastStoptime", "firstStoptime"]); } else if (config.sortingAlgorithm === "first") { const longestTripStoptimes = getLongestTripStoptimes(trips, config); const firstStopId = first(longestTripStoptimes).stop_id; sortedTrips = sortTripsByStoptimeAtStop(trips, firstStopId); } else if (config.sortingAlgorithm === "last") { const longestTripStoptimes = getLongestTripStoptimes(trips, config); const lastStopId = last(longestTripStoptimes).stop_id; sortedTrips = sortTripsByStoptimeAtStop(trips, lastStopId); } return sortedTrips ?? []; }; var sortTripsByStoptimeAtStop = (trips, stopId) => sortBy(trips, (trip) => { const stoptime = find(trip.stoptimes, { stop_id: stopId }); return stoptime ? timeToSeconds(stoptime.departure_time) : void 0; }); var getCalendarDatesForTimetable = (timetable, config) => { const calendarDates = getCalendarDates( { service_id: timetable.service_ids }, [], [["date", "ASC"]] ); const start = moment2(timetable.start_date, "YYYYMMDD"); const end = moment2(timetable.end_date, "YYYYMMDD"); const excludedDates = /* @__PURE__ */ new Set(); const includedDates = /* @__PURE__ */ new Set(); for (const calendarDate of calendarDates) { if (moment2(calendarDate.date, "YYYYMMDD").isBetween( start, end, void 0, "[]" )) { if (calendarDate.exception_type === 1) { includedDates.add(formatDate(calendarDate, config.dateFormat)); } else if (calendarDate.exception_type === 2) { excludedDates.add(formatDate(calendarDate, config.dateFormat)); } } } const includedAndExcludedDates = new Set( [...excludedDates].filter((date) => includedDates.has(date)) ); return { excludedDates: [...excludedDates].filter( (date) => !includedAndExcludedDates.has(date) ), includedDates: [...includedDates].filter( (date) => !includedAndExcludedDates.has(date) ) }; }; var getDaysFromCalendars = (calendars) => { const days2 = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 }; for (const calendar of calendars) { for (const day of Object.keys(days2)) { days2[day] = days2[day] | calendar[day]; } } return days2; }; var getDirectionHeadsignFromTimetable = (timetable) => { const trips = getTrips( { direction_id: timetable.direction_id, route_id: timetable.route_ids }, ["trip_headsign"] ); if (trips.length === 0) { return ""; } const mostCommonHeadsign = flow( countBy, entries, partialRight(maxBy, last), head )(compact(trips.map((trip) => trip.trip_headsign))); return mostCommonHeadsign; }; var getTimetableNotesForTimetable = (timetable, config) => { const noteReferences = [ // Get all notes for this timetable. ...getTimetableNotesReferences({ timetable_id: timetable.timetable_id }), // Get all notes for this route. ...getTimetableNotesReferences({ route_id: timetable.routes.map((route) => route.route_id), timetable_id: null }), // Get all notes for all trips in this timetable. ...getTimetableNotesReferences({ trip_id: timetable.orderedTrips.map((trip) => trip.trip_id) }), // Get all notes for all stops in this timetable. ...getTimetableNotesReferences({ stop_id: timetable.stops.map((stop) => stop.stop_id), trip_id: null, route_id: null, timetable_id: null }) ]; const usedNoteReferences = []; for (const noteReference of noteReferences) { if (noteReference.stop_sequence === "" || noteReference.stop_sequence === null) { usedNoteReferences.push(noteReference); continue; } if (noteReference.stop_id === "" || noteReference.stop_id === null) { timetable.warnings.push( `Timetable Note Reference for note_id=${noteReference.note_id} has a \`stop_sequence\` but no \`stop_id\` - ignoring` ); continue; } const stop = timetable.stops.find( (stop2) => stop2.stop_id === noteReference.stop_id ); if (!stop) { continue; } const tripWithMatchingStopSequence = stop.trips.find( (trip) => trip.stop_sequence === noteReference.stop_sequence ); if (tripWithMatchingStopSequence) { usedNoteReferences.push(noteReference); } } const notes = getTimetableNotes({ note_id: usedNoteReferences.map((noteReference) => noteReference.note_id) }); const symbols = "abcdefghijklmnopqrstuvwxyz".split(""); let symbolIndex = 0; for (const note of notes) { if (note.symbol === "" || note.symbol === null) { note.symbol = symbolIndex < symbols.length - 1 ? symbols[symbolIndex] : symbolIndex - symbols.length; symbolIndex += 1; } } const formattedNotes = usedNoteReferences.map((noteReference) => ({ ...noteReference, ...notes.find((note) => note.note_id === noteReference.note_id) })); return sortBy(formattedNotes, "symbol"); }; var createTimetablePage = ({ timetablePageId, timetables, config }) => { const updatedTimetables = timetables.map((timetable) => { if (!timetable.routes) { timetable.routes = getRoutes({ route_id: timetable.route_ids }); } return timetable; }); const timetablePage = { timetable_page_id: timetablePageId, timetables: updatedTimetables, routes: updatedTimetables.flatMap((timetable) => timetable.routes) }; const filename = generateTimetablePageFileName(timetablePage, config); return { ...timetablePage, filename }; }; var createTimetable = ({ route, directionId, tripHeadsign, calendars, calendarDates }) => { const serviceIds = uniq([ ...calendars?.map((calendar) => calendar.service_id) ?? [], ...calendarDates?.map((calendarDate) => calendarDate.service_id) ?? [] ]); const days2 = { monday: null, tuesday: null, wednesday: null, thursday: null, friday: null, saturday: null, sunday: null }; let startDate = null; let endDate = null; if (calendars && calendars.length > 0) { Object.assign(days2, getDaysFromCalendars(calendars)); startDate = parseInt( moment2.min( calendars.map((calendar) => moment2(calendar.start_date, "YYYYMMDD")) ).format("YYYYMMDD"), 10 ); endDate = parseInt( moment2.max(calendars.map((calendar) => moment2(calendar.end_date, "YYYYMMDD"))).format("YYYYMMDD"), 10 ); } const timetableId = formatTimetableId({ routeIds: [route.route_id], directionId, days: days2, dates: calendarDates?.map((calendarDate) => calendarDate.date) }); return { timetable_id: timetableId, route_ids: [route.route_id], direction_id: directionId === null ? null : directionId, direction_name: tripHeadsign === null ? null : tripHeadsign, routes: [route], include_exceptions: calendarDates && calendarDates.length > 0 ? 1 : 0, service_ids: serviceIds, service_notes: null, timetable_label: null, start_time: null, end_time: null, orientation: null, timetable_sequence: null, show_trip_continuation: null, start_date: startDate, end_date: endDate, ...days2 }; }; var convertRoutesToTimetablePages = (config) => { const routes = getRoutes(); const timetablePages = []; const { calendars, calendarDates } = getCalendarsFromConfig(config); for (const route of routes) { const trips = getTrips( { route_id: route.route_id }, ["trip_headsign", "direction_id", "trip_id", "service_id"] ); const uniqueTripDirections = orderBy( uniqBy(trips, (trip) => trip.direction_id), "direction_id" ); const sortedCalendars = orderBy(calendars, calendarToCalendarCode, "desc"); const calendarGroups = groupBy(sortedCalendars, calendarToCalendarCode); const calendarDateGroups = groupBy(calendarDates, "service_id"); const timetables = []; for (const uniqueTripDirection of uniqueTripDirections) { for (const calendars2 of Object.values(calendarGroups)) { const tripsForCalendars = trips.filter( (trip) => some(calendars2, { service_id: trip.service_id }) ); if (tripsForCalendars.length > 0) { timetables.push( createTimetable({ route, directionId: uniqueTripDirection.direction_id, tripHeadsign: uniqueTripDirection.trip_headsign, calendars: calendars2 }) ); } } for (const calendarDates2 of Object.values(calendarDateGroups)) { const tripsForCalendarDates = trips.filter( (trip) => some(calendarDates2, { service_id: trip.service_id }) ); if (tripsForCalendarDates.length > 0) { timetables.push( createTimetable({ route, directionId: uniqueTripDirection.direction_id, tripHeadsign: uniqueTripDirection.trip_headsign, calendarDates: calendarDates2 }) ); } } } if (timetables.length === 0) { continue; } if (config.groupTimetablesIntoPages === true) { timetablePages.push( createTimetablePage({ timetablePageId: `route_${route.route_short_name ?? route.route_long_name}`, timetables, config }) ); } else { for (const timetable of timetables) { timetablePages.push( createTimetablePage({ timetablePageId: timetable.timetable_id, timetables: [timetable], config }) ); } } } return timetablePages; }; var generateTripsByFrequencies = (trip, frequencies, config) => { const formattedFrequencies = frequencies.map( (frequency) => formatFrequency(frequency, config) ); const resetTrip = resetStoptimesToMidnight(trip); const trips = []; for (const frequency of formattedFrequencies) { const startSeconds = secondsAfterMidnight(frequency.start_time); const endSeconds = secondsAfterMidnight(frequency.end_time); for (let offset = startSeconds; offset < endSeconds; offset += frequency.headway_secs) { const newTrip = cloneDeep(resetTrip); trips.push({ ...newTrip, trip_id: `${resetTrip.trip_id}_freq_${trips.length}`, stoptimes: updateStoptimesByOffset(newTrip, offset) }); } } return trips; }; var duplicateStopsForDifferentArrivalDeparture = (stopIds, timetable, config) => { if (config.showArrivalOnDifference === null || config.showArrivalOnDifference === void 0) { return stopIds; } for (const trip of timetable.orderedTrips) { for (const stoptime of trip.stoptimes) { const timepointDifference = fromGTFSTime(stoptime.departure_time).diff( fromGTFSTime(stoptime.arrival_time), "minutes" ); if (timepointDifference < config.showArrivalOnDifference) { continue; } const index = stopIds.indexOf(stoptime.stop_id); if (index === 0 || index === stopIds.length - 1) { continue; } if (stoptime.stop_id === stopIds[index + 1] || stoptime.stop_id === stopIds[index - 1]) { continue; } stopIds.splice(index, 0, stoptime.stop_id); } } return stopIds; }; var getStopOrder = (timetable, config) => { const timetableStopOrders = getTimetableStopOrders( { timetable_id: timetable.timetable_id }, ["stop_id"], [["stop_sequence", "ASC"]] ); if (timetableStopOrders.length > 0) { return timetableStopOrders.map( (timetableStopOrder) => timetableStopOrder.stop_id ); } try { const stopGraph = []; const timepointStopIds = new Set( timetable.orderedTrips.flatMap( (trip) => trip.stoptimes.filter((stoptime) => isTimepoint(stoptime)).map((stoptime) => stoptime.stop_id) ) ); for (const trip of timetable.orderedTrips) { const sortedStopIds = trip.stoptimes.filter((stoptime) => { if (config.showOnlyTimepoint === true) { return timepointStopIds.has(stoptime.stop_id); } return true; }).map((stoptime) => stoptime.stop_id); for (const [index, stopId] of sortedStopIds.entries()) { if (index === sortedStopIds.length - 1) { continue; } stopGraph.push([stopId, sortedStopIds[index + 1]]); } } if (stopGraph.length === 0 && config.showOnlyTimepoint === true) { timetable.warnings.push( `Timetable ${timetable.timetable_id}'s trips have stoptimes with timepoints but \`showOnlyTimepoint\` is true. Try setting \`showOnlyTimepoint\` to false.` ); } const stopIds = toposort(stopGraph); return duplicateStopsForDifferentArrivalDeparture( stopIds, timetable, config ); } catch { const longestTripStoptimes = getLongestTripStoptimes( timetable.orderedTrips, config ); const stopIds = longestTripStoptimes.map( (stoptime) => stoptime.stop_id ); const missingStopIds = difference( new Set( timetable.orderedTrips.flatMap( (trip) => trip.stoptimes.map((stoptime) => stoptime.stop_id) ) ), new Set(stopIds) ); if (missingStopIds.length > 0) { timetable.warnings.push( `Timetable ${timetable.timetable_id} stops are unable to be topologically sorted and has no \`timetable_stop_order.txt\`. Falling back to using the using the stop order from trip with most stoptimes, but this does not include stop_ids ${formatListForDisplay(missingStopIds)}. Try manually specifying stops with \`timetable_stop_order.txt\`. Read more at https://gtfstohtml.com/docs/timetable-stop-order` ); } return duplicateStopsForDifferentArrivalDeparture( stopIds, timetable, config ); } }; var getStopsForTimetable = (timetable, config) => { if (timetable.orderedTrips.length === 0) { return []; } const orderedStopIds = getStopOrder(timetable, config); const orderedStops = orderedStopIds.map((stopId, index) => { const stops = getStops({ stop_id: stopId }); if (stops.length === 0) { throw new Error( `No stop found found for stop_id=${stopId} in timetable_id=${timetable.timetable_id}` ); } const stop = { ...stops[0], trips: [] }; if (index < orderedStopIds.length - 1 && stopId === orderedStopIds[index + 1]) { stop.type = "arrival"; } else if (index > 0 && stopId === orderedStopIds[index - 1]) { stop.type = "departure"; } return stop; }); if (config.showStopCity) { const stopAttributes = getStopAttributes({ stop_id: orderedStopIds }); for (const stopAttribute of stopAttributes) { const stop = orderedStops.find( (stop2) => stop2.stop_id === stopAttribute.stop_id ); if (stop) { stop.stop_city = stopAttribute.stop_city; } } } return orderedStops; }; var getCalendarsFromConfig = (config) => { const db = openDb(); let whereClause = ""; const whereClauses = []; if (config.endDate) { if (!moment2(config.endDate).isValid()) { throw new Error(`Invalid endDate=${config.endDate} in config.json`); } whereClauses.push( `start_date <= ${sqlString.escape(moment2(config.endDate).format("YYYYMMDD"))}` ); } if (config.startDate) { if (!moment2(config.startDate).isValid()) { throw new Error(`Invalid startDate=${config.startDate} in config.json`); } whereClauses.push( `end_date >= ${sqlString.escape(moment2(config.startDate).format("YYYYMMDD"))}` ); } if (whereClauses.length > 0) { whereClause = `WHERE ${whereClauses.join(" AND ")}`; } const calendars = db.prepare(`SELECT * FROM calendar ${whereClause}`).all(); const serviceIds = calendars.map((calendar) => calendar.service_id); const calendarDates = db.prepare( `SELECT * FROM calendar_dates WHERE exception_type = 1 AND service_id NOT IN (${serviceIds.map((serviceId) => `'${serviceId}'`).join(", ")})` ).all(); return { calendars, calendarDates }; }; var getCalendarsFromTimetable = (timetable) => { const db = openDb(); let whereClause = ""; const whereClauses = []; if (timetable.end_date) { if (!moment2(timetable.end_date, "YYYYMMDD", true).isValid()) { throw new Error( `Invalid end_date=${timetable.end_date} for timetable_id=${timetable.timetable_id}` ); } whereClauses.push(`start_date <= ${sqlString.escape(timetable.end_date)}`); } if (timetable.start_date) { if (!moment2(timetable.start_date, "YYYYMMDD", true).isValid()) { throw new Error( `Invalid start_date=${timetable.start_date} for timetable_id=${timetable.timetable_id}` ); } whereClauses.push(`end_date >= ${sqlString.escape(timetable.start_date)}`); } const days2 = getDaysFromCalendars([timetable]); const dayQueries = reduce( days2, (memo, value, key) => { if (value === 1) { memo.push(`${key} = 1`); } return memo; }, [] ); if (dayQueries.length > 0) { whereClauses.push(`(${dayQueries.join(" OR ")})`); } if (whereClauses.length > 0) { whereClause = `WHERE ${whereClauses.join(" AND ")}`; } return db.prepare(`SELECT * FROM calendar ${whereClause}`).all(); }; var getCalendarDatesForDateRange = (startDate, endDate) => { const db = openDb(); const whereClauses = []; if (endDate) { whereClauses.push(`date <= ${sqlString.escape(endDate)}`); } if (startDate) { whereClauses.push(`date >= ${sqlString.escape(startDate)}`); } const calendarDates = db.prepare( `SELECT service_id, date, exception_type FROM calendar_dates WHERE ${whereClauses.join( " AND " )}` ).all(); return calendarDates; }; var getAllStationStopIds = (stopId) => { const stops = getStops({ stop_id: stopId }); if (stops.length === 0) { throw new Error(`No stop found for stop_id=${stopId}`); } const stop = stops[0]; if (isNullOrEmpty(stop.parent_station)) { return [stopId]; } const stopsInParentStation = getStops( { parent_station: stop.parent_station }, ["stop_id"] ); return [ stop.parent_station, ...stopsInParentStation.map((stop2) => stop2.stop_id) ]; }; var getTripsWithSameBlock = (trip, timetable) => { const trips = getTrips( { block_id: trip.block_id, service_id: timetable.service_ids }, ["trip_id", "route_id"] ); for (const blockTrip of trips) { const stopTimes = getStoptimes( { trip_id: blockTrip.trip_id }, [], [["stop_sequence", "ASC"]] ); if (stopTimes.length === 0) { throw new Error( `No stoptimes found found for trip_id=${blockTrip.trip_id}` ); } blockTrip.firstStoptime = first(stopTimes); blockTrip.lastStoptime = last(stopTimes); } return sortBy(trips, (trip2) => trip2.firstStoptime.departure_timestamp); }; var addTripContinuation = (trip, timetable) => { if (!trip.block_id || trip.stoptimes.length === 0) { return; } const maxContinuesAsWaitingTimeSeconds = 60 * 60; const firstStoptime = first(trip.stoptimes); const firstStopIds = getAllStationStopIds(firstStoptime.stop_id); const lastStoptime = last(trip.stoptimes); const lastStopIds = getAllStationStopIds(lastStoptime.stop_id); const blockTrips = getTripsWithSameBlock(trip, timetable); const previousTrip = findLast( blockTrips, (blockTrip) => blockTrip.lastStoptime.arrival_timestamp <= firstStoptime.departure_timestamp ); if (previousTrip && previousTrip.route_id !== trip.route_id && previousTrip.lastStoptime.arrival_timestamp >= firstStoptime.departure_timestamp - maxContinuesAsWaitingTimeSeconds && firstStopIds.includes(previousTrip.lastStoptime.stop_id)) { const routes = getRoutes({ route_id: previousTrip.route_id }); previousTrip.route = routes[0]; trip.continues_from_route = previousTrip; } const nextTrip = find( blockTrips, (blockTrip) => blockTrip.firstStoptime.departure_timestamp >= lastStoptime.arrival_timestamp ); if (nextTrip && nextTrip.route_id !== trip.route_id && nextTrip.firstStoptime.departure_timestamp <= lastStoptime.arrival_timestamp + maxContinuesAsWaitingTimeSeconds && lastStopIds.includes(nextTrip.firstStoptime.stop_id)) { const routes = getRoutes({ route_id: nextTrip.route_id }); nextTrip.route = routes[0]; trip.continues_as_route = nextTrip; } }; var filterTrips = (timetable, config) => { let filteredTrips = timetable.orderedTrips; for (const trip of filteredTrips) { const combinedStoptimes = []; for (const [index, stoptime] of trip.stoptimes.entries()) { if (index === 0 || stoptime.stop_id !== trip.stoptimes[index - 1].stop_id) { combinedStoptimes.push(stoptime); } else { combinedStoptimes[combinedStoptimes.length - 1].departure_time = stoptime.departure_time; } } trip.stoptimes = combinedStoptimes; } const timetableStopIds = new Set( timetable.stops.map((stop) => stop.stop_id) ); for (const trip of filteredTrips) { trip.stoptimes = trip.stoptimes.filter( (stoptime) => timetableStopIds.has(stoptime.stop_id) ); } filteredTrips = filteredTrips.filter( (trip) => trip.stoptimes.length > 1 ); if (config.showDuplicateTrips === false) { filteredTrips = deduplicateTrips(filteredTrips); } return filteredTrips; }; var getTripsForTimetable = (timetable, calendars, config) => { const tripQuery = { route_id: timetable.route_ids, service_id: timetable.service_ids }; if (!isNullOrEmpty(timetable.direction_id)) { tripQuery.direction_id = timetable.direction_id; } const trips = getTrips(tripQuery); if (trips.length === 0) { timetable.warnings.push( `No trips found for route_id=${timetable.route_ids.join( "_" )}, direction_id=${timetable.direction_id}, service_ids=${JSON.stringify( timetable.service_ids )}, timetable_id=${timetable.timetable_id}` ); } const frequencies = getFrequencies({ trip_id: trips.map((trip) => trip.trip_id) }); timetable.service_ids = uniq(trips.map((trip) => trip.service_id)); const formattedTrips = []; for (const trip of trips) { const formattedTrip = formatTrip(trip, timetable, calendars, config); formattedTrip.stoptimes = getStoptimes( { trip_id: formattedTrip.trip_id }, [], [["stop_sequence", "ASC"]] ); if (formattedTrip.stoptimes.length === 0) { timetable.warnings.push( `No stoptimes found for trip_id=${formattedTrip.trip_id}, route_id=${timetable.route_ids.join("_")}, timetable_id=${timetable.timetable_id}` ); } if (timetable.start_timestamp !== "" && timetable.start_timestamp !== null && timetable.start_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp < timetable.start_timestamp) { return; } if (timetable.end_timestamp !== "" && timetable.end_timestamp !== null && timetable.end_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp >= timetable.end_timestamp) { return; } if (timetable.show_trip_continuation) { addTripContinuation(formattedTrip, timetable); if (formattedTrip.continues_as_route) { timetable.has_continues_as_route = true; } if (formattedTrip.continues_from_route) { timetable.has_continues_from_route = true; } } const tripFrequencies = frequencies.filter( (frequency) => frequency.trip_id === trip.trip_id ); if (tripFrequencies.length === 0) { formattedTrips.push(formattedTrip); } else { const frequencyTrips = generateTripsByFrequencies( formattedTrip, frequencies, config ); formattedTrips.push(...frequencyTrips); timetable.frequencies = frequencies; timetable.frequencyExactTimes = some(frequencies, { exact_times: 1 }); } } if (config.useParentStation) { const stopIds = []; for (const trip of formattedTrips) { for (const stoptime of trip.stoptimes) { stopIds.push(stoptime.stop_id); } } const stops = getStops( { stop_id: uniq(stopIds) }, ["parent_station", "stop_id"] ); for (const trip of formattedTrips) { for (const stoptime of trip.stoptimes) { const stop = stops.find((stop2) => stop2.stop_id === stoptime.stop_id); if (stop?.parent_station) { stoptime.stop_id = stop.parent_station; } } } } return sortTrips(formattedTrips, config); }; var formatTimetables = (timetables, config) => { const formattedTimetables = timetables.map((timetable) => { timetable.warnings = []; const dayList = formatDays(timetable, config); const calendars = getCalendarsFromTimetable(timetable); const serviceIds = /* @__PURE__ */ new Set(); for (const calendar of calendars) { serviceIds.add(calendar.service_id); } if (timetable.include_exceptions === 1) { const calendarDates = getCalendarDatesForDateRange( timetable.start_date, timetable.end_date ); const calendarDateGroups = groupBy(calendarDates, "service_id"); for (const [serviceId, calendarDateGroup] of Object.entries( calendarDateGroups )) { const calendar = calendars.find( (c) => c.service_id === serviceId ); if (calendarDateGroup.some( (calendarDate) => calendarDate.exception_type === 1 )) { serviceIds.add(serviceId); } const calendarDateGroupExceptionType2 = calendarDateGroup.filter( (calendarDate) => calendarDate.exception_type === 2 ); if (timetable.start_date && timetable.end_date && calendar && calendarDateGroupExceptionType2.length > 0) { const datesDuringDateRange = calendarToDateList( calendar, timetable.start_date, timetable.end_date ); if (datesDuringDateRange.length === 0) { serviceIds.delete(serviceId); } const everyDateIsExcluded = datesDuringDateRange.every( (dateDuringDateRange) => calendarDateGroupExceptionType2.some( (calendarDate) => calendarDate.date === dateDuringDateRange ) ); if (everyDateIsExcluded) { serviceIds.delete(serviceId); } } } } Object.assign(timetable, { noServiceSymbolUsed: false, requestDropoffSymbolUsed: false, noDropoffSymbolUsed: false, requestPickupSymbolUsed: false, noPickupSymbolUsed: false, interpolatedStopSymbolUsed: false, showStopCity: config.showStopCity, showStopDescription: config.showStopDescription, noServiceSymbol: config.noServiceSymbol, requestDropoffSymbol: config.requestDropoffSymbol, noDropoffSymbol: config.noDropoffSymbol, requestPickupSymbol: config.requestPickupSymbol, noPickupSymbol: config.noPickupSymbol, interpolatedStopSymbol: config.interpolatedStopSymbol, orientation: timetable.orientation || config.defaultOrientation, service_ids: Array.from(serviceIds), dayList, dayListLong: formatDaysLong(dayList, config) }); timetable.orderedTrips = getTripsForTimetable(timetable, calendars, config); timetable.stops = getStopsForTimetable(timetable, config); timetable.calendarDates = getCalendarDatesForTimetable(timetable, config); timetable.timetable_label = formatTimetableLabel(timetable); timetable.notes = getTimetableNotesForTimetable(timetable, config); if (config.showMap) { timetable.geojson = getTimetableGeoJSON(timetable, config); } timetable.trip_ids = uniq( timetable.orderedTrips.map((trip) => trip.trip_id) ); timetable.orderedTrips = filterTrips(timetable, config); timetable.stops = formatStops(timetable, config); return timetable; }); if (config.allowEmptyTimetables) { return formattedTimetables; } return formattedTimetables.filter( (timetable) => timetable.orderedTrips.length > 0 ); }; function getTimetablePagesForAgency(config) { const timetables = mergeTimetablesWithSameId(getTimetables()); const routes = getRoutes(); const formattedTimetables = timetables.map((timetable) => { return