minotor
Version:
A lightweight client-side transit routing library.
389 lines (352 loc) • 13.4 kB
text/typescript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { StopId } from '../stops/stops.js';
import { StopRouteIndex } from '../timetable/route.js';
import { Duration, Time } from '../timetable/time.js';
import { TransferType, TripStop } from '../timetable/timetable.js';
import { AccessPoint } from './access.js';
import type { IRaptorState } from './raptor.js';
/**
* Sentinel value used in the internal arrival-time array to mark stops not yet reached.
* 0xFFFF = 65 535 minutes ≈ 45.5 days, safely beyond any realistic transit arrival time.
*/
export const UNREACHED_TIME: Time = 0xffff;
export type OriginNode = { stopId: StopId; arrival: Time };
export type AccessEdge = {
arrival: Time;
from: StopId;
to: StopId;
duration: Duration;
};
/** A boarded transit trip that carries the passenger from one stop to another. */
export type VehicleEdge = TripStop & {
arrival: Time;
hopOffStopIndex: StopRouteIndex;
/** modeling in-seat transfer */
continuationOf?: VehicleEdge;
};
/** A walking or guaranteed connection between two stops. */
export type TransferEdge = {
arrival: Time;
from: StopId;
to: StopId;
type: TransferType;
minTransferTime?: Duration;
};
export type RoutingEdge = OriginNode | AccessEdge | VehicleEdge | TransferEdge;
/** The earliest arrival at a stop together with how many legs were needed to reach it. */
export type Arrival = {
arrival: Time;
legNumber: number;
};
/**
* Encapsulates all mutable state for a single RAPTOR routing query.
*/
export class RoutingState implements IRaptorState {
/** Origin stop IDs for this query. */
origins: StopId[];
/** Destination stop IDs for this query. */
readonly destinations: StopId[];
/**
* Routing graph: the best edge used to reach each stop, per round.
* Indexed as graph[round][stopId]. Entries are undefined for stops not
* reached in that particular round.
*/
// TODO do not expose
readonly graph: (RoutingEdge | undefined)[][];
// TODO Can use typed arrays to represent the graph
// Uint32 [(alightStopId -> ? =index/to), boardingStopId (stopIndex,from),
// RouteId/TransferId, TripId (if not transfer), (previous_round, previous_stop
// -> allows to reconstruct transfers incl. continuous]
// TODO should try to reuse them in range raptor and use only one init
// TODO take out arrival times from Graph
// private arrivalTimes: Uint16Array[];
/**
* Earliest arrival time at each stop (minutes from midnight), indexed by stop ID.
* Pre-filled with UNREACHED_TIME; updated exclusively through updateArrival().
* Not readonly so that fromTestData() can replace the arrays directly.
*/
private earliestArrivalTimes: Uint16Array;
/**
* Round number (leg count) in which each stop was first reached, indexed by stop ID.
* Zero-initialized by the typed array; updated exclusively through updateArrival().
* Not readonly so that fromTestData() can replace the arrays directly.
*/
private earliestArrivalLegs: Uint8Array;
/**
* Fast O(1) membership test for destination stops.
* Built once at construction time from the `destinations` array.
*/
private readonly destinationSet: Set<StopId>;
/**
* Cached best arrival time at any destination stop, kept up-to-date by
* {@link updateArrival} so that destination pruning is always O(1).
*/
private _destinationBest: Time = UNREACHED_TIME;
/**
* Maximum arrival time allowed for this run. Defaults to UNREACHED_TIME when
* the query has no maxDuration limit.
*/
maxArrivalTime: Time = UNREACHED_TIME;
/**
* Query-level maximum duration, retained so resetFor() can recompute the
* absolute max arrival time for each departure-time iteration.
*/
private readonly maxDuration?: Duration;
/**
* Every stop that has received an arrival improvement during the current run,
* in the order the improvements occurred. Used by {@link resetFor} to clear
* only the touched entries instead of scanning the entire array.
*/
private readonly reachedStops: StopId[] = [];
constructor(
departureTime: Time,
destinations: StopId[],
accessPaths: AccessPoint[],
nbStops: number,
maxRounds: number = 0,
maxDuration?: Duration,
) {
this.destinations = destinations;
this.maxDuration = maxDuration;
this.maxArrivalTime =
maxDuration === undefined ? UNREACHED_TIME : departureTime + maxDuration;
this.destinationSet = new Set(destinations);
this.earliestArrivalTimes = new Uint16Array(nbStops).fill(UNREACHED_TIME);
this.earliestArrivalLegs = new Uint8Array(nbStops);
this.origins = []; // overwritten by seedAccessPaths below
this.graph = [new Array<RoutingEdge | undefined>(nbStops)];
for (let r = 1; r <= maxRounds; r++) {
this.graph.push(new Array<RoutingEdge | undefined>(nbStops));
}
this.seedAccessPaths(departureTime, accessPaths);
}
/**
* Seeds round-0 arrivals and {@link origins} from a set of access paths.
* Called by the constructor and by {@link resetFor}.
* Assumes {@link earliestArrivalTimes} and {@link graph}[0] are already
* allocated and in their "cleared" state (all entries at UNREACHED_TIME /
* undefined) before this method runs.
*/
private seedAccessPaths(depTime: Time, accessPaths: AccessPoint[]): void {
const seededOrigins = new Set<StopId>();
for (const access of accessPaths) {
const arrival = depTime + access.duration;
if (arrival > this.maxArrivalTime) continue;
const edge: OriginNode | AccessEdge =
access.duration === 0
? { stopId: access.fromStopId, arrival: depTime }
: {
arrival,
from: access.fromStopId,
to: access.toStopId,
duration: access.duration,
};
const stop = access.toStopId;
if (arrival < this.earliestArrivalTimes[stop]!) {
this.earliestArrivalTimes[stop] = arrival;
this.graph[0]![stop] = edge;
}
seededOrigins.add(stop);
}
for (const stop of seededOrigins) {
this.reachedStops.push(stop);
}
this.origins = Array.from(seededOrigins);
for (let i = 0; i < this.destinations.length; i++) {
const t = this.earliestArrivalTimes[this.destinations[i]!]!;
if (t < this._destinationBest) this._destinationBest = t;
}
}
/** Total number of stops in the timetable */
get nbStops(): number {
return this.earliestArrivalTimes.length;
}
/**
* Returns the earliest known arrival time at a stop.
* Returns UNREACHED_TIME if the stop has not been reached yet.
*/
arrivalTime(stop: StopId): Time {
return this.earliestArrivalTimes[stop]!;
}
/**
* Earliest arrival at any destination stop; {@link UNREACHED_TIME} if none
* has been reached yet. Updated automatically by {@link updateArrival}. O(1).
*/
get destinationBest(): Time {
return this._destinationBest;
}
/**
* In standard RAPTOR the improvement bound is simply the per-run earliest
* arrival; the `round` argument is ignored.
*/
improvementBound(_round: number, stop: StopId): Time {
return this.arrivalTime(stop);
}
/** No-op in standard RAPTOR — there are no shared cross-run labels to propagate. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
initRound(_round: number): void {}
/**
* Records a new earliest arrival at a stop.
*
* @param stop The stop that was reached.
* @param time The arrival time in minutes from midnight.
* @param leg The round number (number of transit legs taken so far).
*/
updateArrival(stop: StopId, time: Time, leg: number): void {
this.reachedStops.push(stop);
this.earliestArrivalTimes[stop] = time;
this.earliestArrivalLegs[stop] = leg;
if (this.destinationSet.has(stop) && time < this._destinationBest) {
this._destinationBest = time;
}
}
/**
* Resets this state for a new departure-time iteration **without
* reallocating** the underlying arrays.
*
* Only the stops recorded in {@link reachedStops} are touched — all other
* entries are already at their initial bound values.
*
* After this call the state is equivalent to a freshly constructed
* {@link RoutingState} for the given `depTime` and `accessPaths`.
*
* @param depTime New origin departure time.
* @param accessPaths Access legs for this departure-time slot.
*/
resetFor(depTime: Time, accessPaths: AccessPoint[]): void {
for (const stop of this.reachedStops) {
this.earliestArrivalTimes[stop] = UNREACHED_TIME;
this.earliestArrivalLegs[stop] = 0;
for (let r = 0; r < this.graph.length; r++) {
this.graph[r]![stop] = undefined;
}
}
this.reachedStops.length = 0;
this._destinationBest = UNREACHED_TIME;
this.maxArrivalTime =
this.maxDuration === undefined
? UNREACHED_TIME
: depTime + this.maxDuration;
this.seedAccessPaths(depTime, accessPaths);
}
/**
* Iterates over every stop that has been reached, yielding its stop ID,
* earliest arrival time, and the number of legs taken to reach it.
*
* Unreached stops (those still at UNREACHED_TIME) are skipped entirely.
*
* @example
* ```ts
* for (const { stop, arrival, legNumber } of routingState.arrivals()) {
* console.log(`Stop ${stop}: arrived at ${arrival} after ${legNumber} leg(s)`);
* }
* ```
*/
*arrivals(): Generator<{ stop: StopId; arrival: Time; legNumber: number }> {
for (let stop = 0; stop < this.earliestArrivalTimes.length; stop++) {
const time = this.earliestArrivalTimes[stop]!;
if (time < UNREACHED_TIME) {
yield {
stop,
arrival: time,
legNumber: this.earliestArrivalLegs[stop]!,
};
}
}
}
/**
* Finds the earliest arrival time at any stop from a given set of destinations.
*
* @param routingState The routing state containing arrival times and destinations.
* @returns The earliest arrival time among the provided destinations.
*/
earliestArrivalAtAnyDestination(): Time {
return this._destinationBest;
}
/**
* Returns the earliest arrival at a stop as an {@link Arrival} object,
* or undefined if the stop has not been reached.
*/
getArrival(stop: StopId): Arrival | undefined {
const time = this.earliestArrivalTimes[stop]!;
if (time >= UNREACHED_TIME) return undefined;
return { arrival: time, legNumber: this.earliestArrivalLegs[stop]! };
}
/**
* Returns `true` if `stop` is one of the query's destination stops.
* O(1) — backed by a `Set` built at construction time.
*/
isDestination(stop: StopId): boolean {
return this.destinationSet.has(stop);
}
/**
* Creates a {@link RoutingState} from fully-specified raw data.
*
* Use this in tests instead of constructing the object through the production
* constructor, which is designed for incremental algorithm state.
*
* @param nbStops Total number of stops (sets array sizes).
* @param origins Origin stop IDs.
* @param destinations Destination stop IDs.
* @param arrivals Each entry is `[stop, time, leg]` — the earliest arrival
* time in minutes and the round number for one stop.
* @param graph One element per round. Each round is a sparse list of
* `[stop, edge]` pairs; stops absent from the list are
* left as `undefined` in the dense output array.
*
* @internal For use in tests only.
*/
static fromTestData({
nbStops,
origins = [],
destinations = [],
arrivals = [],
graph = [],
}: {
nbStops: number;
origins?: StopId[];
destinations?: StopId[];
arrivals?: [stop: StopId, time: Time, leg: number][];
graph?: [stop: StopId, edge: RoutingEdge][][];
}): RoutingState {
const state = new RoutingState(
0,
destinations,
origins.map((stop) => ({
fromStopId: stop,
toStopId: stop,
duration: 0,
})),
nbStops,
);
// Replace the arrival arrays with freshly built ones so the constructor's
// origin-seeding doesn't bleed into the test state.
const earliestArrivalTimes = new Uint16Array(nbStops).fill(UNREACHED_TIME);
const earliestArrivalLegs = new Uint8Array(nbStops);
for (const [stop, time, leg] of arrivals) {
earliestArrivalTimes[stop] = time;
earliestArrivalLegs[stop] = leg;
}
state.earliestArrivalTimes = earliestArrivalTimes;
state.earliestArrivalLegs = earliestArrivalLegs;
// Recompute _destinationBest from the test data since we bypassed updateArrival.
// fromTestData is a static method of RoutingState, so private access is allowed.
state._destinationBest = UNREACHED_TIME;
for (const dest of destinations) {
const t = earliestArrivalTimes[dest];
if (t !== undefined && t < state._destinationBest)
state._destinationBest = t;
}
// Convert the sparse per-round representation to dense arrays and replace
// the graph in-place.
const denseRounds = graph.map((round) => {
const arr = new Array<RoutingEdge | undefined>(nbStops);
for (const [stop, edge] of round) {
arr[stop] = edge;
}
return arr;
});
state.graph.splice(0, state.graph.length, ...denseRounds);
return state;
}
}