minotor
Version:
A lightweight client-side transit routing library.
465 lines (426 loc) • 14.7 kB
text/typescript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BinaryReader, BinaryWriter } from '@bufbuild/protobuf/wire';
import { StopId } from '../stops/stops.js';
import {
deserializeRoutesAdjacency,
deserializeServiceRoutesMap,
deserializeStopsAdjacency,
deserializeTripTransfers,
serializeRoutesAdjacency,
serializeServiceRoutesMap,
serializeStopsAdjacency,
serializeTripTransfers,
} from './io.js';
import { Timetable as ProtoTimetable } from './proto/v1/timetable.js';
import {
PickUpDropOffTypes,
Route,
RouteId,
StopRouteIndex,
TripRouteIndex,
} from './route.js';
import { Duration, DURATION_ZERO, Time, TIME_ORIGIN } from './time.js';
import { encode, TripStopId } from './tripStopId.js';
export const TransferTypes = {
RECOMMENDED: 1,
GUARANTEED: 2,
REQUIRES_MINIMAL_TIME: 3,
IN_SEAT: 4,
} as const;
export type TransferType = (typeof TransferTypes)[keyof typeof TransferTypes];
export type TransferTypeString = keyof typeof TransferTypes;
export type Transfer = {
destination: StopId;
type: TransferType;
minTransferTime?: Duration;
};
export type TripStop = {
stopIndex: StopRouteIndex;
routeId: RouteId;
tripIndex: TripRouteIndex;
};
export type StopAdjacency = {
transfers?: Transfer[];
routes: RouteId[];
};
export type TripTransfers = Map<TripStopId, TripStop[]>;
export type ServiceRouteId = number;
export const RouteTypes = {
TRAM: 1,
SUBWAY: 2,
RAIL: 3,
BUS: 4,
FERRY: 5,
CABLE_TRAM: 6,
AERIAL_LIFT: 7,
FUNICULAR: 8,
TROLLEYBUS: 9,
MONORAIL: 10,
} as const;
export type RouteType = (typeof RouteTypes)[keyof typeof RouteTypes];
export type RouteTypeString = keyof typeof RouteTypes;
// A service refers to a collection of trips that are displayed to riders as a single service.
// As opposed to a route which consists of the subset of trips from a service which shares the same list of stops.
// Service is here a synonym for route in the GTFS sense.
export type ServiceRoute = {
type: RouteType;
name: string;
routes: RouteId[];
};
export type ServiceRouteInfo = Omit<ServiceRoute, 'routes'>;
export const ALL_TRANSPORT_MODES: Set<RouteType> = new Set(
Object.values(RouteTypes),
);
const EMPTY_TRIP_BOARDINGS: TripStop[] = [];
/**
* The internal transit timetable format.
*/
export class Timetable {
private readonly stopsAdjacency: StopAdjacency[];
private readonly routesAdjacency: Route[];
private readonly serviceRoutes: ServiceRoute[];
private readonly tripContinuations?: TripTransfers;
private readonly guaranteedTripTransfers?: TripTransfers;
private readonly activeStops: Set<StopId>;
constructor(
stopsAdjacency: StopAdjacency[],
routesAdjacency: Route[],
routes: ServiceRoute[],
tripContinuations?: TripTransfers,
guaranteedTripTransfers?: TripTransfers,
) {
this.stopsAdjacency = stopsAdjacency;
this.routesAdjacency = routesAdjacency;
this.serviceRoutes = routes;
this.tripContinuations = tripContinuations;
this.guaranteedTripTransfers = guaranteedTripTransfers;
this.activeStops = new Set<StopId>();
for (let i = 0; i < stopsAdjacency.length; i++) {
const stop = stopsAdjacency[i]!;
if (
stop.routes.length > 0 ||
(stop.transfers && stop.transfers.length > 0)
) {
this.activeStops.add(i);
}
}
}
/**
* Serializes the Timetable into a binary array.
*
* @returns The serialized binary data.
*/
serialize(): Uint8Array {
const protoTimetable = {
stopsAdjacency: serializeStopsAdjacency(this.stopsAdjacency),
routesAdjacency: serializeRoutesAdjacency(this.routesAdjacency),
serviceRoutes: serializeServiceRoutesMap(this.serviceRoutes),
tripContinuations: serializeTripTransfers(
this.tripContinuations || new Map<TripStopId, TripStop[]>(),
),
guaranteedTripTransfers: serializeTripTransfers(
this.guaranteedTripTransfers || new Map<TripStopId, TripStop[]>(),
),
};
const writer = new BinaryWriter();
ProtoTimetable.encode(protoTimetable, writer);
return writer.finish();
}
/**
* Deserializes a binary protobuf into a Timetable object.
*
* @param data - The binary data to deserialize.
* @returns The deserialized Timetable object.
*/
static fromData(data: Uint8Array): Timetable {
const reader = new BinaryReader(data);
const protoTimetable = ProtoTimetable.decode(reader);
return new Timetable(
deserializeStopsAdjacency(protoTimetable.stopsAdjacency),
deserializeRoutesAdjacency(protoTimetable.routesAdjacency),
deserializeServiceRoutesMap(protoTimetable.serviceRoutes),
deserializeTripTransfers(protoTimetable.tripContinuations),
deserializeTripTransfers(protoTimetable.guaranteedTripTransfers),
);
}
/**
* Checks if the given stop is active on the timetable.
* An active stop is a stop reached by a route that is active on the timetable
* or by a transfer reachable from an active route.
*
* @param stopId - The ID of the stop to check.
* @returns True if the stop is active, false otherwise.
*/
isActive(stopId: StopId): boolean {
return this.activeStops.has(stopId);
}
/**
* Returns the total number of stops in the timetable.
*/
nbStops(): number {
return this.stopsAdjacency.length;
}
/**
* Retrieves the route associated with the given route ID.
*
* @param routeId - The ID of the route to be retrieved.
* @returns The route corresponding to the provided ID,
* or undefined if no such route exists.
*/
getRoute(routeId: RouteId): Route | undefined {
return this.routesAdjacency[routeId];
}
/**
* Retrieves all transfer options available at the specified stop.
*
* @param stopId - The ID of the stop to get transfers for.
* @returns An array of transfer options available at the stop.
*/
getTransfers(stopId: StopId): Transfer[] {
const stopAdjacency = this.stopsAdjacency[stopId];
if (!stopAdjacency) {
throw new Error(`Stop ID ${stopId} not found`);
}
return stopAdjacency.transfers || [];
}
/**
* Retrieves all trip continuation options available at the specified stop for a given trip.
*
* @param stopIndex - The index in the route of the stop to get trip continuations for.
* @param routeId - The ID of the route to get continuations for.
* @param tripIndex - The index of the trip to get continuations for.
* @returns An array of trip continuation options available at the stop for the specified trip.
*/
getContinuousTrips(
stopIndex: StopRouteIndex,
routeId: RouteId,
tripIndex: TripRouteIndex,
): TripStop[] {
const tripContinuations = this.tripContinuations?.get(
encode(stopIndex, routeId, tripIndex),
);
if (!tripContinuations) {
return EMPTY_TRIP_BOARDINGS;
}
return tripContinuations;
}
/**
* Retrieves the service route associated with the given route.
* A service route refers to a collection of trips that are displayed
* to riders as a single service.
*
* @param route - The route for which the service route is to be retrieved.
* @returns The service route corresponding to the provided route.
*/
getServiceRouteInfo(route: Route): ServiceRouteInfo {
const serviceRoute = this.serviceRoutes[route.serviceRoute()];
if (!serviceRoute) {
throw new Error(
`Service route not found for route ID: ${route.serviceRoute()}`,
);
}
return { type: serviceRoute.type, name: serviceRoute.name };
}
/**
* Finds all routes passing through a stop.
*
* @param stopId - The ID of the stop to find routes for.
* @returns An array of routes passing through the specified stop.
*/
routesPassingThrough(stopId: StopId): Route[] {
const stopData = this.stopsAdjacency[stopId];
if (!stopData) {
return [];
}
const routes: Route[] = [];
for (let i = 0; i < stopData.routes.length; i++) {
const routeId = stopData.routes[i]!;
const route = this.routesAdjacency[routeId];
if (route) {
routes.push(route);
}
}
return routes;
}
/**
* Finds routes that are reachable from a set of stop IDs.
* Also identifies the first stop index available to hop on each route among
* the input stops.
*
* @param fromStops - The set of stop IDs to find reachable routes from.
* @param transportModes - The set of transport modes to consider for reachable routes.
* @returns A map of reachable routes to the first stop index available to hop on each route.
*/
findReachableRoutes(
fromStops: Set<StopId>,
transportModes: Set<RouteType> = ALL_TRANSPORT_MODES,
): Map<Route, StopRouteIndex> {
const reachableRoutes = new Map<Route, StopRouteIndex>();
// Skip the per-route mode check entirely when all modes are allowed,
// which is the common case.
const filterByMode = transportModes !== ALL_TRANSPORT_MODES;
for (const originStop of fromStops) {
const stopData = this.stopsAdjacency[originStop];
if (!stopData) continue;
for (let i = 0; i < stopData.routes.length; i++) {
const route = this.routesAdjacency[stopData.routes[i]!];
if (!route) continue;
if (filterByMode) {
const serviceRoute = this.serviceRoutes[route.serviceRoute()];
if (!serviceRoute || !transportModes.has(serviceRoute.type)) continue;
}
const originStopIndex = route.stopRouteIndices(originStop)[0]!;
const existingHopOnStopIndex = reachableRoutes.get(route);
if (existingHopOnStopIndex !== undefined) {
if (originStopIndex < existingHopOnStopIndex) {
reachableRoutes.set(route, originStopIndex);
}
} else {
reachableRoutes.set(route, originStopIndex);
}
}
}
return reachableRoutes;
}
/**
* Checks if a trip transfer is guaranteed for a given trip boarding.
*
* @param fromTripStop - The trip stop for the trip transfer origin.
* @param toTripStop - The trip stop to check if it's guaranteed.
* @returns True if the trip transfer is guaranteed, false otherwise.
*/
isTripTransferGuaranteed(
fromTripStop: TripStop,
toTripStop: TripStop,
): boolean {
const tripBoardingId = encode(
fromTripStop.stopIndex,
fromTripStop.routeId,
fromTripStop.tripIndex,
);
const guaranteedTransfers =
this.guaranteedTripTransfers?.get(tripBoardingId);
if (!guaranteedTransfers) {
return false;
}
for (let i = 0; i < guaranteedTransfers.length; i++) {
const transfer = guaranteedTransfers[i]!;
if (
transfer.stopIndex === toTripStop.stopIndex &&
transfer.routeId === toTripStop.routeId &&
transfer.tripIndex === toTripStop.tripIndex
) {
return true;
}
}
return false;
}
/**
* Finds the first trip on `route` at `stopIndex` that can be boarded, starting
* from `earliestTrip` and respecting pickup availability, transfer guarantees,
* and minimum transfer times.
*
* @param stopIndex Stop at which boarding is attempted.
* @param route The route to search.
* @param earliestTrip First trip index to consider.
* @param after Earliest time after which boarding is allowed.
* @param beforeTrip Exclusive upper bound on the trip index (omit to search all).
* @param fromTripStop The alighted trip stop when transferring from another trip;
* `undefined` when boarding from a walk or origin.
* @param transferTime Minimum transfer time required between trips.
* @returns The index of the first boardable trip, or `undefined` if none found.
*/
findFirstBoardableTrip(
stopIndex: StopRouteIndex,
route: Route,
earliestTrip: TripRouteIndex,
after: Time = TIME_ORIGIN,
beforeTrip?: TripRouteIndex,
fromTripStop?: TripStop,
transferTime: Duration = DURATION_ZERO,
): TripRouteIndex | undefined {
const nbTrips = route.getNbTrips();
for (let t = earliestTrip; t < (beforeTrip ?? nbTrips); t++) {
const pickup = route.pickUpTypeFrom(stopIndex, t);
if (pickup === PickUpDropOffTypes.NOT_AVAILABLE) {
continue;
}
if (fromTripStop === undefined) {
return t;
}
const isGuaranteed = this.isTripTransferGuaranteed(fromTripStop, {
stopIndex,
routeId: route.id,
tripIndex: t,
});
if (isGuaranteed) {
return t;
}
const departure = route.departureFrom(stopIndex, t);
const requiredTime = after + transferTime;
if (departure >= requiredTime) {
return t;
}
}
return undefined;
}
/**
* Retrieves all guaranteed trip transfer options available at the specified stop for a given trip.
*
* @param stopIndex - The index in the route of the stop to get guaranteed trip transfers for.
* @param routeId - The ID of the route to get guaranteed transfers for.
* @param tripIndex - The index of the trip to get guaranteed transfers for.
* @returns An array of trip boarding options that are guaranteed for the specified trip.
*/
getGuaranteedTripTransfers(
stopIndex: StopRouteIndex,
routeId: RouteId,
tripIndex: TripRouteIndex,
): TripStop[] {
const guaranteedTripTransfers = this.guaranteedTripTransfers?.get(
encode(stopIndex, routeId, tripIndex),
);
if (!guaranteedTripTransfers) {
return EMPTY_TRIP_BOARDINGS;
}
return guaranteedTripTransfers;
}
}
export const routeTypeToString = (type: RouteType): RouteTypeString => {
switch (type) {
case RouteTypes.TRAM:
return 'TRAM';
case RouteTypes.SUBWAY:
return 'SUBWAY';
case RouteTypes.RAIL:
return 'RAIL';
case RouteTypes.BUS:
return 'BUS';
case RouteTypes.FERRY:
return 'FERRY';
case RouteTypes.CABLE_TRAM:
return 'CABLE_TRAM';
case RouteTypes.AERIAL_LIFT:
return 'AERIAL_LIFT';
case RouteTypes.FUNICULAR:
return 'FUNICULAR';
case RouteTypes.TROLLEYBUS:
return 'TROLLEYBUS';
case RouteTypes.MONORAIL:
return 'MONORAIL';
}
};
export const transferTypeToString = (
type: TransferType,
): TransferTypeString => {
switch (type) {
case TransferTypes.RECOMMENDED:
return 'RECOMMENDED';
case TransferTypes.GUARANTEED:
return 'GUARANTEED';
case TransferTypes.REQUIRES_MINIMAL_TIME:
return 'REQUIRES_MINIMAL_TIME';
case TransferTypes.IN_SEAT:
return 'IN_SEAT';
}
};