minotor
Version:
A lightweight client-side transit routing library.
427 lines (389 loc) • 14 kB
text/typescript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { StopId } from '../stops/stops.js';
import {
PickUpDropOffTypes,
Route,
StopRouteIndex,
TripRouteIndex,
} from '../timetable/route.js';
import { Duration, DURATION_ZERO, Time } from '../timetable/time.js';
import { Timetable, TransferTypes, TripStop } from '../timetable/timetable.js';
import { QueryOptions } from './query.js';
import { RoutingEdge, TransferEdge, VehicleEdge } from './state.js';
/**
* Common interface for all variants of RAPTOR routing.
*/
export interface IRaptorState {
/** Origin stop IDs for this run. */
readonly origins: StopId[];
/** Per-round routing graph; `graph[round][stop]` is the best edge used to reach `stop`. */
readonly graph: (RoutingEdge | undefined)[][];
/** Per-run earliest arrival at a stop. Used for boarding decisions. */
arrivalTime(stop: StopId): Time;
/**
* Tightest known upper bound on the arrival time at `stop` in `round`.
*/
improvementBound(round: number, stop: StopId): Time;
/**
* Best known arrival time at any destination.
*/
readonly destinationBest: Time;
/** Latest arrival time allowed by the current query/run. */
readonly maxArrivalTime: Time;
/** Returns `true` if `stop` is one of the query's destination stops. */
isDestination(stop: StopId): boolean;
/**
* Records a new arrival at `stop`, updating all relevant state.
*
* In Range RAPTOR mode this also updates the cross-run shared labels.
*/
updateArrival(stop: StopId, time: Time, round: number): void;
/**
* Propagates labels from round `k-1` into round `k` before routes are scanned.
* No-op in standard RAPTOR mode.
*/
initRound(round: number): void;
}
type TripContinuation = TripStop & {
previousEdge: VehicleEdge;
};
type Round = number;
/**
* Encapsulates the core RAPTOR algorithm, operating on a {@link Timetable} and
* an {@link IRaptorState} provided by the caller.
*
* @see https://www.microsoft.com/en-us/research/wp-content/uploads/2012/01/raptor_alenex.pdf
*/
export class Raptor {
private readonly timetable: Timetable;
constructor(timetable: Timetable) {
this.timetable = timetable;
}
run(options: QueryOptions, state: IRaptorState): void {
const markedStops = new Set<StopId>(state.origins);
for (let round = 1; round <= options.maxTransfers + 1; round++) {
state.initRound(round);
const edgesAtCurrentRound = state.graph[round]!;
const reachableRoutes = this.timetable.findReachableRoutes(
markedStops,
options.transportModes,
);
markedStops.clear();
for (const [route, hopOnStopIndex] of reachableRoutes) {
for (const stop of this.scanRoute(
route,
hopOnStopIndex,
round,
state,
options,
)) {
markedStops.add(stop);
}
}
let continuations = this.findTripContinuations(
markedStops,
edgesAtCurrentRound,
);
const stopsFromContinuations = new Set<StopId>();
while (continuations.length > 0) {
stopsFromContinuations.clear();
for (const continuation of continuations) {
const route = this.timetable.getRoute(continuation.routeId)!;
for (const stop of this.scanRouteContinuation(
route,
continuation.stopIndex,
round,
state,
continuation,
)) {
stopsFromContinuations.add(stop);
markedStops.add(stop);
}
}
continuations = this.findTripContinuations(
stopsFromContinuations,
edgesAtCurrentRound,
);
}
for (const stop of this.considerTransfers(
options,
round,
markedStops,
state,
)) {
markedStops.add(stop);
}
if (markedStops.size === 0) break;
}
}
/**
* Finds trip continuations for the given marked stops and edges at the current round.
* @param markedStops The set of marked stops.
* @param edgesAtCurrentRound The array of edges at the current round, indexed by stop ID.
* @returns An array of trip continuations.
*/
private findTripContinuations(
markedStops: Set<StopId>,
edgesAtCurrentRound: (RoutingEdge | undefined)[],
): TripContinuation[] {
const continuations: TripContinuation[] = [];
for (const stopId of markedStops) {
const arrival = edgesAtCurrentRound[stopId];
if (!arrival || !('routeId' in arrival)) continue;
const continuousTrips = this.timetable.getContinuousTrips(
arrival.hopOffStopIndex,
arrival.routeId,
arrival.tripIndex,
);
for (const trip of continuousTrips) {
continuations.push({
routeId: trip.routeId,
stopIndex: trip.stopIndex,
tripIndex: trip.tripIndex,
previousEdge: arrival,
});
}
}
return continuations;
}
/**
* Scans a route for an in-seat trip continuation.
*
* The boarded trip and entry stop are fixed, so there is no need to probe for
* earlier boardings.
*
* @param route The route to scan
* @param hopOnStopIndex The stop index where the continuation begins
* @param round The current RAPTOR round
* @param routingState Current routing state
* @param tripContinuation The in-seat continuation descriptor
* @param shared Optional shared state for Range RAPTOR mode
*/
private scanRouteContinuation(
route: Route,
hopOnStopIndex: StopRouteIndex,
round: Round,
state: IRaptorState,
tripContinuation: TripContinuation,
): Set<StopId> {
const newlyMarkedStops = new Set<StopId>();
const edgesAtCurrentRound = state.graph[round]!;
const nbStops = route.getNbStops();
const routeId = route.id;
const tripIndex = tripContinuation.tripIndex;
const tripStopOffset = route.tripStopOffset(tripIndex);
const previousEdge = tripContinuation.previousEdge;
for (
let currentStopIndex = hopOnStopIndex;
currentStopIndex < nbStops;
currentStopIndex++
) {
const currentStop: StopId = route.stops[currentStopIndex]!;
const arrivalTime = route.arrivalAtOffset(
currentStopIndex,
tripStopOffset,
);
const dropOffType = route.dropOffTypeAtOffset(
currentStopIndex,
tripStopOffset,
);
if (
dropOffType !== PickUpDropOffTypes.NOT_AVAILABLE &&
arrivalTime <= state.maxArrivalTime &&
arrivalTime < state.improvementBound(round, currentStop) &&
arrivalTime < state.destinationBest
) {
edgesAtCurrentRound[currentStop] = {
routeId,
stopIndex: hopOnStopIndex,
tripIndex,
arrival: arrivalTime,
hopOffStopIndex: currentStopIndex,
continuationOf: previousEdge,
};
state.updateArrival(currentStop, arrivalTime, round);
newlyMarkedStops.add(currentStop);
}
}
return newlyMarkedStops;
}
/**
* Scans a route using the standard RAPTOR boarding logic.
*
* Iterates through all stops from the hop-on point, maintaining the current
* best trip and improving arrival times when possible. At each marked stop it
* also checks whether an earlier (or first) trip can be boarded, upgrading the
* active trip when one is found.
*
* @param route The route to scan
* @param hopOnStopIndex The stop index where passengers can first board
* @param round The current RAPTOR round
* @param state Current routing state
* @param options Query options (minTransferTime, etc.)
*/
private scanRoute(
route: Route,
hopOnStopIndex: StopRouteIndex,
round: Round,
state: IRaptorState,
options: QueryOptions,
): Set<StopId> {
const newlyMarkedStops = new Set<StopId>();
const edgesAtCurrentRound = state.graph[round]!;
const edgesAtPreviousRound = state.graph[round - 1]!;
const nbStops = route.getNbStops();
const routeId = route.id;
let activeTripIndex: TripRouteIndex | undefined;
let activeTripBoardStopIndex = hopOnStopIndex;
// tripStopOffset = activeTripIndex * nbStops, precomputed when the trip changes.
let activeTripStopOffset = 0;
for (
let currentStopIndex = hopOnStopIndex;
currentStopIndex < nbStops;
currentStopIndex++
) {
const currentStop: StopId = route.stops[currentStopIndex]!;
// If on a trip, check whether alighting here improves the global best.
if (activeTripIndex !== undefined) {
const arrivalTime = route.arrivalAtOffset(
currentStopIndex,
activeTripStopOffset,
);
const dropOffType = route.dropOffTypeAtOffset(
currentStopIndex,
activeTripStopOffset,
);
if (
dropOffType !== PickUpDropOffTypes.NOT_AVAILABLE &&
arrivalTime <= state.maxArrivalTime &&
arrivalTime < state.improvementBound(round, currentStop) &&
arrivalTime < state.destinationBest
) {
edgesAtCurrentRound[currentStop] = {
routeId,
stopIndex: activeTripBoardStopIndex,
tripIndex: activeTripIndex,
arrival: arrivalTime,
hopOffStopIndex: currentStopIndex,
};
state.updateArrival(currentStop, arrivalTime, round);
newlyMarkedStops.add(currentStop);
}
}
// Check whether we can board an earlier (or first) trip at this stop.
const previousEdge = edgesAtPreviousRound[currentStop];
const earliestArrivalOnPreviousRound = previousEdge?.arrival;
if (
earliestArrivalOnPreviousRound !== undefined &&
(activeTripIndex === undefined ||
earliestArrivalOnPreviousRound <=
route.departureAtOffset(currentStopIndex, activeTripStopOffset))
) {
const earliestTrip = route.findEarliestTrip(
currentStopIndex,
earliestArrivalOnPreviousRound,
activeTripIndex,
);
if (earliestTrip === undefined) {
continue;
}
const fromTripStop =
previousEdge && 'routeId' in previousEdge
? {
stopIndex: previousEdge.hopOffStopIndex,
routeId: previousEdge.routeId,
tripIndex: previousEdge.tripIndex,
}
: undefined;
const firstBoardableTrip = this.timetable.findFirstBoardableTrip(
currentStopIndex,
route,
earliestTrip,
earliestArrivalOnPreviousRound,
activeTripIndex,
fromTripStop,
options.minTransferTime,
);
if (firstBoardableTrip !== undefined) {
const departureTime = route.departureFrom(
currentStopIndex,
firstBoardableTrip,
);
// At round 1, enforce maxInitialWaitingTime: skip boarding if the
// traveler would have to wait longer than the allowed threshold at
// the first boarding stop.
const exceedsInitialWait =
round === 1 &&
options.maxInitialWaitingTime !== undefined &&
departureTime - earliestArrivalOnPreviousRound >
options.maxInitialWaitingTime;
const exceedsMaxDuration = departureTime > state.maxArrivalTime;
if (!exceedsInitialWait && !exceedsMaxDuration) {
activeTripIndex = firstBoardableTrip;
activeTripBoardStopIndex = currentStopIndex;
activeTripStopOffset = route.tripStopOffset(firstBoardableTrip);
}
}
}
}
return newlyMarkedStops;
}
/**
* Processes all currently marked stops to find available transfers
* and determines if using these transfers would result in earlier arrival times
* at destination stops. It handles different transfer types including in-seat
* transfers and walking transfers with appropriate minimum transfer times.
*
* @param options Query options (minTransferTime, etc.)
* @param round The current round number in the RAPTOR algorithm
* @param markedStops The set of currently marked stops
* @param state Current routing state
*/
private considerTransfers(
options: QueryOptions,
round: number,
markedStops: Set<StopId>,
state: IRaptorState,
): Set<StopId> {
const newlyMarkedStops = new Set<StopId>();
const arrivalsAtCurrentRound = state.graph[round]!;
for (const stop of markedStops) {
const currentArrival = arrivalsAtCurrentRound[stop];
// Skip transfers if the last leg was also a transfer
if (!currentArrival || 'type' in currentArrival) continue;
const transfers = this.timetable.getTransfers(stop);
for (const transfer of transfers) {
let transferTime: Duration;
if (transfer.minTransferTime) {
transferTime = transfer.minTransferTime;
} else if (transfer.type === TransferTypes.IN_SEAT) {
transferTime = DURATION_ZERO;
} else {
transferTime = options.minTransferTime;
}
const arrivalAfterTransfer = currentArrival.arrival + transferTime;
if (
arrivalAfterTransfer <= state.maxArrivalTime &&
arrivalAfterTransfer <
state.improvementBound(round, transfer.destination) &&
arrivalAfterTransfer < state.destinationBest
) {
arrivalsAtCurrentRound[transfer.destination] = {
arrival: arrivalAfterTransfer,
from: stop,
to: transfer.destination, // TODO needed?
minTransferTime: transferTime || undefined,
type: transfer.type,
} as TransferEdge;
state.updateArrival(
transfer.destination,
arrivalAfterTransfer,
round,
);
newlyMarkedStops.add(transfer.destination);
}
}
}
return newlyMarkedStops;
}
}