UNPKG

gtfs-to-html

Version:

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

1,174 lines (1,165 loc) 82.1 kB
import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { access, copyFile, cp, mkdir, readFile, readdir, rm } from "node:fs/promises"; import { GtfsErrorCategory, formatGtfsError, getAgencies, getCalendarDates, getCalendars, getFeedInfo, getFrequencies, getRoutes, getShapesAsGeoJSON, getStopAttributes, getStops, getStopsAsGeoJSON, getStoptimes, getTimetableNotes, getTimetableNotesReferences, getTimetablePages, getTimetableStopOrders, getTimetables, getTrips, isGtfsError, openDb } from "gtfs"; import sanitize from "sanitize-filename"; import cssEscape from "css.escape"; import { createWriteStream } from "node:fs"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; import * as _ from "lodash-es"; import { clone, cloneDeep, compact, countBy, difference, entries, every, find, findLast, first, flatMap, flow, groupBy, head, last, maxBy, noop, omit, orderBy, partialRight, reduce, size, some, sortBy, uniq, uniqBy, zip, zipObject } from "lodash-es"; import { ZipArchive } from "archiver"; import beautify from "js-beautify"; import xss from "xss"; import { renderFile } from "pug"; import puppeteer from "puppeteer"; import { marked } from "marked"; import moment from "moment"; import { stringify } from "csv-stringify"; import sqlString from "sqlstring"; import toposort from "toposort"; import simplify from "@turf/simplify"; import { featureCollection, round } from "@turf/helpers"; import { clearLine, cursorTo } from "node:readline"; import * as colors from "yoctocolors"; import Table from "cli-table"; //#region \0rolldown/runtime.js var __defProp = Object.defineProperty; var __exportAll = (all, no_symbols) => { let target = {}; for (var name in all) { __defProp(target, name, { get: all[name], enumerable: true }); } if (!no_symbols) { __defProp(target, Symbol.toStringTag, { value: "Module" }); } return target; }; //#endregion //#region src/lib/time-utils.ts 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 days = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ]; const calendar = {}; for (const [index, day] of days.entries()) calendar[day] = code[index] === "1" ? 1 : 0; 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 combineCalendars(calendars) { const combinedCalendar = { 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(combinedCalendar)) if (calendar[day] === 1) combinedCalendar[day] = 1; return combinedCalendar; } function secondsAfterMidnight(timeString) { return moment.duration(timeString).asSeconds(); } function minutesAfterMidnight(timeString) { return moment.duration(timeString).asMinutes(); } function updateTimeByOffset(timeString, offsetSeconds) { return toGTFSTime(fromGTFSTime(timeString).add(offsetSeconds, "seconds")); } //#endregion //#region src/lib/errors.ts let GtfsToHtmlErrorCategory = /* @__PURE__ */ function(GtfsToHtmlErrorCategory) { GtfsToHtmlErrorCategory["CONFIG"] = "config"; GtfsToHtmlErrorCategory["DATABASE"] = "database"; GtfsToHtmlErrorCategory["GTFS"] = "gtfs"; GtfsToHtmlErrorCategory["FILE_SYSTEM"] = "file_system"; GtfsToHtmlErrorCategory["TEMPLATE"] = "template"; GtfsToHtmlErrorCategory["QUERY"] = "query"; GtfsToHtmlErrorCategory["VALIDATION"] = "validation"; GtfsToHtmlErrorCategory["INTERNAL"] = "internal"; return GtfsToHtmlErrorCategory; }({}); /** * Error codes are a public API contract and should remain stable. */ let GtfsToHtmlErrorCode = /* @__PURE__ */ function(GtfsToHtmlErrorCode) { GtfsToHtmlErrorCode["CONFIG_INVALID"] = "GTFS_TO_HTML_CONFIG_INVALID"; GtfsToHtmlErrorCode["CONFIG_FILE_NOT_FOUND"] = "GTFS_TO_HTML_CONFIG_FILE_NOT_FOUND"; GtfsToHtmlErrorCode["CONFIG_PARSE_FAILED"] = "GTFS_TO_HTML_CONFIG_PARSE_FAILED"; GtfsToHtmlErrorCode["CONFIG_DATE_INVALID"] = "GTFS_TO_HTML_CONFIG_DATE_INVALID"; GtfsToHtmlErrorCode["CONFIG_MISSING_AGENCIES"] = "GTFS_TO_HTML_CONFIG_MISSING_AGENCIES"; GtfsToHtmlErrorCode["DATABASE_OPEN_FAILED"] = "GTFS_TO_HTML_DATABASE_OPEN_FAILED"; GtfsToHtmlErrorCode["GTFS_IMPORT_FAILED"] = "GTFS_TO_HTML_GTFS_IMPORT_FAILED"; GtfsToHtmlErrorCode["FILE_SYSTEM_WRITE_FAILED"] = "GTFS_TO_HTML_FILE_SYSTEM_WRITE_FAILED"; GtfsToHtmlErrorCode["OUTPUT_DIRECTORY_NOT_EMPTY"] = "GTFS_TO_HTML_OUTPUT_DIRECTORY_NOT_EMPTY"; GtfsToHtmlErrorCode["QUERY_RESULT_NOT_FOUND"] = "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND"; GtfsToHtmlErrorCode["QUERY_RESULT_AMBIGUOUS"] = "GTFS_TO_HTML_QUERY_RESULT_AMBIGUOUS"; GtfsToHtmlErrorCode["QUERY_INVALID"] = "GTFS_TO_HTML_QUERY_INVALID"; GtfsToHtmlErrorCode["TIMETABLE_GENERATION_FAILED"] = "GTFS_TO_HTML_TIMETABLE_GENERATION_FAILED"; return GtfsToHtmlErrorCode; }({}); var GtfsToHtmlError = class extends Error { code; category; isOperational; details; constructor(message, options) { super(message, { cause: options.cause }); this.name = "GtfsToHtmlError"; this.code = options.code; this.category = options.category; this.isOperational = options.isOperational ?? true; this.details = options.details; } }; function isGtfsToHtmlError(error) { if (!error || typeof error !== "object") return false; const candidate = error; return candidate.name === "GtfsToHtmlError" && typeof candidate.message === "string" && typeof candidate.code === "string" && typeof candidate.category === "string" && typeof candidate.isOperational === "boolean"; } /** * GTFS parsing failures can come from parsing, validation or GTFS zip structure checks. */ function isGtfsParsingError(error) { return isGtfsError(error) && [ GtfsErrorCategory.PARSE, GtfsErrorCategory.VALIDATION, GtfsErrorCategory.ZIP ].includes(error.category); } function toGtfsToHtmlError(error, fallback) { if (isGtfsToHtmlError(error)) return error; return new GtfsToHtmlError(fallback.message, { ...fallback, cause: error }); } function formatGtfsToHtmlError(error, options = { verbosity: "developer" }) { if (!isGtfsToHtmlError(error)) { const message = error instanceof Error ? error.message : String(error); return options.verbosity === "user" ? message : `UNKNOWN_ERROR: ${message}`; } if (options.verbosity === "user") return error.message; return [ `${error.code}: ${error.message}`, `category=${error.category}`, error.details ? `details=${JSON.stringify(error.details)}` : null ].filter(Boolean).join(" | "); } //#endregion //#region src/lib/log-utils.ts 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 ("url" in agency) logText.push(`Source: ${agency.url}`); else if ("path" in agency) 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(`\n${formatWarning(text)}\n`); }; } function logError(config) { if (config.logFunction) return config.logFunction; return (text) => { process.stdout.write(`\n${formatError(text)}\n`); }; } function formatWarning(text) { const warningMessage = `${colors.underline("Warning")}: ${text}`; return colors.yellow(warningMessage); } function formatError(error, options = {}) { const verbosity = options.verbosity ?? "developer"; const sourceLabel = isGtfsToHtmlError(error) ? "GTFS-to-HTML" : isGtfsError(error) ? "GTFS" : null; const messageText = isGtfsToHtmlError(error) ? formatGtfsToHtmlError(error, { verbosity }) : isGtfsError(error) ? formatGtfsError(error, { verbosity }) : error instanceof Error ? error.message : String(error); const labeledMessage = sourceLabel ? `[${sourceLabel}] ${messageText}` : messageText; const errorMessage = `${colors.underline("Error")}: ${labeledMessage.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(["📄 Timetable Pages", String(stats.timetablePages)], ["🕑 Timetables", String(stats.timetables)], ["📅 Calendar Service IDs", String(stats.calendars)], ["🔄 Routes", String(stats.routes)], ["🚍 Trips", String(stats.trips)], ["🛑 Stops", String(stats.stops)], ["⛔️ Warnings", String(stats.warnings.length)]); log(config)(table.toString()); }; } const generateProgressBarString = (barTotal, barProgress, size = 40) => { const line = "-"; const slider = "="; if (!barTotal) throw new GtfsToHtmlError("Total value is either not provided or invalid", { code: "GTFS_TO_HTML_QUERY_INVALID", category: "validation", details: { field: "barTotal", value: barTotal } }); if (!barProgress && barProgress !== 0) throw new GtfsToHtmlError("Current value is either not provided or invalid", { code: "GTFS_TO_HTML_QUERY_INVALID", category: "validation", details: { field: "barProgress", value: barProgress } }); if (isNaN(barTotal)) throw new GtfsToHtmlError("Total value is not an integer", { code: "GTFS_TO_HTML_QUERY_INVALID", category: "validation", details: { field: "barTotal", value: barTotal } }); if (isNaN(barProgress)) throw new GtfsToHtmlError("Current value is not an integer", { code: "GTFS_TO_HTML_QUERY_INVALID", category: "validation", details: { field: "barProgress", value: barProgress } }); if (isNaN(size)) throw new GtfsToHtmlError("Size is not an integer", { code: "GTFS_TO_HTML_QUERY_INVALID", category: "validation", details: { field: "size", value: size } }); if (barProgress > barTotal) return slider.repeat(size + 2); const percentage = barProgress / barTotal; const progress = Math.round(size * percentage); const emptyProgress = size - progress; return slider.repeat(progress) + line.repeat(emptyProgress); }; 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}", String(barProgress)).replace("{total}", String(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); } }; } /** * Formats an error's stack trace for terminal output * @param {Error | string} error - The error object or message to format * @returns {string} Dimmed stack trace, or an empty string if none is available * @example * const formattedStack = formatStackTrace(new Error('Connection failed')); * console.error(formattedStack); */ function formatStackTrace(error) { if (error instanceof Error && error.stack) return colors.dim(error.stack); return ""; } //#endregion //#region src/lib/trip-id-utils.ts const getBaseTripId = (tripId) => tripId.replace(/_freq_\d+$/, ""); const getBaseTripIds = (trips) => Array.from(new Set(trips.map((trip) => getBaseTripId(trip.trip_id)))); //#endregion //#region src/lib/geojson-utils.ts const mergeGeojson = (...geojsons) => featureCollection(geojsons.flatMap((geojson) => geojson.features)); const truncateGeoJSONDecimals = (geojson, config) => { for (const feature of geojson.features) if (feature.geometry.type === "Point") feature.geometry.coordinates = feature.geometry.coordinates.map((number) => round(number, config.coordinatePrecision ?? 5)); else if (feature.geometry.type === "LineString") feature.geometry.coordinates = feature.geometry.coordinates.map((coordinate) => coordinate.map((number) => round(number, config.coordinatePrecision ?? 5))); else if (feature.geometry.type === "MultiLineString") feature.geometry.coordinates = feature.geometry.coordinates.map((linestring) => linestring.map((coordinate) => coordinate.map((number) => round(number, config.coordinatePrecision ?? 5)))); return geojson; }; function getTimetableGeoJSON(timetable, config) { const tripIds = getBaseTripIds(timetable.orderedTrips); const shapesGeojsons = timetable.route_ids.map((routeId) => getShapesAsGeoJSON({ route_id: routeId, direction_id: timetable.direction_id, trip_id: tripIds })); const stopsGeojsons = timetable.route_ids.map((routeId) => getStopsAsGeoJSON({ route_id: routeId, direction_id: timetable.direction_id, trip_id: tripIds })); const geojson = mergeGeojson(...shapesGeojsons, ...stopsGeojsons); let simplifiedGeojson; try { simplifiedGeojson = simplify(geojson, { tolerance: 1 / 10 ** (config.coordinatePrecision ?? 5), 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 ?? 5), highQuality: true }); } catch { logWarning(config)("Unable to simplify geojson"); simplifiedGeojson = geojson; } return truncateGeoJSONDecimals(simplifiedGeojson, config); } //#endregion //#region src/lib/template-functions.ts var template_functions_exports = /* @__PURE__ */ __exportAll({ formatTripName: () => formatTripName, formatTripNameForCSV: () => formatTripNameForCSV, getNotesForStop: () => getNotesForStop, getNotesForStoptime: () => getNotesForStoptime, getNotesForTimetableLabel: () => getNotesForTimetableLabel, getNotesForTrip: () => getNotesForTrip, hasNotesOrNotices: () => hasNotesOrNotices, timetableHasDifferentDays: () => timetableHasDifferentDays, timetablePageHasDifferentDays: () => timetablePageHasDifferentDays, timetablePageHasDifferentLabels: () => timetablePageHasDifferentLabels }); 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; } //#endregion //#region package.json var package_default = { name: "gtfs-to-html", version: "2.13.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": "tsdown && node scripts/copy-browser-assets.js", "typecheck": "tsc --noEmit", "start": "node ./dist/app", "prepare": "husky && pnpm run typecheck && pnpm run build", "prepack": "husky && pnpm run typecheck && pnpm run build", "prepublishOnly": "pnpm run typecheck" }, dependencies: { "@turf/helpers": "^7.3.5", "@turf/simplify": "^7.3.5", "archiver": "^8.0.0", "cli-table": "^0.3.11", "css.escape": "^1.5.1", "csv-stringify": "^6.8.1", "express": "^5.2.1", "gtfs": "^4.20.0", "js-beautify": "^2.0.3", "lodash-es": "^4.18.1", "marked": "^18.0.7", "moment": "^2.30.1", "pug": "^3.0.4", "puppeteer": "^25.4.0", "sanitize-filename": "^1.6.4", "sqlstring": "^2.3.3", "toposort": "^2.0.2", "xss": "^1.0.15", "yargs": "^18.1.0", "yoctocolors": "^2.2.0" }, devDependencies: { "@maplibre/maplibre-gl-geocoder": "^1.9.4", "@types/archiver": "^8.0.0", "@types/cli-table": "^0.3.4", "@types/css.escape": "^1.5.2", "@types/express": "^5.0.6", "@types/js-beautify": "^1.14.3", "@types/lodash-es": "^4.17.12", "@types/node": "^26", "@types/pug": "^2.0.10", "@types/sqlstring": "^2.3.2", "@types/toposort": "^2.0.7", "@types/yargs": "^17.0.35", "anchorme": "^3.0.8", "gtfs-realtime-pbf-js-module": "^1.0.0", "husky": "^9.1.7", "lint-staged": "^17.2.0", "maplibre-gl": "^5.24.0", "pbf": "^5.1.2", "prettier": "^3.9.6", "tsdown": "^0.22.14", "typescript": "^7.0.2" }, engines: { "node": ">= 22" }, packageManager: "pnpm@11.17.0", "release-it": { "github": { "release": true }, "plugins": { "@release-it/keep-a-changelog": { "filename": "CHANGELOG.md" } }, "hooks": { "after:bump": "pnpm run build" } }, prettier: { "singleQuote": true }, "lint-staged": { "*.js": "prettier --write", "*.ts": "prettier --write", "*.json": "prettier --write" } }; //#endregion //#region src/lib/utils.ts const { version } = package_default; const isTimepoint = (stoptime) => { if (isNullOrEmpty(stoptime.timepoint)) return !isNullOrEmpty(stoptime.arrival_time) && !isNullOrEmpty(stoptime.departure_time); return stoptime.timepoint === 1; }; const getLongestTripStoptimes = (trips, config) => { return maxBy(trips.map((trip) => trip.stoptimes.filter((stoptime) => { if (config.showOnlyTimepoint === true) return isTimepoint(stoptime); return true; })), (stoptimes) => size(stoptimes)); }; const 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 every(trips, (trip) => trip.stoptimes.find((tripStoptime) => tripStoptime.stop_id === stoptime.stop_id && tripStoptime.arrival_time !== null)); }); return commonStoptime ? commonStoptime.stop_id : null; }; const 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); else { const existingTrip = uniqueTrips.get(tripSignature); if (!existingTrip) continue; if (!existingTrip.additional_service_ids) existingTrip.additional_service_ids = []; existingTrip.additional_service_ids.push(trip.service_id); uniqueTrips.set(tripSignature, existingTrip); } } return Array.from(uniqueTrips.values()); }; const 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 firstStopId = first(getLongestTripStoptimes(trips, config))?.stop_id ?? ""; sortedTrips = sortTripsByStoptimeAtStop(trips, firstStopId); } else if (config.sortingAlgorithm === "last") { const lastStopId = last(getLongestTripStoptimes(trips, config))?.stop_id ?? ""; sortedTrips = sortTripsByStoptimeAtStop(trips, lastStopId); } return sortedTrips ?? []; }; const sortTripsByStoptimeAtStop = (trips, stopId) => sortBy(trips, (trip) => { const stoptime = find(trip.stoptimes, { stop_id: stopId }); return stoptime ? timeToSeconds(stoptime.departure_time) : void 0; }); const getCalendarDatesForTimetable = (timetable, config) => { const calendarDates = getCalendarDates({ service_id: timetable.service_ids }, [], [["date", "ASC"]]); const start = moment(timetable.start_date, "YYYYMMDD"); const end = moment(timetable.end_date, "YYYYMMDD"); const excludedDates = /* @__PURE__ */ new Set(); const includedDates = /* @__PURE__ */ new Set(); for (const calendarDate of calendarDates) if (moment(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)) }; }; const getDaysFromCalendars = (calendars) => { const days = { 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(days)) days[day] = days[day] | calendar[day]; return days; }; const getDirectionHeadsignFromTimetable = (timetable) => { const trips = getTrips({ direction_id: timetable.direction_id, route_id: timetable.route_ids }, ["trip_headsign"]); if (trips.length === 0) return ""; return flow(countBy, entries, partialRight(maxBy, last), head)(compact(trips.map((trip) => trip.trip_headsign))); }; const getTimetableNotesForTimetable = (timetable, config) => { const noteReferences = [ ...getTimetableNotesReferences({ timetable_id: timetable.timetable_id }), ...getTimetableNotesReferences({ route_id: timetable.routes.map((route) => route.route_id), timetable_id: null }), ...getTimetableNotesReferences({ trip_id: getBaseTripIds(timetable.orderedTrips) }), ...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 === 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((stop) => stop.stop_id === noteReference.stop_id); if (!stop) continue; if (stop.trips.find((trip) => trip.stop_sequence === noteReference.stop_sequence)) 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] : String(symbolIndex - symbols.length); symbolIndex += 1; } return sortBy(usedNoteReferences.map((noteReference) => ({ ...noteReference, ...notes.find((note) => note.note_id === noteReference.note_id) })), "symbol"); }; const 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 }; }; const createTimetable = ({ route, directionId, tripHeadsign, calendars, calendarDates }) => { const serviceIds = uniq([...calendars?.map((calendar) => calendar.service_id) ?? [], ...calendarDates?.map((calendarDate) => calendarDate.service_id) ?? []]); const days = { 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(days, getDaysFromCalendars(calendars)); startDate = parseInt(moment.min(calendars.map((calendar) => moment(calendar.start_date, "YYYYMMDD"))).format("YYYYMMDD"), 10); endDate = parseInt(moment.max(calendars.map((calendar) => moment(calendar.end_date, "YYYYMMDD"))).format("YYYYMMDD"), 10); } return { timetable_id: formatTimetableId({ routeIds: [route.route_id], directionId, days, dates: calendarDates?.map((calendarDate) => calendarDate.date) }), route_ids: [route.route_id], direction_id: directionId ?? null, direction_name: tripHeadsign ?? null, 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, ...days }; }; const 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 calendarGroups = groupBy(orderBy(calendars, calendarToCalendarCode, "desc"), calendarToCalendarCode); const calendarDateGroups = groupBy(calendarDates, "service_id"); const timetables = []; for (const uniqueTripDirection of uniqueTripDirections) { for (const calendars of Object.values(calendarGroups)) if (trips.filter((trip) => some(calendars, { service_id: trip.service_id })).length > 0) timetables.push(createTimetable({ route, directionId: uniqueTripDirection.direction_id, tripHeadsign: uniqueTripDirection.trip_headsign, calendars })); for (const calendarDates of Object.values(calendarDateGroups)) if (trips.filter((trip) => some(calendarDates, { service_id: trip.service_id })).length > 0) timetables.push(createTimetable({ route, directionId: uniqueTripDirection.direction_id, tripHeadsign: uniqueTripDirection.trip_headsign, calendarDates })); } if (timetables.length === 0) continue; if (config.groupTimetablesIntoPages === true) timetablePages.push(createTimetablePage({ timetablePageId: `route_${route.route_id}`, timetables, config })); else for (const timetable of timetables) timetablePages.push(createTimetablePage({ timetablePageId: timetable.timetable_id, timetables: [timetable], config })); } return timetablePages; }; const 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; }; const 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) { if (fromGTFSTime(stoptime.departure_time).diff(fromGTFSTime(stoptime.arrival_time), "minutes") < config.showArrivalOnDifference) continue; const stopId = stoptime.stop_id ?? ""; const index = stopIds.indexOf(stopId); if (index === 0 || index === stopIds.length - 1) continue; if (stopId === stopIds[index + 1] || stopId === stopIds[index - 1]) continue; stopIds.splice(index, 0, stopId); } return stopIds; }; const 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 stopIds = (getLongestTripStoptimes(timetable.orderedTrips, config) ?? []).map((stoptime) => stoptime.stop_id); const missingStopIds = difference(Array.from(new Set(timetable.orderedTrips.flatMap((trip) => trip.stoptimes.map((stoptime) => stoptime.stop_id)))), Array.from(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); } }; const 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 GtfsToHtmlError(`No stop found found for stop_id=${stopId} in timetable_id=${timetable.timetable_id}`, { code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND", category: "query", details: { entity: "stop", stopId, timetableId: 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((stop) => stop.stop_id === stopAttribute.stop_id); if (stop) stop.stop_city = stopAttribute.stop_city; } } return orderedStops; }; const getCalendarsFromConfig = (config) => { const db = openDb(config); let whereClause = ""; const whereClauses = []; if (config.endDate) { if (!moment(config.endDate).isValid()) throw new GtfsToHtmlError(`Invalid endDate=${config.endDate} in config.json`, { code: "GTFS_TO_HTML_CONFIG_DATE_INVALID", category: "config", details: { field: "endDate", value: config.endDate } }); whereClauses.push(`start_date <= ${sqlString.escape(moment(config.endDate).format("YYYYMMDD"))}`); } if (config.startDate) { if (!moment(config.startDate).isValid()) throw new GtfsToHtmlError(`Invalid startDate=${config.startDate} in config.json`, { code: "GTFS_TO_HTML_CONFIG_DATE_INVALID", category: "config", details: { field: "startDate", value: config.startDate } }); whereClauses.push(`end_date >= ${sqlString.escape(moment(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 calendarDatesQuery = serviceIds.length > 0 ? `SELECT * FROM calendar_dates WHERE exception_type = 1 AND service_id NOT IN (${serviceIds.map((serviceId) => sqlString.escape(serviceId)).join(", ")})` : "SELECT * FROM calendar_dates WHERE exception_type = 1"; return { calendars, calendarDates: db.prepare(calendarDatesQuery).all() }; }; const getCalendarsFromTimetable = (timetable, config) => { const db = openDb(config); let whereClause = ""; const whereClauses = []; if (timetable.end_date) { if (!moment(timetable.end_date, "YYYYMMDD", true).isValid()) throw new GtfsToHtmlError(`Invalid end_date=${timetable.end_date} for timetable_id=${timetable.timetable_id}`, { code: "GTFS_TO_HTML_QUERY_INVALID", category: "validation", details: { field: "end_date", value: timetable.end_date, timetableId: timetable.timetable_id } }); whereClauses.push(`start_date <= ${sqlString.escape(timetable.end_date)}`); } if (timetable.start_date) { if (!moment(timetable.start_date, "YYYYMMDD", true).isValid()) throw new GtfsToHtmlError(`Invalid start_date=${timetable.start_date} for timetable_id=${timetable.timetable_id}`, { code: "GTFS_TO_HTML_QUERY_INVALID", category: "validation", details: { field: "start_date", value: timetable.start_date, timetableId: timetable.timetable_id } }); whereClauses.push(`end_date >= ${sqlString.escape(timetable.start_date)}`); } const dayQueries = reduce(getDaysFromCalendars([timetable]), (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(); }; const getCalendarDatesForDateRange = (startDate, endDate, config) => { const db = openDb(config); const whereClauses = []; if (endDate) whereClauses.push(`date <= ${sqlString.escape(endDate)}`); if (startDate) whereClauses.push(`date >= ${sqlString.escape(startDate)}`); const whereClause = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(" AND ")}` : ""; return db.prepare(`SELECT service_id, date, exception_type FROM calendar_dates${whereClause}`).all(); }; const getAllStationStopIds = (stopId) => { const stops = getStops({ stop_id: stopId }); if (stops.length === 0) throw new GtfsToHtmlError(`No stop found for stop_id=${stopId}`, { code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND", category: "query", details: { entity: "stop", 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((stop) => stop.stop_id)]; }; const 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 GtfsToHtmlError(`No stoptimes found found for trip_id=${blockTrip.trip_id}`, { code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND", category: "query", details: { entity: "stoptime", tripId: blockTrip.trip_id } }); blockTrip.firstStoptime = first(stopTimes); blockTrip.lastStoptime = last(stopTimes); } return sortBy(trips, (trip) => trip.firstStoptime?.departure_timestamp ?? 0); }; const addTripContinuation = (trip, timetable) => { if (!trip.block_id || trip.stoptimes.length === 0) return; const maxContinuesAsWaitingTimeSeconds = 3600; const firstStoptime = first(trip.stoptimes); const lastStoptime = last(trip.stoptimes); if (!firstStoptime || !lastStoptime) return; const firstStopIds = getAllStationStopIds(firstStoptime.stop_id ?? ""); const lastStopIds = getAllStationStopIds(lastStoptime.stop_id ?? ""); const blockTrips = getTripsWithSameBlock(trip, timetable); const previousTrip = findLast(blockTrips, (blockTrip) => (blockTrip.lastStoptime?.arrival_timestamp ?? 0) <= (firstStoptime.departure_timestamp ?? 0)); if (previousTrip && previousTrip.route_id !== trip.route_id && (previousTrip.lastStoptime?.arrival_timestamp ?? 0) >= (firstStoptime.departure_timestamp ?? 0) - maxContinuesAsWaitingTimeSeconds && firstStopIds.includes(previousTrip.lastStoptime?.stop_id ?? "")) { previousTrip.route = getRoutes({ route_id: previousTrip.route_id })[0]; trip.continues_from_route = previousTrip; } const nextTrip = find(blockTrips, (blockTrip) => (blockTrip.firstStoptime?.departure_timestamp ?? 0) >= (lastStoptime.arrival_timestamp ?? 0)); if (nextTrip && nextTrip.route_id !== trip.route_id && (nextTrip.firstStoptime?.departure_timestamp ?? 0) <= (lastStoptime.arrival_timestamp ?? 0) + maxContinuesAsWaitingTimeSeconds && lastStopIds.includes(nextTrip.firstStoptime?.stop_id ?? "")) { nextTrip.route = getRoutes({ route_id: nextTrip.route_id })[0]; trip.continues_as_route = nextTrip; } }; const filterTrips = (timetable, calendars, 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); const timetableDays = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ].filter((day) => timetable[day] === 1); if (timetableDays.length > 1) { const warnedServiceIds = /* @__PURE__ */ new Set(); for (const trip of filteredTrips) { const tripServiceIds = [trip.service_id, ...trip.additional_service_ids ?? []]; const tripCalendars = calendars.filter((c) => tripServiceIds.includes(c.service_id)); if (tripCalendars.length === 0) continue; const tripDays = getDaysFromCalendars(tripCalendars); if (timetableDays.filter((day) => (tripDays[day] ?? 0) !== 1).length > 0) { const serviceIdKey = tripServiceIds.sort().join("|"); if (!warnedServiceIds.has(serviceIdKey)) { warnedServiceIds.add(serviceIdKey); const tripDayList = formatDays(tripDays, config); const timetableDayList = formatDays(timetable, config); timetable.warnings?.push(`Timetable ${timetable.timetable_id} (Routes: ${timetable.routes.map((route) => route.route_short_name).join(", ")}) covers ${timetableDayList} but some trips (service_id=${tripServiceIds.join(", ")}) only run on ${tripDayList}. This may indicate a data issue in the GTFS or that you should generate separate timetables for different days of the week.`); } } } } return filteredTrips.map((trip) => { trip.dayList = formatDays(combineCalendars(calendars.filter((calendar) => { return [trip.service_id, ...trip.additional_service_ids || []].includes(calendar.service_id); }) ?? []), config); trip.dayListLong = formatDaysLong(trip.dayList, config); if (timetable.routes.length === 1) trip.route_short_name = timetable.routes[0].route_short_name; else trip.route_short_name = timetable.routes.find((route) => route.route_id === trip.route_id)?.route_short_name; return trip; }); }; const 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 = trip; 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 !== null && (trip.stoptimes[0].arrival_timestamp ?? 0) < timetable.start_timestamp) continue; if (timetable.end_timestamp !== null && (trip.stoptimes[0].arrival_timestamp ?? 0) >= timetable.end_timestamp) continue; 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, tripFrequencies, 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((stop) => stop.stop_id === stoptime.stop_id); if (stop?.parent_station) stoptime.stop_id = stop.parent_station; } } return sortTrips(formattedTrips, config); }; const formatTimetables = (timetables, config) => { const formattedTimetables = timetables.map((timetable) => { timetable.warnings = []; const dayList = formatDays(timetable, config); const calendars = getCalendarsFromTimetable(timetable, config); const serviceIds = /* @__PURE__ */ new Set(); for (const calendar of calendars) serviceIds.add(calendar.service_id); if (timetable.include_exceptions === 1) { const calendarDateGroups = groupBy(getCalendarDatesForDateRange(timetable.start_date, timetable.end_date, config), "service_id"); for (const [serviceId, calendarDateGroup] of Object.entries(calendarDateGroups)) { const calendar = calendars.find((c) => c.service_id === serviceId); if (ca