348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import { loadGtfsStops, loadGtfsRoutes, GtfsStop, GtfsRoute } from './lib/gtfs';
|
|
import { config } from './config';
|
|
|
|
// ============================================================================
|
|
// CLI Arguments
|
|
// ============================================================================
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
let stopId = config.defaultStop.stopId;
|
|
let routeId = config.defaultRoute.routeId;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--stop' && args[i + 1]) {
|
|
stopId = args[i + 1];
|
|
i++;
|
|
} else if (args[i] === '--route' && args[i + 1]) {
|
|
routeId = args[i + 1];
|
|
i++;
|
|
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
console.log(`
|
|
Usage: npm run tracker [options]
|
|
|
|
Options:
|
|
--stop <stopId> Stop ID to track (default: ${config.defaultStop.stopId})
|
|
--route <routeId> Route ID to track (default: ${config.defaultRoute.routeId})
|
|
--help, -h Show this help message
|
|
|
|
Examples:
|
|
npm run tracker
|
|
npm run tracker -- --stop 1571 --route 125
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
return { stopId, routeId };
|
|
}
|
|
|
|
const { stopId: TARGET_STOP_ID, routeId: TARGET_ROUTE_ID } = parseArgs();
|
|
|
|
// ============================================================================
|
|
// Configuration
|
|
// ============================================================================
|
|
|
|
// ============================================================================
|
|
// Additional Types
|
|
// ============================================================================
|
|
|
|
interface StopTime {
|
|
scheduledArrival: number;
|
|
scheduledDeparture: number;
|
|
realtimeArrival: number;
|
|
realtimeDeparture: number;
|
|
arrivalDelay: number;
|
|
departureDelay: number;
|
|
timepoint: boolean;
|
|
realtime: boolean;
|
|
realtimeState: string;
|
|
serviceDay: number;
|
|
headsign: string;
|
|
}
|
|
|
|
|
|
interface Pattern {
|
|
routeId: number;
|
|
index: number;
|
|
stopTimes: StopTime[];
|
|
}
|
|
|
|
interface StopArrivalData {
|
|
id: number;
|
|
patterns: Pattern[];
|
|
distance: number;
|
|
}
|
|
|
|
interface BusArrival {
|
|
tripId: string;
|
|
routeName: string;
|
|
stopName: string;
|
|
headsign: string;
|
|
arrivalTime: Date;
|
|
scheduledTime: Date;
|
|
delaySeconds: number;
|
|
minutesUntilArrival: number;
|
|
isApproaching: boolean;
|
|
realtimeState: string;
|
|
isRealtime: boolean;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Real-time Data Fetching
|
|
// ============================================================================
|
|
|
|
async function getNextBuses(
|
|
stops: Map<string, GtfsStop>,
|
|
routes: Map<string, GtfsRoute>
|
|
): Promise<BusArrival[]> {
|
|
const targetStop = stops.get(TARGET_STOP_ID);
|
|
|
|
if (!targetStop) {
|
|
throw new Error(`Stop ${TARGET_STOP_ID} not found in GTFS data`);
|
|
}
|
|
|
|
// Fetch nearby arrivals using JSON API
|
|
const radius = 50; // 50 meters
|
|
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${targetStop.stop_lat}&longitude=${targetStop.stop_lon}&radius=${radius}`;
|
|
|
|
const response = await fetch(nearbyUrl);
|
|
const nearbyData = await response.json() as StopArrivalData[];
|
|
|
|
const now = new Date();
|
|
const maxTime = new Date(now.getTime() + config.tracking.minutesAhead * 60000);
|
|
|
|
const arrivals: BusArrival[] = [];
|
|
|
|
// Get route info
|
|
const targetRoute = routes.get(TARGET_ROUTE_ID);
|
|
|
|
// Process the nearby data
|
|
for (const stopData of nearbyData) {
|
|
if (stopData.id.toString() !== TARGET_STOP_ID) continue;
|
|
|
|
for (const pattern of stopData.patterns) {
|
|
// Filter by target route
|
|
if (pattern.routeId.toString() !== TARGET_ROUTE_ID) continue;
|
|
|
|
const routeInfo = routes.get(pattern.routeId.toString());
|
|
if (!routeInfo) continue;
|
|
|
|
for (const stopTime of pattern.stopTimes) {
|
|
// Convert service day + seconds to actual timestamp
|
|
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);
|
|
|
|
// Only include buses arriving within our time window
|
|
if (arrivalTime > maxTime || arrivalTime < now) continue;
|
|
|
|
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
|
|
|
|
arrivals.push({
|
|
tripId: `${pattern.routeId}.${pattern.index}`,
|
|
routeName: `${routeInfo.route_short_name} - ${routeInfo.route_long_name}`,
|
|
stopName: targetStop.stop_name,
|
|
headsign: stopTime.headsign,
|
|
arrivalTime: arrivalTime,
|
|
scheduledTime: scheduledTime,
|
|
delaySeconds: stopTime.arrivalDelay,
|
|
minutesUntilArrival: minutesUntil,
|
|
isApproaching: minutesUntil <= 5 && minutesUntil >= 0,
|
|
realtimeState: stopTime.realtimeState,
|
|
isRealtime: stopTime.realtime,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by arrival time
|
|
arrivals.sort((a, b) => a.arrivalTime.getTime() - b.arrivalTime.getTime());
|
|
|
|
return arrivals;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Display Functions
|
|
// ============================================================================
|
|
|
|
function formatArrival(arrival: BusArrival, index: number): string {
|
|
const delayMin = Math.floor(arrival.delaySeconds / 60);
|
|
|
|
let delayText = "";
|
|
if (delayMin > 0) {
|
|
delayText = ` [${delayMin}min LATE]`;
|
|
} else if (delayMin < -3) {
|
|
delayText = ` [${Math.abs(delayMin)}min EARLY]`;
|
|
} else {
|
|
delayText = " [ON TIME]";
|
|
}
|
|
|
|
const timeStr = arrival.arrivalTime.toLocaleTimeString('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
|
|
let status = "";
|
|
if (arrival.minutesUntilArrival <= 0) {
|
|
status = " >> ARRIVING NOW!";
|
|
} else if (arrival.isApproaching) {
|
|
status = " >> APPROACHING";
|
|
}
|
|
|
|
const minutesText = arrival.minutesUntilArrival === 0
|
|
? "NOW"
|
|
: `${arrival.minutesUntilArrival} min`;
|
|
|
|
const realtimeIndicator = arrival.isRealtime ? "[LIVE]" : "[SCHED]";
|
|
|
|
return ` ${index + 1}. ${timeStr} (in ${minutesText}) ${delayText} ${realtimeIndicator}${status}`;
|
|
}
|
|
|
|
async function displayBusSchedule(
|
|
stops: Map<string, GtfsStop>,
|
|
routes: Map<string, GtfsRoute>
|
|
) {
|
|
console.clear();
|
|
|
|
const targetStop = stops.get(TARGET_STOP_ID);
|
|
const targetRoute = routes.get(TARGET_ROUTE_ID);
|
|
|
|
console.log("=".repeat(75));
|
|
console.log(` BUS TRACKER - SKOPJE PUBLIC TRANSPORT`);
|
|
console.log("=".repeat(75));
|
|
console.log(` Route: ${targetRoute?.route_short_name} - ${targetRoute?.route_long_name}`);
|
|
console.log(` Stop: ${targetStop?.stop_name} (Code: ${targetStop?.stop_code})`);
|
|
console.log(` Location: ${targetStop?.stop_lat.toFixed(5)}, ${targetStop?.stop_lon.toFixed(5)}`);
|
|
console.log("=".repeat(75));
|
|
console.log(` Updated: ${new Date().toLocaleString('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
})}\n`);
|
|
|
|
try {
|
|
const arrivals = await getNextBuses(stops, routes);
|
|
|
|
if (arrivals.length === 0) {
|
|
console.log(` No buses scheduled in the next ${config.tracking.minutesAhead} minutes.\n`);
|
|
console.log(" This could mean:");
|
|
console.log(" - No buses are currently running on this route");
|
|
console.log(" - The next bus is more than 90 minutes away");
|
|
console.log(" - Service has ended for the day\n");
|
|
return;
|
|
}
|
|
|
|
console.log(` Found ${arrivals.length} upcoming bus${arrivals.length > 1 ? 'es' : ''}:\n`);
|
|
|
|
// Show all buses (or first 10 if too many)
|
|
const showCount = Math.min(arrivals.length, 10);
|
|
arrivals.slice(0, showCount).forEach((arrival, index) => {
|
|
console.log(formatArrival(arrival, index));
|
|
if (arrival.headsign && index < 5) {
|
|
console.log(` Direction: ${arrival.headsign}`);
|
|
}
|
|
});
|
|
|
|
if (arrivals.length > showCount) {
|
|
console.log(`\n ... and ${arrivals.length - showCount} more later`);
|
|
}
|
|
|
|
// Highlight the next bus
|
|
const nextBus = arrivals[0];
|
|
if (nextBus) {
|
|
console.log("\n" + "-".repeat(75));
|
|
console.log(" NEXT BUS:");
|
|
console.log("-".repeat(75));
|
|
|
|
if (nextBus.minutesUntilArrival <= 0) {
|
|
console.log(" >> BUS IS ARRIVING NOW! HEAD TO THE STOP! <<");
|
|
} else if (nextBus.minutesUntilArrival <= 2) {
|
|
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minute${nextBus.minutesUntilArrival > 1 ? 's' : ''}! Run! <<`);
|
|
} else if (nextBus.minutesUntilArrival <= 5) {
|
|
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minutes - Time to head to the stop <<`);
|
|
} else if (nextBus.minutesUntilArrival <= 15) {
|
|
console.log(` >> ${nextBus.minutesUntilArrival} minutes - Start getting ready <<`);
|
|
} else {
|
|
console.log(` >> ${nextBus.minutesUntilArrival} minutes - You have time to relax <<`);
|
|
}
|
|
|
|
console.log(` Direction: ${nextBus.headsign}`);
|
|
console.log(` Data source: ${nextBus.isRealtime ? 'Real-time tracking' : 'Scheduled times'}`);
|
|
|
|
const delayMin = Math.floor(nextBus.delaySeconds / 60);
|
|
if (delayMin > 5) {
|
|
console.log(` WARNING: Bus is running ${delayMin} minutes LATE`);
|
|
} else if (delayMin < -3) {
|
|
console.log(` NOTE: Bus is ${Math.abs(delayMin)} minutes EARLY`);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log(`\n ERROR: ${error}\n`);
|
|
console.log(" Please check:");
|
|
console.log(" - Your internet connection");
|
|
console.log(" - The API is accessible");
|
|
console.log(" - The GTFS files are present in ./gtfs/ directory\n");
|
|
}
|
|
|
|
console.log("\n" + "=".repeat(75));
|
|
console.log(` Auto-refresh: Every ${config.tracking.refreshInterval.terminal / 1000} seconds`);
|
|
console.log(" Press Ctrl+C to stop");
|
|
console.log("=".repeat(75));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Program
|
|
// ============================================================================
|
|
|
|
async function startMonitoring() {
|
|
console.log("Loading GTFS data...\n");
|
|
|
|
try {
|
|
const stops = loadGtfsStops();
|
|
const routes = loadGtfsRoutes();
|
|
|
|
console.log(`Loaded ${stops.size} stops`);
|
|
console.log(`Loaded ${routes.size} routes\n`);
|
|
|
|
// Verify target stop and route exist
|
|
const targetStop = stops.get(TARGET_STOP_ID);
|
|
const targetRoute = routes.get(TARGET_ROUTE_ID);
|
|
|
|
if (!targetStop) {
|
|
console.error(`ERROR: Stop ${TARGET_STOP_ID} not found in GTFS data`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!targetRoute) {
|
|
console.error(`ERROR: Route ${TARGET_ROUTE_ID} not found in GTFS data`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("Configuration validated");
|
|
console.log("Starting bus monitor...\n");
|
|
|
|
// Initial display
|
|
await displayBusSchedule(stops, routes);
|
|
|
|
// Set up periodic refresh
|
|
setInterval(async () => {
|
|
await displayBusSchedule(stops, routes);
|
|
}, config.tracking.refreshInterval.terminal);
|
|
|
|
} catch (error) {
|
|
console.error(`Failed to start: ${error}`);
|
|
console.error("\nPlease ensure:");
|
|
console.error(" 1. GTFS files exist in ./gtfs/ directory");
|
|
console.error(" 2. Files include: stops.txt and routes.txt");
|
|
console.error(" 3. Node.js has permission to read the files");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Start the application
|
|
startMonitoring();
|