minotor
Version:
A lightweight client-side transit routing library.
309 lines (281 loc) • 10.9 kB
text/typescript
import log from 'loglevel';
import { DateTime } from 'luxon';
import StreamZip from 'node-stream-zip';
import { StopId } from '../stops/stops.js';
import { StopsIndex } from '../stops/stopsIndex.js';
import { RouteType, Timetable } from '../timetable/timetable.js';
import { TransferGenerator } from '../transfers/generator.js';
import { FrequenciesMap, parseFrequencies } from './frequencies.js';
import { standardGtfsProfile } from './profiles/standard.js';
import { indexRoutes, parseRoutes } from './routes.js';
import { parseCalendar, parseCalendarDates, ServiceIds } from './services.js';
import { parseStops } from './stops.js';
import {
addGeneratedTransfers,
addMissingSiblingTransfers,
buildTripTransfers,
ForbiddenTransfersMap,
GtfsTripTransfer,
parseTransfers,
TransfersMap,
} from './transfers.js';
import {
buildStopsAdjacencyStructure,
parseStopTimes,
parseTrips,
} from './trips.js';
import { Maybe } from './utils.js';
const CALENDAR_FILE = 'calendar.txt';
const CALENDAR_DATES_FILE = 'calendar_dates.txt';
const FREQUENCIES_FILE = 'frequencies.txt';
const ROUTES_FILE = 'routes.txt';
const TRIPS_FILE = 'trips.txt';
const STOP_TIMES_FILE = 'stop_times.txt';
const STOPS_FILE = 'stops.txt';
const TRANSFERS_FILE = 'transfers.txt';
export type GtfsProfile = {
routeTypeParser: (routeType: number) => Maybe<RouteType>;
/**
* Derive fallback transfers between active stops that share a parent station.
*
* @default true
*/
deriveSiblingTransfers?: boolean;
};
export class GtfsParser {
private path: string;
private profile: GtfsProfile;
private transferGenerator?: TransferGenerator;
constructor(
path: string,
profile: GtfsProfile = standardGtfsProfile,
transferGenerator?: TransferGenerator,
) {
// TODO: support input from multiple sources
this.path = path;
this.profile = profile;
this.transferGenerator = transferGenerator;
}
/**
* Parses a GTFS feed to extract all the data relevant to a given day in a transit-planner friendly format.
*
* @param date The active date.
* @returns The parsed timetable.
*/
async parseTimetable(date: Date): Promise<Timetable> {
log.setLevel('INFO');
const zip = new StreamZip.async({ file: this.path });
const entries = await zip.entries();
const datetime = DateTime.fromJSDate(date);
const activeServiceIds: ServiceIds = new Set();
const activeStopIds = new Set<StopId>();
log.info(`Parsing ${STOPS_FILE}`);
const stopsStart = performance.now();
const stopsStream = await zip.stream(STOPS_FILE);
const parsedStops = await parseStops(stopsStream);
const stopsEnd = performance.now();
log.info(
`${parsedStops.size} parsed stops. (${(stopsEnd - stopsStart).toFixed(2)}ms)`,
);
if (entries[CALENDAR_FILE]) {
log.info(`Parsing ${CALENDAR_FILE}`);
const calendarStart = performance.now();
const calendarStream = await zip.stream(CALENDAR_FILE);
await parseCalendar(calendarStream, activeServiceIds, datetime);
const calendarEnd = performance.now();
log.info(
`${activeServiceIds.size} valid services. (${(calendarEnd - calendarStart).toFixed(2)}ms)`,
);
}
if (entries[CALENDAR_DATES_FILE]) {
log.info(`Parsing ${CALENDAR_DATES_FILE}`);
const calendarDatesStart = performance.now();
const calendarDatesStream = await zip.stream(CALENDAR_DATES_FILE);
await parseCalendarDates(calendarDatesStream, activeServiceIds, datetime);
const calendarDatesEnd = performance.now();
log.info(
`${activeServiceIds.size} valid services. (${(calendarDatesEnd - calendarDatesStart).toFixed(2)}ms)`,
);
}
log.info(`Parsing ${ROUTES_FILE}`);
const routesStart = performance.now();
const routesStream = await zip.stream(ROUTES_FILE);
const validGtfsRoutes = await parseRoutes(routesStream, this.profile);
const routesEnd = performance.now();
log.info(
`${validGtfsRoutes.size} valid GTFS routes. (${(routesEnd - routesStart).toFixed(2)}ms)`,
);
log.info(`Parsing ${TRIPS_FILE}`);
const tripsStart = performance.now();
const tripsStream = await zip.stream(TRIPS_FILE);
const trips = await parseTrips(
tripsStream,
activeServiceIds,
validGtfsRoutes,
);
const tripsEnd = performance.now();
log.info(
`${trips.size} valid trips. (${(tripsEnd - tripsStart).toFixed(2)}ms)`,
);
let transfers: TransfersMap = new Map();
let forbiddenTransfers: ForbiddenTransfersMap = new Map();
let tripContinuationsList: GtfsTripTransfer[] = [];
let guaranteedTripTransfersList: GtfsTripTransfer[] = [];
if (entries[TRANSFERS_FILE]) {
log.info(`Parsing ${TRANSFERS_FILE}`);
const transfersStart = performance.now();
const transfersStream = await zip.stream(TRANSFERS_FILE);
const {
transfers: parsedTransfers,
forbiddenTransfers: parsedForbiddenTransfers,
tripContinuations: parsedTripContinuations,
guaranteedTripTransfers: parsedGuaranteedTripTransfers,
} = await parseTransfers(transfersStream, parsedStops, activeServiceIds);
transfers = parsedTransfers;
forbiddenTransfers = parsedForbiddenTransfers;
tripContinuationsList = parsedTripContinuations;
guaranteedTripTransfersList = parsedGuaranteedTripTransfers;
const transfersEnd = performance.now();
log.info(
`${transfers.size} valid transfers and ${tripContinuationsList.length} trip continuations and ${guaranteedTripTransfersList.length} guaranteed trip transfers. (${(transfersEnd - transfersStart).toFixed(2)}ms)`,
);
}
let frequenciesMap: FrequenciesMap | undefined;
if (entries[FREQUENCIES_FILE]) {
log.info(`Parsing ${FREQUENCIES_FILE}`);
const frequenciesStart = performance.now();
const frequenciesStream = await zip.stream(FREQUENCIES_FILE);
frequenciesMap = await parseFrequencies(
frequenciesStream,
new Set(trips.keys()),
);
const frequenciesEnd = performance.now();
log.info(
`${frequenciesMap.size} trips with frequency data. (${(frequenciesEnd - frequenciesStart).toFixed(2)}ms)`,
);
}
log.info(`Parsing ${STOP_TIMES_FILE}`);
const stopTimesStart = performance.now();
const stopTimesStream = await zip.stream(STOP_TIMES_FILE);
const { routes, serviceRoutesMap, tripsMapping } = await parseStopTimes(
stopTimesStream,
parsedStops,
trips,
activeStopIds,
frequenciesMap,
);
const serviceRoutes = indexRoutes(validGtfsRoutes, serviceRoutesMap);
const stopTimesEnd = performance.now();
log.info(
`${routes.length} valid unique routes. (${(stopTimesEnd - stopTimesStart).toFixed(2)}ms)`,
);
if (this.profile.deriveSiblingTransfers !== false) {
const siblingTransfersStart = performance.now();
const siblingTransfersAdded = addMissingSiblingTransfers(
parsedStops,
activeStopIds,
transfers,
forbiddenTransfers,
);
const siblingTransfersEnd = performance.now();
log.info(
`${siblingTransfersAdded} sibling transfers added. (${(siblingTransfersEnd - siblingTransfersStart).toFixed(2)}ms)`,
);
}
if (this.transferGenerator) {
log.info('Generating virtual transfers');
const virtualTransfersStart = performance.now();
// Only route-served stops with coordinates are worth connecting: a
// transfer to a stop no route calls at is a dead end for the router.
const originStops = Array.from(parsedStops.values()).filter(
(stop) =>
activeStopIds.has(stop.id) &&
stop.lat !== undefined &&
stop.lon !== undefined,
);
const stopsIndex = new StopsIndex(Array.from(parsedStops.values()));
const generatedTransfers = await this.transferGenerator.generate(
originStops,
stopsIndex,
);
const addedTransfers = addGeneratedTransfers(
generatedTransfers,
activeStopIds,
transfers,
forbiddenTransfers,
);
const virtualTransfersEnd = performance.now();
log.info(
`${addedTransfers} virtual transfers added. (${(virtualTransfersEnd - virtualTransfersStart).toFixed(2)}ms)`,
);
}
log.info('Building stops adjacency structure');
const stopsAdjacencyStart = performance.now();
const stopsAdjacency = buildStopsAdjacencyStructure(
serviceRoutes,
routes,
transfers,
parsedStops.size,
activeStopIds,
);
const stopsAdjacencyEnd = performance.now();
log.info(
`${stopsAdjacency.length} valid stops in the structure. (${(stopsAdjacencyEnd - stopsAdjacencyStart).toFixed(2)}ms)`,
);
await zip.close();
// temporary timetable for building continuations
const timetable = new Timetable(stopsAdjacency, routes, serviceRoutes);
log.info('Building in-seat trip continuations');
const tripContinuationsStart = performance.now();
const tripContinuations = buildTripTransfers(
tripsMapping,
tripContinuationsList,
timetable,
activeStopIds,
);
const tripContinuationsEnd = performance.now();
log.info(
`${tripContinuations.size} in-seat trip continuations origins created. (${(tripContinuationsEnd - tripContinuationsStart).toFixed(2)}ms)`,
);
log.info('Parsing complete.');
log.info('Building guaranteed trip transfers');
const guaranteedTripTransfersStart = performance.now();
const guaranteedTripTransfers = buildTripTransfers(
tripsMapping,
guaranteedTripTransfersList,
timetable,
activeStopIds,
);
const guaranteedTripTransfersEnd = performance.now();
log.info(
`${guaranteedTripTransfers.size} guaranteed trip transfers origins created. (${(guaranteedTripTransfersEnd - guaranteedTripTransfersStart).toFixed(2)}ms)`,
);
log.info('Parsing complete.');
return new Timetable(
stopsAdjacency,
routes,
serviceRoutes,
tripContinuations,
guaranteedTripTransfers,
);
}
/**
* Parses a GTFS feed to extract all stops.
*
* @param activeStops The set of active stop IDs to include in the index.
* @returns An index of stops.
*/
async parseStops(): Promise<StopsIndex> {
const zip = new StreamZip.async({ file: this.path });
log.info(`Parsing ${STOPS_FILE}`);
const stopsStart = performance.now();
const stopsStream = await zip.stream(STOPS_FILE);
const stops = await parseStops(stopsStream);
const stopsEnd = performance.now();
log.info(
`${stops.size} parsed stops. (${(stopsEnd - stopsStart).toFixed(2)}ms)`,
);
await zip.close();
return new StopsIndex(Array.from(stops.values()));
}
}