UNPKG

gtfs-to-blocks

Version:

Generate CSV of transit departure times organized by block_id in GTFS.

440 lines (435 loc) 13.8 kB
// src/lib/gtfs-to-blocks.ts import { join as join2 } from "node:path"; import { writeFile } from "node: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 "node:path"; import { access, mkdir, readdir, readFile, rm } from "node:fs/promises"; import untildify from "untildify"; async function prepDirectory(outputPath, config) { try { await access(outputPath); } catch (error) { try { await mkdir(outputPath, { recursive: true }); } 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 }); } } // src/lib/log-utils.ts import { clearLine, cursorTo } from "node: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 DISTINCT service_id FROM calendar WHERE start_date <= ? AND end_date >= ?" ).all([config.date, config.date]); if (calendars.length === 0) { throw new Error( `No calendars found for ${moment2(config.date, "YYYYMMDD").format( "MMM D, YYYY" )}` ); } const routes = getRoutes(); const serviceIds = calendars.map((calendar) => calendar.service_id); 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