UNPKG

minotor

Version:

A lightweight client-side transit routing library.

386 lines (364 loc) 13.7 kB
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; } }