diff --git a/src/routes/api.ts b/src/routes/api.ts index 9e64f97..7ecbe82 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -370,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/services/bustimeCommon.ts b/src/services/bustimeCommon.ts index d5d6596..c7ba73c 100644 --- a/src/services/bustimeCommon.ts +++ b/src/services/bustimeCommon.ts @@ -1,5 +1,7 @@ import z from "zod"; +// ========== patterns & bus lines ========= + const PatternPtSchema = z.object({ seq: z.int(), typ: z.string(), @@ -20,9 +22,8 @@ export const PatternSchema = z.object({ }).meta({ id: 'Pattern' }); export type Pattern = z.infer -export const PatternsArraySchema = z.array(PatternSchema); - 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(), @@ -142,3 +143,43 @@ function normalizeStopName(rawStopName: string): string { .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 158fc3c..0622dd4 100644 --- a/src/services/graphBuilder.ts +++ b/src/services/graphBuilder.ts @@ -7,11 +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 { makeBusRouteLines } from './bustimeCommon'; +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); @@ -33,17 +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); - state.cachedRoutes[r.rt] = patterns.map((p) => makeBusRouteLines(r.rt, p, false)).flat(1); + 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); - state.cachedRideRoutes[r.rt] = patterns.map((p) => makeBusRouteLines(r.rt, p, true)).flat(1); + 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(); @@ -100,22 +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[]) { +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: any) => { - trip.stops.forEach((stop: any) => { + 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; } @@ -127,13 +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[]) { +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: any) => { - trip.stops.forEach((stop: any) => { + preds.forEach((trip) => { + trip.stops.forEach((stop) => { if (stop.stpid && stop.stpnm) { state.rideStopIdToName[stop.stpid] = stop.stpnm; } @@ -197,38 +206,55 @@ 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; }, []); @@ -252,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) { @@ -307,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]; @@ -328,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++; } @@ -339,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; } @@ -385,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 }; @@ -415,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] = []; @@ -448,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 a9c3a3a..a0552eb 100644 --- a/src/services/journey.ts +++ b/src/services/journey.ts @@ -1,7 +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 { StopTime, Trip } from '@/raptor/types'; +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. @@ -76,44 +78,85 @@ export async function planJourney( return processJourneys(journeys, oLat, oLon, dLat, dLon); } -interface FormattedLegCommon { - origin_id: string, - origin: string, - destination_id: string, - destination: string, - destinationName: string, - startTime: number, - endTime: number, - duration: number, - originID: string, - destinationID: string, -}; -export interface FormattedLegWalk extends - FormattedLegCommon, - Partial> // leaves just the path_coords field for now -{ - mode: 'walk' +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(), }; -export interface FormattedLegBus extends FormattedLegCommon { - mode: 'bus', - stopTimes: StopTime[], - trip: Trip, - tripId: string, - rt: string, - vid: string | null, -}; +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, + busPathSegments: z.array( + z.object({ rt: z.nullable(z.string()), path: z.array(LatLonSchema) }) + .meta({ id: 'FormattedLegBusPathRouteSegment' }) + ), + stopCoords: z.array( + z.object({ rt: z.nullable(z.string()), location: LatLonSchema }) + .meta({ id: 'FormattedLegBusStopCoord' }) + ), + mode: z.literal('bus'), + stopTimes: z.array(StopTimeSchema), + trip: TripSchema, + tripId: z.string(), + rt: z.string(), + vid: z.nullable(z.string()), +}).meta({ id: 'FormattedLegBus' }); -export type FormattedLeg = FormattedLegWalk | FormattedLegBus -async function processJourneys(journeys: Journey[], oLat: number, oLon: number, dLat: number, dLon: number) { +export const FormattedLegSchema = z.discriminatedUnion('mode', [FormattedLegWalkSchema, FormattedLegBusSchema]) + .meta({ id: 'FormattedLeg' }) +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.type === 'Transfer'; let formattedLeg: FormattedLeg; - const formattedLegCommon: FormattedLegCommon = { + 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, @@ -127,6 +170,9 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, }; 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', @@ -134,8 +180,9 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, trip: leg.trip, tripId: leg.trip.tripId, vid: leg.trip.vid, - // fallback to route of the first stop or the route associated with the trip id - rt: leg.rt || leg.trip.stopTimes[0].rt || state.tatripidToRt[leg.trip.tripId] || 'UNKNOWN' + rt: rt ?? 'UNKNOWN', + busPathSegments: paths, + stopCoords: stops, }; } else { const cached = walking.getCachedWalk(leg.origin, leg.destination); @@ -150,12 +197,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); - formattedLeg = {...formattedLegCommon, ...data, mode: 'walk'} + formattedLeg = {...formattedLegCommon, ...data, mode: 'walk'}; } catch (e) { - formattedLeg = {...formattedLegCommon, path_coords: [], mode: 'walk'} + formattedLeg = {...formattedLegCommon, path_coords: [], mode: 'walk'}; } } else { - formattedLeg = {...formattedLegCommon, mode: 'walk'} + formattedLeg = {...formattedLegCommon, path_coords: [], mode: 'walk'}; } } } @@ -182,4 +229,80 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, 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 e163c9d..5d65465 100644 --- a/src/services/mbus.ts +++ b/src/services/mbus.ts @@ -1,7 +1,8 @@ -import axios from 'axios'; import * as process from "node:process"; +import axios from 'axios'; import dotenv from "dotenv"; -import { Pattern, PatternsArraySchema } from './bustimeCommon'; +import * as z from 'zod'; +import * as bustime from './bustimeCommon'; dotenv.config(); @@ -45,13 +46,13 @@ export async function fetchRoutes() { } /** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string): Promise { +export async function fetchPatterns(rt: string): Promise { try { const res = await client.get('/getpatterns', { params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } }); const resData = res.data['bustime-response']?.ptr as unknown; - const patterns = PatternsArraySchema.parse(resData); + const patterns = z.array(bustime.PatternSchema).parse(resData); return patterns; } catch (e) { console.error("Fetch Patterns failed", e); @@ -60,13 +61,13 @@ export async function fetchPatterns(rt: string): Promise { } /** 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(','), @@ -75,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 cb7a08b..340cb4f 100644 --- a/src/services/ride.ts +++ b/src/services/ride.ts @@ -3,7 +3,8 @@ import axios from 'axios'; import * as process from "node:process"; import dotenv from "dotenv"; -import { Pattern, PatternsArraySchema } from './bustimeCommon'; +import * as z from 'zod'; +import * as bustime from './bustimeCommon'; dotenv.config(); @@ -47,13 +48,13 @@ export async function fetchRoutes() { } /** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string): Promise { +export async function fetchPatterns(rt: string): Promise { try { const res = await client.get('/getpatterns', { params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } }); const resData = res.data['bustime-response']?.ptr as unknown; - return PatternsArraySchema.parse(resData); + return z.array(bustime.PatternSchema).parse(resData); } catch (e) { console.error("Fetch Patterns failed", e); return []; @@ -61,13 +62,13 @@ export async function fetchPatterns(rt: string): Promise { } /** 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(','), @@ -77,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 037c2ed..0f55254 100644 --- a/src/state/transitState.ts +++ b/src/state/transitState.ts @@ -1,18 +1,22 @@ +import { Key } from "@/types"; import { Trip, TransfersByOrigin, Interchange } from "../raptor/types"; -import { BusRouteLine, Pattern } from "@/services/bustimeCommon"; +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 = {}; +export const cachedRoutesLegacy: Record = {}; +export const cachedRideRoutesLegacy: Record = {}; /** Represents a bus prediction. */ export type Prediction = { @@ -26,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 = {}; 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 a4356de..ec0f3f0 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -207,6 +207,7 @@ describe('API Endpoints', () => { expect(error.response.status).toBe(400); expect(error.response.data).toHaveProperty('error'); expect(error.response.data.error).toContain('invalid query params'); + 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/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);