Skip to content
31 changes: 20 additions & 11 deletions src/raptor/McRaptorAlgorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,32 @@ export interface Journey {
}
}

/**
* Represents a single segment of a journey, either a transit trip or a walking transfer.
*/
export interface JourneyLeg {
type: 'Trip' | 'Transfer';
interface JourneyLegCommon {
origin: StopID;
destination: StopID;
startTime: number;
endTime: number;
trip?: Trip;
transfer?: Transfer;
duration: number;
originID: StopID;
destinationID: StopID;
};

export interface JourneyLegTrip extends JourneyLegCommon {
type: 'Trip';
trip: Trip;
rt?: string;
stopTimes?: StopTime[];
}
stopTimes: StopTime[];
};

export interface JourneyLegTransfer extends JourneyLegCommon {
type: 'Transfer',
transfer: Transfer,
};

/**
* Represents a single segment of a journey, either a transit trip or a walking transfer.
*/
export type JourneyLeg = JourneyLegTransfer | JourneyLegTrip;

/**
* Implementation of the McRAPTOR (Multi-Criteria Round-Based Public Transit Routing) algorithm.
Expand Down Expand Up @@ -360,8 +369,8 @@ export class McRaptorAlgorithm {

for (const j of allJourneys) {
const tripsSignature = j.legs
.filter(l => l.type === 'Trip' && l.trip)
.map(l => l.trip!.tripId)
.filter((l) => l.type === 'Trip')
.map(l => l.trip.tripId)
.join('|');

if (!tripsSignature) {
Expand Down
33 changes: 13 additions & 20 deletions src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as journeyService from '../services/journey';
import * as reminderService from '../services/reminder';
import * as graphBuilder from '../services/graphBuilder';
import { startBackgroundJobs } from '../jobs';
import { BusRouteLineSchema } from "../services/bustimeCommon";
import * as documented from "./documented";

/**
Expand Down Expand Up @@ -188,27 +189,19 @@ export function getRidePositions(req: express.Request, res: express.Response) {
}
router.get('/getRidePositions', getRidePositions);

/**
* Returns all cached route patterns.
* @param req - Express request
* @param res - Express response
* @returns JSON object with `routes` mapping route IDs to patterns.
*/
export function getAllRoutes(req: express.Request, res: express.Response) {
res.json({ routes: state.cachedRoutes });
}
router.get('/getAllRoutes', getAllRoutes);
documented.addGetRoute(
documented.globalContext, router, '/getAllRoutes',
{ ...documented.emptyFormat, resBody: z.array(BusRouteLineSchema) },
() => documented.makeSuccessResponse(Object.values(state.cachedRoutes).flat(1)),
{ description: 'get all cached route patterns' }
);

/**
* Returns all cached ride route patterns.
* @param req - Express request
* @param res - Express response
* @returns JSON object with `routes` mapping route IDs to patterns.
*/
export function getAllRideRoutes(req: express.Request, res: express.Response) {
res.json({ routes: state.cachedRideRoutes });
}
router.get('/getAllRideRoutes', getAllRideRoutes);
documented.addGetRoute(
documented.globalContext, router, '/getAllRideRoutes',
{ ...documented.emptyFormat, resBody: z.array(BusRouteLineSchema) },
() => documented.makeSuccessResponse(Object.values(state.cachedRideRoutes).flat(1)),
{ description: 'get all cached ride route patterns' },
)

/**
* Returns the route timing cache used for extrapolation.
Expand Down
144 changes: 144 additions & 0 deletions src/services/bustimeCommon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import z from "zod";

const PatternPtSchema = z.object({
seq: z.int(),
typ: z.string(),
stpid: z.optional(z.string()),
stpnm: z.optional(z.string()),
pdist: z.optional(z.number()),
lat: z.number(),
lon: z.number(),
}).meta({ id: 'PatternPt' });

export const PatternSchema = z.object({
pid: z.int(),
ln: z.number(),
rtdir: z.string(),
pt: z.array(PatternPtSchema),
dtrid: z.optional(z.string()),
dtrpt: z.optional(z.array(PatternPtSchema)),
}).meta({ id: 'Pattern' });
export type Pattern = z.infer<typeof PatternSchema>

export const PatternsArraySchema = z.array(PatternSchema);

export const LatLonSchema = z.object({ lat: z.number(), lon: z.number() }).meta({ id: 'LatLon' });

export const BusStopSchema = z.object({
id: z.string(),
name: z.string(),
location: LatLonSchema,
routeId: z.string(),
rotation: z.number(),
isRide: z.boolean(),
}).meta({ id: 'BusStop' });
export type BusStop = z.infer<typeof BusStopSchema>;

export function makeBusStop(
{ id, name, lat, lon }: { id?: string, name?: string, lat?: number, lon?: number },
routeId: string, rotation: number, isRide: boolean
): BusStop {
return {
id: id ?? '',
name: name ? normalizeStopName(name) : '',
location: { lat: lat ?? 0, lon: lon ?? 0 },
routeId, rotation, isRide,
};
}

/** doesn't include color or image url, which are still handled by the frontend */
export const BusRouteLineSchema = z.object({
routeId: z.string(),
routeDirection: z.string(),
points: z.array(LatLonSchema),
stops: z.array(z.object({ index: z.int(), stop: BusStopSchema })),
}).meta({ id: 'BusRouteLine' });
export type BusRouteLine = z.infer<typeof BusRouteLineSchema>;

