UNPKG

transit-departures-widget

Version:

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

434 lines (430 loc) 14.2 kB
// src/lib/transit-departures-widget.ts import path from "path"; import { clone, omit } from "lodash-es"; import { writeFile } from "node:fs/promises"; import { importGtfs, openDb as openDb2 } from "gtfs"; import sanitize from "sanitize-filename"; import Timer from "timer-machine"; import untildify2 from "untildify"; // src/lib/file-utils.ts import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { access, cp, mkdir, readdir, readFile, rm } from "node:fs/promises"; import beautify from "js-beautify"; import pug from "pug"; import untildify from "untildify"; function getPathToViewsFolder(config) { if (config.templatePath) { return untildify(config.templatePath); } const __dirname = dirname(fileURLToPath(import.meta.url)); let viewsFolderPath; if (__dirname.endsWith("/dist/bin") || __dirname.endsWith("/dist/app")) { viewsFolderPath = resolve(__dirname, "../../views/widget"); } else if (__dirname.endsWith("/dist")) { viewsFolderPath = resolve(__dirname, "../views/widget"); } else { viewsFolderPath = resolve(__dirname, "views/widget"); } return viewsFolderPath; } function getPathToTemplateFile(templateFileName, config) { const fullTemplateFileName = config.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`; return join(getPathToViewsFolder(config), fullTemplateFileName); } async function prepDirectory(outputPath, config) { try { await access(outputPath); } catch (error) { try { await mkdir(outputPath, { recursive: true }); await mkdir(join(outputPath, "data")); } catch (error2) { if (error2?.code === "ENOENT") { throw new Error( `Unable to write to ${outputPath}. Try running this command from a writable directory.` ); } throw error2; } } const files = await readdir(outputPath); if (config.overwriteExistingFiles === false && files.length > 0) { throw new Error( `Output directory ${outputPath} is not empty. Please specify an empty directory.` ); } if (config.overwriteExistingFiles === true) { await rm(join(outputPath, "*"), { recursive: true, force: true }); } } async function copyStaticAssets(config, outputPath) { const viewsFolderPath = getPathToViewsFolder(config); const foldersToCopy = ["css", "js", "img"]; for (const folder of foldersToCopy) { if (await access(join(viewsFolderPath, folder)).then(() => true).catch(() => false)) { await cp(join(viewsFolderPath, folder), join(outputPath, folder), { recursive: true }); } } } async function renderFile(templateFileName, templateVars, config) { const templatePath = getPathToTemplateFile(templateFileName, config); const html = await pug.renderFile(templatePath, templateVars); if (config.beautify === true) { return beautify.html_beautify(html, { indent_size: 2 }); } return html; } // src/lib/log-utils.ts import { clearLine, cursorTo } from "node:readline"; import { noop } from "lodash-es"; import * as colors from "yoctocolors"; function log(config) { if (config.verbose === false) { return noop; } if (config.logFunction) { return config.logFunction; } return (text, overwrite) => { if (overwrite === true && process.stdout.isTTY) { clearLine(process.stdout, 0); cursorTo(process.stdout, 0); } else { process.stdout.write("\n"); } process.stdout.write(text); }; } function logWarning(config) { if (config.logFunction) { return config.logFunction; } return (text) => { process.stdout.write(` ${formatWarning(text)} `); }; } function logError(config) { if (config.logFunction) { return config.logFunction; } return (text) => { process.stdout.write(` ${formatError(text)} `); }; } function formatWarning(text) { const warningMessage = `${colors.underline("Warning")}: ${text}`; return colors.yellow(warningMessage); } function formatError(error) { const messageText = error instanceof Error ? error.message : error; const errorMessage = `${colors.underline("Error")}: ${messageText.replace( "Error: ", "" )}`; return colors.red(errorMessage); } // src/lib/utils.ts import { join as join2 } from "path"; 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"; import { I18n } from "i18n"; var getCalendarsForDateRange = (config) => { const db = openDb(config); let whereClause = ""; const whereClauses = []; if (config.endDate) { whereClauses.push(`start_date <= ${sqlString.escape(config.endDate)}`); } if (config.startDate) { whereClauses.push(`end_date >= ${sqlString.escape(config.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, config) { const db = openDb(config); const directions = getDirections({ route_id: route.route_id }, [ "direction_id", "direction" ]); const calendars = getCalendarsForDateRange(config); 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: config.__("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) ); return longestTripStoptimes.map((stoptime) => stoptime.stop_id); } function getStopsForDirection(route, direction, config) { const db = openDb(config); const calendars = getCalendarsForDateRange(config); 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 (config.includeCoordinates) { stopFields.push("stop_lat", "stop_lon"); } const stops = getStops({ stop_id: deduplicatedStopIds }, stopFields); return deduplicatedStopIds.map( (stopId) => stops.find((stop) => stop.stop_id === stopId) ); } function generateTransitDeparturesWidgetHtml(config) { const templateVars = { config, __: config.__ }; return renderFile("widget", templateVars, config); } function generateTransitDeparturesWidgetJson(config) { const routes = getRoutes(); const stops = []; const filteredRoutes = []; const calendars = getCalendarsForDateRange(config); for (const route of routes) { route.route_full_name = formatRouteName(route); const directions = getDirectionsForRoute(route, config); if (directions.length === 0) { logWarning(config)( `route_id ${route.route_id} has no directions - skipping` ); continue; } for (const direction of directions) { const directionStops = getStopsForDirection(route, direction, config); stops.push(...directionStops); direction.stopIds = directionStops.map((stop) => stop?.stop_id); const trips = getTrips( { route_id: route.route_id, direction_id: direction.direction_id, service_id: calendars.map( (calendar) => calendar.service_id ) }, ["trip_id"] ); direction.tripIds = trips.map((trip) => trip.trip_id); } route.directions = directions; filteredRoutes.push(route); } const sortedRoutes = sortBy( sortBy(filteredRoutes, (route) => route.route_short_name?.toLowerCase()), (route) => Number.parseInt(route.route_short_name, 10) ); 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; return stop; }) ); const sortedStops = sortBy(uniqBy(stops, "stop_id"), "stop_name"); return { routes: removeNulls(sortedRoutes), stops: removeNulls(sortedStops) }; } function removeNulls(data) { if (Array.isArray(data)) { return data.map(removeNulls).filter((item) => item !== null && item !== void 0); } else if (typeof data === "object" && data !== null) { 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 setDefaultConfig(initialConfig) { const defaults = { beautify: false, noHead: false, refreshIntervalSeconds: 20, skipImport: false, timeFormat: "12hour", includeCoordinates: false, overwriteExistingFiles: true, verbose: true }; const config = Object.assign(defaults, initialConfig); const viewsFolderPath = getPathToViewsFolder(config); const i18n = new I18n({ directory: join2(viewsFolderPath, "locales"), defaultLocale: config.locale, updateFiles: false }); const configWithI18n = Object.assign(config, { __: i18n.__ }); return configWithI18n; } 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/lib/transit-departures-widget.ts async function transitDeparturesWidget(initialConfig) { const config = setDefaultConfig(initialConfig); try { openDb2(config); } catch (error) { if (error?.code === "SQLITE_CANTOPEN") { logError(config)( `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.` ); } throw error; } if (!config.agency) { throw new Error("No agency defined in `config.json`"); } const timer = new Timer(); const agencyKey = config.agency.agency_key ?? "unknown"; const outputPath = config.outputPath ? untildify2(config.outputPath) : path.join(process.cwd(), "html", sanitize(agencyKey)); timer.start(); if (!config.skipImport) { const gtfsImportConfig = { ...clone(omit(config, "agency")), agencies: [ { agency_key: config.agency.agency_key, path: config.agency.gtfs_static_path, url: config.agency.gtfs_static_url } ] }; await importGtfs(gtfsImportConfig); } await prepDirectory(outputPath, config); if (config.noHead !== true) { await copyStaticAssets(config, outputPath); } log(config)(`${agencyKey}: Generating Transit Departures Widget HTML`); config.assetPath = ""; const { routes, stops } = generateTransitDeparturesWidgetJson(config); await writeFile( path.join(outputPath, "data", "routes.json"), JSON.stringify(routes, null, 2) ); await writeFile( path.join(outputPath, "data", "stops.json"), JSON.stringify(stops, null, 2) ); const html = await generateTransitDeparturesWidgetHtml(config); await writeFile(path.join(outputPath, "index.html"), html); timer.stop(); log(config)( `${agencyKey}: Transit Departures Widget HTML created at ${outputPath}` ); const seconds = Math.round(timer.time() / 1e3); log(config)(`${agencyKey}: HTML generation required ${seconds} seconds`); } var transit_departures_widget_default = transitDeparturesWidget; export { transit_departures_widget_default as default }; //# sourceMappingURL=index.js.map