minotor
Version:
A lightweight client-side transit routing library.
478 lines (436 loc) • 16.3 kB
text/typescript
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;
}
}