Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion src/raptor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,69 @@ export type TransfersByOrigin = Record<StopID, Transfer[]>;
/**
* Map defining the minimum time required to switch vehicles at each stop.
*/
export type Interchange = Record<StopID, Time>;
export type Interchange = Record<StopID, Time>;

/**
* Classification of a user's relationship to nearby buses.
*/
export type OnBusStatus = 'on_bus' | 'near_bus' | 'waiting_at_stop' | 'not_near_bus';

/**
* A single user location sample provided by the frontend for motion validation.
*/
export interface LocationSample {
lat: number;
lon: number;
/** Epoch milliseconds. */
timestamp: number;
/** Speed in m/s from the device, if available. */
speed?: number;
/** Heading in degrees from the device, if available. */
heading?: number;
}

/**
* Result of classifying whether a user is on a bus, near one, or waiting at a stop.
*/
export interface OnBusClassification {
status: OnBusStatus;
/** Vehicle ID of the matched bus, when applicable. */
vid?: string;
/** Motion-correlation confidence score from 0 to 1. */
confidence: number;
/** Human-readable explanation of the classification. */
reason: string;
}

/**
* Context describing a stop the user is physically standing at.
*/
export interface AtStopContext {
stopId: StopID;
stopName: string;
distanceMeters: number;
/** 0 when the user is considered to be at the stop. */
walkTimeSeconds: number;
}

/**
* Routing context for a user confirmed to be aboard a specific bus.
*/
export interface OnBusContext {
vid: string;
tripId: TripID;
rt: string;
/** Virtual stop ID injected into the graph, e.g. "ON_BUS_341". */
virtualStopId: StopID;
/** Index in the original trip's stopTimes where the user can next alight. */
boardStopIndex: number;
busLat: number;
busLon: number;
/** Trip copy starting from the virtual on-bus stop. */
trimmedTrip: Trip;
classification: OnBusClassification;
/** Physical stop the bus is currently at/servicing, if stopped at one. */
currentStopId?: StopID;
currentStopName?: string;
isStoppedAtStop: boolean;
}
118 changes: 113 additions & 5 deletions src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import * as meta from '../services/metadata';
import * as journeyService from '../services/journey';
import * as reminderService from '../services/reminder';
import * as graphBuilder from '../services/graphBuilder';
import * as onBusService from '../services/onBus';
import { haversine } from '../walking/loadMap';
import { LocationSample } from '../raptor/types';
import { startBackgroundJobs } from '../jobs';

/**
Expand Down Expand Up @@ -364,15 +367,35 @@ export function getNearestStops(req: express.Request, res: express.Response) {
}
router.get('/nearest-stops', getNearestStops);

/** Parses a locationTrail provided as a JSON query string or a request body array. */
function parseLocationTrail(raw: unknown): LocationSample[] {
let parsed: unknown = raw;
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) return [];
return parsed.filter((s: any) =>
s && typeof s.lat === 'number' && typeof s.lon === 'number' && typeof s.timestamp === 'number'
);
}

