gtfs-to-blocks
Version:
Generate CSV of transit departure times organized by block_id in GTFS.
457 lines (452 loc) • 14.2 kB
JavaScript
// src/lib/gtfs-to-blocks.ts
import { join as join2 } from "path";
import { writeFile } from "fs/promises";
import { sortBy } from "lodash-es";
import {
openDb,
importGtfs,
getStoptimes,
getDeadheadTimes,
getRoutes
} from "gtfs";
import sanitize from "sanitize-filename";
import Timer from "timer-machine";
// src/lib/file-utils.ts
import { join, resolve } from "path";
import { access, mkdir, readdir, readFile, rm } from "fs/promises";
import untildify from "untildify";
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 });
}
}
// src/lib/log-utils.ts
import { clearLine, cursorTo } from "readline";
import PrettyError from "pretty-error";
import { noop } from "lodash-es";
import chalk from "chalk";
import Table from "cli-table";
var pe = new PrettyError();
pe.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(`
${formatWarning(text)}
`);
};
}
function formatWarning(text) {
return `${chalk.yellow.underline("Warning")}${chalk.yellow(
":"
)} ${chalk.yellow(text)}`;
}
function logStats(config) {
if (config.logFunction) {
return noop;
}
return (stats) => {
const table = new Table({
colWidths: [40, 20],
head: ["Item", "Count"]
});
table.push(
["\u{1F68D} Trips", stats.trips],
["\u{1F551} Trip Segments", stats.tripSegments],
["\u26D4\uFE0F Warnings", stats.warnings.length]
);
log(config)(table.toString());
};
}
var 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;
const progressText = slider.repeat(progress);
const emptyProgressText = line.repeat(emptyProgress);
return progressText + emptyProgressText;
};
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);
}
};
}
// src/lib/utils.ts
import { stringify } from "csv-stringify";
import moment from "moment";
function setDefaultConfig(initialConfig) {
const defaults = {
timeFormat: "HH:mm:ss",
date: moment().format("YYYYMMDD"),
includeDeadheads: true,
overwriteExistingFiles: true
};
const config = Object.assign(defaults, initialConfig);
return config;
}
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()
});
}
// src/lib/formatters.ts
import { getCalendars, getOpsLocations, getStops } from "gtfs";
import { uniq } from "lodash-es";
var 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(
(calendar2) => calendar2.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()
};
});
}
// src/lib/gtfs-to-blocks.ts
import moment2 from "moment";
import untildify2 from "untildify";
var 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 ? untildify2(config.outputPath) : join2(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 ${moment2(
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 sortedTripSegments = sortBy(tripSegments, [
// Sort by integer else alphabetically
(tripSegment) => parseInt(tripSegment.blockId, 10) || tripSegment.blockId,
(tripSegment) => fromGTFSTime(tripSegment.departureTime)
]);
const formattedTripSegments = formatTripSegments(sortedTripSegments, config);
await prepDirectory(outputPath, config);
config.assetPath = "../";
const csv = await generateCSV(formattedTripSegments);
const csvPath = join2(outputPath, "blocks.csv");
await writeFile(csvPath, csv);
log(config)(
`${agencyKey}: block export for ${moment2(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;
};
var gtfs_to_blocks_default = gtfsToBlocks;
export {
gtfs_to_blocks_default as default
};
//# sourceMappingURL=index.js.map