UNPKG

@siva-sub/mcp-public-transport

Version:

A Model Context Protocol server for Singapore transport data with real-time information and routing

156 lines (155 loc) 6.82 kB
/** * MRT Exit Service * Provides MRT station exit information for precise navigation */ import axios from 'axios'; import { logger } from '../utils/logger.js'; import { APIError } from '../utils/errors.js'; export class MRTExitService { cache; mrtExitData = []; dataLoaded = false; constructor(cache) { this.cache = cache; } /** * Load MRT exit data from Singapore's open data */ async loadMRTExitData() { if (this.dataLoaded) return; const cacheKey = 'mrt_exit_data'; try { this.mrtExitData = await this.cache.getOrSet(cacheKey, async () => { logger.info('Loading MRT exit data from Singapore Open Data'); const datasetId = 'd_b39d3a0871985372d7e1637193335da5'; const pollUrl = `https://api-open.data.gov.sg/v1/public/api/datasets/${datasetId}/poll-download`; // Get download URL const pollResponse = await axios.get(pollUrl); if (pollResponse.data.code !== 0) { throw new Error(pollResponse.data.errMsg || 'Failed to get MRT data URL'); } const downloadUrl = pollResponse.data.data.url; // Download the actual data const dataResponse = await axios.get(downloadUrl); const geoJsonData = dataResponse.data; // Parse GeoJSON features into MRT exit data const exits = []; for (const feature of geoJsonData.features) { const description = feature.properties.Description; const coordinates = feature.geometry.coordinates; // Parse station name and exit code from description HTML const stationMatch = description.match(/<th>STATION_NA<\/th>\s*<td>([^<]+)<\/td>/); const exitMatch = description.match(/<th>EXIT_CODE<\/th>\s*<td>([^<]+)<\/td>/); if (stationMatch && exitMatch && coordinates) { exits.push({ stationName: stationMatch[1].trim(), exitCode: exitMatch[1].trim(), coordinates: { latitude: coordinates[1], longitude: coordinates[0], }, }); } } logger.info(`Loaded ${exits.length} MRT exits`); return exits; }, 86400); // Cache for 24 hours this.dataLoaded = true; } catch (error) { logger.error('Failed to load MRT exit data', error); throw new APIError('Failed to load MRT exit data', 'MRT_DATA_ERROR', 500); } } /** * Find the best MRT exit for a destination */ async findBestMRTExit(stationName, destinationLat, destinationLng) { await this.loadMRTExitData(); // Normalize station name for matching const normalizedStationName = this.normalizeStationName(stationName); // Find all exits for this station const stationExits = this.mrtExitData.filter(exit => this.normalizeStationName(exit.stationName) === normalizedStationName); if (stationExits.length === 0) { logger.warn(`No exits found for station: ${stationName}`); return null; } // Calculate distances to destination for each exit const exitsWithDistance = stationExits.map(exit => ({ ...exit, distance: this.calculateDistance(exit.coordinates.latitude, exit.coordinates.longitude, destinationLat, destinationLng), })); // Sort by distance (closest first) exitsWithDistance.sort((a, b) => a.distance - b.distance); const bestExit = exitsWithDistance[0]; const alternativeExits = exitsWithDistance.slice(1, 4); // Up to 3 alternatives const walkingDistance = Math.round(bestExit.distance); const walkingTime = Math.ceil(walkingDistance / 80); // ~80m per minute walking speed return { stationName: bestExit.stationName, recommendedExit: bestExit, alternativeExits, walkingDistance, walkingTime, reason: this.generateExitReason(bestExit, alternativeExits), }; } /** * Get all exits for a specific station */ async getStationExits(stationName) { await this.loadMRTExitData(); const normalizedStationName = this.normalizeStationName(stationName); return this.mrtExitData.filter(exit => this.normalizeStationName(exit.stationName) === normalizedStationName); } /** * Find nearby MRT exits within a radius */ async findNearbyMRTExits(latitude, longitude, radiusMeters = 500) { await this.loadMRTExitData(); const nearbyExits = this.mrtExitData .map(exit => ({ ...exit, distance: this.calculateDistance(exit.coordinates.latitude, exit.coordinates.longitude, latitude, longitude), })) .filter(exit => exit.distance <= radiusMeters) .sort((a, b) => a.distance - b.distance); return nearbyExits; } normalizeStationName(stationName) { return stationName .toUpperCase() .replace(/\s+MRT\s+STATION$/i, '') .replace(/\s+STATION$/i, '') .replace(/\s+/g, ' ') .trim(); } calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371e3; // Earth's radius in metres const φ1 = lat1 * Math.PI / 180; const φ2 = lat2 * Math.PI / 180; const Δφ = (lat2 - lat1) * Math.PI / 180; const Δλ = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos1) * Math.cos2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } generateExitReason(bestExit, alternatives) { if (alternatives.length === 0) { return `${bestExit.exitCode} is the only available exit`; } const distanceDiff = alternatives[0].distance - bestExit.distance; if (distanceDiff < 50) { return `${bestExit.exitCode} is marginally closer than other exits`; } else if (distanceDiff < 100) { return `${bestExit.exitCode} is the closest exit, saving about ${Math.round(distanceDiff)}m of walking`; } else { return `${bestExit.exitCode} is significantly closer, saving over ${Math.round(distanceDiff)}m compared to other exits`; } } }