417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
import express, { Request, Response } from 'express';
|
|
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
|
|
import { loadGtfsStops, loadGtfsRoutes } from './lib/gtfs';
|
|
import { config, StopConfig, RouteConfig } from './config';
|
|
import {
|
|
initDatabase,
|
|
logVehiclePosition,
|
|
logVehiclePositions,
|
|
logArrival,
|
|
getDatabaseStats,
|
|
cleanupOldData,
|
|
getVehicleHistory,
|
|
getRouteVehiclePositions,
|
|
getStopArrivalHistory,
|
|
getRouteDelayStats,
|
|
getStopDelayStats,
|
|
getRouteHourlyPattern
|
|
} from './lib/database';
|
|
|
|
const app = express();
|
|
|
|
// Initialize database
|
|
try {
|
|
initDatabase();
|
|
console.log('Database initialized successfully');
|
|
} catch (error) {
|
|
console.error('Failed to initialize database:', error);
|
|
console.log('Continuing without data logging...');
|
|
}
|
|
|
|
// Load GTFS data
|
|
const stops = loadGtfsStops();
|
|
const routes = loadGtfsRoutes();
|
|
|
|
// Serve static files
|
|
import * as path from 'path';
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Serve OpenAPI specification
|
|
app.get('/openapi.yaml', (req: Request, res: Response) => {
|
|
res.sendFile(path.join(__dirname, 'openapi.yaml'));
|
|
});
|
|
|
|
// API Endpoints
|
|
app.get('/api/config', (req: Request, res: Response) => {
|
|
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
|
|
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
|
|
|
|
const stop = stops.get(stopId);
|
|
const route = routes.get(routeId);
|
|
|
|
res.json({
|
|
stop: {
|
|
id: stopId,
|
|
code: stop?.stop_code,
|
|
name: stop?.stop_name,
|
|
lat: stop?.stop_lat,
|
|
lon: stop?.stop_lon,
|
|
},
|
|
route: {
|
|
id: routeId,
|
|
shortName: route?.route_short_name,
|
|
longName: route?.route_long_name,
|
|
},
|
|
defaults: {
|
|
stopId: config.defaultStop.stopId,
|
|
routeId: config.defaultRoute.routeId,
|
|
}
|
|
});
|
|
});
|
|
|
|
app.get('/api/stops', (req: Request, res: Response) => {
|
|
const stopsList = Array.from(stops.values()).map(stop => ({
|
|
id: stop.stop_id,
|
|
code: stop.stop_code,
|
|
name: stop.stop_name,
|
|
lat: stop.stop_lat,
|
|
lon: stop.stop_lon,
|
|
}));
|
|
res.json(stopsList);
|
|
});
|
|
|
|
app.get('/api/routes', (req: Request, res: Response) => {
|
|
const routesList = Array.from(routes.values()).map(route => ({
|
|
id: route.route_id,
|
|
shortName: route.route_short_name,
|
|
longName: route.route_long_name,
|
|
}));
|
|
res.json(routesList);
|
|
});
|
|
|
|
app.get('/api/arrivals', async (req: Request, res: Response) => {
|
|
try {
|
|
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
|
|
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
|
|
|
|
const stop = stops.get(stopId);
|
|
if (!stop) {
|
|
return res.status(404).json({ error: `Stop ${stopId} not found` });
|
|
}
|
|
|
|
const radius = 50;
|
|
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${stop.stop_lat}&longitude=${stop.stop_lon}&radius=${radius}`;
|
|
|
|
const response = await fetch(nearbyUrl);
|
|
const nearbyData = await response.json() as any[];
|
|
|
|
const now = new Date();
|
|
const arrivals: any[] = [];
|
|
|
|
for (const stopData of nearbyData) {
|
|
if (stopData.id.toString() !== stopId) continue;
|
|
|
|
for (const pattern of stopData.patterns) {
|
|
if (pattern.routeId.toString() !== routeId) continue;
|
|
|
|
const routeInfo = routes.get(pattern.routeId.toString());
|
|
|
|
for (const stopTime of pattern.stopTimes) {
|
|
const serviceDay = new Date(stopTime.serviceDay * 1000);
|
|
const arrivalTime = new Date(serviceDay.getTime() + stopTime.realtimeArrival * 1000);
|
|
const scheduledTime = new Date(serviceDay.getTime() + stopTime.scheduledArrival * 1000);
|
|
|
|
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
|
|
|
|
if (minutesUntil >= -2 && minutesUntil <= config.tracking.minutesAhead) {
|
|
arrivals.push({
|
|
arrivalTime: arrivalTime.toISOString(),
|
|
scheduledTime: scheduledTime.toISOString(),
|
|
minutesUntil: minutesUntil,
|
|
delaySeconds: stopTime.arrivalDelay,
|
|
headsign: stopTime.headsign,
|
|
isRealtime: stopTime.realtime,
|
|
realtimeState: stopTime.realtimeState,
|
|
});
|
|
|
|
// Log arrival to database (use scheduled time as primary timestamp to avoid duplicates)
|
|
try {
|
|
logArrival({
|
|
timestamp: scheduledTime.getTime(),
|
|
stopId,
|
|
routeId,
|
|
scheduledTime: scheduledTime.getTime(),
|
|
predictedTime: arrivalTime.getTime(),
|
|
delaySeconds: stopTime.arrivalDelay,
|
|
isRealtime: stopTime.realtime,
|
|
headsign: stopTime.headsign,
|
|
});
|
|
} catch (dbError) {
|
|
// Fail silently if database is not available
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
arrivals.sort((a, b) => new Date(a.arrivalTime).getTime() - new Date(b.arrivalTime).getTime());
|
|
|
|
res.json(arrivals);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch arrivals' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/vehicles', async (req: Request, res: Response) => {
|
|
try {
|
|
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
|
|
|
|
// Fetch all vehicles from JSON API
|
|
const vehiclesResponse = await fetch(config.apiEndpoints.vehiclesJson);
|
|
|
|
if (!vehiclesResponse.ok) {
|
|
throw new Error(`HTTP error! status: ${vehiclesResponse.status}`);
|
|
}
|
|
|
|
const allVehicles = await vehiclesResponse.json() as any[];
|
|
|
|
// Fetch trip updates to find which vehicles are on our route
|
|
const tripUpdatesResponse = await fetch(config.apiEndpoints.gtfsRtTripUpdates);
|
|
|
|
if (!tripUpdatesResponse.ok) {
|
|
console.warn('Could not fetch trip updates, returning all vehicles');
|
|
// Return all vehicles with basic info
|
|
res.json(allVehicles.slice(0, 20).map((v: any) => ({
|
|
id: v.id,
|
|
vehicleId: v.identificationNumber,
|
|
label: v.inventoryNumber,
|
|
lat: v.positionLatitude,
|
|
lon: v.positionLongitude,
|
|
bearing: v.positionBearing,
|
|
speed: v.positionSpeed,
|
|
timestamp: v.positionModifiedAt,
|
|
tripId: '',
|
|
currentStopSequence: 0,
|
|
currentStatus: 'UNKNOWN',
|
|
})));
|
|
return;
|
|
}
|
|
|
|
const buffer = await tripUpdatesResponse.arrayBuffer();
|
|
|
|
if (buffer.byteLength === 0) {
|
|
console.warn('Empty trip updates feed');
|
|
res.json([]);
|
|
return;
|
|
}
|
|
|
|
let feed;
|
|
try {
|
|
feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
|
|
new Uint8Array(buffer)
|
|
);
|
|
} catch (decodeError) {
|
|
console.error('Failed to decode GTFS-RT feed:', decodeError);
|
|
res.json([]);
|
|
return;
|
|
}
|
|
|
|
// Find trip IDs and vehicle IDs for our route
|
|
const routeVehicleIds = new Set<string>();
|
|
const vehicleTripMap = new Map<string, any>();
|
|
|
|
for (const entity of feed.entity) {
|
|
if (!entity.tripUpdate) continue;
|
|
|
|
const tripUpdate = entity.tripUpdate;
|
|
|
|
// Filter by target route
|
|
if (tripUpdate.trip?.routeId !== routeId) continue;
|
|
|
|
// Get vehicle ID if available
|
|
if (tripUpdate.vehicle?.id) {
|
|
routeVehicleIds.add(tripUpdate.vehicle.id);
|
|
vehicleTripMap.set(tripUpdate.vehicle.id, {
|
|
tripId: tripUpdate.trip.tripId,
|
|
routeId: tripUpdate.trip.routeId,
|
|
});
|
|
}
|
|
|
|
// Also try vehicle label
|
|
if (tripUpdate.vehicle?.label) {
|
|
routeVehicleIds.add(tripUpdate.vehicle.label);
|
|
vehicleTripMap.set(tripUpdate.vehicle.label, {
|
|
tripId: tripUpdate.trip.tripId,
|
|
routeId: tripUpdate.trip.routeId,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Filter vehicles by route
|
|
const activeVehicles: any[] = [];
|
|
|
|
for (const vehicle of allVehicles) {
|
|
// Try to match by identification number or inventory number
|
|
const vehicleId = vehicle.identificationNumber || vehicle.inventoryNumber?.toString();
|
|
|
|
if (vehicleId && (routeVehicleIds.has(vehicleId) || routeVehicleIds.has(vehicle.inventoryNumber?.toString()))) {
|
|
const tripInfo = vehicleTripMap.get(vehicleId) || vehicleTripMap.get(vehicle.inventoryNumber?.toString());
|
|
|
|
activeVehicles.push({
|
|
id: vehicle.id,
|
|
vehicleId: vehicle.identificationNumber,
|
|
label: vehicle.inventoryNumber,
|
|
lat: vehicle.positionLatitude,
|
|
lon: vehicle.positionLongitude,
|
|
bearing: vehicle.positionBearing,
|
|
speed: vehicle.positionSpeed,
|
|
timestamp: vehicle.positionModifiedAt,
|
|
tripId: tripInfo?.tripId || '',
|
|
currentStopSequence: 0,
|
|
currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN',
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`Found ${routeVehicleIds.size} vehicle IDs for route ${routeId}`);
|
|
console.log(`Matched ${activeVehicles.length} vehicles from JSON API`);
|
|
|
|
// Log vehicle positions to database
|
|
try {
|
|
const positions = activeVehicles.map(v => ({
|
|
timestamp: Date.now(),
|
|
vehicleId: v.vehicleId || v.label,
|
|
routeId,
|
|
tripId: v.tripId,
|
|
latitude: v.lat,
|
|
longitude: v.lon,
|
|
speed: v.speed,
|
|
bearing: v.bearing,
|
|
currentStatus: v.currentStatus,
|
|
}));
|
|
logVehiclePositions(positions);
|
|
} catch (dbError) {
|
|
// Fail silently if database is not available
|
|
}
|
|
|
|
res.json(activeVehicles);
|
|
} catch (error) {
|
|
console.error('Error fetching vehicles:', error);
|
|
res.status(500).json({ error: 'Failed to fetch vehicles', details: String(error) });
|
|
}
|
|
});
|
|
|
|
// Historical Data API Endpoints
|
|
|
|
app.get('/api/stats/db', async (req: Request, res: Response) => {
|
|
try {
|
|
const stats = await getDatabaseStats();
|
|
res.json(stats);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Database not available' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/history/vehicle/:vehicleId', async (req: Request, res: Response) => {
|
|
try {
|
|
const vehicleId = req.params.vehicleId;
|
|
const hours = parseInt(req.query.hours as string) || 24;
|
|
const endTime = Date.now();
|
|
const startTime = endTime - (hours * 60 * 60 * 1000);
|
|
|
|
const history = await getVehicleHistory(vehicleId, startTime, endTime);
|
|
res.json(history);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch vehicle history' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/history/route/:routeId/vehicles', async (req: Request, res: Response) => {
|
|
try {
|
|
const routeId = req.params.routeId;
|
|
const hours = parseInt(req.query.hours as string) || 24;
|
|
const endTime = Date.now();
|
|
const startTime = endTime - (hours * 60 * 60 * 1000);
|
|
|
|
const positions = await getRouteVehiclePositions(routeId, startTime, endTime);
|
|
res.json(positions);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch route vehicle history' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/history/stop/:stopId/arrivals', async (req: Request, res: Response) => {
|
|
try {
|
|
const stopId = req.params.stopId;
|
|
const routeId = (req.query.routeId as string) || '';
|
|
const hours = parseInt(req.query.hours as string) || 24;
|
|
const endTime = Date.now();
|
|
const startTime = endTime - (hours * 60 * 60 * 1000);
|
|
|
|
const arrivals = await getStopArrivalHistory(stopId, routeId, startTime, endTime);
|
|
res.json(arrivals);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch arrival history' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stats/route/:routeId/delays', async (req: Request, res: Response) => {
|
|
try {
|
|
const routeId = req.params.routeId;
|
|
const hours = parseInt(req.query.hours as string) || 24;
|
|
|
|
const stats = await getRouteDelayStats(routeId, hours);
|
|
res.json(stats);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch delay stats' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stats/stop/:stopId/delays', async (req: Request, res: Response) => {
|
|
try {
|
|
const stopId = req.params.stopId;
|
|
const hours = parseInt(req.query.hours as string) || 24;
|
|
|
|
const stats = await getStopDelayStats(stopId, hours);
|
|
res.json(stats);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch delay stats' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stats/route/:routeId/hourly', async (req: Request, res: Response) => {
|
|
try {
|
|
const routeId = req.params.routeId;
|
|
const days = parseInt(req.query.days as string) || 7;
|
|
|
|
const pattern = await getRouteHourlyPattern(routeId, days);
|
|
res.json(pattern);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch hourly pattern' });
|
|
}
|
|
});
|
|
|
|
// Start server
|
|
app.listen(config.server.port, () => {
|
|
console.log(`
|
|
===========================================================================
|
|
BUS TRACKER WEB APP
|
|
===========================================================================
|
|
Server running at: http://localhost:${config.server.port}
|
|
|
|
Pages:
|
|
- Main Tracker: http://localhost:${config.server.port}
|
|
- Analytics: http://localhost:${config.server.port}/analytics.html
|
|
- API Docs: http://localhost:${config.server.port}/api-docs.html
|
|
|
|
Features:
|
|
- Real-time bus arrivals and vehicle tracking
|
|
- Interactive map interface
|
|
- Historical analytics and delay statistics
|
|
- Complete REST API with OpenAPI specification
|
|
|
|
Press Ctrl+C to stop the server
|
|
===========================================================================
|
|
`);
|
|
});
|