@siva-sub/mcp-public-transport
Version:
A Model Context Protocol server for Singapore transport data with real-time information and routing
284 lines (283 loc) • 11.6 kB
JavaScript
/**
* OneMap Themes Service
* Provides access to Singapore's thematic layers including landmarks, facilities, and points of interest
*/
import { logger } from '../utils/logger.js';
export class ThemesService {
oneMapService;
cacheService;
CACHE_DURATION = {
THEMES_LIST: 86400, // 24 hours - themes don't change often
THEME_DATA: 3600, // 1 hour - theme data can be updated
THEME_STATUS: 300, // 5 minutes - status checks
};
constructor(oneMapService, cacheService) {
this.oneMapService = oneMapService;
this.cacheService = cacheService;
}
/**
* Get all available themes from OneMap
*/
async getAllThemes() {
const cacheKey = 'onemap_all_themes';
const cachedThemes = this.cacheService.get(cacheKey);
if (cachedThemes) {
return cachedThemes;
}
try {
const token = await this.oneMapService.ensureValidToken();
const response = await this.oneMapService['client'].get('/api/public/themesvc/getAllThemesInfo', {
params: { moreInfo: 'Y' },
headers: { 'Authorization': token }
});
const themes = (response.data.Theme_Names || []).map((theme) => ({
themeName: theme.THEMENAME,
queryName: theme.QUERYNAME,
icon: theme.ICON,
expiryDate: theme.EXPIRY_DATE,
publishedDate: theme.PUBLISHED_DATE,
category: theme.CATEGORY,
themeOwner: theme.THEME_OWNER
}));
this.cacheService.set(cacheKey, themes, this.CACHE_DURATION.THEMES_LIST);
return themes;
}
catch (error) {
logger.error('Failed to get all themes', error);
throw error;
}
}
/**
* Search for landmarks and facilities near a location
*/
async findLandmarksNear(latitude, longitude, radius = 1000, categories) {
try {
// Get all available themes
const allThemes = await this.getAllThemes();
// Filter themes by category if specified
const relevantThemes = categories
? allThemes.filter(theme => categories.some(cat => theme.category.toLowerCase().includes(cat.toLowerCase())))
: allThemes.filter(theme =>
// Focus on landmark and facility categories
['education', 'health', 'community', 'recreation', 'transport', 'shopping', 'tourism'].some(cat => theme.category.toLowerCase().includes(cat)));
// Calculate search extents based on radius
const extents = this.calculateExtents(latitude, longitude, radius);
// Search each relevant theme
const searchPromises = relevantThemes.slice(0, 10).map(theme => // Limit to prevent API overload
this.searchThemeInArea(theme, extents, latitude, longitude, radius));
const results = await Promise.allSettled(searchPromises);
return results
.filter((result) => result.status === 'fulfilled' && result.value.features.length > 0)
.map(result => result.value)
.sort((a, b) => b.features.length - a.features.length); // Sort by number of features
}
catch (error) {
logger.error('Failed to find landmarks near location', error);
throw error;
}
}
/**
* Search for specific types of facilities (e.g., schools, hospitals, parks)
*/
async findFacilitiesByType(facilityType, latitude, longitude, radius = 2000) {
try {
const allThemes = await this.getAllThemes();
// Find themes that match the facility type
const matchingThemes = allThemes.filter(theme => theme.themeName.toLowerCase().includes(facilityType.toLowerCase()) ||
theme.category.toLowerCase().includes(facilityType.toLowerCase()) ||
theme.queryName.toLowerCase().includes(facilityType.toLowerCase()));
if (matchingThemes.length === 0) {
logger.warn(`No themes found for facility type: ${facilityType}`);
return [];
}
let extents;
if (latitude && longitude) {
extents = this.calculateExtents(latitude, longitude, radius);
}
const searchPromises = matchingThemes.map(theme => this.searchTheme(theme, extents, latitude, longitude, radius));
const results = await Promise.allSettled(searchPromises);
return results
.filter((result) => result.status === 'fulfilled' && result.value.features.length > 0)
.map(result => result.value);
}
catch (error) {
logger.error(`Failed to find facilities of type: ${facilityType}`, error);
throw error;
}
}
/**
* Get landmarks near a postal code
*/
async findLandmarksNearPostalCode(postalCode, radius = 1000, categories) {
try {
// Geocode the postal code first
const location = await this.oneMapService.geocode(postalCode);
if (!location) {
throw new Error(`Could not find location for postal code: ${postalCode}`);
}
return this.findLandmarksNear(location.latitude, location.longitude, radius, categories);
}
catch (error) {
logger.error(`Failed to find landmarks near postal code: ${postalCode}`, error);
throw error;
}
}
/**
* Search a specific theme within an area
*/
async searchThemeInArea(theme, extents, centerLat, centerLng, radius) {
return this.searchTheme(theme, extents, centerLat, centerLng, radius);
}
/**
* Search a specific theme
*/
async searchTheme(theme, extents, centerLat, centerLng, radius) {
const cacheKey = `theme_${theme.queryName}_${extents || 'all'}`;
const cachedResult = this.cacheService.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
try {
const token = await this.oneMapService.ensureValidToken();
const params = { queryName: theme.queryName };
if (extents) {
params.extents = extents;
}
const response = await this.oneMapService['client'].get('/api/public/themesvc/retrieveTheme', {
params,
headers: { 'Authorization': token }
});
const searchResults = response.data.SrchResults || [];
const features = [];
// Process search results
for (const result of searchResults) {
if (result.NAME || result.DESCRIPTION) {
// Extract coordinates
let coordinates = [0, 0];
if (result.LatLng) {
try {
// Parse coordinates from LatLng string
const coordsMatch = result.LatLng.match(/\[([\d.,\s]+)\]/);
if (coordsMatch) {
const coords = coordsMatch[1].split(',').map((c) => parseFloat(c.trim()));
if (coords.length >= 2) {
coordinates = [coords[1], coords[0]]; // [lng, lat] -> [lat, lng]
}
}
}
catch (e) {
logger.warn(`Failed to parse coordinates for ${result.NAME}`, e);
}
}
// Calculate distance if center point provided
let distance;
if (centerLat && centerLng && coordinates[0] !== 0 && coordinates[1] !== 0) {
distance = this.calculateDistance(centerLat, centerLng, coordinates[0], coordinates[1]);
}
const feature = {
name: result.NAME || result.DESCRIPTION || 'Unknown',
description: result.DESCRIPTION || result.NAME || '',
hyperlink: result.HYPERLINK,
category: theme.category,
owner: theme.themeOwner,
coordinates,
properties: {
...result,
theme: theme.themeName
},
iconName: result.ICON_NAME,
distance
};
features.push(feature);
}
}
// Sort by distance if available
if (centerLat && centerLng) {
features.sort((a, b) => (a.distance || Infinity) - (b.distance || Infinity));
}
const result = {
theme,
features,
totalCount: features.length,
searchArea: centerLat && centerLng && radius ? {
center: [centerLat, centerLng],
radius
} : undefined
};
this.cacheService.set(cacheKey, result, this.CACHE_DURATION.THEME_DATA);
return result;
}
catch (error) {
logger.error(`Failed to search theme: ${theme.queryName}`, error);
return {
theme,
features: [],
totalCount: 0
};
}
}
/**
* Calculate search extents based on center point and radius
*/
calculateExtents(lat, lng, radiusMeters) {
// Convert radius from meters to degrees (approximate)
const radiusDegrees = radiusMeters / 111000; // Rough conversion
const minLat = lat - radiusDegrees;
const maxLat = lat + radiusDegrees;
const minLng = lng - radiusDegrees;
const maxLng = lng + radiusDegrees;
return `${minLat},${minLng},${maxLat},${maxLng}`;
}
/**
* Calculate distance between two points in meters
*/
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371e3; // Earth's radius in meters
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.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Get popular landmark categories
*/
getPopularCategories() {
return [
'education',
'health',
'community',
'recreation',
'transport',
'shopping',
'tourism',
'government',
'religious',
'sports'
];
}
/**
* Get common facility types
*/
getCommonFacilityTypes() {
return [
'schools',
'hospitals',
'clinics',
'parks',
'libraries',
'community centers',
'shopping malls',
'mrt stations',
'bus stops',
'kindergartens',
'hawker centres',
'sports facilities',
'places of worship'
];
}
}