transit-departures-widget
Version:
Build a realtime transit departures tool from GTFS and GTFS-Realtime.
581 lines (574 loc) • 18.3 kB
JavaScript
// 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