minotor
Version:
A lightweight client-side transit routing library.
386 lines (364 loc) • 13.7 kB
text/typescript
import { Timetable } from '../router.js';
import { SourceStopId, StopId } from '../stops/stops.js';
import { StopsIndex } from '../stops/stopsIndex.js';
import {
PickUpDropOffTypeString,
RawPickUpDropOffType,
Route as TimetableRoute,
} from '../timetable/route.js';
import { Time } from '../timetable/time.js';
import {
routeTypeToString,
TransferTypes,
transferTypeToString,
} from '../timetable/timetable.js';
import {
Access,
Leg,
Route,
ServiceRouteInfo,
Transfer,
VehicleLeg,
} from './route.js';
import { Arrival, RoutingState, TransferEdge, VehicleEdge } from './router.js';
import { AccessEdge } from './state.js';
/**
* Details about the pickup and drop-off modalities at each stop in each trip of a route.
*/
const pickUpDropOffTypeMap: PickUpDropOffTypeString[] = [
'REGULAR',
'NOT_AVAILABLE',
'MUST_PHONE_AGENCY',
'MUST_COORDINATE_WITH_DRIVER',
];
/**
* Converts a numerical representation of a pick-up/drop-off type
* into its corresponding string representation.
*
* @param numericalType - The numerical value representing the pick-up/drop-off type.
* @returns The corresponding PickUpDropOffTypeString as a string.
* @throws An error if the numerical type is invalid.
*/
const toPickupDropOffType = (
rawType: RawPickUpDropOffType,
): PickUpDropOffTypeString => {
const type = pickUpDropOffTypeMap[rawType];
if (!type) {
throw new Error(`Invalid pickup/drop-off type ${rawType}`);
}
return type;
};
export class Result {
private readonly destinations: ReadonlySet<StopId>;
public readonly routingState: RoutingState;
public readonly stopsIndex: StopsIndex;
public readonly timetable: Timetable;
constructor(
destinations: ReadonlySet<StopId>,
routingState: RoutingState,
stopsIndex: StopsIndex,
timetable: Timetable,
) {
this.destinations = destinations;
this.routingState = routingState;
this.stopsIndex = stopsIndex;
this.timetable = timetable;
}
/**
* Expands a target stop or stop set to all equivalent concrete stop IDs.
*
* When `to` is omitted, defaults to the resolved destinations stored on this
* result.
*
* Equivalent stops are expanded here so destination handling has a single
* source of truth shared by route reconstruction and arrival lookups.
*/
private expandDestinations(to?: StopId | Set<StopId>): Set<StopId> {
const targets: Iterable<StopId> =
to instanceof Set ? to : to !== undefined ? [to] : this.destinations;
const expanded = new Set<StopId>();
for (const target of targets) {
for (const equivalentStop of this.stopsIndex.equivalentStops(target)) {
expanded.add(equivalentStop.id);
}
}
return expanded;
}
/**
* Reconstructs the best route to a stop by SourceStopId.
* (to any stop reachable in less time / transfers than this result's
* destination set)
*
* @param to The destination stop by SourceStopId.
* @returns a route to the destination stop if it exists.
*/
bestRouteToSourceStopId(
to: SourceStopId | Set<SourceStopId>,
): Route | undefined {
if (to instanceof Set) {
const stopIds = new Set<StopId>();
for (const sourceId of to) {
const found = this.stopsIndex.findStopBySourceStopId(sourceId);
if (found !== undefined) stopIds.add(found.id);
}
return stopIds.size === 0 ? undefined : this.bestRoute(stopIds);
}
const stopId = this.stopsIndex.findStopBySourceStopId(to)?.id;
return stopId === undefined ? undefined : this.bestRoute(stopId);
}
/**
* Reconstructs the best route to a stop.
* (to any stop reachable in less time / transfers than this result's
* destination set)
*
* @param to The destination stop. Defaults to this result's resolved
* destinations.
* @returns a route to the destination stop if it exists.
*/
bestRoute(to?: StopId | Set<StopId>): Route | undefined {
const destinationStops = this.expandDestinations(to);
// Find the fastest-reached destination across all equivalent stops.
let fastestDestination: StopId | undefined = undefined;
let fastestArrivalTime: Time | undefined = undefined;
let fastestLegNumber: number | undefined = undefined;
for (const destination of destinationStops) {
const arrivalData = this.routingState.getArrival(destination);
if (
arrivalData !== undefined &&
(fastestArrivalTime === undefined ||
arrivalData.arrival < fastestArrivalTime)
) {
fastestDestination = destination;
fastestArrivalTime = arrivalData.arrival;
fastestLegNumber = arrivalData.legNumber;
}
}
if (fastestDestination === undefined || fastestLegNumber === undefined) {
return undefined;
}
// Reconstruct the path by walking backwards through the routing graph.
const route: Leg[] = [];
let currentStop = fastestDestination;
let round = fastestLegNumber;
let previousVehicleEdge: VehicleEdge | undefined;
while (round >= 0) {
const edge = this.routingState.graph[round]?.[currentStop];
if (!edge) {
if (round === 0) break;
throw new Error(
`No edge arriving at stop ${currentStop} at round ${round}`,
);
}
let leg: Leg;
if ('routeId' in edge) {
// Walk the continuationOf chain to find the earliest (boarding) edge.
let boardingEdge: VehicleEdge;
let vehicleLeg: VehicleLeg;
if (!edge.continuationOf) {
boardingEdge = edge;
vehicleLeg = this.buildVehicleLeg([edge]);
} else {
let vehicleEdge: VehicleEdge = edge;
const chainedEdges: VehicleEdge[] = [vehicleEdge];
while (vehicleEdge.continuationOf) {
chainedEdges.push(vehicleEdge.continuationOf);
vehicleEdge = vehicleEdge.continuationOf;
}
boardingEdge = vehicleEdge;
vehicleLeg = this.buildVehicleLeg(chainedEdges);
}
leg = vehicleLeg;
// Insert a guaranteed transfer leg between consecutive vehicle legs if
// applicable. Because we are building the array in reverse, the
// guaranteed transfer is pushed after the alighting leg so that after
// the final reverse() it sits between the two vehicle legs.
if (
previousVehicleEdge &&
this.timetable.isTripTransferGuaranteed(
{
stopIndex: boardingEdge.hopOffStopIndex,
routeId: boardingEdge.routeId,
tripIndex: boardingEdge.tripIndex,
},
{
stopIndex: previousVehicleEdge.stopIndex,
routeId: previousVehicleEdge.routeId,
tripIndex: previousVehicleEdge.tripIndex,
},
)
) {
route.push(
this.buildGuaranteedTransferLeg(boardingEdge, previousVehicleEdge),
);
}
previousVehicleEdge = boardingEdge;
} else if ('type' in edge) {
leg = this.buildTransferLeg(edge);
previousVehicleEdge = undefined;
} else if ('duration' in edge) {
leg = this.buildAccessLeg(edge);
previousVehicleEdge = undefined;
} else {
break;
}
route.push(leg);
currentStop = leg.from.id;
if ('routeId' in edge) {
round -= 1;
}
}
return new Route(route.reverse());
}
private buildServiceRouteInfo(route: TimetableRoute): ServiceRouteInfo {
const serviceRouteInfo = this.timetable.getServiceRouteInfo(route);
return {
type: routeTypeToString(serviceRouteInfo.type),
name: serviceRouteInfo.name,
};
}
/**
* Builds a vehicle leg from a chain of vehicle edges.
*
* @param edges Array of vehicle edges representing continuous trips on transit vehicles.
* edges[0] is the alighting edge (last in the journey); edges[length-1] is the
* boarding edge (first in the journey).
* @returns A vehicle leg with departure/arrival information and route details
* @throws Error if the edges array is empty
*/
private buildVehicleLeg(edges: VehicleEdge[]): VehicleLeg {
if (edges.length === 0) {
throw new Error('Cannot build vehicle leg from empty edges');
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const firstEdge = edges[edges.length - 1]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const lastEdge = edges[0]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const firstRoute = this.timetable.getRoute(firstEdge.routeId)!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const lastRoute = this.timetable.getRoute(lastEdge.routeId)!;
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
from: this.stopsIndex.findStopById(
firstRoute.stopId(firstEdge.stopIndex),
)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
to: this.stopsIndex.findStopById(
lastRoute.stopId(lastEdge.hopOffStopIndex),
)!,
// The route info comes from the first boarded route in case of continuous trips
route: this.buildServiceRouteInfo(firstRoute),
departureTime: firstRoute.departureFrom(
firstEdge.stopIndex,
firstEdge.tripIndex,
),
arrivalTime: lastEdge.arrival,
pickUpType: toPickupDropOffType(
firstRoute.pickUpTypeFrom(firstEdge.stopIndex, firstEdge.tripIndex),
),
dropOffType: toPickupDropOffType(
lastRoute.dropOffTypeAt(lastEdge.hopOffStopIndex, lastEdge.tripIndex),
),
};
}
/**
* Builds a transfer leg from a transfer edge.
*
* @param edge Transfer edge representing a walking connection between stops
* @returns A transfer leg with from/to stops and transfer details
*/
private buildTransferLeg(edge: TransferEdge): Transfer {
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
from: this.stopsIndex.findStopById(edge.from)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
to: this.stopsIndex.findStopById(edge.to)!,
minTransferTime: edge.minTransferTime,
type: transferTypeToString(edge.type),
};
}
/**
* Builds a transfer leg from a transfer edge.
*
* @param edge Transfer edge representing a walking connection between stops
* @returns A transfer leg with from/to stops and transfer details
*/
private buildAccessLeg(edge: AccessEdge): Access {
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
from: this.stopsIndex.findStopById(edge.from)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
to: this.stopsIndex.findStopById(edge.to)!,
duration: edge.duration,
};
}
/**
* Builds a guaranteed transfer leg between two consecutive vehicle legs.
*
* @param fromEdge The vehicle edge we're alighting from
* @param toEdge The vehicle edge we're boarding
* @returns A transfer leg with type 'GUARANTEED'
*/
private buildGuaranteedTransferLeg(
fromEdge: VehicleEdge,
toEdge: VehicleEdge,
): Transfer {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fromRoute = this.timetable.getRoute(fromEdge.routeId)!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const toRoute = this.timetable.getRoute(toEdge.routeId)!;
const fromStopId = fromRoute.stopId(fromEdge.hopOffStopIndex);
const toStopId = toRoute.stopId(toEdge.stopIndex);
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
from: this.stopsIndex.findStopById(fromStopId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
to: this.stopsIndex.findStopById(toStopId)!,
type: transferTypeToString(TransferTypes.GUARANTEED),
};
}
/**
* Returns the arrival time at any stop reachable in less time / transfers
* than this result's destination set.
*
* @param stop The target stop for which to return the arrival time.
* @param maxTransfers The optional maximum number of transfers allowed.
* @returns The arrival time if the target stop is reachable, otherwise undefined.
*/
arrivalAt(stop: StopId, maxTransfers?: number): Arrival | undefined {
const equivalentStops = this.stopsIndex.equivalentStops(stop);
let earliestArrival: Arrival | undefined = undefined;
for (const equivalentStop of equivalentStops) {
let arrivalTime;
if (
maxTransfers === undefined ||
this.routingState.getArrival(equivalentStop.id)?.legNumber ===
maxTransfers + 1
) {
arrivalTime = this.routingState.getArrival(equivalentStop.id);
} else {
// We have no guarantee that the stop was visited in the last round,
// so we need to check all rounds if it's not found in the last one.
for (let i = maxTransfers + 1; i >= 0; i--) {
const arrivalEdge = this.routingState.graph[i]?.[equivalentStop.id];
if (arrivalEdge !== undefined) {
arrivalTime = {
arrival: arrivalEdge.arrival,
legNumber: i,
};
break;
}
}
}
if (arrivalTime !== undefined) {
if (
earliestArrival === undefined ||
arrivalTime.arrival < earliestArrival.arrival
) {
earliestArrival = arrivalTime;
}
}
}
return earliestArrival;
}
}