UNPKG

minotor

Version:

A lightweight client-side transit routing library.

477 lines (440 loc) 15.5 kB
import log from 'loglevel'; import { SourceStopId, StopId } from '../stops/stops.js'; import { SerializedRoute } from '../timetable/io.js'; import type { RouteId, TripRouteIndex } from '../timetable/route.js'; import { PickUpDropOffTypes, Route } from '../timetable/route.js'; import { ServiceRoute, ServiceRouteId, StopAdjacency, } from '../timetable/timetable.js'; import { FrequenciesMap } from './frequencies.js'; import { GtfsRouteId, GtfsRoutesMap } from './routes.js'; import { ServiceId, ServiceIds } from './services.js'; import { GtfsStopsMap } from './stops.js'; import { GtfsTime, toTime } from './time.js'; import { TransfersMap } from './transfers.js'; import { hashIds, parseCsv } from './utils.js'; export type GtfsTripId = string; export type GtfsTripIdsMap = Map<GtfsTripId, GtfsRouteId>; export type TripsMapping = Map< GtfsTripId, { routeId: RouteId; tripRouteIndex: TripRouteIndex } >; type TripEntry = { route_id: GtfsRouteId; service_id: ServiceId; trip_id: GtfsTripId; }; export type GtfsPickupDropOffType = | '' // Not specified | '0' // Regularly scheduled | '1' // Not available | '2' // Must phone agency | '3'; // Must coordinate with driver type StopTimeEntry = { trip_id: GtfsTripId; arrival_time?: GtfsTime; departure_time?: GtfsTime; stop_id: SourceStopId; stop_sequence: number; pickup_type?: GtfsPickupDropOffType; drop_off_type?: GtfsPickupDropOffType; }; export type SerializedPickUpDropOffType = 0 | 1 | 2 | 3; /** * Intermediate data structure for building routes during parsing */ type RouteBuilder = { serviceRouteId: ServiceRouteId; stops: StopId[]; trips: Array<{ gtfsTripId: GtfsTripId; firstDeparture: number; arrivalTimes: number[]; departureTimes: number[]; pickUpTypes: SerializedPickUpDropOffType[]; dropOffTypes: SerializedPickUpDropOffType[]; }>; }; /** * Encodes pickup/drop-off types into a Uint8Array using 2 bits per value. * Layout per byte: [drop_off_1][pickup_1][drop_off_0][pickup_0] for stops 0 and 1 */ export const encodePickUpDropOffTypes = ( pickUpTypes: SerializedPickUpDropOffType[], dropOffTypes: SerializedPickUpDropOffType[], ): Uint8Array => { const stopsCount = pickUpTypes.length; // Each byte stores 2 pickup/drop-off pairs (4 bits each) const arraySize = Math.ceil(stopsCount / 2); const encoded = new Uint8Array(arraySize); for (let i = 0; i < stopsCount; i++) { const byteIndex = Math.floor(i / 2); const isSecondPair = i % 2 === 1; const dropOffType = dropOffTypes[i]; const pickUpType = pickUpTypes[i]; if ( dropOffType !== undefined && pickUpType !== undefined && byteIndex < encoded.length ) { if (isSecondPair) { // Second pair: upper 4 bits const currentByte = encoded[byteIndex]; if (currentByte !== undefined) { encoded[byteIndex] = currentByte | (dropOffType << 4) | (pickUpType << 6); } } else { // First pair: lower 4 bits const currentByte = encoded[byteIndex]; if (currentByte !== undefined) { encoded[byteIndex] = currentByte | dropOffType | (pickUpType << 2); } } } } return encoded; }; /** * Sorts trips by departure time and creates optimized typed arrays */ const finalizeRouteFromBuilder = ( builder: RouteBuilder, ): [SerializedRoute, GtfsTripId[]] => { builder.trips.sort((a, b) => a.firstDeparture - b.firstDeparture); const stopsCount = builder.stops.length; const tripsCount = builder.trips.length; const stopsArray = new Uint32Array(builder.stops); const stopTimesArray = new Uint16Array(stopsCount * tripsCount * 2); const allPickUpTypes: SerializedPickUpDropOffType[] = []; const allDropOffTypes: SerializedPickUpDropOffType[] = []; const gtfsTripIds = []; for (let tripIndex = 0; tripIndex < tripsCount; tripIndex++) { const trip = builder.trips[tripIndex]; if (!trip) { throw new Error(`Missing trip data at index ${tripIndex}`); } gtfsTripIds.push(trip.gtfsTripId); const baseIndex = tripIndex * stopsCount * 2; for (let stopIndex = 0; stopIndex < stopsCount; stopIndex++) { const timeIndex = baseIndex + stopIndex * 2; const arrivalTime = trip.arrivalTimes[stopIndex]; const departureTime = trip.departureTimes[stopIndex]; const pickUpType = trip.pickUpTypes[stopIndex]; const dropOffType = trip.dropOffTypes[stopIndex]; if ( arrivalTime === undefined || departureTime === undefined || pickUpType === undefined || dropOffType === undefined ) { throw new Error( `Missing trip data for trip ${tripIndex} at stop ${stopIndex}`, ); } stopTimesArray[timeIndex] = arrivalTime; stopTimesArray[timeIndex + 1] = departureTime; allDropOffTypes.push(dropOffType); allPickUpTypes.push(pickUpType); } } // Use 2-bit encoding for pickup/drop-off types const pickupDropOffTypesArray = encodePickUpDropOffTypes( allPickUpTypes, allDropOffTypes, ); return [ { serviceRouteId: builder.serviceRouteId, stops: stopsArray, stopTimes: stopTimesArray, pickupDropOffTypes: pickupDropOffTypesArray, }, gtfsTripIds, ]; }; /** * Parses the trips.txt file from a GTFS feed * * @param tripsStream The readable stream containing the trips data. * @param serviceIds A mapping of service IDs to corresponding route IDs. * @param serviceRoutes A mapping of route IDs to route details. * @returns A mapping of trip IDs to corresponding route IDs. */ export const parseTrips = async ( tripsStream: NodeJS.ReadableStream, serviceIds: ServiceIds, validGtfsRoutes: GtfsRoutesMap, ): Promise<GtfsTripIdsMap> => { const trips: GtfsTripIdsMap = new Map(); for await (const rawLine of parseCsv(tripsStream, ['stop_sequence'])) { const line = rawLine as TripEntry; if (!serviceIds.has(line.service_id)) { // The trip doesn't correspond to an active service continue; } if (!validGtfsRoutes.has(line.route_id)) { // The trip doesn't correspond to a supported route continue; } trips.set(line.trip_id, line.route_id); } return trips; }; export const buildStopsAdjacencyStructure = ( serviceRoutes: ServiceRoute[], routes: Route[], transfersMap: TransfersMap, nbStops: number, activeStops: Set<StopId>, ): StopAdjacency[] => { const stopsAdjacency = new Array<StopAdjacency>(nbStops); for (let i = 0; i < nbStops; i++) { stopsAdjacency[i] = { routes: [], }; } for (let index = 0; index < routes.length; index++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const route = routes[index]!; for (let j = 0; j < route.getNbStops(); j++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const stop = route.stops[j]!; if (activeStops.has(stop)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion stopsAdjacency[stop]!.routes.push(index); } } const serviceRoute = serviceRoutes[route.serviceRoute()]; if (serviceRoute === undefined) { throw new Error( `Service route ${route.serviceRoute()} not found for route ${index}.`, ); } serviceRoute.routes.push(index); } for (const [stop, transfers] of transfersMap) { for (let i = 0; i < transfers.length; i++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const transfer = transfers[i]!; if (activeStops.has(stop) || activeStops.has(transfer.destination)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const stopAdj = stopsAdjacency[stop]!; if (!stopAdj.transfers) { stopAdj.transfers = []; } stopAdj.transfers.push(transfer); activeStops.add(transfer.destination); activeStops.add(stop); } } } return stopsAdjacency; }; /** * Parses the stop_times.txt data from a GTFS feed. * * @param stopTimesStream The readable stream containing the stop times data. * @param stopsMap A map of parsed stops from the GTFS feed. * @param activeTripIds A map of valid trip IDs to corresponding route IDs. * @param activeStopIds A set of valid stop IDs. * @param frequenciesMap An optional map of frequency windows per trip. * When provided, trips listed in this map are expanded into one concrete * trip per departure that fits within each frequency window, instead of * being kept as a single template trip. * @returns A mapping of route IDs to route details. The routes returned correspond to the set of trips from GTFS that share the same stop list. */ export const parseStopTimes = async ( stopTimesStream: NodeJS.ReadableStream, stopsMap: GtfsStopsMap, activeTripIds: GtfsTripIdsMap, activeStopIds: Set<StopId>, frequenciesMap?: FrequenciesMap, ): Promise<{ routes: Route[]; serviceRoutesMap: Map<GtfsRouteId, ServiceRouteId>; tripsMapping: TripsMapping; }> => { /** * Adds a trip to the appropriate route builder. * If the trip has frequency windows, it is expanded into one concrete trip * per departure that fits within each window (last departure strictly before * end_time). Otherwise the trip is added as-is. */ const addTrip = (currentTripId: GtfsTripId) => { const gtfsRouteId = activeTripIds.get(currentTripId); if (!gtfsRouteId || stops.length === 0) { stops = []; arrivalTimes = []; departureTimes = []; pickUpTypes = []; dropOffTypes = []; return; } const firstDeparture = departureTimes[0]; if (firstDeparture === undefined) { log.warn(`Empty trip ${currentTripId}`); stops = []; arrivalTimes = []; departureTimes = []; pickUpTypes = []; dropOffTypes = []; return; } const routeId = `${gtfsRouteId}_${hashIds(stops)}`; let routeBuilder = routeBuilders.get(routeId); if (!routeBuilder) { let serviceRouteId = serviceRoutesMap.get(gtfsRouteId); if (serviceRouteId === undefined) { serviceRouteId = currentServiceRouteId; serviceRoutesMap.set(gtfsRouteId, serviceRouteId); currentServiceRouteId = currentServiceRouteId + 1; } routeBuilder = { serviceRouteId, stops, trips: [], }; routeBuilders.set(routeId, routeBuilder); for (const stop of stops) { activeStopIds.add(stop); } } const frequencyWindows = frequenciesMap?.get(currentTripId); if (frequencyWindows && frequencyWindows.length > 0) { // The stop times in stop_times.txt are a template. Compute offsets // relative to the template's first departure (T0) and generate one // concrete trip per departure within each frequency window. const T0 = firstDeparture; for (const window of frequencyWindows) { let S = window.startTime; while (S < window.endTime) { const offset = S - T0; routeBuilder.trips.push({ firstDeparture: S, gtfsTripId: `${currentTripId}:freq:${S}`, arrivalTimes: arrivalTimes.map((t) => t + offset), departureTimes: departureTimes.map((t) => t + offset), pickUpTypes: [...pickUpTypes], dropOffTypes: [...dropOffTypes], }); S += window.headwayMins; } } } else { routeBuilder.trips.push({ firstDeparture, gtfsTripId: currentTripId, arrivalTimes: arrivalTimes, departureTimes: departureTimes, pickUpTypes: pickUpTypes, dropOffTypes: dropOffTypes, }); } stops = []; arrivalTimes = []; departureTimes = []; pickUpTypes = []; dropOffTypes = []; }; type BuilderRouteId = string; const routeBuilders: Map<BuilderRouteId, RouteBuilder> = new Map(); const serviceRoutesMap: Map<GtfsRouteId, ServiceRouteId> = new Map(); // incrementally generate service route IDs let currentServiceRouteId = 0; let previousSeq = 0; let stops: StopId[] = []; let arrivalTimes: number[] = []; let departureTimes: number[] = []; let pickUpTypes: SerializedPickUpDropOffType[] = []; let dropOffTypes: SerializedPickUpDropOffType[] = []; let currentTripId: GtfsTripId | undefined = undefined; for await (const rawLine of parseCsv(stopTimesStream, ['stop_sequence'])) { const line = rawLine as StopTimeEntry; if (line.trip_id === currentTripId && line.stop_sequence <= previousSeq) { log.warn( `Stop sequences not increasing for trip ${line.trip_id}: ${line.stop_sequence} > ${previousSeq}.`, ); continue; } if (!line.arrival_time && !line.departure_time) { log.warn( `Missing arrival or departure time for ${line.trip_id} at stop ${line.stop_id}.`, ); continue; } if (line.pickup_type === '1' && line.drop_off_type === '1') { // Warning: could potentially lead to issues if there is an in-seat transfer // at this stop - it can be not boardable nor alightable but still useful for an in-seat transfer. // This doesn't seem to happen in practice for now so keeping this condition to save memory. continue; } if (currentTripId && line.trip_id !== currentTripId && stops.length > 0) { addTrip(currentTripId); } currentTripId = line.trip_id; const stopData = stopsMap.get(line.stop_id); if (!stopData) { log.warn(`Unknown stop ID: ${line.stop_id}`); continue; } stops.push(stopData.id); const departure = line.departure_time ?? line.arrival_time; const arrival = line.arrival_time ?? line.departure_time; if (!arrival || !departure) { log.warn(`Missing time data for ${line.trip_id} at stop ${line.stop_id}`); continue; } arrivalTimes.push(toTime(arrival)); departureTimes.push(toTime(departure)); pickUpTypes.push(parsePickupDropOffType(line.pickup_type)); dropOffTypes.push(parsePickupDropOffType(line.drop_off_type)); previousSeq = line.stop_sequence; } if (currentTripId) { addTrip(currentTripId); } const routesAdjacency: Route[] = []; const tripsMapping = new Map< GtfsTripId, { routeId: RouteId; tripRouteIndex: TripRouteIndex } >(); for (const [, routeBuilder] of routeBuilders) { const [routeData, gtfsTripIds] = finalizeRouteFromBuilder(routeBuilder); const routeId = routesAdjacency.length; routesAdjacency.push( new Route( routeId, routeData.stopTimes, routeData.pickupDropOffTypes, routeData.stops, routeData.serviceRouteId, ), ); gtfsTripIds.forEach((tripId, index) => { tripsMapping.set(tripId, { routeId, tripRouteIndex: index, }); }); } return { routes: routesAdjacency, serviceRoutesMap, tripsMapping }; }; const parsePickupDropOffType = ( gtfsType?: GtfsPickupDropOffType, ): SerializedPickUpDropOffType => { switch (gtfsType) { default: return PickUpDropOffTypes.REGULAR; case '0': return PickUpDropOffTypes.REGULAR; case '1': return PickUpDropOffTypes.NOT_AVAILABLE; case '2': return PickUpDropOffTypes.MUST_PHONE_AGENCY; case '3': return PickUpDropOffTypes.MUST_COORDINATE_WITH_DRIVER; } };