gtfs-to-blocks
Version:
Generate CSV of transit departure times organized by block_id in GTFS.
345 lines (339 loc) • 13.5 kB
JavaScript
import { join, resolve } from "node:path";
import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { noop, sortBy, uniq } from "lodash-es";
import { getCalendars, getDeadheadTimes, getOpsLocations, getRoutes, getStops, getStoptimes, importGtfs, openDb } from "gtfs";
import sanitize from "sanitize-filename";
import Timer from "timer-machine";
import { homedir } from "node:os";
import { clearLine, cursorTo } from "node:readline";
import PrettyError from "pretty-error";
import chalk from "chalk";
import Table from "cli-table";
import { stringify } from "csv-stringify";
import moment from "moment";
//#region src/lib/file-utils.ts
const homeDirectory = homedir();
async function getConfig(argv) {
try {
const data = await readFile(resolve(untildify(argv.configPath)), "utf8").catch((error) => {
console.error(/* @__PURE__ */ new Error(`Cannot find configuration file at \`${argv.configPath}\`. Use config-sample.json as a starting point, pass --configPath option`));
throw error;
});
const config = JSON.parse(data);
if (argv.skipImport === true) config.skipImport = argv.skipImport;
return config;
} catch (error) {
console.error(/* @__PURE__ */ new Error(`Cannot parse configuration file at \`${argv.configPath}\`. Check to ensure that it is valid JSON.`));
throw error;
}
}
async function prepDirectory(outputPath, config) {
try {
await access(outputPath);
} catch {
try {
await mkdir(outputPath, { recursive: true });
} catch (error) {
if (error?.code === "ENOENT") throw new Error(`Unable to write to ${outputPath}. Try running this command from a writable directory.`);
throw error;
}
}
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
});
}
/**
* Converts a tilde path to a full path
* @param pathWithTilde The path to convert
* @returns The full path
*/
function untildify(pathWithTilde) {
return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde;
}
//#endregion
//#region src/lib/log-utils.ts
new PrettyError().start();
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(`\n${formatWarning(text)}\n`);
};
}
function formatWarning(text) {
return `${chalk.yellow.underline("Warning")}${chalk.yellow(":")} ${chalk.yellow(text)}`;
}
function formatError(error) {
const message = error instanceof Error ? error.message : error;
return `${chalk.red.underline("Error")}${chalk.red(":")} ${chalk.red(message.replace("Error: ", ""))}`;
}
function logStats(config) {
if (config.logFunction) return noop;
return (stats) => {
const table = new Table({
colWidths: [40, 20],
head: ["Item", "Count"]
});
table.push(["🚍 Trips", stats.trips], ["🕑 Trip Segments", stats.tripSegments], ["⛔️ Warnings", stats.warnings.length]);
log(config)(table.toString());
};
}
const generateProgressBarString = (barTotal, barProgress, size = 40) => {
const line = "-";
const slider = "=";
if (!barTotal) throw new Error("Total value is either not provided or invalid");
if (!barProgress && barProgress !== 0) throw new Error("Current value is either not provided or invalid");
if (isNaN(barTotal)) throw new Error("Total value is not an integer");
if (isNaN(barProgress)) throw new Error("Current value is not an integer");
if (isNaN(size)) throw new Error("Size is not an integer");
if (barProgress > barTotal) return slider.repeat(size + 2);
const percentage = barProgress / barTotal;
const progress = Math.round(size * percentage);
const emptyProgress = size - progress;
return slider.repeat(progress) + line.repeat(emptyProgress);
};
function progressBar(formatString, barTotal, config) {
let barProgress = 0;
if (config.verbose === false) return {
increment: noop,
interrupt: noop
};
if (barTotal === 0) return null;
const renderProgressString = () => formatString.replace("{value}", barProgress).replace("{total}", barTotal).replace("{bar}", generateProgressBarString(barTotal, barProgress));
log(config)(renderProgressString(), true);
return {
interrupt(text) {
logWarning(config)(text);
logWarning(config)("");
},
increment() {
barProgress += 1;
log(config)(renderProgressString(), true);
}
};
}
//#endregion
//#region src/lib/utils.ts
function setDefaultConfig(initialConfig) {
const defaults = {
timeFormat: "HH:mm:ss",
date: moment().format("YYYYMMDD"),
includeDeadheads: true,
overwriteExistingFiles: true
};
return Object.assign(defaults, initialConfig);
}
async function generateCSV(tripSegments) {
const lines = [];
lines.push([
"Block ID",
"Route ID",
"Route",
"Trip ID",
"Direction ID",
"Days",
"Departure Location",
"Arrival Location",
"Departure Time",
"Arrival Time",
"Trip Headsign",
"Stop Headsign",
"Is Deadhead"
]);
for (const tripSegment of tripSegments) lines.push([
tripSegment.blockId,
tripSegment.routeId,
tripSegment.routeName,
tripSegment.tripId,
tripSegment.directionId,
tripSegment.dayList,
tripSegment.departureLocation,
tripSegment.arrivalLocation,
tripSegment.departureTime,
tripSegment.arrivalTime,
tripSegment.tripHeadsign,
tripSegment.stopHeadsign,
tripSegment.isDeadhead
]);
return stringify(lines);
}
function fromGTFSTime(timeString) {
const duration = moment.duration(timeString);
return moment({
hour: duration.hours(),
minute: duration.minutes(),
second: duration.seconds()
});
}
//#endregion
//#region src/lib/formatters.ts
const days = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
];
function formatDays(calendar) {
const daysShort = [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
];
let daysInARow = 0;
let dayString = "";
if (!calendar) return "";
for (let i = 0; i <= 6; i += 1) {
const currentDayOperating = calendar[days[i]] === 1;
const previousDayOperating = i > 0 ? calendar[days[i - 1]] === 1 : false;
const nextDayOperating = i < 6 ? calendar[days[i + 1]] === 1 : false;
if (currentDayOperating) {
if (dayString.length > 0) {
if (!previousDayOperating) dayString += ", ";
else if (daysInARow === 1) dayString += "-";
}
daysInARow += 1;
if (dayString.length === 0 || !nextDayOperating || i === 6 || !previousDayOperating) dayString += daysShort[i];
} else daysInARow = 0;
}
if (dayString.length === 0) dayString = "No regular service days";
return dayString;
}
function formatTripSegments(tripSegments, config) {
const stopIds = uniq(tripSegments.flatMap((tripSegment) => [tripSegment.departureStopId, tripSegment.arrivalStopId]));
const serviceIds = uniq(tripSegments.map((tripSegment) => tripSegment.serviceId));
const stops = getStops({ stop_id: stopIds });
const opsLocations = getOpsLocations({ ops_location_id: stopIds });
const calendars = getCalendars({ service_id: serviceIds });
return tripSegments.map((tripSegment) => {
const calendar = calendars.find((calendar) => calendar.service_id === tripSegment.serviceId);
const departureStop = stops.find((stop) => stop.stop_id === tripSegment.departureStopId) ?? opsLocations.find((opsLocation) => opsLocation.ops_location_id === tripSegment.departureStopId);
const arrivalStop = stops.find((stop) => stop.stop_id === tripSegment.arrivalStopId) ?? opsLocations.find((opsLocation) => opsLocation.ops_location_id === tripSegment.arrivalStopId);
return {
blockId: tripSegment.blockId,
routeId: tripSegment.routeId,
routeName: tripSegment.route?.route_short_name ?? tripSegment.route?.route_long_name ?? "",
tripId: tripSegment.tripId,
directionId: tripSegment.directionId,
departureTime: fromGTFSTime(tripSegment.departureTime).format(config.timeFormat),
arrivalTime: fromGTFSTime(tripSegment.arrivalTime).format(config.timeFormat),
dayList: formatDays(calendar),
departureLocation: departureStop.stop_name ?? departureStop.ops_location_name,
arrivalLocation: arrivalStop.stop_name ?? arrivalStop.ops_location_name,
tripHeadsign: tripSegment.tripHeadsign,
stopHeadsign: tripSegment.stopHeadsign,
isDeadhead: tripSegment.isDeadhead.toString()
};
});
}
//#endregion
//#region src/lib/gtfs-to-blocks.ts
const gtfsToBlocks = async (initialConfig) => {
const config = setDefaultConfig(initialConfig);
const timer = new Timer();
timer.start();
const db = openDb({ sqlitePath: config.sqlitePath });
if (!config.agencies || config.agencies.length === 0) throw new Error("No agencies defined in `config.json`");
if (!config.skipImport) await importGtfs(config);
const agencyKey = config.agencies.map((agency) => agency.agency_key).join("-");
const outputPath = config.outputPath ? untildify(config.outputPath) : join(process.cwd(), "output", sanitize(agencyKey));
const outputStats = {
trips: 0,
tripSegments: 0,
warnings: []
};
const calendars = db.prepare("SELECT service_id FROM calendar WHERE start_date <= ? AND end_date >= ?").all([config.date, config.date]);
const calendarDates = db.prepare("SELECT service_id, exception_type FROM calendar_dates WHERE date = ?").all([config.date]);
const serviceIds = new Set(calendars.map((calendar) => calendar.service_id));
for (const calendarDate of calendarDates) if (calendarDate.exception_type === 1) serviceIds.add(calendarDate.service_id);
else if (calendarDate.exception_type === 2) serviceIds.delete(calendarDate.service_id);
if (serviceIds.size === 0) throw new Error(`No calendar or calendar dates found for ${moment(config.date, "YYYYMMDD").format("MMM D, YYYY")}`);
const routes = getRoutes();
const trips = db.prepare(`SELECT trip_id, direction_id, service_id, block_id, route_id, trip_headsign FROM trips where service_id IN (${[...serviceIds].map(() => "?").join(", ")})`).all([...serviceIds]);
const deadheads = config.includeDeadheads ? db.prepare(`SELECT deadhead_id, service_id, block_id FROM deadheads where service_id IN (${[...serviceIds].map(() => "?").join(", ")})`).all([...serviceIds]) : [];
const tripSegments = [];
const bar = progressBar(`${agencyKey}: Generating trip segments {bar} {value}/{total}`, trips.length, config);
for (const trip of trips) try {
const stoptimes = getStoptimes({ trip_id: trip.trip_id }, [], [["stop_sequence", "ASC"]]);
for (const [index, stoptime] of stoptimes.entries()) if (index < stoptimes.length - 1) {
tripSegments.push({
blockId: trip.block_id,
routeId: trip.route_id,
route: routes.find((route) => route.route_id === trip.route_id),
tripId: trip.trip_id,
tripHeadsign: trip.trip_headsign,
stopHeadsign: stoptime.stop_headsign,
directionId: trip.direction_id,
serviceId: trip.service_id,
departureStopId: stoptime.stop_id,
arrivalStopId: stoptimes[index + 1].stop_id,
departureTime: stoptime.departure_time,
arrivalTime: stoptimes[index + 1].arrival_time,
isDeadhead: false
});
outputStats.tripSegments += 1;
}
outputStats.trips += 1;
bar?.increment();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
outputStats.warnings.push(errorMessage);
bar?.interrupt(errorMessage);
}
for (const deadhead of deadheads) try {
const deadheadTimes = await getDeadheadTimes({ deadhead_id: deadhead.deadhead_id }, [], [["location_sequence", "ASC"]]);
for (const [index, deadheadTime] of deadheadTimes.entries()) if (index < deadheadTimes.length - 1) {
tripSegments.push({
blockId: deadhead.block_id,
tripId: deadhead.deadhead_id,
serviceId: deadhead.service_id,
departureStopId: deadheadTime.ops_location_id ?? deadheadTime.stop_id,
arrivalStopId: deadheadTimes[index + 1].ops_location_id ?? deadheadTimes[index + 1].stop_id,
departureTime: deadheadTime.departure_time,
arrivalTime: deadheadTimes[index + 1].arrival_time,
isDeadhead: true
});
outputStats.tripSegments += 1;
}
outputStats.trips += 1;
bar?.increment();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
outputStats.warnings.push(errorMessage);
bar?.interrupt(errorMessage);
}
const formattedTripSegments = formatTripSegments(sortBy(tripSegments, [(tripSegment) => parseInt(tripSegment.blockId, 10) || tripSegment.blockId, (tripSegment) => fromGTFSTime(tripSegment.departureTime)]), config);
await prepDirectory(outputPath, config);
config.assetPath = "../";
const csv = await generateCSV(formattedTripSegments);
const csvPath = join(outputPath, "blocks.csv");
await writeFile(csvPath, csv);
log(config)(`${agencyKey}: block export for ${moment(config.date, "YYYYMMDD").format("MMM D, YYYY")} created at ${csvPath}`);
logStats(config)(outputStats);
const seconds = Math.round(timer.time() / 1e3);
log(config)(`${agencyKey}: block export generation required ${seconds} seconds`);
timer.stop();
return csvPath;
};
//#endregion
export { formatError as n, getConfig as r, gtfsToBlocks as t };
//# sourceMappingURL=src-Cv2nPRQE.js.map