/**
* Plans a journey between origin and destination coordinates.
* Optionally starts the journey from a bus the user is riding: pass
* `originVid` (vehicle ID), `onBus=true`, and `locationTrail` (URL-encoded
* JSON array of recent location samples) for motion validation.
* @param req - Express request
* @param res - Express response
* @returns JSON object with `journeys` array containing possible routes.
* @returns JSON object with `journeys` array and, when on-bus routing was
* requested, an `originContext` describing how the origin was resolved.
*/
export async function planJourney(req: express.Request, res: express.Response) {
try {
const { originLat, originLon, destLat, destLon, walkingPenalty, range } = req.query;
const { originLat, originLon, destLat, destLon, walkingPenalty, range, originVid, onBus, locationTrail } = req.query;

if (!originLat || !originLon || !destLat || !destLon) {
res.status(400).json({ error: 'coordinates are required' });
Expand All @@ -382,24 +405,109 @@ export async function planJourney(req: express.Request, res: express.Response) {
const now = new Date();
const secondsSinceMidnight = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds();

const results = await journeyService.planJourney(
// On-bus mode: explicit onBus=true, or implied by originVid unless onBus=false
const onBusFlag = typeof onBus === 'string' ? onBus.toLowerCase() : undefined;
const onBusEnabled = onBusFlag === 'true' || onBusFlag === '1'
|| (!!originVid && onBusFlag !== 'false' && onBusFlag !== '0');

let onBusOptions: journeyService.OnBusOptions | undefined;
if (onBusEnabled) {
const trailRaw = locationTrail !== undefined ? locationTrail : (req.body ? req.body.locationTrail : undefined);
onBusOptions = {
vid: originVid ? String(originVid) : undefined,
locationTrail: parseLocationTrail(trailRaw)
};
}

const result = await journeyService.planJourney(
parseFloat(originLat as string), parseFloat(originLon as string),
parseFloat(destLat as string), parseFloat(destLon as string),
secondsSinceMidnight,
{
walkingPenalty: walkingPenalty ? parseFloat(walkingPenalty as string) : undefined,
range: range ? parseInt(range as string) : undefined
range: range ? parseInt(range as string) : undefined,
onBus: onBusOptions
}
);

res.json({ journeys: results });
res.json({
journeys: result.journeys,
...(result.originContext ? { originContext: result.originContext } : {})
});
} catch (error) {
console.error("Journey plan error:", error);
res.status(500).json({ error: 'Journey planning failed' });
}
}
router.get('/plan-journey', planJourney);

const LocationSampleSchema = z.object({
lat: z.number(),
lon: z.number(),
timestamp: z.number(),
speed: z.number().optional(),
heading: z.number().optional(),
});
const DetectOnBusBody = z.object({
lat: z.number(),
lon: z.number(),
locationTrail: z.array(LocationSampleSchema).default([]),
candidateVid: z.string().optional(),
});

/**
* Classifies whether the user is on a bus, near one, or waiting at a stop,
* based on their current position and recent location trail.
* @param req - Express request, expects `DetectOnBusBody` in the body
* @param res - Express response
* @returns JSON object with `status`, `onBus`, and matched bus details.
*/
export function detectOnBus(req: express.Request, res: express.Response) {
const result = DetectOnBusBody.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: result.error.message });
return;
}
try {
const { lat, lon, locationTrail, candidateVid } = result.data;
const classification = onBusService.classifyOnBusStatus(lat, lon, locationTrail, candidateVid);

const response: any = {
status: classification.status,
onBus: classification.status === 'on_bus',
confidence: classification.confidence,
reason: classification.reason,
};

if (classification.vid) {
response.vid = classification.vid;
const bus = state.curBusPositions.buses.find(b => (b.vid || b.id) === classification.vid);
if (bus) {
const busLat = parseFloat(bus.lat);
const busLon = parseFloat(bus.lon);
if (!isNaN(busLat) && !isNaN(busLon)) {
response.busLat = busLat;
response.busLon = busLon;
response.distanceMeters = Math.round(haversine(lat, lon, busLat, busLon));
}
if (bus.rt) response.rt = bus.rt;
if (bus.des) response.des = bus.des;
}
const trip = graphBuilder.findTripByVid(classification.vid);
if (trip) {
response.tripId = trip.tripId;
if (!response.rt) response.rt = state.tatripidToRt[trip.tripId];
}
}

res.json(response);
} catch (error) {
console.error('detect-on-bus error:', error);
res.status(500).json({ error: 'Detection failed' });
}
}
router.post('/detect-on-bus', detectOnBus);

/**
* Saves the current graph state to a file (DEV mode only).
* @param req - Express request
Expand Down
8 changes: 8 additions & 0 deletions src/services/graphBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@ const DEFAULT_RIDE_ROUTES = ["3", "4", "5", "6", "22", "23", "25", "26", "27", "
export async function updateBusPositions() {
const buses = await mbus.fetchVehicles(DEFAULT_ROUTES);
state.curBusPositions.buses = buses;
state.recordBusPositionHistory(buses);
const ridesBusses = await rideBus.fetchVehicles(DEFAULT_RIDE_ROUTES);
state.curRidePositions.buses = ridesBusses;
}

/** Finds the active (non-virtual) trip in the cached graph for a vehicle ID. */
export function findTripByVid(vid: string): Trip | null {
return state.cachedGraph.trips.find(
t => t.vid === vid && t.tripId !== 'VIRTUAL_ORIGIN_TRIP' && t.tripId !== 'VIRTUAL_DESTINATION_TRIP'
) || null;
}

/** Initializes route data, caching patterns and stop locations. */
export async function initializeRoutes() {
if (process.env.DEV_CACHE === 'true') return;
Expand Down
Loading
Loading