UNPKG

gtfs-to-html

Version:

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

1,587 lines (1,575 loc) 66.4 kB
var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/app/index.ts import { dirname as dirname2, join as join2 } from "path"; import { fileURLToPath as fileURLToPath2 } from "url"; import { readFileSync } from "fs"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { openDb as openDb2 } from "gtfs"; import express from "express"; import untildify2 from "untildify"; // src/lib/formatters.ts import { clone, find as find2, first as first2, groupBy as groupBy2, last as last2, omit, sortBy as sortBy2, zipObject } from "lodash-es"; import moment3 from "moment"; // src/lib/time-utils.ts import moment from "moment"; function fromGTFSTime(timeString) { const duration = moment.duration(timeString); return moment({ hour: duration.hours(), minute: duration.minutes(), second: duration.seconds() }); } function toGTFSTime(time) { return time.format("HH:mm:ss"); } function fromGTFSDate(gtfsDate) { return moment(gtfsDate, "YYYYMMDD"); } function toGTFSDate(date) { return moment(date).format("YYYYMMDD"); } function calendarToCalendarCode(c) { if (c.service_id) { return c.service_id; } return `${c.monday}${c.tuesday}${c.wednesday}${c.thursday}${c.friday}${c.saturday}${c.sunday}`; } function calendarCodeToCalendar(code) { const days2 = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ]; const calendar = {}; for (const [index, day] of days2.entries()) { calendar[day] = code[index]; } return calendar; } function secondsAfterMidnight(timeString) { return moment.duration(timeString).asSeconds(); } function minutesAfterMidnight(timeString) { return moment.duration(timeString).asMinutes(); } function updateTimeByOffset(timeString, offsetSeconds) { const newTime = fromGTFSTime(timeString); return toGTFSTime(newTime.add(offsetSeconds, "seconds")); } // src/lib/utils.ts import { cloneDeep, compact, countBy, difference, entries, every as every2, find, findLast, first, flatMap as flatMap2, flattenDeep, flow, groupBy, head, last, maxBy, partialRight, reduce, size, some, sortBy, uniq, uniqBy, zip } from "lodash-es"; import { getCalendarDates, getTrips, getTimetableNotesReferences, getTimetableNotes, getRoutes, getCalendars, getTimetableStopOrders, getStops, getStopAttributes, getStoptimes, getFrequencies, getTimetables, getTimetablePages, getAgencies as getAgencies2, openDb } from "gtfs"; import { stringify } from "csv-stringify"; import moment2 from "moment"; import sqlString from "sqlstring"; import toposort from "toposort"; // src/lib/file-utils.ts import { dirname, join, resolve } from "path"; import { createWriteStream } from "fs"; import { fileURLToPath } from "url"; import { access, cp, copyFile, mkdir, readdir, readFile, rm } from "fs/promises"; import * as _ from "lodash-es"; import archiver from "archiver"; import beautify from "js-beautify"; import sanitizeHtml from "sanitize-html"; import { renderFile } from "pug"; import puppeteer from "puppeteer"; import sanitize from "sanitize-filename"; import untildify from "untildify"; import { marked } from "marked"; // src/lib/template-functions.ts var template_functions_exports = {}; __export(template_functions_exports, { formatHtmlId: () => formatHtmlId, formatTripName: () => formatTripName, formatTripNameForCSV: () => formatTripNameForCSV, getNotesForStop: () => getNotesForStop, getNotesForStoptime: () => getNotesForStoptime, getNotesForTimetableLabel: () => getNotesForTimetableLabel, getNotesForTrip: () => getNotesForTrip, hasNotesOrNotices: () => hasNotesOrNotices, timetableHasDifferentDays: () => timetableHasDifferentDays, timetablePageHasDifferentDays: () => timetablePageHasDifferentDays, timetablePageHasDifferentLabels: () => timetablePageHasDifferentLabels }); import { every } from "lodash-es"; function formatHtmlId(id) { return id.replace(/([^\w[\]{}.:-])\s?/g, ""); } 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; } // src/lib/file-utils.ts function getPathToViewsFolder(config2) { if (config2.templatePath) { return untildify(config2.templatePath); } const __dirname = dirname(fileURLToPath(import.meta.url)); let viewsFolderPath; if (__dirname.endsWith("/dist/bin") || __dirname.endsWith("/dist/app")) { viewsFolderPath = resolve(__dirname, "../../views/default"); } else if (__dirname.endsWith("/dist")) { viewsFolderPath = resolve(__dirname, "../views/default"); } else { viewsFolderPath = resolve(__dirname, "../../views/default"); } return viewsFolderPath; } function getPathToTemplateFile(templateFileName, config2) { const fullTemplateFileName = config2.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`; return join(getPathToViewsFolder(config2), fullTemplateFileName); } function generateFileName(timetable, config2, extension = "html") { let filename = timetable.timetable_id; for (const route of timetable.routes) { filename += isNullOrEmpty(route.route_short_name) ? `_${route.route_long_name.replace(/\s/g, "-")}` : `_${route.route_short_name.replace(/\s/g, "-")}`; } if (!isNullOrEmpty(timetable.direction_id)) { filename += `_${timetable.direction_id}`; } filename += `_${formatDays(timetable, config2).replace(/\s/g, "")}.${extension}`; return sanitize(filename).toLowerCase(); } async function renderTemplate(templateFileName, templateVars, config2) { const templatePath = getPathToTemplateFile(templateFileName, config2); const html = await renderFile(templatePath, { _, md: (text) => sanitizeHtml(marked.parseInline(text)), ...template_functions_exports, formatRouteColor, formatRouteTextColor, ...templateVars }); if (config2.beautify === true) { return beautify.html_beautify(html, { indent_size: 2 }); } return html; } // src/lib/geojson-utils.ts import { getShapesAsGeoJSON, getStopsAsGeoJSON } from "gtfs"; import { flatMap } from "lodash-es"; import simplify from "@turf/simplify"; import { featureCollection, round } from "@turf/helpers"; // src/lib/log-utils.ts import { clearLine, cursorTo } from "readline"; import { noop } from "lodash-es"; import * as colors from "yoctocolors"; import { getAgencies, getFeedInfo } from "gtfs"; import Table from "cli-table"; function logWarning(config2) { if (config2.logFunction) { return config2.logFunction; } return (text) => { process.stdout.write(` ${formatWarning(text)} `); }; } function formatWarning(text) { const warningMessage = `${colors.underline("Warning")}: ${text}`; return colors.yellow(warningMessage); } // src/lib/geojson-utils.ts var mergeGeojson = (...geojsons) => featureCollection(flatMap(geojsons, (geojson) => geojson.features)); var truncateGeoJSONDecimals = (geojson, config2) => { for (const feature of geojson.features) { if (feature.geometry.coordinates) { if (feature.geometry.type.toLowerCase() === "point") { feature.geometry.coordinates = feature.geometry.coordinates.map( (number) => round(number, config2.coordinatePrecision) ); } else if (feature.geometry.type.toLowerCase() === "linestring") { feature.geometry.coordinates = feature.geometry.coordinates.map( (coordinate) => coordinate.map( (number) => round(number, config2.coordinatePrecision) ) ); } else if (feature.geometry.type.toLowerCase() === "multilinestring") { feature.geometry.coordinates = feature.geometry.coordinates.map( (linestring) => linestring.map( (coordinate) => coordinate.map( (number) => round(number, config2.coordinatePrecision) ) ) ); } } } return geojson; }; function getTimetableGeoJSON(timetable, config2) { const shapesGeojsons = timetable.route_ids.map( (routeId) => getShapesAsGeoJSON({ route_id: routeId, direction_id: timetable.direction_id, trip_id: timetable.orderedTrips.map((trip) => trip.trip_id) }) ); const stopsGeojsons = timetable.route_ids.map( (routeId) => getStopsAsGeoJSON({ route_id: routeId, direction_id: timetable.direction_id, trip_id: timetable.orderedTrips.map((trip) => trip.trip_id) }) ); const geojson = mergeGeojson(...shapesGeojsons, ...stopsGeojsons); let simplifiedGeojson; try { simplifiedGeojson = simplify(geojson, { tolerance: 1 / 10 ** config2.coordinatePrecision, highQuality: true }); } catch { timetable.warnings.push( `Timetable ${timetable.timetable_id} - Unable to simplify geojson` ); simplifiedGeojson = geojson; } return truncateGeoJSONDecimals(simplifiedGeojson, config2); } function getAgencyGeoJSON(config2) { const shapesGeojsons = getShapesAsGeoJSON(); const stopsGeojsons = getStopsAsGeoJSON(); const geojson = mergeGeojson(shapesGeojsons, stopsGeojsons); let simplifiedGeojson; try { simplifiedGeojson = simplify(geojson, { tolerance: 1 / 10 ** config2.coordinatePrecision, highQuality: true }); } catch { logWarning(config2)("Unable to simplify geojson"); simplifiedGeojson = geojson; } return truncateGeoJSONDecimals(simplifiedGeojson, config2); } // package.json var package_default = { name: "gtfs-to-html", version: "2.10.15", 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" ], type: "module", main: "./dist/index.js", types: "./dist/index.d.ts", files: [ "dist", "docker", "examples", "views/default", "config-sample.json" ], bin: { "gtfs-to-html": "dist/bin/gtfs-to-html.js" }, scripts: { build: "tsup", start: "node ./dist/app", prepare: "husky" }, dependencies: { "@turf/helpers": "^7.2.0", "@turf/simplify": "^7.2.0", anchorme: "^3.0.8", archiver: "^7.0.1", "cli-table": "^0.3.11", "csv-stringify": "^6.6.0", express: "^5.1.0", gtfs: "^4.17.5", "gtfs-realtime-pbf-js-module": "^1.0.0", "js-beautify": "^1.15.4", "lodash-es": "^4.17.21", marked: "^16.1.1", moment: "^2.30.1", pbf: "^4.0.1", "pretty-error": "^4.0.0", pug: "^3.0.3", puppeteer: "^24.15.0", "sanitize-filename": "^1.6.3", "sanitize-html": "^2.17.0", sqlstring: "^2.3.3", "timer-machine": "^1.1.0", toposort: "^2.0.2", untildify: "^5.0.0", yargs: "^18.0.0", yoctocolors: "^2.1.1" }, devDependencies: { "@types/archiver": "^6.0.3", "@types/express": "^5.0.3", "@types/insane": "^1.0.0", "@types/js-beautify": "^1.14.3", "@types/lodash-es": "^4.17.12", "@types/morgan": "^1.9.10", "@types/node": "^22", "@types/pug": "^2.0.10", "@types/sanitize-html": "^2.16.0", "@types/timer-machine": "^1.1.3", "@types/yargs": "^17.0.33", husky: "^9.1.7", "lint-staged": "^16.1.2", prettier: "^3.6.2", tsup: "^8.5.0", typescript: "^5.8.3" }, engines: { node: ">= 20.11.0" }, "release-it": { github: { release: true }, plugins: { "@release-it/keep-a-changelog": { filename: "CHANGELOG.md" } }, hooks: { "after:bump": "npm run build" } }, prettier: { singleQuote: true }, "lint-staged": { "*.js": "prettier --write", "*.ts": "prettier --write", "*.json": "prettier --write" } }; // src/lib/utils.ts var { version } = package_default; var isTimepoint = (stoptime) => { if (isNullOrEmpty(stoptime.timepoint)) { return !isNullOrEmpty(stoptime.arrival_time) && !isNullOrEmpty(stoptime.departure_time); } return stoptime.timepoint === 1; }; var getLongestTripStoptimes = (trips, config2) => { const filteredTripStoptimes = trips.map( (trip) => trip.stoptimes.filter((stoptime) => { if (config2.showOnlyTimepoint === true) { return isTimepoint(stoptime); } return true; }) ); return maxBy(filteredTripStoptimes, (stoptimes) => size(stoptimes)); }; var findCommonStopId = (trips, config2) => { const longestTripStoptimes = getLongestTripStoptimes(trips, config2); if (!longestTripStoptimes) { return null; } const commonStoptime = longestTripStoptimes.find((stoptime, idx) => { if (idx === 0 && stoptime.stop_id === last(longestTripStoptimes).stop_id) { return false; } if (isNullOrEmpty(stoptime.arrival_time)) { return false; } return every2( trips, (trip) => trip.stoptimes.find( (tripStoptime) => tripStoptime.stop_id === stoptime.stop_id && tripStoptime.arrival_time !== null ) ); }); return commonStoptime ? commonStoptime.stop_id : null; }; var deduplicateTrips = (trips) => { if (trips.length <= 1) { return trips; } const uniqueTrips = /* @__PURE__ */ new Map(); for (const trip of trips) { const tripSignature = trip.stoptimes.map( (stoptime) => `${stoptime.stop_id}|${stoptime.departure_time}|${stoptime.arrival_time}` ).join("|"); if (!uniqueTrips.has(tripSignature)) { uniqueTrips.set(tripSignature, trip); } } return Array.from(uniqueTrips.values()); }; var sortTrips = (trips, config2) => { let sortedTrips; let commonStopId; if (config2.sortingAlgorithm === "common") { commonStopId = findCommonStopId(trips, config2); if (commonStopId) { sortedTrips = sortTripsByStoptimeAtStop(trips, commonStopId); } else { sortedTrips = sortTrips(trips, { ...config2, sortingAlgorithm: "beginning" }); } } else if (config2.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"], ["asc", "asc"] ); } else if (config2.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"], ["asc", "asc"] ); } else if (config2.sortingAlgorithm === "first") { const longestTripStoptimes = getLongestTripStoptimes(trips, config2); const firstStopId = first(longestTripStoptimes).stop_id; sortedTrips = sortTripsByStoptimeAtStop(trips, firstStopId); } else if (config2.sortingAlgorithm === "last") { const longestTripStoptimes = getLongestTripStoptimes(trips, config2); const lastStopId = last(longestTripStoptimes).stop_id; sortedTrips = sortTripsByStoptimeAtStop(trips, lastStopId); } if (config2.showDuplicateTrips === false) { return deduplicateTrips(sortedTrips ?? []); } return sortedTrips ?? []; }; var sortTripsByStoptimeAtStop = (trips, stopId) => sortBy(trips, (trip) => { const stoptime = find(trip.stoptimes, { stop_id: stopId }); return stoptime ? timeToSeconds(stoptime.departure_time) : void 0; }); var getCalendarDatesForTimetable = (timetable, config2) => { const calendarDates = getCalendarDates( { service_id: timetable.service_ids }, [], [["date", "ASC"]] ); const start = fromGTFSDate(timetable.start_date); const end = fromGTFSDate(timetable.end_date); const excludedDates = /* @__PURE__ */ new Set(); const includedDates = /* @__PURE__ */ new Set(); for (const calendarDate of calendarDates) { if (moment2(calendarDate.date, "YYYYMMDD").isBetween(start, end)) { if (calendarDate.exception_type === 1) { includedDates.add(formatDate(calendarDate, config2.dateFormat)); } else if (calendarDate.exception_type === 2) { excludedDates.add(formatDate(calendarDate, config2.dateFormat)); } } } const includedAndExcludedDates = new Set( [...excludedDates].filter((date) => includedDates.has(date)) ); return { excludedDates: [...excludedDates].filter( (date) => !includedAndExcludedDates.has(date) ), includedDates: [...includedDates].filter( (date) => !includedAndExcludedDates.has(date) ) }; }; var getDaysFromCalendars = (calendars) => { const days2 = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 }; for (const calendar of calendars) { for (const day of Object.keys(days2)) { days2[day] = days2[day] | calendar[day]; } } return days2; }; var getDirectionHeadsignFromTimetable = (timetable) => { const trips = getTrips( { direction_id: timetable.direction_id, route_id: timetable.route_ids }, ["trip_headsign"] ); if (trips.length === 0) { return ""; } const mostCommonHeadsign = flow( countBy, entries, partialRight(maxBy, last), head )(compact(trips.map((trip) => trip.trip_headsign))); return mostCommonHeadsign; }; var getTimetableNotesForTimetable = (timetable, config2) => { const noteReferences = [ // Get all notes for this timetable. ...getTimetableNotesReferences({ timetable_id: timetable.timetable_id }), // Get all notes for this route. ...getTimetableNotesReferences({ route_id: timetable.routes.map((route) => route.route_id), timetable_id: null }), // Get all notes for all trips in this timetable. ...getTimetableNotesReferences({ trip_id: timetable.orderedTrips.map((trip) => trip.trip_id) }), // Get all notes for all stops in this timetable. ...getTimetableNotesReferences({ stop_id: timetable.stops.map((stop) => stop.stop_id), trip_id: null, route_id: null, timetable_id: null }) ]; const usedNoteReferences = []; for (const noteReference of noteReferences) { if (noteReference.stop_sequence === "" || noteReference.stop_sequence === null) { usedNoteReferences.push(noteReference); continue; } if (noteReference.stop_id === "" || noteReference.stop_id === null) { timetable.warnings.push( `Timetable Note Reference for note_id=${noteReference.note_id} has a \`stop_sequence\` but no \`stop_id\` - ignoring` ); continue; } const stop = timetable.stops.find( (stop2) => stop2.stop_id === noteReference.stop_id ); if (!stop) { continue; } const tripWithMatchingStopSequence = stop.trips.find( (trip) => trip.stop_sequence === noteReference.stop_sequence ); if (tripWithMatchingStopSequence) { usedNoteReferences.push(noteReference); } } const notes = getTimetableNotes({ note_id: usedNoteReferences.map((noteReference) => noteReference.note_id) }); const symbols = "abcdefghijklmnopqrstuvwxyz".split(""); let symbolIndex = 0; for (const note of notes) { if (note.symbol === "" || note.symbol === null) { note.symbol = symbolIndex < symbols.length - 1 ? symbols[symbolIndex] : symbolIndex - symbols.length; symbolIndex += 1; } } const formattedNotes = usedNoteReferences.map((noteReference) => ({ ...noteReference, ...notes.find((note) => note.note_id === noteReference.note_id) })); return sortBy(formattedNotes, "symbol"); }; var convertTimetableToTimetablePage = (timetable, config2) => { if (!timetable.routes) { timetable.routes = getRoutes({ route_id: timetable.route_ids }); } const filename = generateFileName(timetable, config2, "html"); return { timetable_page_id: timetable.timetable_id, timetable_page_label: timetable.timetable_label, timetables: [timetable], filename }; }; var convertRouteToTimetablePage = (route, direction, calendars, calendarDates, config2) => { const timetable = { route_ids: [route.route_id], direction_id: direction ? direction.direction_id : void 0, direction_name: direction ? direction.trip_headsign : void 0, routes: [route], include_exceptions: calendarDates && calendarDates.length > 0 ? 1 : 0, service_id: calendarDates && calendarDates.length > 0 ? calendarDates[0].service_id : null, service_notes: null, timetable_label: null, start_time: null, end_time: null, orientation: null, timetable_sequence: null, show_trip_continuation: null, start_date: null, end_date: null }; if (calendars && calendars.length > 0) { Object.assign(timetable, getDaysFromCalendars(calendars)); timetable.start_date = toGTFSDate( moment2.min( calendars.map((calendar) => fromGTFSDate(calendar.start_date)) ) ); timetable.end_date = toGTFSDate( moment2.max(calendars.map((calendar) => fromGTFSDate(calendar.end_date))) ); } timetable.timetable_id = formatTimetableId(timetable); return convertTimetableToTimetablePage(timetable, config2); }; var convertRoutesToTimetablePages = (config2) => { const db = openDb(config2); const routes = getRoutes(); let whereClause = ""; const whereClauses = []; if (config2.endDate) { whereClauses.push( `start_date <= ${sqlString.escape(toGTFSDate(moment2(config2.endDate)))}` ); } if (config2.startDate) { whereClauses.push( `end_date >= ${sqlString.escape(toGTFSDate(moment2(config2.startDate)))}` ); } if (whereClauses.length > 0) { whereClause = `WHERE ${whereClauses.join(" AND ")}`; } const calendars = db.prepare(`SELECT * FROM calendar ${whereClause}`).all(); const serviceIds = calendars.map((calendar) => calendar.service_id); const calendarDates = db.prepare( `SELECT * FROM calendar_dates WHERE exception_type = 1 AND service_id NOT IN (${serviceIds.map((serviceId) => `'${serviceId}'`).join(", ")})` ).all(); const timetablePages = routes.map((route) => { const trips = getTrips( { route_id: route.route_id }, ["trip_headsign", "direction_id", "trip_id", "service_id"] ); const directions = uniqBy(trips, (trip) => trip.direction_id); const dayGroups = groupBy(calendars, calendarToCalendarCode); const calendarDateGroups = groupBy(calendarDates, "service_id"); return directions.map((direction) => [ Object.values(dayGroups).map((calendars2) => { const tripsForCalendars = trips.filter( (trip) => some(calendars2, { service_id: trip.service_id }) ); if (tripsForCalendars.length > 0) { return convertRouteToTimetablePage( route, direction, calendars2, null, config2 ); } }), Object.values(calendarDateGroups).map((calendarDates2) => { const tripsForCalendarDates = trips.filter( (trip) => some(calendarDates2, { service_id: trip.service_id }) ); if (tripsForCalendarDates.length > 0) { return convertRouteToTimetablePage( route, direction, null, calendarDates2, config2 ); } }) ]); }); return compact(flattenDeep(timetablePages)); }; var generateTripsByFrequencies = (trip, frequencies, config2) => { const formattedFrequencies = frequencies.map( (frequency) => formatFrequency(frequency, config2) ); const resetTrip = resetStoptimesToMidnight(trip); const trips = []; for (const frequency of formattedFrequencies) { const startSeconds = secondsAfterMidnight(frequency.start_time); const endSeconds = secondsAfterMidnight(frequency.end_time); for (let offset = startSeconds; offset < endSeconds; offset += frequency.headway_secs) { const newTrip = cloneDeep(resetTrip); trips.push({ ...newTrip, trip_id: `${resetTrip.trip_id}_freq_${trips.length}`, stoptimes: updateStoptimesByOffset(newTrip, offset) }); } } return trips; }; var duplicateStopsForDifferentArrivalDeparture = (stopIds, timetable, config2) => { if (config2.showArrivalOnDifference === null) { return stopIds; } for (const trip of timetable.orderedTrips) { for (const stoptime of trip.stoptimes) { const timepointDifference = fromGTFSTime(stoptime.departure_time).diff( fromGTFSTime(stoptime.arrival_time), "minutes" ); if (timepointDifference < config2.showArrivalOnDifference) { continue; } const index = stopIds.indexOf(stoptime.stop_id); if (index === 0 || index === stopIds.length - 1) { continue; } if (stoptime.stop_id === stopIds[index + 1] || stoptime.stop_id === stopIds[index - 1]) { continue; } stopIds.splice(index, 0, stoptime.stop_id); } } return stopIds; }; var getStopOrder = (timetable, config2) => { 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 (config2.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 && config2.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, config2 ); } catch { const longestTripStoptimes = getLongestTripStoptimes( timetable.orderedTrips, config2 ); const stopIds = longestTripStoptimes.map( (stoptime) => stoptime.stop_id ); const missingStopIds = difference( new Set( timetable.orderedTrips.flatMap( (trip) => trip.stoptimes.map((stoptime) => stoptime.stop_id) ) ), new Set(stopIds) ); if (missingStopIds.length > 0) { timetable.warnings.push( `Timetable ${timetable.timetable_id} stops are unable to be topologically sorted and has no \`timetable_stop_order.txt\`. Falling back to using the using the stop order from trip with most stoptimes, but this does not include stop_ids ${formatListForDisplay(missingStopIds)}. Try manually specifying stops with \`timetable_stop_order.txt\`. Read more at https://gtfstohtml.com/docs/timetable-stop-order` ); } return duplicateStopsForDifferentArrivalDeparture( stopIds, timetable, config2 ); } }; var getStopsForTimetable = (timetable, config2) => { if (timetable.orderedTrips.length === 0) { return []; } const orderedStopIds = getStopOrder(timetable, config2); const orderedStops = orderedStopIds.map((stopId, index) => { const stops = getStops({ stop_id: stopId }); if (stops.length === 0) { throw new Error( `No stop found found for stop_id=${stopId} in timetable_id=${timetable.timetable_id}` ); } const stop = { ...stops[0], trips: [] }; if (index < orderedStopIds.length - 1 && stopId === orderedStopIds[index + 1]) { stop.type = "arrival"; } else if (index > 0 && stopId === orderedStopIds[index - 1]) { stop.type = "departure"; } return stop; }); if (timetable.showStopCity) { const stopAttributes = getStopAttributes({ stop_id: orderedStopIds }); for (const stopAttribute of stopAttributes) { const stop = orderedStops.find( (stop2) => stop2.stop_id === stopAttribute.stop_id ); if (stop) { stop.stop_city = stopAttribute.stop_city; } } } return orderedStops; }; var getCalendarsFromTimetable = (timetable) => { const db = openDb(); let whereClause = ""; const whereClauses = []; if (timetable.end_date) { if (!moment2(timetable.end_date, "YYYYMMDD", true).isValid()) { throw new Error( `Invalid end_date=${timetable.end_date} for timetable_id=${timetable.timetable_id}` ); } whereClauses.push(`start_date <= ${sqlString.escape(timetable.end_date)}`); } if (timetable.start_date) { if (!moment2(timetable.start_date, "YYYYMMDD", true).isValid()) { throw new Error( `Invalid start_date=${timetable.start_date} for timetable_id=${timetable.timetable_id}` ); } whereClauses.push(`end_date >= ${sqlString.escape(timetable.start_date)}`); } const days2 = getDaysFromCalendars([timetable]); const dayQueries = reduce( days2, (memo, value, key) => { if (value === 1) { memo.push(`${key} = 1`); } return memo; }, [] ); if (dayQueries.length > 0) { whereClauses.push(`(${dayQueries.join(" OR ")})`); } if (whereClauses.length > 0) { whereClause = `WHERE ${whereClauses.join(" AND ")}`; } return db.prepare(`SELECT * FROM calendar ${whereClause}`).all(); }; var getCalendarDatesServiceIds = (startDate, endDate) => { const db = openDb(); const whereClauses = ["exception_type = 1"]; if (endDate) { whereClauses.push(`date <= ${sqlString.escape(endDate)}`); } if (startDate) { whereClauses.push(`date >= ${sqlString.escape(startDate)}`); } const calendarDates = db.prepare( `SELECT DISTINCT service_id FROM calendar_dates WHERE ${whereClauses.join( " AND " )}` ).all(); return calendarDates.map((calendarDate) => calendarDate.service_id); }; var getAllStationStopIds = (stopId) => { const stops = getStops({ stop_id: stopId }); if (stops.length === 0) { throw new Error(`No stop found for stop_id=${stopId}`); } const stop = stops[0]; if (isNullOrEmpty(stop.parent_station)) { return [stopId]; } const stopsInParentStation = getStops( { parent_station: stop.parent_station }, ["stop_id"] ); return [ stop.parent_station, ...stopsInParentStation.map((stop2) => stop2.stop_id) ]; }; var getTripsWithSameBlock = (trip, timetable) => { const trips = getTrips( { block_id: trip.block_id, service_id: timetable.service_ids }, ["trip_id", "route_id"] ); for (const blockTrip of trips) { const stopTimes = getStoptimes( { trip_id: blockTrip.trip_id }, [], [["stop_sequence", "ASC"]] ); if (stopTimes.length === 0) { throw new Error( `No stoptimes found found for trip_id=${blockTrip.trip_id}` ); } blockTrip.firstStoptime = first(stopTimes); blockTrip.lastStoptime = last(stopTimes); } return sortBy(trips, (trip2) => trip2.firstStoptime.departure_timestamp); }; var addTripContinuation = (trip, timetable) => { if (!trip.block_id || trip.stoptimes.length === 0) { return; } const maxContinuesAsWaitingTimeSeconds = 60 * 60; const firstStoptime = first(trip.stoptimes); const firstStopIds = getAllStationStopIds(firstStoptime.stop_id); const lastStoptime = last(trip.stoptimes); const lastStopIds = getAllStationStopIds(lastStoptime.stop_id); const blockTrips = getTripsWithSameBlock(trip, timetable); const previousTrip = findLast( blockTrips, (blockTrip) => blockTrip.lastStoptime.arrival_timestamp <= firstStoptime.departure_timestamp ); if (previousTrip && previousTrip.route_id !== trip.route_id && previousTrip.lastStoptime.arrival_timestamp >= firstStoptime.departure_timestamp - maxContinuesAsWaitingTimeSeconds && firstStopIds.includes(previousTrip.lastStoptime.stop_id)) { const routes = getRoutes({ route_id: previousTrip.route_id }); previousTrip.route = routes[0]; trip.continues_from_route = previousTrip; } const nextTrip = find( blockTrips, (blockTrip) => blockTrip.firstStoptime.departure_timestamp >= lastStoptime.arrival_timestamp ); if (nextTrip && nextTrip.route_id !== trip.route_id && nextTrip.firstStoptime.departure_timestamp <= lastStoptime.arrival_timestamp + maxContinuesAsWaitingTimeSeconds && lastStopIds.includes(nextTrip.firstStoptime.stop_id)) { const routes = getRoutes({ route_id: nextTrip.route_id }); nextTrip.route = routes[0]; trip.continues_as_route = nextTrip; } }; var filterTrips = (timetable) => { 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); return filteredTrips; }; var getTripsForTimetable = (timetable, calendars, config2) => { const tripQuery = { route_id: timetable.route_ids, service_id: timetable.service_ids }; if (!isNullOrEmpty(timetable.direction_id)) { tripQuery.direction_id = timetable.direction_id; } const trips = getTrips(tripQuery); if (trips.length === 0) { timetable.warnings.push( `No trips found for route_id=${timetable.route_ids.join( "_" )}, direction_id=${timetable.direction_id}, service_ids=${JSON.stringify( timetable.service_ids )}, timetable_id=${timetable.timetable_id}` ); } const frequencies = getFrequencies({ trip_id: trips.map((trip) => trip.trip_id) }); timetable.service_ids = uniq(trips.map((trip) => trip.service_id)); const formattedTrips = []; for (const trip of trips) { const formattedTrip = formatTrip(trip, timetable, calendars, config2); formattedTrip.stoptimes = getStoptimes( { trip_id: formattedTrip.trip_id }, [], [["stop_sequence", "ASC"]] ); if (formattedTrip.stoptimes.length === 0) { timetable.warnings.push( `No stoptimes found for trip_id=${formattedTrip.trip_id}, route_id=${timetable.route_ids.join("_")}, timetable_id=${timetable.timetable_id}` ); } if (timetable.start_timestamp !== "" && timetable.start_timestamp !== null && timetable.start_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp < timetable.start_timestamp) { return; } if (timetable.end_timestamp !== "" && timetable.end_timestamp !== null && timetable.end_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp >= timetable.end_timestamp) { return; } if (timetable.show_trip_continuation) { addTripContinuation(formattedTrip, timetable); if (formattedTrip.continues_as_route) { timetable.has_continues_as_route = true; } if (formattedTrip.continues_from_route) { timetable.has_continues_from_route = true; } } const tripFrequencies = frequencies.filter( (frequency) => frequency.trip_id === trip.trip_id ); if (tripFrequencies.length === 0) { formattedTrips.push(formattedTrip); } else { const frequencyTrips = generateTripsByFrequencies( formattedTrip, frequencies, config2 ); formattedTrips.push(...frequencyTrips); timetable.frequencies = frequencies; timetable.frequencyExactTimes = some(frequencies, { exact_times: 1 }); } } if (config2.useParentStation) { const stopIds = []; for (const trip of formattedTrips) { for (const stoptime of trip.stoptimes) { stopIds.push(stoptime.stop_id); } } const stops = getStops( { stop_id: uniq(stopIds) }, ["parent_station", "stop_id"] ); for (const trip of formattedTrips) { for (const stoptime of trip.stoptimes) { const stop = stops.find((stop2) => stop2.stop_id === stoptime.stop_id); if (stop?.parent_station) { stoptime.stop_id = stop.parent_station; } } } } return sortTrips(formattedTrips, config2); }; var formatTimetables = (timetables, config2) => { const formattedTimetables = timetables.map((timetable) => { timetable.warnings = []; const dayList = formatDays(timetable, config2); const calendars = getCalendarsFromTimetable(timetable); let serviceIds = calendars.map((calendar) => calendar.service_id); if (timetable.include_exceptions === 1) { const calendarDatesServiceIds = getCalendarDatesServiceIds( timetable.start_date, timetable.end_date ); serviceIds = uniq([...serviceIds, ...calendarDatesServiceIds]); } Object.assign(timetable, { noServiceSymbolUsed: false, requestDropoffSymbolUsed: false, noDropoffSymbolUsed: false, requestPickupSymbolUsed: false, noPickupSymbolUsed: false, interpolatedStopSymbolUsed: false, showStopCity: config2.showStopCity, showStopDescription: config2.showStopDescription, noServiceSymbol: config2.noServiceSymbol, requestDropoffSymbol: config2.requestDropoffSymbol, noDropoffSymbol: config2.noDropoffSymbol, requestPickupSymbol: config2.requestPickupSymbol, noPickupSymbol: config2.noPickupSymbol, interpolatedStopSymbol: config2.interpolatedStopSymbol, orientation: timetable.orientation || config2.defaultOrientation, service_ids: serviceIds, dayList, dayListLong: formatDaysLong(dayList, config2) }); timetable.orderedTrips = getTripsForTimetable(timetable, calendars, config2); timetable.stops = getStopsForTimetable(timetable, config2); timetable.calendarDates = getCalendarDatesForTimetable(timetable, config2); timetable.timetable_label = formatTimetableLabel(timetable); timetable.notes = getTimetableNotesForTimetable(timetable, config2); if (config2.showMap) { timetable.geojson = getTimetableGeoJSON(timetable, config2); } timetable.orderedTrips = filterTrips(timetable); timetable.stops = formatStops(timetable, config2); return timetable; }); if (config2.allowEmptyTimetables) { return formattedTimetables; } return formattedTimetables.filter( (timetable) => timetable.orderedTrips.length > 0 ); }; function getTimetablePagesForAgency(config2) { const timetables = mergeTimetablesWithSameId(getTimetables()); if (timetables.length === 0) { return convertRoutesToTimetablePages(config2); } const timetablePages = getTimetablePages( {}, [], [["timetable_page_id", "ASC"]] ); if (timetablePages.length === 0) { return timetables.map( (timetable) => convertTimetableToTimetablePage(timetable, config2) ); } const routes = getRoutes(); return timetablePages.map((timetablePage) => { timetablePage.timetables = sortBy( timetables.filter( (timetable) => timetable.timetable_page_id === timetablePage.timetable_page_id ), "timetable_sequence" ); for (const timetable of timetablePage.timetables) { timetable.routes = routes.filter( (route) => timetable.route_ids.includes(route.route_id) ); } return timetablePage; }); } var getTimetablePageById = (timetablePageId, config2) => { const timetablePages = getTimetablePages({ timetable_page_id: timetablePageId }); const timetables = mergeTimetablesWithSameId(getTimetables()); if (timetablePages.length > 1) { throw new Error( `Multiple timetable_pages found for timetable_page_id=${timetablePageId}` ); } if (timetablePages.length === 1) { const timetablePage = timetablePages[0]; timetablePage.timetables = sortBy( timetables.filter( (timetable) => timetable.timetable_page_id === timetablePageId ), "timetable_sequence" ); for (const timetable of timetablePage.timetables) { timetable.routes = getRoutes({ route_id: timetable.route_ids }); } return timetablePage; } if (timetables.length > 0) { const timetablePageTimetables = timetables.filter( (timetable) => timetable.timetable_id === timetablePageId ); if (timetablePageTimetables.length === 0) { throw new Error( `No timetable found for timetable_page_id=${timetablePageId}` ); } return convertTimetableToTimetablePage(timetablePageTimetables[0], config2); } let calendarCode; let calendars; let calendarDates; let serviceId; let directionId = ""; const parts = timetablePageId.split("|"); if (parts.length > 2) { directionId = Number.parseInt(parts.pop(), 10); calendarCode = parts.pop(); } else if (parts.length > 1) { directionId = null; calendarCode = parts.pop(); } const routeId = parts.join("|"); const routes = getRoutes({ route_id: routeId }); const trips = getTrips( { route_id: routeId, direction_id: directionId }, ["trip_headsign", "direction_id"] ); const directions = uniqBy(trips, (trip) => trip.direction_id); if (directions.length === 0) { throw new Error( `No trips found for timetable_page_id=${timetablePageId} route_id=${routeId} direction_id=${directionId}` ); } if (/^[01]*$/.test(calendarCode)) { calendars = getCalendars({ ...calendarCodeToCalendar(calendarCode) }); } else { serviceId = calendarCode; calendarDates = getCalendarDates({ exception_type: 1, service_id: serviceId }); } return convertRouteToTimetablePage( routes[0], directions[0], calendars, calendarDates, config2 ); }; function setDefaultConfig(initialConfig) { const defaults = { allowEmptyTimetables: false, beautify: false, coordinatePrecision: 5, dateFormat: "MMM D, YYYY", daysShortStrings: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], daysStrings: [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" ], defaultOrientation: "vertical", interpolatedStopSymbol: "\u2022", interpolatedStopText: "Estimated time of arrival", gtfsToHtmlVersion: version, linkStopUrls: false, mapStyleUrl: "https://tiles.openfreemap.org/styles/liberty", menuType: "jump", noDropoffSymbol: "\u2021", noDropoffText: "No drop off available", noHead: false, noPickupSymbol: "**", noPickupText: "No pickup available", noServiceSymbol: "-", noServiceText: "No service at this stop", outputFormat: "html", overwriteExistingFiles: true, requestDropoffSymbol: "\u2020", requestDropoffText: "Must request drop off", requestPickupSymbol: "***", requestPickupText: "Request stop - call for pickup", serviceNotProvidedOnText: "Service not provided on", serviceProvidedOnText: "Service provided on", showArrivalOnDifference: 0.2, showCalendarExceptions: true, showDuplicateTrips: false, showMap: false, showOnlyTimepoint: false, showRouteTitle: true, showStopCity: false, showStopDescription: false, showStoptimesForRequestStops: true, skipImport: false, sortingAlgorithm: "common", timeFormat: "h:mma", useParentStation: true, verbose: true, zipOutput: false }; const config2 = Object.assign(defaults, initialConfig); if (config2.outputFormat === "pdf") { config2.noHead = false; config2.menuType = "none"; } config2.hasGtfsRealtimeVehiclePositions = config2.agencies.some( (agency) => agency.realtimeVehiclePositions?.url ); config2.hasGtfsRealtimeTripUpdates = config2.agencies.some( (agency) => agency.realtimeTripUpdates?.url ); config2.hasGtfsRealtimeAlerts = config2.agencies.some( (agency) => agency.realtimeAlerts?.url ); return config2; } function getFormattedTimetablePage(timetablePageId, config2) { const timetablePage = getTimetablePageById( timetablePageId, config2 ); const timetableRoutes = getRoutes( { route_id: timetablePage.route_ids }, ["agency_id"] ); const consolidatedTimetables = formatTimetables( timetablePage.timetables, config2 ); for (const timetable of consolidatedTimetables) { if (isNullOrEmpty(timetable.direction_name)) { timetable.direction_name = getDirectionHeadsignFromTimetable(timetable); } if (!timetable.routes) { timetable.routes = getRoutes({ route_id: timetable.route_ids }); } } const uniqueRoutes = uniqBy( flatMap2(consolidatedTimetables, (timetable) => timetable.routes), "route_id" ); const formattedTimetablePage = { ...timetablePage, consolidatedTimetables, dayList: formatDays(getDaysFromCalendars(consolidatedTimetables), config2), dayLists: uniq( consolidatedTimetables.map((timetable) => timetable.dayList) ), route_ids: uniqueRoutes.map((route) => route.route_id), agency_ids: uniq(compact(timetableRoutes.map((route) => route.agency_id))), filename: timetablePage.filename ?? `${timetablePage.timetable_page_id}.html`, timetable_page_label: timetable