diff --git a/src/app.ts b/src/app.ts index c5508a6..cd789ba 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,12 +1,14 @@ import express from "express"; -import mbus from "./routes/api" +import mbus from "./routes/api"; +import v4 from "./routes/v4"; import * as documented from "./routes/documented"; const app = express(); app.use(express.json()); documented.addRouter(documented.globalContext, app, "/mbus/api/v3", mbus); +documented.addRouter(documented.globalContext, app, "/api/v4", v4); app.use("/docs", express.static("docs")); const PORT = process.env.PORT || 3000; diff --git a/src/raptor/McRaptorAlgorithm.ts b/src/raptor/McRaptorAlgorithm.ts index 164b1f8..43d1bba 100644 --- a/src/raptor/McRaptorAlgorithm.ts +++ b/src/raptor/McRaptorAlgorithm.ts @@ -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. @@ -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) { diff --git a/src/routes/api.ts b/src/routes/api.ts index eda7f80..7ecbe82 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -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"; /** @@ -188,27 +189,15 @@ 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); +// remove when mb2 support is dropped +router.get('/getAllRoutes', (_, res) => { + res.json({ routes: state.cachedRoutesLegacy }) +}); -/** - * 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); +// remove when mb2 support is dropped +router.get('/getAllRideRoutes', (_, res) => { + res.json({ routes: state.cachedRideRoutesLegacy }); +}); /** * Returns the route timing cache used for extrapolation. @@ -381,7 +370,7 @@ documented.addGetRoute( .transform((x) => x === undefined ? undefined : parseInt(x)) .pipe(z.optional(z.number())), }), - resBody: z.any(), + resBody: z.object({ journeys: z.array(journeyService.ProcessedJourneySchema) }), }, async (_, { originLat, originLon, destLat, destLon, walkingPenalty, range }) => { try { diff --git a/src/routes/v4.ts b/src/routes/v4.ts new file mode 100644 index 0000000..d6b832e --- /dev/null +++ b/src/routes/v4.ts @@ -0,0 +1,31 @@ +/** + * Changes to the served api that are NOT backwards compatible with mb2 should go here. + * + * Try to use documented instead of raw express. + * @module + */ + +import express from 'express'; +import * as z from 'zod'; +import * as state from '@/state/transitState'; +import { BusRouteLineSchema } from '@/services/bustimeCommon'; +import * as documented from './documented'; + +const router = express.Router(); +const ctx = documented.globalContext; + +documented.addGetRoute( + ctx, router, '/getAllRoutes', + { ...documented.emptyFormat, resBody: z.array(BusRouteLineSchema) }, + async () => documented.makeSuccessResponse(Object.values(state.cachedRoutes).flat(1)), + { description: 'get all cached route patterns' } +); + +documented.addGetRoute( + ctx, router, '/getAllRideRoutes', + { ...documented.emptyFormat, resBody: z.array(BusRouteLineSchema) }, + async () => documented.makeSuccessResponse(Object.values(state.cachedRideRoutes).flat(1)), + { description: 'get all cached ride route patterns' }, +) + +export default router; diff --git a/src/services/bustimeCommon.ts b/src/services/bustimeCommon.ts new file mode 100644 index 0000000..c7ba73c --- /dev/null +++ b/src/services/bustimeCommon.ts @@ -0,0 +1,185 @@ +import z from "zod"; + +// ========== patterns & bus lines ========= + +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 + +export const LatLonSchema = z.object({ lat: z.number(), lon: z.number() }).meta({ id: 'LatLon' }); +export type LatLon = z.infer; + +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; + +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; + +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(); +} + +// ========= predictions ========== + +// fields that were not present in practice are commented out +// might've missed a field given it happened with prdctdn (in response fields table but not the xml schema)... +export const PredictionSchema = z.object({ + tmstmp: z.string(), + typ: z.string(), + stpid: z.string(), + stpnm: z.string(), + vid: z.string(), // number in schema + dstp: z.number(), + rt: z.string(), + rtdd: z.string(), + rtdir: z.string(), + des: z.string(), + prdtm: z.string(), + dly: z.optional(z.boolean()), + dyn: z.number(), + tablockid: z.string(), + tatripid: z.string(), + origtatripno: z.string(), + prdctdn: z.string(), // not in xml schema + zone: z.string(), + psgld: z.string(), + // gtfsseq: z.string(), + nbus: z.optional(z.string()), + stst: z.optional(z.number()), + stsd: z.optional(z.string()), // number? in schema + // flagStop: z.number(), +}).meta({ id: 'Prediction' }); +export type Prediction = z.infer; + +export const GetPredictionsResponseSchema = z.object({ + 'bustime-response': z.object({ + 'prd': z.optional(z.array(PredictionSchema)), + 'error': z.optional(z.unknown()), + }) +}).meta({ id: 'GetPredictionsResponse' }); +export type GetPredictionsResponse = z.infer; + diff --git a/src/services/graphBuilder.ts b/src/services/graphBuilder.ts index 68ac957..0622dd4 100644 --- a/src/services/graphBuilder.ts +++ b/src/services/graphBuilder.ts @@ -7,10 +7,19 @@ import * as process from "node:process"; import { MaxPriorityQueue } from '@datastructures-js/priority-queue'; import * as fs from 'fs'; import * as path from 'path'; +import * as bustime from './bustimeCommon'; +import { toKey } from '@/types'; 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"]; +type FormattedPrediction = { + tatripid: string, + vid: string, + des: string, + stops: Array<{ stpnm: string, stpid: string, prdctdn: string, rt: string, rtdir: string, prdtm: number, isExtrapolated?: boolean }>, +}; + /** Fetches and updates current bus positions in state. */ export async function updateBusPositions() { const buses = await mbus.fetchVehicles(DEFAULT_ROUTES); @@ -32,15 +41,18 @@ 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) => bustime.makeBusRouteLines(r.rt, p, false)).flat(1); + state.cachedRoutesLegacy[r.rt] = patterns; })); 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) => bustime.makeBusRouteLines(r.rt, p, true)).flat(1); + state.cachedRideRoutesLegacy[r.rt] = patterns; })); + buildStopToStopPaths(); buildStopLocationMap(); buildRideStops(); await buildWalkingTransfers(); @@ -57,11 +69,10 @@ export async function rebuildGraph() { try { console.log(`Rebuilding graph...`); const allStopIds = new Set(); - 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); @@ -74,11 +85,10 @@ export async function rebuildGraph() { // extra stuff to update the busses for the ride const rideStopIds = new Set(); - 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); @@ -99,25 +109,22 @@ export async function rebuildGraph() { * Populates lookup maps for stop names and trip-to-route mappings. * @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; - } - })); - }); - preds.forEach((trip: any) => { - trip.stops.forEach((stop: any) => { +function populateLookupMaps(preds: FormattedPrediction[]) { + Object.values(state.cachedRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => state.stopIdToName[stop.id] = stop.name); + preds.forEach((trip) => { + trip.stops.forEach((stop) => { if (stop.stpid && stop.stpnm) { state.stopIdToName[stop.stpid] = stop.stpnm; } }); }); - preds.forEach((trip: any) => { + preds.forEach((trip) => { if (trip.tatripid && trip.stops.length > 0) { - const firstStopWithRt = trip.stops.find((s: any) => s.rt); + const firstStopWithRt = trip.stops.find((s) => s.rt); if (firstStopWithRt) { state.tatripidToRt[trip.tatripid] = firstStopWithRt.rt; } @@ -129,16 +136,13 @@ function populateLookupMaps(preds: any[]) { * Populates the lookup map for ride stop names * @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; - } - })); - }); - preds.forEach((trip: any) => { - trip.stops.forEach((stop: any) => { +function populateRideLookupMaps(preds: FormattedPrediction[]) { + Object.values(state.cachedRideRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => state.rideStopIdToName[stop.id] = stop.name); + preds.forEach((trip) => { + trip.stops.forEach((stop) => { if (stop.stpid && stop.stpnm) { state.rideStopIdToName[stop.stpid] = stop.stpnm; } @@ -152,13 +156,12 @@ function populateRideLookupMaps(preds: any[]) { */ function buildStopLocationMap() { const locs: Record = {}; - 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); } @@ -168,13 +171,11 @@ function buildStopLocationMap() { */ function buildRideStops() { const locs: Record = {}; - 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); } @@ -205,52 +206,67 @@ async function buildWalkingTransfers() { }); } +function buildStopToStopPaths() { + const data = state.cachedStopToStopPaths; + data.clear(); + for (const directory of [state.cachedRoutes, state.cachedRideRoutes]) { + for (const rtId in directory) { + for (const rt of state.cachedRoutes[rtId] ?? []) { + for (let i = 1; i < rt.stops.length; i++) { + const from = rt.stops[i - 1]; + const to = rt.stops[i]; + const key = toKey({ rt: rtId, from: from.stop.id, to: to.stop.id }); + if (data.has(key)) continue; + data.set(key, rt.points.slice(from.index, to.index)); + } + } + } + } +} + /** * Processes raw prediction chunks into a structured format. * Handles flattening, sorting, and extrapolating predictions. * @param rawChunks Raw API response chunks */ -function processPredictions(rawChunks: any[]) { - const formattedPredictions = rawChunks.flat().reduce((acc: any[], chunk: any) => { - if (chunk['bustime-response']?.['prd']) { - chunk['bustime-response']['prd'].forEach((prd: any) => { - let trip = acc.find((t: any) => t.tatripid === prd.tatripid); - // If no tatripid, try to match by vid (mbus API specifics) - if (!trip && prd.vid) trip = acc.find((t: any) => t.vid === prd.vid); - // If no match, create new trip - if (!trip) { - trip = { tatripid: prd.tatripid, vid: prd.vid, des: prd.des, stops: [] }; - acc.push(trip); - } else { - if (!trip.tatripid) trip.tatripid = prd.tatripid; - if (!trip.vid && prd.vid) trip.vid = prd.vid; - } - // If no stop, create new stop - let stop = trip.stops.find((s: any) => s.stpid === prd.stpid); - if (!stop) { - stop = { stpnm: prd.stpnm, stpid: prd.stpid, prdctdn: null, rt: null, rtdir: null }; - trip.stops.push(stop); - } - stop.rtdir = prd.rtdir; - stop.rt = prd.rt; - stop.prdctdn = prd.prdctdn === "DUE" ? "1" : prd.prdctdn; - stop.prdtm = parseInt(prd.prdtm); - }); - } +function processPredictions(rawChunks: Array): FormattedPrediction[] { + const formattedPredictions = rawChunks.flat().reduce((acc, chunk) => { + if (!chunk || !chunk['bustime-response'].prd) return acc; + chunk['bustime-response']['prd'].forEach((prd) => { + let trip = acc.find((t) => t.tatripid === prd.tatripid); + // If no tatripid, try to match by vid (mbus API specifics) + if (!trip && prd.vid) trip = acc.find((t) => t.vid === prd.vid); + // If no match, create new trip + if (!trip) { + trip = { tatripid: prd.tatripid, vid: prd.vid, des: prd.des, stops: [] }; + acc.push(trip); + } else { + if (!trip.tatripid) trip.tatripid = prd.tatripid; + if (!trip.vid && prd.vid) trip.vid = prd.vid; + } + // If no stop, create new stop + let stop = trip.stops.find((s) => s.stpid === prd.stpid); + if (!stop) { + stop = { stpnm: prd.stpnm, stpid: prd.stpid, prdctdn: '', rt: '', rtdir: '', prdtm: 0 }; + trip.stops.push(stop); + } + stop.rtdir = prd.rtdir; + stop.rt = prd.rt; + stop.prdctdn = prd.prdctdn === "DUE" ? "1" : prd.prdctdn; + stop.prdtm = parseInt(prd.prdtm); + }); return acc; }, []); // build index maps const routeInfoFilter: Record = {}; - for (const [routeName, routeList] of Object.entries(state.cachedRoutes as Record)) { + 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 }); } } } @@ -262,14 +278,14 @@ function processPredictions(rawChunks: any[]) { } // sort predictions based on route (oddly complicated) - formattedPredictions.forEach((trip: any) => { + formattedPredictions.forEach((trip) => { if (trip.stops.length == 0) return; - const minPrdctdn = Math.min(...trip.stops.map((s: any) => parseInt(s.prdctdn, 10))); - const firstRoute = trip.stops.find((s: any) => parseInt(s.prdctdn, 10) === minPrdctdn)?.rt; + const minPrdctdn = Math.min(...trip.stops.map((s) => parseInt(s.prdctdn, 10))); + const firstRoute = trip.stops.find((s) => parseInt(s.prdctdn, 10) === minPrdctdn)?.rt; if (!firstRoute) return; - trip.stops.sort((a: any, b: any) => { + trip.stops.sort((a, b) => { const diffTime = parseInt(a.prdctdn, 10) - parseInt(b.prdctdn, 10); if (diffTime !== 0) return diffTime; if (a.rt + a.rtdir !== b.rt + b.rtdir) { @@ -317,7 +333,7 @@ function processPredictions(rawChunks: any[]) { // extrapolate predictions - formattedPredictions.forEach((trip: any) => { + formattedPredictions.forEach((trip) => { let stopsAdded = 0; while (stopsAdded < 20 && trip.stops.length > 0) { const lastStop = trip.stops[trip.stops.length - 1]; @@ -338,9 +354,10 @@ function processPredictions(rawChunks: any[]) { stpnm: state.cachedStopLocations[nextStopId]?.name || nextStopId, stpid: nextStopId, prdctdn: nextPrdctdn, + prdtm: lastStop.prdtm + diff * 60 * 1000, rt: rtNext, rtdir: rtdir, - isExtrapolated: true + isExtrapolated: true, }); stopsAdded++; } @@ -349,44 +366,47 @@ function processPredictions(rawChunks: any[]) { return formattedPredictions; } - /** * COPIED FROM PROCESS PREDICTIONS AND MODIFIED TO WORK WITH THE RIDE * @param rawChunks Raw API response chunks */ -function processRidePredictions(rawChunks: any[]) { - const formattedPredictions = rawChunks.flat().reduce((acc: any[], chunk: any) => { - if (chunk['bustime-response']?.['prd']) { - chunk['bustime-response']['prd'].forEach((prd: any) => { - let trip = acc.find((t: any) => t.tatripid === prd.tatripid); - // If no tatripid, try to match by vid (mbus API specifics) - if (!trip && prd.vid) trip = acc.find((t: any) => t.vid === prd.vid); - // If no match, create new trip - if (!trip) { - trip = { tatripid: prd.tatripid, vid: prd.vid, des: prd.des, stops: [] }; - acc.push(trip); - } else { - if (!trip.tatripid) trip.tatripid = prd.tatripid; - if (!trip.vid && prd.vid) trip.vid = prd.vid; - } - // If no stop, create new stop - let stop = trip.stops.find((s: any) => s.stpid === prd.stpid); - if (!stop) { - stop = { stpnm: prd.stpnm, stpid: prd.stpid, prdctdn: null, rt: null, rtdir: null }; - trip.stops.push(stop); - } - stop.rtdir = prd.rtdir; - stop.rt = prd.rt; - stop.prdctdn = prd.prdctdn === "DUE" ? "1" : prd.prdctdn; - // console.log(prd.prdtm); - // prdtm is in format YYYYMMDD HH:MM:SS - // stop.prdtm = parseInt(prd.prdtm); - // TODO: use actual timestamp - stop.prdtm = Date.now() + (parseInt(stop.prdctdn) + 0.5) * 60 * 1000; - }); - } - return acc; - }, []); +function processRidePredictions(rawChunks: Array) { + const formattedPredictions = rawChunks + .flat() + .reduce( + (acc, chunk) => { + if (!chunk || !chunk['bustime-response'].prd) return acc; + chunk['bustime-response'].prd.forEach((prd) => { + let trip = acc.find((t) => t.tatripid === prd.tatripid); + // If no tatripid, try to match by vid (mbus API specifics) + if (!trip && prd.vid) trip = acc.find((t) => t.vid === prd.vid); + // If no match, create new trip + if (!trip) { + trip = { tatripid: prd.tatripid, vid: prd.vid, des: prd.des, stops: [] }; + acc.push(trip); + } else { + if (!trip.tatripid) trip.tatripid = prd.tatripid; + if (!trip.vid && prd.vid) trip.vid = prd.vid; + } + // If no stop, create new stop + let stop = trip.stops.find((s) => s.stpid === prd.stpid); + if (!stop) { + stop = { stpnm: prd.stpnm, stpid: prd.stpid, prdctdn: "", rt: "", rtdir: "", prdtm: 0 }; + trip.stops.push(stop); + } + stop.rtdir = prd.rtdir; + stop.rt = prd.rt; + stop.prdctdn = prd.prdctdn === "DUE" ? "1" : prd.prdctdn; + // console.log(prd.prdtm); + // prdtm is in format YYYYMMDD HH:MM:SS + // stop.prdtm = parseInt(prd.prdtm); + // TODO: use actual timestamp + stop.prdtm = Date.now() + (parseInt(stop.prdctdn) + 0.5) * 60 * 1000; + }); + return acc; + }, + [] + ); return formattedPredictions; } @@ -395,12 +415,12 @@ function processRidePredictions(rawChunks: any[]) { * Updates global prediction lookup caches (by VID and Stop ID). * @param preds List of processed predictions */ -function updatePredictionLookups(preds: any[]) { +function updatePredictionLookups(preds: FormattedPrediction[]) { for (const key in state.cachedPredsByVid) delete state.cachedPredsByVid[key]; for (const key in state.cachedPredsByStopId) delete state.cachedPredsByStopId[key]; - preds.forEach((trip: any) => { - trip.stops.forEach((stop: any) => { + preds.forEach((trip) => { + trip.stops.forEach((stop) => { if (stop.isExtrapolated) return; const predObj = { ...stop, vid: trip.vid, tatripid: trip.tatripid, des: trip.des }; @@ -425,12 +445,12 @@ function updatePredictionLookups(preds: any[]) { * Updates global prediction lookup caches (by VID and Stop ID). * @param preds List of processed predictions */ -function updateRideLookups(preds: any[]) { +function updateRideLookups(preds: FormattedPrediction[]) { for (const key in state.cachedRidePredsByVid) delete state.cachedRidePredsByVid[key]; for (const key in state.cachedRidePredsByStopId) delete state.cachedRidePredsByStopId[key]; - preds.forEach((trip: any) => { - trip.stops.forEach((stop: any) => { + preds.forEach((trip) => { + trip.stops.forEach((stop) => { const predObj = { ...stop, vid: trip.vid, tatripid: trip.tatripid, des: trip.des }; if (!state.cachedRidePredsByStopId[stop.stpid]) state.cachedRidePredsByStopId[stop.stpid] = []; @@ -458,13 +478,13 @@ export function sortPreds(x: Record) { * Converts processed predictions into the Trip format used by the Raptor algorithm. * @param preds List of processed predictions */ -function convertToTrips(preds: any[]): Trip[] { +function convertToTrips(preds: FormattedPrediction[]): Trip[] { const trips: Trip[] = []; const now = new Date(); const currentTime = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds(); - preds.forEach((p: any) => { - const stopTimes: StopTime[] = p.stops.map((s: any) => ({ + preds.forEach((p) => { + const stopTimes: StopTime[] = p.stops.map((s) => ({ stop: s.stpid, arrivalTime: currentTime + (parseInt(s.prdctdn) * 60), departureTime: currentTime + (parseInt(s.prdctdn) * 60), diff --git a/src/services/journey.ts b/src/services/journey.ts index f5a0776..5a7c85e 100644 --- a/src/services/journey.ts +++ b/src/services/journey.ts @@ -1,6 +1,9 @@ +import * as z from 'zod'; import * as state from '../state/transitState'; import * as walking from '../walking/walkingMap'; -import { McRaptorAlgorithm, Journey, JourneyLeg } from "../raptor/McRaptorAlgorithm"; +import { McRaptorAlgorithm, Journey, JourneyLeg, JourneyLegTrip } from "@/raptor/McRaptorAlgorithm"; +import { BusRouteLine, BusStop, LatLon, LatLonSchema } from './bustimeCommon'; +import { toKey } from '@/types'; /** * Plans a journey between two coordinates using the McRaptor algorithm. @@ -75,12 +78,78 @@ export async function planJourney( return processJourneys(journeys, oLat, oLon, dLat, dLon); } -async function processJourneys(journeys: Journey[], oLat: number, oLon: number, dLat: number, dLon: number) { + +const formattedLegCommonFields = { + origin_id: z.string(), + origin: z.string(), + destination_id: z.string(), + destination: z.string(), + destinationName: z.string(), + startTime: z.number(), + endTime: z.number(), + duration: z.number(), + originID: z.string(), + destinationID: z.string(), +}; + +const FormattedLegWalkSchema = z.object({ + ...formattedLegCommonFields, + path_coords: z.array(LatLonSchema), + mode: z.literal('walk') +}).meta({ id: 'FormattedLegWalk' }); + +// conains the fields of StopTime used in the frontend +const StopTimeSchema = z.object({ + stop: z.string(), + arrivalTime: z.number(), + departureTime: z.number(), + pickUp: z.boolean(), + dropOff: z.boolean(), +}).meta({ id: 'StopTime' }); + +const TripSchema = z.object({ + tripId: z.string(), + vid: z.nullable(z.string()), + stopTimes: z.array(StopTimeSchema), +}).meta({ id: 'Trip' }); + +const FormattedLegBusSchema = z.object({ + ...formattedLegCommonFields, + busPathCoords: z.array(z.object({ rt: z.nullable(z.string()), path: z.array(LatLonSchema) })), + stopCoords: z.array(z.object({ rt: z.nullable(z.string()), location: LatLonSchema })), + mode: z.literal('bus'), + stopTimes: z.array(StopTimeSchema), + trip: TripSchema, + tripId: z.string(), + rt: z.string(), + vid: z.nullable(z.string()), +}); + + +export const FormattedLegSchema = z.discriminatedUnion('mode', [FormattedLegWalkSchema, FormattedLegBusSchema]) +export type FormattedLeg = z.infer; + +export const ProcessedJourneySchema = z.object({ + legs: z.array(FormattedLegSchema), + arrivalTime: z.number(), + departureTime: z.number(), + criteria: z.object({ + arrivalTime: z.number(), + walkingDistance: z.number(), + transferCount: z.number(), + }), +}).meta({ id: 'ProcessedJourney' }); +export type ProcessedJourney = z.infer; + +async function processJourneys( + journeys: Journey[], oLat: number, oLon: number, dLat: number, dLon: number +): Promise { const processLeg = async (leg: JourneyLeg) => { - const isWalk = !leg.trip; + const isWalk = leg.type === 'Transfer'; - const formattedLeg: any = { + let formattedLeg: FormattedLeg; + const formattedLegCommon = { origin_id: leg.origin, origin: leg.origin === 'VIRTUAL_ORIGIN' ? 'Start' : (leg.origin === 'VIRTUAL_DESTINATION' ? 'End' : (state.stopIdToName[leg.origin] || leg.origin)), destination_id: leg.destination, @@ -89,28 +158,30 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, startTime: Math.round(leg.startTime), endTime: Math.round(leg.endTime), duration: Math.round(leg.duration), - mode: isWalk ? 'walk' : 'bus', originID: leg.originID, destinationID: leg.destinationID, - stopTimes: leg.stopTimes, - trip: leg.trip, - rt: leg.rt }; - if (leg.trip) { - formattedLeg.tripId = leg.trip.tripId; - formattedLeg.vid = leg.trip.vid; - if (!formattedLeg.rt) { - const firstStop = leg.trip.stopTimes[0]; - formattedLeg.rt = firstStop.rt || state.tatripidToRt[leg.trip.tripId] || 'UNKNOWN'; - } - } - - if (isWalk) { + if (!isWalk) { + // fallback to route of the first stop or the route associated with the trip id + const rt: string | undefined = leg.rt || leg.trip.stopTimes[0].rt || state.tatripidToRt[leg.trip.tripId]; + const { paths, stops } = getBusLegPolyline(leg); + formattedLeg = { + ...formattedLegCommon, + mode: 'bus', + stopTimes: leg.stopTimes, + trip: leg.trip, + tripId: leg.trip.tripId, + vid: leg.trip.vid, + rt: rt ?? 'UNKNOWN', + busPathCoords: paths, + stopCoords: stops, + }; + } else { const cached = walking.getCachedWalk(leg.origin, leg.destination); if (cached) { - Object.assign(formattedLeg, cached); + formattedLeg = {...formattedLegCommon, ...cached, mode: 'walk'} } else { const l1 = leg.origin === 'VIRTUAL_ORIGIN' ? { lat: oLat, lon: oLon } : state.cachedStopLocations[leg.origin]; const l2 = leg.destination === 'VIRTUAL_DESTINATION' ? { lat: dLat, lon: dLon } : state.cachedStopLocations[leg.destination]; @@ -119,10 +190,12 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, try { const data = await walking.getWalkingResponse(l1.lat, l1.lon, l2.lat, l2.lon); data.duration = Math.round(data.duration); - Object.assign(formattedLeg, data); + formattedLeg = {...formattedLegCommon, ...data, mode: 'walk'}; } catch (e) { - formattedLeg.path_coords = []; + formattedLeg = {...formattedLegCommon, path_coords: [], mode: 'walk'}; } + } else { + formattedLeg = {...formattedLegCommon, path_coords: [], mode: 'walk'}; } } } @@ -144,9 +217,85 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, })); return processedList - .filter((j: any) => j !== null) - .sort((a: any, b: any) => + .filter((j) => j !== null) + .sort((a, b) => a.arrivalTime - b.arrivalTime || a.criteria.walkingDistance - b.criteria.walkingDistance ); -} \ No newline at end of file +} + +function getBusLegPolyline(leg: JourneyLegTrip): { + paths: Array<{ rt: string | null, path: LatLon[] }>, + stops: Array<{ rt: string | null, location: LatLon }> +} { + // the + 1 is in case the use of rounding w/ dep/arr times ever does something in the future + // (should all be whole numbers currently) + const relevantSts = (() => { + // debug: see whole trip + // return leg.trip.stopTimes; + + // find the subset of the trip that will actually be ridden on + const sts = leg.trip.stopTimes; + const relevantStart = sts.findIndex((st) => st.departureTime <= leg.startTime + 1 && st.stop === leg.originID); + const relevantEnd = sts.findIndex((st, i) => i > relevantStart && st.stop === leg.destinationID); + return relevantStart != -1 && relevantEnd != -1 ? sts.slice(relevantStart, relevantEnd) : sts; + })(); + + const fallback = (() => { + const fallbackStopPoints = relevantSts + .map((st) => { return { rt: st.rt ?? null, location: state.cachedStopLocations[st.stop] }; }); + // the whole path should get rendered with the transfer route style if falling back + return { paths: fallbackStopPoints.map((x) => { return { rt: x.rt, path: [x.location] }; }), stops: fallbackStopPoints }; + })(); + // debug: show fallback + // return fallback; + + // relevant portion should always be one route in practice but trips do often contain multiple (e.g. CN->CS) + // and this remains supported + const lines = (() => { + const routes = new Set(); + relevantSts + .map((st) => st.rt) + .filter((rt) => rt !== undefined) + .forEach((rt) => routes.add(rt)); + return Array.from(routes).flatMap((r) => state.cachedRoutes[r]); + })(); + if (!lines.length) { + console.warn('getBusLegPolyline had to use fallback: no route info'); + return fallback; + } + + if (!relevantSts.length) { + console.warn('getBusLegPolyline had to use fallback: no relevant stop times'); + return fallback; + } + + const pathEdges = relevantSts + .slice(1) + .map(({ rt, stop: to }, i) => { + if (!rt) return null; + const from = relevantSts[i].stop; + const path = state.cachedStopToStopPaths.get(toKey({ rt: rt, from, to })); + if (!path) return null; + return { rt, path }; + }) + .filter((x) => x !== null); + + return pathEdges.reduce>( + ({ paths, stops }, edge) => { + if (!paths.length || paths[paths.length - 1].rt !== edge.rt) { + if (edge.path.length > 0) + stops.push({ rt: edge.rt, location: edge.path[0] }); + if (edge.path.length > 1) + stops.push({ rt: edge.rt, location: edge.path[edge.path.length - 1] }); + paths.push(edge); + return { paths, stops }; + } + paths[paths.length - 1].path.push(...edge.path.slice(1)); + if (edge.path.length > 1) + stops.push({ rt: edge.rt, location: edge.path[edge.path.length - 1] }); + return { paths, stops }; + }, + { paths: [], stops: [] }, + ); +} diff --git a/src/services/mbus.ts b/src/services/mbus.ts index a479f5a..5d65465 100644 --- a/src/services/mbus.ts +++ b/src/services/mbus.ts @@ -1,6 +1,8 @@ -import axios from 'axios'; import * as process from "node:process"; +import axios from 'axios'; import dotenv from "dotenv"; +import * as z from 'zod'; +import * as bustime from './bustimeCommon'; dotenv.config(); @@ -44,25 +46,28 @@ export async function fetchRoutes() { } /** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string) { +export async function fetchPatterns(rt: string): Promise { try { const res = await client.get('/getpatterns', { params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } }); - return res.data['bustime-response']?.ptr || []; + const resData = res.data['bustime-response']?.ptr as unknown; + const patterns = z.array(bustime.PatternSchema).parse(resData); + return patterns; } catch (e) { + console.error("Fetch Patterns failed", e); return []; } } /** Fetches predictions for multiple stop IDs. */ -export async function fetchPredictions(stopIds: string[], routes: string[]) { +export async function fetchPredictions(stopIds: string[], routes: string[]): Promise> { const chunks = []; for (let i = 0; i < stopIds.length; i += 10) chunks.push(stopIds.slice(i, i + 10)); const promises = chunks.map(async chunk => { try { - const res = await client.get('/getpredictions', { + const res = await client.get('/getpredictions', { params: { requestType: 'getpredictions', stpid: chunk.join(','), @@ -71,9 +76,10 @@ export async function fetchPredictions(stopIds: string[], routes: string[]) { unixTime: true, } }); - return res.data; + return bustime.GetPredictionsResponseSchema.parse(res.data); } catch (e) { - return []; + console.warn("/getpredictions call failed:", e); + return null; } }); diff --git a/src/services/reminder.ts b/src/services/reminder.ts index 509db08..f1cdfbe 100644 --- a/src/services/reminder.ts +++ b/src/services/reminder.ts @@ -3,7 +3,8 @@ import { getMessaging } from "firebase-admin/messaging"; import { applicationDefault, initializeApp } from "firebase-admin/app"; import * as state from "@/state/transitState"; -import { BaseEvent, DelayEvent, eventsEqual, toKey, Key, RegistrationToken, ThresholdEvent, fromKey, delayEvent, thresholdEvent } from "./reminderTypes"; +import { BaseEvent, DelayEvent, eventsEqual, RegistrationToken, ThresholdEvent, delayEvent, thresholdEvent } from "./reminderTypes"; +import { fromKey, Key, toKey } from "@/types"; export * from "./reminderTypes"; diff --git a/src/services/reminderTypes.ts b/src/services/reminderTypes.ts index 5f4f54c..0137cbb 100644 --- a/src/services/reminderTypes.ts +++ b/src/services/reminderTypes.ts @@ -1,18 +1,5 @@ /** The utility and POD types used for reminders */ -import stringify from "fast-json-stable-stringify"; - -export type Key = string & { readonly __brand: "key", readonly __phantomData: T }; - -/** REQUIRES: the value passed in is safe to stringify */ -export function toKey(x: T): Key { - return stringify(x) as Key; -} - -export function fromKey(key: Key): T { - return JSON.parse(key); -} - /** @internal */ export type CoreEvent = { stpid: string, diff --git a/src/services/ride.ts b/src/services/ride.ts index bd8fd31..340cb4f 100644 --- a/src/services/ride.ts +++ b/src/services/ride.ts @@ -3,6 +3,8 @@ import axios from 'axios'; import * as process from "node:process"; import dotenv from "dotenv"; +import * as z from 'zod'; +import * as bustime from './bustimeCommon'; dotenv.config(); @@ -46,25 +48,27 @@ export async function fetchRoutes() { } /** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string) { +export async function fetchPatterns(rt: string): Promise { try { const res = await client.get('/getpatterns', { params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } }); - return res.data['bustime-response']?.ptr || []; + const resData = res.data['bustime-response']?.ptr as unknown; + return z.array(bustime.PatternSchema).parse(resData); } catch (e) { + console.error("Fetch Patterns failed", e); return []; } } /** Fetches predictions for multiple stop IDs. */ -export async function fetchPredictions(stopIds: string[], routes: string[]) { +export async function fetchPredictions(stopIds: string[], routes: string[]): Promise> { const chunks = []; for (let i = 0; i < stopIds.length; i += 10) chunks.push(stopIds.slice(i, i + 10)); const promises = chunks.map(async chunk => { try { - const res = await client.get('/getpredictions', { + const res = await client.get('/getpredictions', { params: { requestType: 'getpredictions', stpid: chunk.join(','), @@ -74,9 +78,10 @@ export async function fetchPredictions(stopIds: string[], routes: string[]) { unixTime: true, } }); - return res.data; + return bustime.GetPredictionsResponseSchema.parse(res.data); } catch (e) { - return []; + console.warn("/getpredictions failed:", chunk, routes, e); + return null; } }); diff --git a/src/state/transitState.ts b/src/state/transitState.ts index 6686a27..0f55254 100644 --- a/src/state/transitState.ts +++ b/src/state/transitState.ts @@ -1,13 +1,22 @@ +import { Key } from "@/types"; import { Trip, TransfersByOrigin, Interchange } from "../raptor/types"; +import * as bustime from '@/services/bustimeCommon'; /** Current positions of all buses. */ export const curBusPositions = { buses: [] as any[] }; /** Current positions of all ride buses. */ export const curRidePositions = { buses: [] as any[] }; /** Cache of route patterns and static data. */ -export const cachedRoutes: Record = {}; +export const cachedRoutes: Record = {}; /** Cache of route patterns and static data for the ride. */ -export const cachedRideRoutes: Record = {}; +export const cachedRideRoutes: Record = {}; + +/** Poly lines to use when constructing the path taken by a bus leg */ +export const cachedStopToStopPaths: Map, bustime.LatLon[]> = new Map(); + +// Remove when support for mb2 is dropped +export const cachedRoutesLegacy: Record = {}; +export const cachedRideRoutesLegacy: Record = {}; /** Represents a bus prediction. */ export type Prediction = { @@ -21,7 +30,7 @@ export type Prediction = { prdtm: number, /** minutes until arrival, or 'DUE' (corresponding to 1 minute) */ prdctdn: string -} & Record; +} & Record; /** Predictions indexed by vehicle ID. */ export const cachedPredsByVid: Record = {}; @@ -51,7 +60,8 @@ export let cachedStopLocations: Record = {}; /** Cache of timing differences between stops for extrapolation. */ -export const routeTimingCache: Record>> = { +export const routeTimingCache: Record>> = { "CN": { "N434NORTHBOUND": { "N500": { "diff": 5, "rtdir": "SOUTHBOUND", "rtNext": "CS" } diff --git a/src/types.ts b/src/types.ts index 9f93b59..6c04d01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,18 @@ -type Route = { - rt: string + +import stringify from "fast-json-stable-stringify"; + +/** typesafe by-value object map keys */ +export type Key = string & { readonly __brand: "key", readonly __phantomData: T }; + +/** REQUIRES: the value passed in is safe to stringify */ +export function toKey(x: T): Key { + return stringify(x) as Key; } -export { - Route -}; \ No newline at end of file +export function fromKey(key: Key): T { + return JSON.parse(key); +} + +export type Route = { + rt: string +} diff --git a/test/api.test.ts b/test/api.test.ts index fc586fc..0299219 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; const SERVER_PORT = 3000; const BASE_URL = `http://localhost:${SERVER_PORT}/mbus/api/v3`; +const V4_BASE_URL = `http://localhost:${SERVER_PORT}/api/v4`; describe('API Endpoints', () => { beforeAll(async () => { @@ -41,7 +42,7 @@ describe('API Endpoints', () => { console.log(`GET /getSelectableRoutes: ${response.data['bustime-response'].routes.length} routes found.`); }); - it('should get all cached routes and confirm structure', async () => { + it('should get all cached routes and confirm structure (mb2 legacy)', async () => { const response = await axios.get(`${BASE_URL}/getAllRoutes`); expect(response.status).toBe(200); expect(response.data).toHaveProperty('routes'); @@ -49,6 +50,13 @@ describe('API Endpoints', () => { console.log(`GET /getAllRoutes: ${Object.keys(response.data.routes).length} cached routes found.`); }); + it('should get all cached routes and confirm structure', async () => { + const response = await axios.get(`${V4_BASE_URL}/getAllRoutes`); + expect(response.status).toBe(200); + expect(typeof response.data).toBe('object'); // should be array + console.log(`GET /getAllRoutes: ${Object.keys(response.data).length} cached routes found.`); + }); + it('should get all bus predictions and log stop IDs', async () => { try { const response = await axios.get(`${BASE_URL}/getAllPredictions`); @@ -171,13 +179,13 @@ describe('API Endpoints', () => { expect(response.status).toBe(200); expect(response.data).toHaveProperty('journeys'); expect(Array.isArray(response.data.journeys)).toBe(true); - console.log('GET /plan-journey (test 1):', JSON.stringify(response.data.journeys, null, 2)); + // console.log('GET /plan-journey (test 1):', JSON.stringify(response.data.journeys, null, 2)); const response2 = await axios.get(`${BASE_URL}/plan-journey?originLat=42.27389558&originLon=-83.73739576&destLat=42.29303061&destLon=-83.7163671`); expect(response2.status).toBe(200); expect(response2.data).toHaveProperty('journeys'); expect(Array.isArray(response2.data.journeys)).toBe(true); - console.log('GET /plan-journey (test 2):', JSON.stringify(response2.data.journeys, null, 2)); + // console.log('GET /plan-journey (test 2):', JSON.stringify(response2.data.journeys, null, 2)); } catch (error) { if (axios.isAxiosError(error)) { console.error('Error fetching path:', error.message); @@ -198,7 +206,7 @@ describe('API Endpoints', () => { if (axios.isAxiosError(error) && error.response) { expect(error.response.status).toBe(400); expect(error.response.data).toHaveProperty('error'); - expect(error.response.data.error).toContain('coordinates are required'); + expect(error.response.data.error).toContain('destLat: Invalid input'); console.log('GET /plan-journey (missing params): Correctly returned 400.'); } else { throw error; diff --git a/test/bustimeCommon.test.ts b/test/bustimeCommon.test.ts new file mode 100644 index 0000000..3449f97 --- /dev/null +++ b/test/bustimeCommon.test.ts @@ -0,0 +1,99 @@ +import { makeBusRouteLines, Pattern } from "@/services/bustimeCommon"; +import { describe, expect, it } from "vitest"; + +describe('makeBusRouteLines', () => { + + it('should handle short routes', () => { + const pointsSingleStop: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + ]; + const pattern = makePattern(0, 0, '', pointsSingleStop, '', pointsSingleStop); + const lines = makeBusRouteLines('', pattern, false); + expect(lines.length).toBe(2); + expect(lines[0]).toEqual(lines[1]); + const line = lines[0]; + expect(line.points).toEqual([{ lat: 45.0, lon: 46.0 }]); + const stop = line.stops[0].stop; + expect(stop.id).toEqual('C1'); + expect(stop.name).toEqual('Central'); + expect(stop.location.lat).toEqual(45); + expect(stop.location.lon).toEqual(46); + }); + + it('should pass through isRide and rt', () => { + for (const rt of ["BB", "CN"]) { + for (const isRide of [true, false]) { + const pointsSingleStop: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + ]; + const pattern = makePattern(0, 0, '', pointsSingleStop, null, null); + const lines = makeBusRouteLines(rt, pattern, isRide); + const line = lines[0]; + expect(line.routeId).toEqual(rt); + for (const stop of line.stops) { + expect(stop.stop.routeId).toBe(rt); + expect(stop.stop.isRide).toBe(isRide); + } + } + } + }); + + it('should handle both route and detour', () => { + const points1: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + makeWaypoint(1, 45.1, 45.9), + makeStop(2, 45.2, 45.8, 'C2', 'Community'), + ]; + const points2: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + makeWaypoint(1, 0.0, 0.0), + makeWaypoint(1, 2.0, 2.0), + makeStop(2, 45.2, 45.8, 'C2', 'Community'), + ]; + + const positions = (points: Pattern['pt']) => + points.map((p) => { return { lat: p.lat, lon: p.lon}; }); + + for (const [points, detourPts] of [[points1, points2], [points2, points1]]) { + const pattern = makePattern(0, 0, '', points, '', detourPts); + const lines = makeBusRouteLines('', pattern, false); + expect(lines[0].points).toEqual(positions(points)); + expect(lines[1].points).toEqual(positions(detourPts)); + } + }); +}); + +function makeStop(seq: number, lat: number, lon: number, stpid: string, stpnm: string): Pattern['pt'][0] { + return { + seq, + typ: "S", + lat, + lon, + pdist: 0.0, + stpid, + stpnm, + }; +} + +function makeWaypoint(seq: number, lat: number, lon: number): Pattern['pt'][0] { + return { + seq, + typ: "W", + lat, + lon, + }; +} + +function makePattern( + pid: number, ln: number, rtdir: string, points: Pattern['pt'], + dtrid: string | null, dtrpt: Pattern['pt'] | null, +): Pattern { + return { + pid: pid, + ln: ln, + rtdir: rtdir, + pt: points, + dtrid: dtrid ?? undefined, + dtrpt: dtrpt ?? undefined, + }; +} diff --git a/test/reminder.test.ts b/test/reminder.test.ts index 2b58f9c..c90a7a6 100644 --- a/test/reminder.test.ts +++ b/test/reminder.test.ts @@ -7,6 +7,7 @@ import * as state from '@/state/transitState'; import { initializeRoutes, rebuildGraph, sortPreds, updateBusPositions } from '@/services/graphBuilder'; import axios from 'axios'; import { configDotenv } from 'dotenv'; +import { fromKey } from '@/types'; const testToken = r.registrationToken("token1"); const testEvent = r.baseEvent({ stpid: "stop1", rtid: "route1" }); @@ -58,7 +59,7 @@ describe('Reminders', () => { expect( remindersLater.reminder.get( Array.from(remindersLater.reminder.keys()) - .find((thresholdEvent) => r.sameBaseEvent(r.fromKey(thresholdEvent), testEvent))! + .find((thresholdEvent) => r.sameBaseEvent(fromKey(thresholdEvent), testEvent))! )!.has(testToken) ) .toBe(true); diff --git a/test/ride.test.ts b/test/ride.test.ts index b58d451..9c1125b 100644 --- a/test/ride.test.ts +++ b/test/ride.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; const SERVER_PORT = 3000; const BASE_URL = `http://localhost:${SERVER_PORT}/mbus/api/v3`; +const V4_BASE_URL = `http://localhost:${SERVER_PORT}/api/v4`; describe('The Ride (AAATA) API Endpoints', () => { @@ -24,7 +25,7 @@ describe('The Ride (AAATA) API Endpoints', () => { }); // --- Ride Routes --- - it('should get all Ride routes', async () => { + it('should get all Ride routes (mb2 legacy)', async () => { const response = await axios.get(`${BASE_URL}/getAllRideRoutes`); expect(response.status).toBe(200); expect(response.data).toHaveProperty('routes'); @@ -35,6 +36,16 @@ describe('The Ride (AAATA) API Endpoints', () => { expect(routeCount).toBeGreaterThanOrEqual(0); }); + it('should get all Ride routes', async () => { + const response = await axios.get(`${V4_BASE_URL}/getAllRideRoutes`); + expect(response.status).toBe(200); + expect(typeof response.data).toBe('object'); + + const routeCount = Object.keys(response.data).length; + console.log(`GET /getAllRideRoutes: ${routeCount} Ride routes found.`); + expect(routeCount).toBeGreaterThanOrEqual(0); + }); + // --- Ride Stops --- it('should get all Ride stops', async () => { const response = await axios.get(`${BASE_URL}/getAllRideStops`);