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