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 Stop ID to track (default: ${config.defaultStop.stopId}) --route 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, routes: Map ): Promise { 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, routes: Map ) { 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();