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
JavaScript
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