UNPKG

transit-departures-widget

Version:

Build a realtime transit departures tool from GTFS and GTFS-Realtime.

581 lines (574 loc) 18.3 kB
// src/app/index.ts import { dirname as dirname2, join as join3 } 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, importGtfs } from "gtfs"; import express from "express"; import { clone, omit } from "lodash-es"; import untildify2 from "untildify"; // src/lib/file-utils.ts import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { access, cp, copyFile, mkdir, readdir, readFile, rm } from "fs/promises"; import beautify from "js-beautify"; import pug from "pug"; import untildify from "untildify"; function getPathToThisModuleFolder() { const __dirname = dirname(fileURLToPath(import.meta.url)); let distFolderPath; if (__dirname.endsWith("/dist/bin") || __dirname.endsWith("/dist/app")) { distFolderPath = resolve(__dirname, "../../"); } else if (__dirname.endsWith("/dist")) { distFolderPath = resolve(__dirname, "../"); } else { distFolderPath = resolve(__dirname, "../../"); } return distFolderPath; } function getPathToViewsFolder(config2) { if (config2.templatePath) { return untildify(config2.templatePath); } return join(getPathToThisModuleFolder(), "views/widget"); } function getPathToTemplateFile(templateFileName, config2) { const fullTemplateFileName = config2.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`; return join(getPathToViewsFolder(config2), fullTemplateFileName); } async function renderFile(templateFileName, templateVars, config2) { const templatePath = getPathToTemplateFile(templateFileName, config2); const html = await pug.renderFile(templatePath, templateVars); if (config2.beautify === true) { return beautify.html_beautify(html, { indent_size: 2 }); } return html; } // src/lib/utils.ts import { openDb, getDirections, getRoutes, getStops, getTrips } from "gtfs"; import { groupBy, last, maxBy, size, sortBy, uniqBy } from "lodash-es"; import sqlString from "sqlstring-sqlite"; import toposort from "toposort"; // src/lib/logging/log.ts import { clearLine, cursorTo } from "readline"; import { noop } from "lodash-es"; import * as colors from "yoctocolors"; var formatWarning = (text) => { const warningMessage = `${colors.underline("Warning")}: ${text}`; return colors.yellow(warningMessage); }; var formatError = (error) => { const messageText = error instanceof Error ? error.message : error; const errorMessage = `${colors.underline("Error")}: ${messageText.replace( "Error: ", "" )}`; return colors.red(errorMessage); }; var logInfo = (config2) => { if (config2.verbose === false) { return noop; } if (config2.logFunction) { return config2.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); }; }; var logWarn = (config2) => { if (config2.logFunction) { return config2.logFunction; } return (text) => { process.stdout.write(` ${formatWarning(text)} `); }; }; var logError = (config2) => { if (config2.logFunction) { return config2.logFunction; } return (text) => { process.stdout.write(` ${formatError(text)} `); }; }; function createLogger(config2) { return { info: logInfo(config2), warn: logWarn(config2), error: logError(config2) }; } // src/lib/logging/messages.ts var messages = { noActiveCalendarsGlobal: "No active calendars found for the configured date range - returning empty routes and stops", noActiveCalendarsForRoute: (routeId) => `route_id ${routeId} has no active calendars in range - skipping directions`, noActiveCalendarsForDirection: (routeId, directionId) => `route_id ${routeId} direction ${directionId} has no active calendars in range - skipping stops`, routeHasNoDirections: (routeId) => `route_id ${routeId} has no directions - skipping`, stopNotFound: (routeId, directionId, stopId) => `stop_id ${stopId} for route ${routeId} direction ${directionId} not found - dropping` }; // src/lib/config/defaults.ts import { join as join2 } from "path"; import { I18n } from "i18n"; function setDefaultConfig(initialConfig) { const defaults = { beautify: false, noHead: false, refreshIntervalSeconds: 20, skipImport: false, timeFormat: "12hour", includeCoordinates: false, overwriteExistingFiles: true, verbose: true }; const config2 = Object.assign(defaults, initialConfig); const viewsFolderPath = getPathToViewsFolder(config2); const i18n = new I18n({ directory: join2(viewsFolderPath, "locales"), defaultLocale: config2.locale, updateFiles: false }); const configWithI18n = Object.assign(config2, { __: i18n.__ }); return configWithI18n; } // src/lib/utils.ts var getCalendarsForDateRange = (config2) => { const db = openDb(config2); let whereClause = ""; const whereClauses = []; if (config2.endDate) { whereClauses.push(`start_date <= ${sqlString.escape(config2.endDate)}`); } if (config2.startDate) { whereClauses.push(`end_date >= ${sqlString.escape(config2.startDate)}`); } if (whereClauses.length > 0) { whereClause = `WHERE ${whereClauses.join(" AND ")}`; } return db.prepare(`SELECT * FROM calendar ${whereClause}`).all(); }; function formatRouteName(route) { let routeName = ""; if (route.route_short_name !== null) { routeName += route.route_short_name; } if (route.route_short_name !== null && route.route_long_name !== null) { routeName += " - "; } if (route.route_long_name !== null) { routeName += route.route_long_name; } return routeName; } function getDirectionsForRoute(route, config2) { const logger = createLogger(config2); const db = openDb(config2); const directions = getDirections({ route_id: route.route_id }, [ "direction_id", "direction" ]).filter((direction) => direction.direction_id !== void 0).map((direction) => ({ direction_id: direction.direction_id, direction: direction.direction })); const calendars = getCalendarsForDateRange(config2); if (calendars.length === 0) { logger.warn(messages.noActiveCalendarsForRoute(route.route_id)); return []; } if (directions.length === 0) { const headsigns = db.prepare( `SELECT direction_id, trip_headsign, count(*) AS count FROM trips WHERE route_id = ? AND service_id IN (${calendars.map((calendar) => `'${calendar.service_id}'`).join(", ")}) GROUP BY direction_id, trip_headsign` ).all(route.route_id); for (const group of Object.values(groupBy(headsigns, "direction_id"))) { const mostCommonHeadsign = maxBy(group, "count"); directions.push({ direction_id: mostCommonHeadsign.direction_id, direction: config2.__("To {{{headsign}}}", { headsign: mostCommonHeadsign.trip_headsign }) }); } } return directions; } function sortStopIdsBySequence(stoptimes) { const stoptimesGroupedByTrip = groupBy(stoptimes, "trip_id"); try { const stopGraph = []; for (const tripStoptimes of Object.values(stoptimesGroupedByTrip)) { const sortedStopIds = sortBy(tripStoptimes, "stop_sequence").map( (stoptime) => stoptime.stop_id ); for (const [index, stopId] of sortedStopIds.entries()) { if (index === sortedStopIds.length - 1) { continue; } stopGraph.push([stopId, sortedStopIds[index + 1]]); } } return toposort( stopGraph ); } catch { } const longestTripStoptimes = maxBy( Object.values(stoptimesGroupedByTrip), (stoptimes2) => size(stoptimes2) ); if (!longestTripStoptimes) { return []; } return longestTripStoptimes.map((stoptime) => stoptime.stop_id); } function getStopsForDirection(route, direction, config2, stopCache) { const logger = createLogger(config2); const db = openDb(config2); const calendars = getCalendarsForDateRange(config2); if (calendars.length === 0) { logger.warn( messages.noActiveCalendarsForDirection( route.route_id, direction.direction_id ) ); return []; } const whereClause = formatWhereClauses({ direction_id: direction.direction_id, route_id: route.route_id, service_id: calendars.map((calendar) => calendar.service_id) }); const stoptimes = db.prepare( `SELECT stop_id, stop_sequence, trip_id FROM stop_times WHERE trip_id IN (SELECT trip_id FROM trips ${whereClause}) ORDER BY stop_sequence ASC` ).all(); const sortedStopIds = sortStopIdsBySequence(stoptimes); const deduplicatedStopIds = sortedStopIds.reduce( (memo, stopId) => { if (last(memo) !== stopId) { memo.push(stopId); } return memo; }, [] ); deduplicatedStopIds.pop(); const stopFields = [ "stop_id", "stop_name", "stop_code", "parent_station" ]; if (config2.includeCoordinates) { stopFields.push("stop_lat", "stop_lon"); } const missingStopIds = stopCache ? deduplicatedStopIds.filter((stopId) => !stopCache.has(stopId)) : deduplicatedStopIds; const fetchedStops = missingStopIds.length ? getStops( { stop_id: missingStopIds }, stopFields ) : []; if (stopCache) { for (const stop of fetchedStops) { stopCache.set(stop.stop_id, stop); } } return deduplicatedStopIds.map((stopId) => { const stop = stopCache?.get(stopId) ?? fetchedStops.find((candidate) => candidate.stop_id === stopId); if (!stop) { logger.warn( messages.stopNotFound(route.route_id, direction.direction_id, stopId) ); } return stop; }).filter(Boolean); } function generateTransitDeparturesWidgetHtml(config2) { const templateVars = { config: config2, __: config2.__ }; return renderFile("widget", templateVars, config2); } function generateTransitDeparturesWidgetJson(config2) { const logger = createLogger(config2); const calendars = getCalendarsForDateRange(config2); if (calendars.length === 0) { logger.warn(messages.noActiveCalendarsGlobal); return { routes: [], stops: [] }; } const routes = getRoutes(); const stops = []; const filteredRoutes = []; const stopCache = /* @__PURE__ */ new Map(); for (const route of routes) { const routeWithFullName = { ...route, route_full_name: formatRouteName(route) }; const directions = getDirectionsForRoute(routeWithFullName, config2); if (directions.length === 0) { logger.warn(messages.routeHasNoDirections(route.route_id)); continue; } const directionsWithData = directions.map((direction) => { const directionStops = getStopsForDirection( routeWithFullName, direction, config2, stopCache ); if (directionStops.length === 0) { return null; } stops.push(...directionStops); const trips = getTrips( { route_id: route.route_id, direction_id: direction.direction_id, service_id: calendars.map( (calendar) => calendar.service_id ) }, ["trip_id"] ); return { ...direction, stopIds: directionStops.map((stop) => stop.stop_id), tripIds: trips.map((trip) => trip.trip_id) }; }).filter(Boolean); if (directionsWithData.length === 0) { continue; } filteredRoutes.push({ ...routeWithFullName, directions: directionsWithData }); } const sortedRoutes = [...filteredRoutes].sort((a, b) => { const aShort = a.route_short_name ?? ""; const bShort = b.route_short_name ?? ""; const aNum = Number.parseInt(aShort, 10); const bNum = Number.parseInt(bShort, 10); if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && aNum !== bNum) { return aNum - bNum; } if (Number.isNaN(aNum) && !Number.isNaN(bNum)) { return 1; } if (!Number.isNaN(aNum) && Number.isNaN(bNum)) { return -1; } return aShort.localeCompare(bShort, void 0, { numeric: true, sensitivity: "base" }); }); const parentStationIds = new Set(stops.map((stop) => stop.parent_station)); const parentStationStops = getStops( { stop_id: Array.from(parentStationIds) }, ["stop_id", "stop_name", "stop_code", "parent_station"] ); stops.push( ...parentStationStops.map((stop) => ({ ...stop, is_parent_station: true })) ); const sortedStops = sortBy(uniqBy(stops, "stop_id"), "stop_name"); return { routes: arrayOfArrays(removeNulls(sortedRoutes)), stops: arrayOfArrays(removeNulls(sortedStops)) }; } function removeNulls(data) { if (Array.isArray(data)) { return data.map(removeNulls).filter((item) => item !== null && item !== void 0); } else if (data !== null && typeof data === "object" && Object.getPrototypeOf(data) === Object.prototype) { return Object.entries(data).reduce( (acc, [key, value]) => { const cleanedValue = removeNulls(value); if (cleanedValue !== null && cleanedValue !== void 0) { acc[key] = cleanedValue; } return acc; }, {} ); } else { return data; } } function arrayOfArrays(array) { if (array.length === 0) { return { fields: [], rows: [] }; } const fields = Array.from( array.reduce((fieldSet, item) => { Object.keys(item ?? {}).forEach((key) => fieldSet.add(key)); return fieldSet; }, /* @__PURE__ */ new Set()) ); return { fields, rows: array.map((item) => fields.map((field) => item?.[field] ?? null)) }; } function formatWhereClause(key, value) { if (Array.isArray(value)) { let whereClause = `${sqlString.escapeId(key)} IN (${value.filter((v) => v !== null).map((v) => sqlString.escape(v)).join(", ")})`; if (value.includes(null)) { whereClause = `(${whereClause} OR ${sqlString.escapeId(key)} IS NULL)`; } return whereClause; } if (value === null) { return `${sqlString.escapeId(key)} IS NULL`; } return `${sqlString.escapeId(key)} = ${sqlString.escape(value)}`; } function formatWhereClauses(query) { if (Object.keys(query).length === 0) { return ""; } const whereClauses = Object.entries(query).map( ([key, value]) => formatWhereClause(key, value) ); return `WHERE ${whereClauses.join(" AND ")}`; } // src/app/index.ts var argv = yargs(hideBin(process.argv)).option("c", { alias: "configPath", describe: "Path to config file", default: "./config.json", type: "string" }).parseSync(); var app = express(); var configPath = argv.configPath || join3(process.cwd(), "config.json"); var selectedConfig = JSON.parse(readFileSync(configPath, "utf8")); var config = setDefaultConfig(selectedConfig); config.noHead = false; config.assetPath = "/"; config.logFunction = console.log; try { openDb2(config); const gtfsPath = config.agency.gtfs_static_path; const gtfsUrl = config.agency.gtfs_static_url; if (!gtfsPath && !gtfsUrl) { throw new Error( "Missing GTFS source. Set `agency.gtfs_static_path` or `agency.gtfs_static_url` in config.json." ); } const agencyImportConfig = { exclude: config.agency.exclude, ...gtfsPath ? { path: gtfsPath } : { url: gtfsUrl } }; const gtfsImportConfig = { ...clone(omit(config, "agency")), agencies: [agencyImportConfig] }; await importGtfs(gtfsImportConfig); } catch (error) { console.error( `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists and run gtfs-to-html to import GTFS before running this app.` ); throw error; } app.set("views", getPathToViewsFolder(config)); app.set("view engine", "pug"); app.use((req, res, next) => { console.log(`${req.method} ${req.url}`); next(); }); var staticAssetPath = config.templatePath === void 0 ? getPathToViewsFolder(config) : untildify2(config.templatePath); app.use(express.static(staticAssetPath)); var frontendLibraryPaths = [ { route: "/js", package: "pbf", subPath: "dist" }, { route: "/js", package: "gtfs-realtime-pbf-js-module", subPath: "" }, { route: "/js", package: "accessible-autocomplete", subPath: "" }, { route: "/css", package: "accessible-autocomplete", subPath: "" } ]; var resolvePackagePath = (packageName, subPath) => { const packagePath = dirname2(fileURLToPath2(import.meta.resolve(packageName))); return subPath ? join3(packagePath, subPath) : packagePath; }; for (const { route, package: pkg, subPath } of frontendLibraryPaths) { app.use(route, express.static(resolvePackagePath(pkg, subPath))); } app.get("/", async (request, response, next) => { try { const html = await generateTransitDeparturesWidgetHtml(config); response.send(html); } catch (error) { next(error); } }); app.get("/data/routes.json", async (request, response, next) => { try { const { routes } = await generateTransitDeparturesWidgetJson(config); response.json(routes); } catch (error) { next(error); } }); app.get("/data/stops.json", async (request, response, next) => { try { const { stops } = await generateTransitDeparturesWidgetJson(config); response.json(stops); } catch (error) { next(error); } }); app.use((req, res) => { res.status(404).send("Not Found"); }); app.use( (err, req, res, next) => { console.error(err.stack); res.status(500).send("Something broke!"); } ); var startServer = async (port2) => { try { await new Promise((resolve2, reject) => { const server = app.listen(port2).once("listening", () => { console.log(`Express server listening on port ${port2}`); resolve2(); }).once("error", (err) => { if (err.code === "EADDRINUSE") { console.log(`Port ${port2} is in use, trying ${port2 + 1}`); server.close(); resolve2(startServer(port2 + 1)); } else { reject(err); } }); }); } catch (err) { console.error("Failed to start server:", err); process.exit(1); } }; var port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3e3; startServer(port); //# sourceMappingURL=index.js.map