UNPKG

minotor

Version:

A lightweight client-side transit routing library.

478 lines (436 loc) 16.3 kB
import { StopId } from '../stops/stops.js'; import { SerializedRoute } from './io.js'; import { Time, TIME_ORIGIN } from './time.js'; import { ServiceRouteId } from './timetable.js'; /** * An internal identifier for routes. * Not to mix with the ServiceRouteId which corresponds to the GTFS RouteId. * This one is used for identifying groups of trips * from a service route sharing the same list of stops. */ export type RouteId = number; export const PickUpDropOffTypes = { REGULAR: 0, NOT_AVAILABLE: 1, MUST_PHONE_AGENCY: 2, MUST_COORDINATE_WITH_DRIVER: 3, } as const; export type PickUpDropOffType = (typeof PickUpDropOffTypes)[keyof typeof PickUpDropOffTypes]; export type PickUpDropOffTypeString = keyof typeof PickUpDropOffTypes; export type RawPickUpDropOffType = PickUpDropOffType; /* * A trip route index corresponds to the index of a given trip in a route. */ export type TripRouteIndex = number; /* * A stop route index corresponds to the index of a given stop in a route. */ export type StopRouteIndex = number; /** * A route identifies all trips of a given service route sharing the same list of stops. */ export class Route { public readonly id: RouteId; /** * Arrivals and departures encoded as minutes from midnight. * Format: [arrival1, departure1, arrival2, departure2, etc.] */ private readonly stopTimes: Uint16Array; /** * PickUp and DropOff types represented as a 2-bit encoded Uint8Array. * Values (2 bits each): * 0: REGULAR * 1: NOT_AVAILABLE * 2: MUST_PHONE_AGENCY * 3: MUST_COORDINATE_WITH_DRIVER * * Encoding format: Each byte contains 2 pickup/drop-off pairs (4 bits each) * Bit layout per byte: [pickup_1 (2 bits)][drop_off_1 (2 bits)][pickup_0 (2 bits)][drop_off_0 (2 bits)] * Example: For stops 0 and 1 in a trip, one byte encodes all 4 values */ private readonly pickupDropOffTypes: Uint8Array; /** * A binary array of stopIds in the route. * [stop1, stop2, stop3,...] */ public readonly stops: Uint32Array; /** * A reverse mapping of each stop with their index in the route: * { * 4: 0, * 5: 1, * ... * } */ private readonly stopIndices: Map<StopId, StopRouteIndex[]>; /** * The identifier of the route as a service shown to users. */ private readonly serviceRouteId: ServiceRouteId; /** * The total number of stops in the route. */ private readonly nbStops: number; /** * The total number of trips in the route. */ private readonly nbTrips: number; constructor( id: RouteId, stopTimes: Uint16Array, pickupDropOffTypes: Uint8Array, stops: Uint32Array, serviceRouteId: ServiceRouteId, ) { this.id = id; this.stopTimes = stopTimes; this.pickupDropOffTypes = pickupDropOffTypes; this.stops = stops; this.serviceRouteId = serviceRouteId; this.nbStops = stops.length; this.nbTrips = this.stopTimes.length / (this.stops.length * 2); this.stopIndices = new Map<StopId, StopRouteIndex[]>(); for (let i = 0; i < stops.length; i++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const stopId = stops[i]!; const existingIndices = this.stopIndices.get(stopId); if (existingIndices) { existingIndices.push(i); } else { this.stopIndices.set(stopId, [i]); } } } /** * Creates a new route from multiple trips with their stops. * * @param params The route parameters including ID, service route ID, and trips. * @returns The new route. */ static of(params: { id: RouteId; serviceRouteId: ServiceRouteId; trips: Array<{ stops: Array<{ id: StopId; arrivalTime: Time; departureTime: Time; dropOffType?: number; pickUpType?: number; }>; }>; }): Route { const { id, serviceRouteId, trips } = params; if (trips.length === 0) { throw new Error('At least one trip must be provided'); } // All trips must have the same stops in the same order // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const firstTrip = trips[0]!; const stopIds = new Uint32Array(firstTrip.stops.map((stop) => stop.id)); const numStops = stopIds.length; // Validate all trips have the same stops for (let tripIndex = 1; tripIndex < trips.length; tripIndex++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const trip = trips[tripIndex]!; if (trip.stops.length !== numStops) { throw new Error( `Trip ${tripIndex} has ${trip.stops.length} stops, expected ${numStops}`, ); } for (let stopIndex = 0; stopIndex < numStops; stopIndex++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (trip.stops[stopIndex]!.id !== stopIds[stopIndex]) { throw new Error( `Trip ${tripIndex} has different stop at index ${stopIndex}`, ); } } } // Create stopTimes array with arrivals and departures for all trips const stopTimes = new Uint16Array(trips.length * numStops * 2); for (let tripIndex = 0; tripIndex < trips.length; tripIndex++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const trip = trips[tripIndex]!; for (let stopIndex = 0; stopIndex < numStops; stopIndex++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const stop = trip.stops[stopIndex]!; const baseIndex = (tripIndex * numStops + stopIndex) * 2; stopTimes[baseIndex] = stop.arrivalTime; stopTimes[baseIndex + 1] = stop.departureTime; } } // Create pickupDropOffTypes array (2-bit encoded) for all trips const totalStopEntries = trips.length * numStops; const pickupDropOffTypes = new Uint8Array(Math.ceil(totalStopEntries / 2)); for (let tripIndex = 0; tripIndex < trips.length; tripIndex++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const trip = trips[tripIndex]!; for (let stopIndex = 0; stopIndex < numStops; stopIndex++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const stop = trip.stops[stopIndex]!; const globalIndex = tripIndex * numStops + stopIndex; const pickUp = stop.pickUpType ?? PickUpDropOffTypes.REGULAR; const dropOff = stop.dropOffType ?? PickUpDropOffTypes.REGULAR; const byteIndex = Math.floor(globalIndex / 2); const isSecondPair = globalIndex % 2 === 1; if (isSecondPair) { // Second pair: pickup in upper 2 bits, dropOff in bits 4-5 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion pickupDropOffTypes[byteIndex]! |= (pickUp << 6) | (dropOff << 4); } else { // First pair: pickup in bits 2-3, dropOff in lower 2 bits // eslint-disable-next-line @typescript-eslint/no-non-null-assertion pickupDropOffTypes[byteIndex]! |= (pickUp << 2) | dropOff; } } } return new Route( id, stopTimes, pickupDropOffTypes, stopIds, serviceRouteId, ); } /** * Serializes the Route into binary arrays. * * @returns The serialized binary data. */ serialize(): SerializedRoute { return { stopTimes: this.stopTimes, pickupDropOffTypes: this.pickupDropOffTypes, stops: this.stops, serviceRouteId: this.serviceRouteId, }; } /** * Retrieves the number of stops in the route. * * @returns The total number of stops in the route. */ getNbStops(): number { return this.nbStops; } /** * Retrieves the number of trips in the route. * * @returns The total number of trips in the route. */ getNbTrips(): number { return this.nbTrips; } /** * Finds the ServiceRouteId of the route. It corresponds the identifier * of the service shown to the end user as a route. * * @returns The ServiceRouteId of the route. */ serviceRoute(): ServiceRouteId { return this.serviceRouteId; } /** * Retrieves the arrival time at a specific stop for a given trip. * * @param stopIndex - The index of the stop in the route. * @param tripIndex - The index of the trip. * @returns The arrival time at the specified stop and trip as a Time. */ arrivalAt(stopIndex: StopRouteIndex, tripIndex: TripRouteIndex): Time { const arrivalIndex = (tripIndex * this.stops.length + stopIndex) * 2; const arrival = this.stopTimes[arrivalIndex]; if (arrival === undefined) { throw new Error( `Arrival time not found for stop ${this.stopId(stopIndex)} (${stopIndex}) at trip index ${tripIndex} in route ${this.serviceRouteId}`, ); } return arrival; } /** * Computes the per-trip base offset used by the hot-path scanning methods. * * Cache the result once when boarding a new trip and pass it to * {@link arrivalAtOffset} and {@link dropOffTypeAtOffset} throughout the * scanning loop to avoid recomputing `tripIndex × nbStops` on every stop. * * @param tripIndex - The index of the trip. * @returns `tripIndex × nbStops`. */ tripStopOffset(tripIndex: TripRouteIndex): number { return tripIndex * this.nbStops; } /** * Hot-path variant of {@link arrivalAt} that accepts a precomputed base offset. * * @param stopIndex - The index of the stop in the route. * @param offset - Precomputed value from {@link tripStopOffset}. * @returns The arrival time at the specified stop. */ arrivalAtOffset(stopIndex: StopRouteIndex, offset: number): Time { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.stopTimes[(offset + stopIndex) * 2]!; } /** * Hot-path variant of {@link departureFrom} that accepts a precomputed base offset. * * @param stopIndex - The index of the stop in the route. * @param offset - Precomputed value from {@link tripStopOffset}. * @returns The departure time at the specified stop. */ departureAtOffset(stopIndex: StopRouteIndex, offset: number): Time { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.stopTimes[(offset + stopIndex) * 2 + 1]!; } /** * Hot-path variant of {@link dropOffTypeAt} that accepts a precomputed base offset. * * @param stopIndex - The index of the stop in the route. * @param offset - Precomputed value from {@link tripStopOffset}. * @returns The drop-off type at the specified stop. */ dropOffTypeAtOffset( stopIndex: StopRouteIndex, offset: number, ): RawPickUpDropOffType { const globalIndex = offset + stopIndex; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const byte = this.pickupDropOffTypes[globalIndex >> 1]!; // Bit layout per byte (two pairs): [pickup_1(2)][dropOff_1(2)][pickup_0(2)][dropOff_0(2)] // First pair → drop-off in lower 2 bits; second pair → drop-off in bits 4-5. return ( globalIndex & 1 ? (byte >> 4) & 0x03 : byte & 0x03 ) as RawPickUpDropOffType; } /** * Retrieves the departure time at a specific stop for a given trip. * * @param stopIndex - The index of the stop in the route. * @param tripIndex - The index of the trip. * @returns The departure time at the specified stop and trip as a Time. */ departureFrom(stopIndex: StopRouteIndex, tripIndex: TripRouteIndex): Time { const departureIndex = (tripIndex * this.stops.length + stopIndex) * 2 + 1; const departure = this.stopTimes[departureIndex]; if (departure === undefined) { throw new Error( `Departure time not found for stop ${this.stopId(stopIndex)} (${stopIndex}) at trip index ${tripIndex} in route ${this.serviceRouteId}`, ); } return departure; } /** * Retrieves the pick-up type for a specific stop and trip. * * @param stopIndex - The index of the stop in the route. * @param tripIndex - The index of the trip. * @returns The pick-up type at the specified stop and trip. */ pickUpTypeFrom( stopIndex: StopRouteIndex, tripIndex: TripRouteIndex, ): RawPickUpDropOffType { const globalIndex = tripIndex * this.stops.length + stopIndex; const byteIndex = globalIndex >> 1; const isSecondPair = (globalIndex & 1) === 1; const byte = this.pickupDropOffTypes[byteIndex]; if (byte === undefined) { throw new Error( `Pick up type not found for stop ${this.stopId(stopIndex)} (${stopIndex}) at trip index ${tripIndex} in route ${this.serviceRouteId}`, ); } const pickUpValue = isSecondPair ? (byte >> 6) & 0x03 // Upper 2 bits for second pair : (byte >> 2) & 0x03; // Bits 2-3 for first pair return pickUpValue as RawPickUpDropOffType; } /** * Retrieves the drop-off type for a specific stop and trip. * * @param stopIndex - The index of the stop in the route. * @param tripIndex - The index of the trip. * @returns The drop-off type at the specified stop and trip. */ dropOffTypeAt( stopIndex: StopRouteIndex, tripIndex: TripRouteIndex, ): RawPickUpDropOffType { const globalIndex = tripIndex * this.stops.length + stopIndex; const byteIndex = globalIndex >> 1; const isSecondPair = (globalIndex & 1) === 1; const byte = this.pickupDropOffTypes[byteIndex]; if (byte === undefined) { throw new Error( `Drop off type not found for stop ${this.stopId(stopIndex)} (${stopIndex}) at trip index ${tripIndex} in route ${this.serviceRouteId}`, ); } const dropOffValue = isSecondPair ? (byte >> 4) & 0x03 // Bits 4-5 for second pair : byte & 0x03; // Lower 2 bits for first pair return dropOffValue as RawPickUpDropOffType; } /** * Finds the earliest trip that can be taken from a specific stop on a given route, * optionally constrained by a latest trip index and a time before which the trip * should not depart. * * * @param stopIndex - The route index of the stop where the trip should be found. * @param [after=Time.origin()] - The earliest time after which the trip should depart. * If not provided, searches all available trips. * @param [beforeTrip] - (Optional) The index of the trip before which the search should be constrained. * If not provided, searches all available trips. * @returns The index of the earliest trip meeting the criteria, or undefined if no such trip is found. */ findEarliestTrip( stopIndex: StopRouteIndex, after: Time = TIME_ORIGIN, beforeTrip?: TripRouteIndex, ): TripRouteIndex | undefined { if (this.nbTrips <= 0) return undefined; let hi = this.nbTrips - 1; if (beforeTrip !== undefined) hi = Math.min(hi, beforeTrip - 1); if (hi < 0) return undefined; let lo = 0; let lb = -1; while (lo <= hi) { const mid = (lo + hi) >>> 1; const depMid = this.departureFrom(stopIndex, mid); if (depMid < after) { lo = mid + 1; } else { lb = mid; hi = mid - 1; } } if (lb === -1) return undefined; return lb; } /** * Retrieves the indices of a stop within the route. * @param stopId The StopId of the stop to locate in the route. * @returns An array of indices where the stop appears in the route, or an empty array if the stop is not found. */ public stopRouteIndices(stopId: StopId): StopRouteIndex[] { const stopIndex = this.stopIndices.get(stopId); if (stopIndex === undefined) { return []; } return stopIndex; } /** * Retrieves the id of a stop at a given index in a route. * @param stopRouteIndex The route index of the stop. * @returns The id of the stop at the given index in the route. */ public stopId(stopRouteIndex: StopRouteIndex): StopId { const stopId = this.stops[stopRouteIndex]; if (stopId === undefined) { throw new Error( `StopId for stop at index ${stopRouteIndex} not found in route ${this.serviceRouteId}`, ); } return stopId; } }