export function makeBusRouteLines(rt: string, pattern: Pattern, isRide: boolean): BusRouteLine[] {

const process = (pointList: Pattern['pt']): {
points: BusRouteLine['points'],
stops: BusRouteLine['stops']
} => {
const points = [];
const stops = [];
for (let i = 0; i < pointList.length; i++) {
const point = pointList[i];
const isLast = i == pointList.length - 1; // bool to check if last
points.push({ lat: point.lat, lon: point.lon });
if (point.typ === 'S') {
// get rotation of stop
let stopRotation;
if (isLast) {
// use the previous 2 points to calculate rotation
stopRotation = pointRotation(
pointList[i - 2]?.lat ?? 0,
pointList[i - 2]?.lon ?? 0,
pointList[i - 1]?.lat ?? 0,
pointList[i - 1]?.lon ?? 0,
);
} else {
// use the next 2 points to calculate rotation
stopRotation = pointRotation(
pointList[i + 1]?.lat ?? 0,
pointList[i + 1]?.lon ?? 0,
pointList[i + 2]?.lat ?? 0,
pointList[i + 2]?.lon ?? 0,
);
}
stops.push({
index: i,
stop : makeBusStop(
{ id: point.stpid, name: point.stpnm, lat: point.lat, lon: point.lon },
rt, stopRotation, isRide
)
});
}
}
return { points, stops };
}

const lines: BusRouteLine[] = [];
{
const { points, stops } = process(pattern.pt);
lines.push({ routeId: rt, points, stops, routeDirection: pattern.rtdir });
}

// Handle detour points if present
if (pattern.dtrpt) {
const { points, stops } = process(pattern.dtrpt);
lines.push({ routeId: rt, points, stops, routeDirection: pattern.rtdir });
}

return lines;
}

/**
* Function to calculate rotation angle between two geographical points
* (used for bus stop icon orientation)
*/
export function pointRotation(lat1: number, lon1: number, lat2: number, lon2: number): number {
const dLat = lat2 - lat1;
const dLon = lon2 - lon1;

// Scale longitude by cos(lat) to correct for east-west distance
const x = dLon * (Math.cos(lat1 * Math.PI / 180.0));
const y = dLat;

let angle = Math.atan2(x, y) * 180.0 / Math.PI;

// Normalize to [0, 360)
if (angle < 0) angle += 360;

return angle;
}

// KEEP THIS IN SYNC WITH THE CORRESPONDING FUNCTION IN THE FRONTEND
function normalizeStopName(rawStopName: string): string {
return rawStopName
.replaceAll('%', '')
.replaceAll(/\s+/g, ' ')
.trim();
}

80 changes: 34 additions & 46 deletions src/services/graphBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as process from "node:process";
import { MaxPriorityQueue } from '@datastructures-js/priority-queue';
import * as fs from 'fs';
import * as path from 'path';
import { makeBusRouteLines } from './bustimeCommon';

