UNPKG

minotor

Version:

A lightweight client-side transit routing library.

415 lines (380 loc) 12.8 kB
import { SourceStopId, StopId } from '../stops/stops.js'; import { SerializedRoute } from '../timetable/io.js'; import { MUST_COORDINATE_WITH_DRIVER, MUST_PHONE_AGENCY, NOT_AVAILABLE, REGULAR, Route, } from '../timetable/route.js'; import { ServiceRoute, ServiceRouteId, StopAdjacency, } from '../timetable/timetable.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 TripId = string; export type TripIdsMap = Map<TripId, GtfsRouteId>; type TripEntry = { route_id: GtfsRouteId; service_id: ServiceId; trip_id: TripId; }; export type GtfsPickupDropOffType = | '' // Not specified | '0' // Regularly scheduled | '1' // Not available | '2' // Must phone agency | '3'; // Must coordinate with driver type StopTimeEntry = { trip_id: TripId; 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<{ 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 => { 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[] = []; for (let tripIndex = 0; tripIndex < tripsCount; tripIndex++) { const trip = builder.trips[tripIndex]; if (!trip) { throw new Error(`Missing trip data at index ${tripIndex}`); } 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, }; }; /** * 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<TripIdsMap> => { const trips: TripIdsMap = 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[] => { // TODO somehow works when it's a map const stopsAdjacency = new Array<StopAdjacency>(nbStops); for (let i = 0; i < nbStops; i++) { stopsAdjacency[i] = { routes: [], transfers: [] }; } 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 stopsAdjacency[stop]!.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. * @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: TripIdsMap, activeStopIds: Set<StopId>, ): Promise<{ routes: Route[]; serviceRoutesMap: Map<GtfsRouteId, ServiceRouteId>; }> => { /** * Adds a trip to the appropriate route builder */ const addTrip = (currentTripId: TripId) => { const gtfsRouteId = activeTripIds.get(currentTripId); if (!gtfsRouteId || stops.length === 0) { stops = []; arrivalTimes = []; departureTimes = []; pickUpTypes = []; dropOffTypes = []; return; } const firstDeparture = departureTimes[0]; if (firstDeparture === undefined) { console.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); } } routeBuilder.trips.push({ firstDeparture, 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: TripId | 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) { console.warn( `Stop sequences not increasing for trip ${line.trip_id}: ${line.stop_sequence} > ${previousSeq}.`, ); continue; } if (!line.arrival_time && !line.departure_time) { console.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') { 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) { console.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) { console.warn( `Missing time data for ${line.trip_id} at stop ${line.stop_id}`, ); continue; } arrivalTimes.push(toTime(arrival).toMinutes()); departureTimes.push(toTime(departure).toMinutes()); pickUpTypes.push(parsePickupDropOffType(line.pickup_type)); dropOffTypes.push(parsePickupDropOffType(line.drop_off_type)); previousSeq = line.stop_sequence; } if (currentTripId) { addTrip(currentTripId); } const routesAdjacency: Route[] = []; for (const [, routeBuilder] of routeBuilders) { const routeData = finalizeRouteFromBuilder(routeBuilder); routesAdjacency.push( new Route( routeData.stopTimes, routeData.pickUpDropOffTypes, routeData.stops, routeData.serviceRouteId, ), ); } return { routes: routesAdjacency, serviceRoutesMap }; }; const parsePickupDropOffType = ( gtfsType?: GtfsPickupDropOffType, ): SerializedPickUpDropOffType => { switch (gtfsType) { default: return REGULAR; case '0': return REGULAR; case '1': return NOT_AVAILABLE; case '2': return MUST_PHONE_AGENCY; case '3': return MUST_COORDINATE_WITH_DRIVER; } };