minotor
Version:
A lightweight client-side transit routing library.
585 lines (516 loc) • 21.5 kB
text/typescript
import repl from 'node:repl';
import fs from 'fs';
import { Query, RangeQuery, Router, StopsIndex, Timetable } from '../router.js';
import type { Stop } from '../stops/stops.js';
import type { RawPickUpDropOffType } from '../timetable/route.js';
import { PickUpDropOffTypes, Route } from '../timetable/route.js';
import { timeFromString, timeToString } from '../timetable/time.js';
import { plotGraphToDotFile } from './utils.js';
export const startRepl = (stopsPath: string, timetablePath: string) => {
const stopsIndex = StopsIndex.fromData(fs.readFileSync(stopsPath));
const timetable = Timetable.fromData(fs.readFileSync(timetablePath));
console.log(`Minotor Transit Router CLI`);
console.log(
'Enter your stop (.find) or routing (.route) queries. Type ".exit" to quit.',
);
const replServer = repl.start({
prompt: 'minotor> ',
ignoreUndefined: true,
});
replServer.context.stopFinder = stopsIndex;
replServer.defineCommand('find', {
help: 'Find stops by name using .find <query>',
action(query: string) {
this.clearBufferedCommand();
let stops = [];
const stopBySourceId = stopsIndex.findStopBySourceStopId(query);
if (stopBySourceId !== undefined) {
stops.push(stopBySourceId);
} else if (!isNaN(Number(query))) {
const stopById = stopsIndex.findStopById(Number(query));
if (stopById !== undefined) {
stops.push(stopById);
}
} else {
stops = stopsIndex.findStopsByName(query);
}
stops.forEach((stop) => {
console.log(`${stop.name} (${stop.sourceStopId} - ${stop.id})`);
});
this.displayPrompt();
},
});
const routeSyntax =
'.route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers] [wait max <N> minutes]';
replServer.defineCommand('route', {
help: `Find a route using ${routeSyntax}`,
action(routeQuery: string) {
this.clearBufferedCommand();
const parts = routeQuery.split(' ').filter(Boolean);
const fromIndex = parts.indexOf('from');
const toIndex = parts.indexOf('to');
const atIndex = parts.indexOf('at');
const beforeIndex = parts.indexOf('before');
const withIndex = parts.indexOf('with');
const waitIndex = parts.indexOf('wait');
const routeClauseIndexes = [beforeIndex, withIndex, waitIndex].filter(
(index) => index !== -1,
);
if (fromIndex === -1 || toIndex === -1 || atIndex === -1) {
console.log(`Usage: ${routeSyntax}`);
this.displayPrompt();
return;
}
const fromId = parts.slice(fromIndex + 1, toIndex).join(' ');
const toId = parts.slice(toIndex + 1, atIndex).join(' ');
// atTime ends at 'before', 'with', 'wait', or the end of the input.
const atTimeEnd = Math.min(
...routeClauseIndexes.filter((index) => index > atIndex),
parts.length,
);
const atTime = parts.slice(atIndex + 1, atTimeEnd).join(' ');
// beforeTime is only present when the 'before' keyword appears.
const beforeTimeEnd = Math.min(
...[withIndex, waitIndex].filter(
(index) => index !== -1 && index > beforeIndex,
),
parts.length,
);
const beforeTime =
beforeIndex !== -1
? parts.slice(beforeIndex + 1, beforeTimeEnd).join(' ')
: undefined;
const maxTransfers =
withIndex !== -1 && parts[withIndex + 1] !== undefined
? parseInt(parts[withIndex + 1] as string)
: 4;
const maxInitialWaitingTime =
waitIndex !== -1 &&
parts[waitIndex + 1] === 'max' &&
parts[waitIndex + 2] !== undefined
? Number(parts[waitIndex + 2])
: undefined;
const waitUnit = parts[waitIndex + 3];
const hasInvalidWaitClause =
waitIndex !== -1 &&
(parts[waitIndex + 1] !== 'max' ||
maxInitialWaitingTime === undefined ||
!Number.isFinite(maxInitialWaitingTime) ||
maxInitialWaitingTime < 0 ||
(waitUnit !== 'minute' && waitUnit !== 'minutes'));
if (!fromId || !toId || !atTime || hasInvalidWaitClause) {
console.log(`Usage: ${routeSyntax}`);
this.displayPrompt();
return;
}
const fromStop =
stopsIndex.findStopBySourceStopId(fromId) ||
(isNaN(Number(fromId))
? undefined
: stopsIndex.findStopById(Number(fromId))) ||
stopsIndex.findStopsByName(fromId)[0];
const toStop =
stopsIndex.findStopBySourceStopId(toId) ||
(isNaN(Number(toId))
? undefined
: stopsIndex.findStopById(Number(toId))) ||
stopsIndex.findStopsByName(toId)[0];
if (!fromStop) {
console.log(`No stop found for 'from' ID or name: ${fromId}`);
this.displayPrompt();
return;
}
if (!toStop) {
console.log(`No stop found for 'to' ID or name: ${toId}`);
this.displayPrompt();
return;
}
try {
const departureTime = timeFromString(atTime);
const router = new Router(timetable, stopsIndex);
if (beforeTime !== undefined) {
const lastDepartureTime = timeFromString(beforeTime);
const queryBuilder = new RangeQuery.Builder()
.from(fromStop.id)
.to(toStop.id)
.departureTime(departureTime)
.lastDepartureTime(lastDepartureTime)
.maxTransfers(maxTransfers);
if (maxInitialWaitingTime !== undefined) {
queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime);
}
const query = queryBuilder.build();
const result = router.rangeRoute(query);
if (result.size === 0) {
console.log(
`No journeys found from ${fromStop.name} to ${toStop.name} ` +
`between ${atTime} and ${beforeTime}.`,
);
} else {
console.log(
`Found ${result.size} Pareto-optimal journey${result.size === 1 ? '' : 's'} ` +
`from ${fromStop.name} to ${toStop.name} ` +
`(window ${atTime}–${beforeTime}):`,
);
const routes = result.getRoutes();
routes.forEach((route, index) => {
const journeyNumber = index + 1;
console.log(`\nJourney ${journeyNumber}:`);
console.log(route.toString());
});
}
} else {
const queryBuilder = new Query.Builder()
.from(fromStop.id)
.to(toStop.id)
.departureTime(departureTime)
.maxTransfers(maxTransfers);
if (maxInitialWaitingTime !== undefined) {
queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime);
}
const query = queryBuilder.build();
const result = router.route(query);
const arrivalTime = result.arrivalAt(toStop.id);
if (arrivalTime === undefined) {
console.log(`Destination not reachable`);
} else {
const transfers = Math.max(0, arrivalTime.legNumber - 1);
console.log(
`Arriving to ${toStop.name} at ${timeToString(arrivalTime.arrival)} ` +
`with ${transfers} transfer${transfers === 1 ? '' : 's'} ` +
`from ${fromStop.name}.`,
);
}
const bestRoute = result.bestRoute(toStop.id);
if (bestRoute) {
console.log(`Found route from ${fromStop.name} to ${toStop.name}:`);
console.log(bestRoute.toString());
} else {
console.log('No route found');
}
}
} catch (error) {
console.log('Error querying route:', error);
}
this.displayPrompt();
},
});
replServer.defineCommand('plot', {
help: 'Plot a network graph using .plot from <stationId> to <stationId> at <HH:mm> [with <N> transfers] [to <graph.dot>]',
action(routeQuery: string) {
this.clearBufferedCommand();
const parts = routeQuery.split(' ').filter(Boolean);
const withTransfersIndex = parts.indexOf('with');
const maxTransfers =
withTransfersIndex !== -1 && parts[withTransfersIndex + 1] !== undefined
? parseInt(parts[withTransfersIndex + 1] as string)
: 1;
const atTimeIndex = parts.indexOf('at');
const atTime = parts
.slice(
atTimeIndex + 1,
withTransfersIndex === -1
? parts.indexOf('to', atTimeIndex) >= 0
? parts.indexOf('to', atTimeIndex)
: parts.length
: withTransfersIndex,
)
.join(' ');
const fromIndex = parts.indexOf('from');
const toIndex = parts.indexOf('to');
const toFileIndex =
toIndex !== -1 && parts.indexOf('to', toIndex + 1) !== -1
? parts.indexOf('to', toIndex + 1)
: -1;
const fromId = parts.slice(fromIndex + 1, toIndex).join(' ');
const toId = parts.slice(toIndex + 1, atTimeIndex).join(' ');
const outputFile =
toFileIndex !== -1
? parts.slice(toFileIndex + 1).join(' ')
: `${fromId.replace(/ /g, '')}-${toId.replace(/ /g, '')}-${atTime.replace(/:/g, '')}.dot`;
if (!fromId || !toId || !atTime || isNaN(maxTransfers)) {
console.log(
'Usage: .plot from <stationId> to <stationId> at <HH:mm> [with <N> transfers] [to <graph.dot>]',
);
this.displayPrompt();
return;
}
const fromStop =
stopsIndex.findStopBySourceStopId(fromId) ||
stopsIndex.findStopsByName(fromId)[0];
const toStop =
stopsIndex.findStopBySourceStopId(toId) ||
stopsIndex.findStopsByName(toId)[0];
if (!fromStop) {
console.log(`No stop found for 'from' ID or name: ${fromId}`);
this.displayPrompt();
return;
}
if (!toStop) {
console.log(`No stop found for 'to' ID or name: ${toId}`);
this.displayPrompt();
return;
}
const departureTime = timeFromString(atTime);
try {
const query = new Query.Builder()
.from(fromStop.id)
.to(toStop.id)
.departureTime(departureTime)
.maxTransfers(maxTransfers)
.build();
const router = new Router(timetable, stopsIndex);
const result = router.route(query);
plotGraphToDotFile(result, outputFile);
} catch (error) {
console.log('Error plotting route:', error);
}
this.displayPrompt();
},
});
const formatPickupDropoffType = (type: RawPickUpDropOffType): string => {
switch (type) {
case PickUpDropOffTypes.REGULAR:
return 'R';
case PickUpDropOffTypes.NOT_AVAILABLE:
return 'N';
case PickUpDropOffTypes.MUST_PHONE_AGENCY:
return 'A';
case PickUpDropOffTypes.MUST_COORDINATE_WITH_DRIVER:
return 'D';
default:
return '?';
}
};
replServer.defineCommand('inspect', {
help: 'Inspect a route or stop using .inspect route <routeId> or .inspect stop <stopId>',
action(inspectQuery: string) {
this.clearBufferedCommand();
const parts = inspectQuery.trim().split(' ');
if (parts.length !== 2) {
console.log(
'Usage: .inspect route <routeId> or .inspect stop <stopId>',
);
this.displayPrompt();
return;
}
const [type, idStr] = parts;
if (type !== 'route' && type !== 'stop') {
console.log(
'Usage: .inspect route <routeId> or .inspect stop <stopId>',
);
this.displayPrompt();
return;
}
const inspectRoute = (routeIdStr: string) => {
const routeId = parseInt(routeIdStr.trim());
if (isNaN(routeId)) {
console.log('Usage: .inspect route <routeId>');
return;
}
const route = timetable.getRoute(routeId);
if (!route) {
console.log(`Route ${routeId} not found`);
return;
}
const serviceRouteInfo = timetable.getServiceRouteInfo(route);
const routeName = serviceRouteInfo.name;
const routeType = serviceRouteInfo.type;
console.log(`\n=== Route ${routeId} ===`);
console.log(`Service Route: ${routeName}`);
console.log(`Type: ${routeType}`);
console.log(`Number of stops: ${route.getNbStops()}`);
console.log(`Number of trips: ${route.getNbTrips()}`);
console.log('\n--- Stops ---');
for (let i = 0; i < route.stops.length; i++) {
const stopId = route.stopId(i);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const stop = stopsIndex.findStopById(stopId)!;
const platform = stop.platform ? ` (Pl. ${stop.platform})` : '';
console.log(
`${i + 1}. ${stop.name}${platform} (${stopId}, ${stop.sourceStopId})`,
);
}
console.log('\n--- Trips ---');
for (let tripIndex = 0; tripIndex < route.getNbTrips(); tripIndex++) {
console.log(`\nTrip ${tripIndex}:`);
for (let stopIndex = 0; stopIndex < route.stops.length; stopIndex++) {
const stopId = route.stopId(stopIndex);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const stop = stopsIndex.findStopById(stopId)!;
const departure = route.departureFrom(stopIndex, tripIndex);
const arrival = route.arrivalAt(stopIndex, tripIndex);
const pickupType = route.pickUpTypeFrom(stopIndex, tripIndex);
const dropOffType = route.dropOffTypeAt(stopIndex, tripIndex);
const pickupStr = formatPickupDropoffType(pickupType);
const dropOffStr = formatPickupDropoffType(dropOffType);
console.log(
` ${stopIndex + 1}. ${stop.name}: arr ${timeToString(arrival)} (${pickupStr}) → dep ${timeToString(departure)} (${dropOffStr})`,
);
}
}
console.log();
};
const inspectStop = (stopIdStr: string) => {
let stop: Stop | undefined;
const stopBySourceId = stopsIndex.findStopBySourceStopId(stopIdStr);
if (stopBySourceId !== undefined) {
stop = stopBySourceId;
} else if (!isNaN(Number(stopIdStr))) {
const stopById = stopsIndex.findStopById(Number(stopIdStr));
if (stopById !== undefined) {
stop = stopById;
}
} else {
const stops = stopsIndex.findStopsByName(stopIdStr);
if (stops.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
stop = stops[0]!;
}
}
if (!stop) {
console.log(`Stop not found: ${stopIdStr}`);
return;
}
console.log(`\n=== Stop ${stop.id} ===`);
console.log(`Name: ${stop.name}`);
if (stop.platform) {
console.log(`Platform: ${stop.platform}`);
}
console.log(`Source ID: ${stop.sourceStopId}`);
const routes: Route[] = timetable.routesPassingThrough(stop.id);
console.log(`Number of routes: ${routes.length}`);
const equivalentStops = stopsIndex
.equivalentStops(stop.id)
.filter((equivStop) => equivStop.id !== stop.id);
console.log(`Number of equivalent stops: ${equivalentStops.length}`);
if (equivalentStops.length > 0) {
console.log('\n--- Equivalent Stops ---');
equivalentStops.forEach((equivStop, index) => {
const platform = equivStop.platform
? ` (Pl. ${equivStop.platform})`
: '';
console.log(
`${index + 1}. ${equivStop.name}${platform} (${equivStop.id}, ${equivStop.sourceStopId})`,
);
});
}
if (routes.length > 0) {
console.log('\n--- Routes ---');
routes.forEach((route, index) => {
const serviceRouteInfo = timetable.getServiceRouteInfo(route);
console.log(
`${index + 1}. Route ${route.id}: ${serviceRouteInfo.name} (${serviceRouteInfo.type})`,
);
});
}
const transfers = timetable.getTransfers(stop.id);
console.log(`Number of transfers: ${transfers.length}`);
if (transfers.length > 0) {
console.log('\n--- Transfers ---');
transfers.forEach((transfer, index) => {
const destStop = stopsIndex.findStopById(transfer.destination);
const platform = destStop?.platform
? ` (Pl. ${destStop.platform})`
: '';
const minTime = transfer.minTransferTime
? ` (min: ${transfer.minTransferTime}min)`
: '';
console.log(
`${index + 1}. ${transfer.type} to ${destStop?.name ?? 'Unknown'}${platform} (${transfer.destination}, ${destStop?.sourceStopId ?? 'N/A'})${minTime}`,
);
});
}
let totalContinuations = 0;
console.log('\n--- Trip Continuations ---');
routes.forEach((route: Route) => {
const serviceRouteInfo = timetable.getServiceRouteInfo(route);
const stopIndices = route.stopRouteIndices(stop.id);
for (let tripIndex = 0; tripIndex < route.getNbTrips(); tripIndex++) {
for (const stopIndex of stopIndices) {
const continuations = timetable.getContinuousTrips(
stopIndex,
route.id,
tripIndex,
);
for (const continuation of continuations) {
totalContinuations++;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destRoute = timetable.getRoute(continuation.routeId)!;
const destStopId = destRoute.stopId(continuation.stopIndex);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destStop = stopsIndex.findStopById(destStopId)!;
const destPlatform = destStop.platform
? ` (Pl. ${destStop.platform})`
: '';
const destServiceRouteInfo =
timetable.getServiceRouteInfo(destRoute);
const originTime = route.departureFrom(stopIndex, tripIndex);
const continuationTime = destRoute.departureFrom(
continuation.stopIndex,
continuation.tripIndex,
);
console.log(
`${totalContinuations}. From Route ${route.id} (${serviceRouteInfo.name}) Trip ${tripIndex} at ${timeToString(originTime)} → ` +
`Route ${continuation.routeId} (${destServiceRouteInfo.name}) Trip ${continuation.tripIndex} at ${timeToString(continuationTime)} ` +
`at ${destStop.name}${destPlatform} (${destStopId}, ${destStop.sourceStopId})`,
);
}
}
}
});
if (totalContinuations === 0) {
console.log('No trip continuations found.');
} else {
console.log(`\nTotal trip continuations: ${totalContinuations}`);
}
let totalGuaranteedTransfers = 0;
console.log('\n--- Guaranteed Trip Transfers ---');
routes.forEach((route: Route) => {
const serviceRouteInfo = timetable.getServiceRouteInfo(route);
const stopIndices = route.stopRouteIndices(stop.id);
for (let tripIndex = 0; tripIndex < route.getNbTrips(); tripIndex++) {
for (const stopIndex of stopIndices) {
const guaranteedTransfers = timetable.getGuaranteedTripTransfers(
stopIndex,
route.id,
tripIndex,
);
for (const guaranteedTrip of guaranteedTransfers) {
totalGuaranteedTransfers++;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destRoute = timetable.getRoute(guaranteedTrip.routeId)!;
const destServiceRouteInfo =
timetable.getServiceRouteInfo(destRoute);
const originTime = route.arrivalAt(stopIndex, tripIndex);
const destinationTime = destRoute.departureFrom(
guaranteedTrip.stopIndex,
guaranteedTrip.tripIndex,
);
console.log(
`${totalGuaranteedTransfers}. From Route ${route.id} (${serviceRouteInfo.name}) Trip ${tripIndex} at ${timeToString(originTime)} → ` +
`Route ${guaranteedTrip.routeId} (${destServiceRouteInfo.name}) Trip ${guaranteedTrip.tripIndex} at ${timeToString(destinationTime)} `,
);
console.log(stopIndex, route.id, tripIndex);
}
}
}
});
if (totalGuaranteedTransfers === 0) {
console.log('No guaranteed trip transfers found.');
} else {
console.log(
`\nTotal guaranteed trip transfers: ${totalGuaranteedTransfers}`,
);
}
console.log();
};
if (type === 'route') {
inspectRoute(idStr ?? '');
} else {
inspectStop(idStr ?? '');
}
this.displayPrompt();
},
});
};