const DEFAULT_ROUTES = ["BB", "CN", "CS", "CSX", "DD", "MX", "NE", "NW", "NX", "OS", "NES", "WS", "WX"];
const DEFAULT_RIDE_ROUTES = ["3", "4", "5", "6", "22", "23", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "42", "43", "44", "45", "46", "47", "61", "62", "63", "64", "65", "66", "67", "68", "104"];
Expand All @@ -32,13 +33,13 @@ export async function initializeRoutes() {
await Promise.all(routesData.map(async (r: any) => {
state.validRoutes.add(r.rt);
const patterns = await mbus.fetchPatterns(r.rt);
if (patterns) state.cachedRoutes[r.rt] = patterns;
state.cachedRoutes[r.rt] = patterns.map((p) => makeBusRouteLines(r.rt, p, false)).flat(1);
}));

await Promise.all(rideRoutesData.map(async (r: any) => {
state.validRideRoutes.add(r.rt);
const patterns = await rideBus.fetchPatterns(r.rt);
if (patterns) state.cachedRideRoutes[r.rt] = patterns;
state.cachedRideRoutes[r.rt] = patterns.map((p) => makeBusRouteLines(r.rt, p, true)).flat(1);
}));

buildStopLocationMap();
Expand All @@ -57,11 +58,10 @@ export async function rebuildGraph() {
try {
console.log(`Rebuilding graph...`);
const allStopIds = new Set<string>();
Object.values(state.cachedRoutes).forEach((patterns: any) => {
patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => {
if (pt.stpid) allStopIds.add(pt.stpid);
}));
});
Object.values(state.cachedRoutes)
.flat(1)
.flatMap((line) => line.stops)
.forEach(({ index: _, stop }) => allStopIds.add(stop.id));

const rawPreds = await mbus.fetchPredictions(Array.from(allStopIds), DEFAULT_ROUTES);
const formattedPreds = processPredictions(rawPreds);
Expand All @@ -74,11 +74,10 @@ export async function rebuildGraph() {

// extra stuff to update the busses for the ride
const rideStopIds = new Set<string>();
Object.values(state.cachedRideRoutes).forEach((patterns: any) => {
patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => {
if (pt.stpid) rideStopIds.add(pt.stpid);
}));
});
Object.values(state.cachedRideRoutes)
.flat(1)
.flatMap((line) => line.stops)
.forEach(({ index: _, stop }) => rideStopIds.add(stop.id));
const rawRidePreds = await rideBus.fetchPredictions(Array.from(rideStopIds), DEFAULT_RIDE_ROUTES);
const formattedRidePreds = processRidePredictions(rawRidePreds);
populateRideLookupMaps(formattedRidePreds);
Expand All @@ -100,13 +99,10 @@ export async function rebuildGraph() {
* @param preds List of processed predictions
*/
function populateLookupMaps(preds: any[]) {
Object.values(state.cachedRoutes).forEach((patterns: any) => {
patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => {
if (pt.stpid && pt.stpnm) {
state.stopIdToName[pt.stpid] = pt.stpnm;
}
}));
});
Object.values(state.cachedRoutes)
.flat(1)
.flatMap((line) => line.stops)
.forEach(({ index: _, stop }) => state.stopIdToName[stop.id] = stop.name);
preds.forEach((trip: any) => {
trip.stops.forEach((stop: any) => {
if (stop.stpid && stop.stpnm) {
Expand All @@ -130,13 +126,10 @@ function populateLookupMaps(preds: any[]) {
* @param preds List of processed predictions from the ride
*/
function populateRideLookupMaps(preds: any[]) {
Object.values(state.cachedRideRoutes).forEach((patterns: any) => {
patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => {
if (pt.stpid && pt.stpnm) {
state.rideStopIdToName[pt.stpid] = pt.stpnm;
}
}));
});
Object.values(state.cachedRideRoutes)
.flat(1)
.flatMap((line) => line.stops)
.forEach(({ index: _, stop }) => state.rideStopIdToName[stop.id] = stop.name);
preds.forEach((trip: any) => {
trip.stops.forEach((stop: any) => {
if (stop.stpid && stop.stpnm) {
Expand All @@ -152,13 +145,12 @@ function populateRideLookupMaps(preds: any[]) {
*/
function buildStopLocationMap() {
const locs: Record<string, any> = {};
Object.values(state.cachedRoutes).forEach((patterns: any) => {
patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => {
if (pt.stpid && pt.lat) {
locs[pt.stpid] = { name: pt.stpnm, lat: parseFloat(pt.lat), lon: parseFloat(pt.lon) };
}
}));
});
Object.values(state.cachedRoutes)
.flat(1)
.flatMap((line) => line.stops)
.forEach(({ index: _, stop }) =>
locs[stop.id] = { name: stop.name, lat: stop.location.lat, lon: stop.location.lon }
);
state.setCachedStopLocations(locs);
walking.buildStopNodeMap(locs);
}
Expand All @@ -168,13 +160,11 @@ function buildStopLocationMap() {
*/
function buildRideStops() {
const locs: Record<string, any> = {};
Object.values(state.cachedRideRoutes).forEach((patterns: any) => {
patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => {
if (pt.stpid && pt.lat) {
locs[pt.stpid] = { name: pt.stpnm, lat: parseFloat(pt.lat), lon: parseFloat(pt.lon) };
}
}));
});
Object.values(state.cachedRideRoutes)
.flat(1)
.flatMap((line) => line.stops)
.forEach(({ index: _, stop }) =>
locs[stop.id] = { name: stop.name, lat: stop.location.lat, lon: stop.location.lon });
state.setCachedRideStopLocations(locs);
}

Expand Down Expand Up @@ -242,15 +232,13 @@ function processPredictions(rawChunks: any[]) {

// build index maps
const routeInfoFilter: Record<string, { stpid: string; rtdir: string }[]> = {};
for (const [routeName, routeList] of Object.entries(state.cachedRoutes as Record<string, any[]>)) {
for (const [routeName, routeList] of Object.entries(state.cachedRoutes)) {
for (const route of routeList) {
const rtdir = route.rtdir;
const rtdir = route.routeDirection;
const routeKey = routeName + rtdir;
if (!routeInfoFilter[routeKey]) routeInfoFilter[routeKey] = [];
for (const point of route.pt) {
if (point.typ !== "W" && point.stpid) {
routeInfoFilter[routeKey].push({ stpid: point.stpid, rtdir });
}
for (const { index: _, stop } of route.stops) {
routeInfoFilter[routeKey].push({ stpid: stop.id, rtdir });
}
}
}
Expand Down
Loading