314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
const axios = require('axios');
|
|
const winston = require('winston');
|
|
|
|
// Configure logger
|
|
const logger = winston.createLogger({
|
|
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
|
format: winston.format.combine(
|
|
winston.format.timestamp(),
|
|
winston.format.json()
|
|
),
|
|
transports: [
|
|
new winston.transports.Console({
|
|
format: winston.format.simple()
|
|
})
|
|
]
|
|
});
|
|
|
|
// Cache for geocoding results (simple in-memory cache)
|
|
const geocodeCache = new Map();
|
|
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
// Clean up old cache entries periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, value] of geocodeCache.entries()) {
|
|
if (now - value.timestamp > CACHE_TTL) {
|
|
geocodeCache.delete(key);
|
|
}
|
|
}
|
|
}, 60 * 60 * 1000); // Run every hour
|
|
|
|
/**
|
|
* Reverse geocode coordinates to get address
|
|
* @param {number} lat - Latitude
|
|
* @param {number} lng - Longitude
|
|
* @returns {Promise<Object>} Geocoding result
|
|
*/
|
|
async function reverseGeocode(lat, lng) {
|
|
// Create cache key - use full precision
|
|
const cacheKey = `${lat},${lng}`;
|
|
|
|
// Check cache first
|
|
const cached = geocodeCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
logger.debug(`Geocoding cache hit for ${cacheKey}`);
|
|
return cached.data;
|
|
}
|
|
|
|
try {
|
|
// Add delay to respect Nominatim rate limits (max 1 request per second)
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
logger.info(`Reverse geocoding: ${lat}, ${lng}`);
|
|
|
|
const response = await axios.get('https://nominatim.openstreetmap.org/reverse', {
|
|
params: {
|
|
format: 'json',
|
|
lat: lat,
|
|
lon: lng,
|
|
zoom: 18,
|
|
addressdetails: 1,
|
|
'accept-language': 'en'
|
|
},
|
|
headers: {
|
|
'User-Agent': 'NocoDB Map Viewer 1.0 (https://github.com/yourusername/nocodb-map-viewer)'
|
|
},
|
|
timeout: 10000
|
|
});
|
|
|
|
if (response.data.error) {
|
|
throw new Error(response.data.error);
|
|
}
|
|
|
|
// Process the response
|
|
const result = processGeocodeResponse(response.data);
|
|
|
|
// Cache the result
|
|
geocodeCache.set(cacheKey, {
|
|
data: result,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
logger.error('Reverse geocoding error:', error.message);
|
|
|
|
if (error.response?.status === 429) {
|
|
throw new Error('Rate limit exceeded. Please try again later.');
|
|
} else if (error.code === 'ECONNABORTED') {
|
|
throw new Error('Geocoding service timeout');
|
|
} else {
|
|
throw new Error('Geocoding service unavailable');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forward geocode address to get coordinates (for search - returns multiple results)
|
|
* @param {string} address - Address to search
|
|
* @param {number} limit - Maximum number of results to return
|
|
* @returns {Promise<Array>} Array of geocoding results
|
|
*/
|
|
async function forwardGeocodeSearch(address, limit = 5) {
|
|
// Create cache key
|
|
const cacheKey = `search:${address.toLowerCase()}:${limit}`;
|
|
|
|
// Check cache first
|
|
const cached = geocodeCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
logger.debug(`Geocoding search cache hit for ${cacheKey}`);
|
|
return cached.data;
|
|
}
|
|
|
|
try {
|
|
// Add delay to respect rate limits
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
logger.info(`Forward geocoding search: ${address}`);
|
|
|
|
const response = await axios.get('https://nominatim.openstreetmap.org/search', {
|
|
params: {
|
|
format: 'json',
|
|
q: address,
|
|
limit: limit,
|
|
addressdetails: 1,
|
|
'accept-language': 'en',
|
|
countrycodes: 'ca' // Limit to Canada for this application
|
|
},
|
|
headers: {
|
|
'User-Agent': 'NocoDB Map Viewer 1.0 (contact@example.com)'
|
|
},
|
|
timeout: 15000
|
|
});
|
|
|
|
if (!response.data || response.data.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Process all results
|
|
const results = response.data.map(item => processGeocodeResponse(item));
|
|
|
|
// Cache the results
|
|
geocodeCache.set(cacheKey, {
|
|
data: results,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
return results;
|
|
|
|
} catch (error) {
|
|
logger.error('Forward geocoding search error:', error.message);
|
|
|
|
if (error.response?.status === 429) {
|
|
throw new Error('Rate limit exceeded. Please try again later.');
|
|
} else if (error.response?.status === 403) {
|
|
throw new Error('Access denied by geocoding service');
|
|
} else if (error.response?.status === 500) {
|
|
throw new Error('Geocoding service internal error');
|
|
} else if (error.code === 'ECONNABORTED') {
|
|
throw new Error('Geocoding request timeout');
|
|
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
throw new Error('Cannot connect to geocoding service');
|
|
} else {
|
|
throw new Error(`Geocoding search failed: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forward geocode address to get coordinates
|
|
* @param {string} address - Address to geocode
|
|
* @returns {Promise<Object>} Geocoding result
|
|
*/
|
|
async function forwardGeocode(address) {
|
|
// Create cache key
|
|
const cacheKey = `addr:${address.toLowerCase()}`;
|
|
|
|
// Check cache first
|
|
const cached = geocodeCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
logger.debug(`Geocoding cache hit for ${cacheKey}`);
|
|
return cached.data;
|
|
}
|
|
|
|
try {
|
|
// Add delay to respect rate limits - increase delay for batch processing
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
|
|
logger.info(`Forward geocoding: ${address}`);
|
|
|
|
const response = await axios.get('https://nominatim.openstreetmap.org/search', {
|
|
params: {
|
|
format: 'json',
|
|
q: address,
|
|
limit: 1,
|
|
addressdetails: 1,
|
|
'accept-language': 'en'
|
|
},
|
|
headers: {
|
|
'User-Agent': 'NocoDB Map Viewer 1.0 (contact@example.com)'
|
|
},
|
|
timeout: 15000 // Increase timeout to 15 seconds
|
|
});
|
|
|
|
if (!response.data || response.data.length === 0) {
|
|
throw new Error('No results found');
|
|
}
|
|
|
|
// Process the first result
|
|
const result = processGeocodeResponse(response.data[0]);
|
|
|
|
// Cache the result
|
|
geocodeCache.set(cacheKey, {
|
|
data: result,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
logger.error('Forward geocoding error:', error.message);
|
|
|
|
if (error.response?.status === 429) {
|
|
throw new Error('Rate limit exceeded. Please try again later.');
|
|
} else if (error.response?.status === 403) {
|
|
throw new Error('Access denied by geocoding service');
|
|
} else if (error.response?.status === 500) {
|
|
throw new Error('Geocoding service internal error');
|
|
} else if (error.code === 'ECONNABORTED') {
|
|
throw new Error('Geocoding request timeout');
|
|
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
throw new Error('Cannot connect to geocoding service');
|
|
} else {
|
|
throw new Error(`Geocoding failed: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process geocoding response into standardized format
|
|
* @param {Object} data - Raw geocoding response
|
|
* @returns {Object} Processed geocoding data
|
|
*/
|
|
function processGeocodeResponse(data) {
|
|
// Extract address components
|
|
const addressComponents = {
|
|
house_number: data.address?.house_number || '',
|
|
road: data.address?.road || '',
|
|
suburb: data.address?.suburb || data.address?.neighbourhood || '',
|
|
city: data.address?.city || data.address?.town || data.address?.village || '',
|
|
state: data.address?.state || data.address?.province || '',
|
|
postcode: data.address?.postcode || '',
|
|
country: data.address?.country || ''
|
|
};
|
|
|
|
// Create formatted address string
|
|
let formattedAddress = '';
|
|
if (addressComponents.house_number) formattedAddress += addressComponents.house_number + ' ';
|
|
if (addressComponents.road) formattedAddress += addressComponents.road + ', ';
|
|
if (addressComponents.suburb) formattedAddress += addressComponents.suburb + ', ';
|
|
if (addressComponents.city) formattedAddress += addressComponents.city + ', ';
|
|
if (addressComponents.state) formattedAddress += addressComponents.state + ' ';
|
|
if (addressComponents.postcode) formattedAddress += addressComponents.postcode;
|
|
|
|
// Clean up formatting
|
|
formattedAddress = formattedAddress.trim().replace(/,$/, '');
|
|
|
|
return {
|
|
fullAddress: data.display_name || '',
|
|
formattedAddress: formattedAddress,
|
|
components: addressComponents,
|
|
coordinates: {
|
|
lat: parseFloat(data.lat),
|
|
lng: parseFloat(data.lon)
|
|
},
|
|
// Backward compatibility
|
|
latitude: parseFloat(data.lat),
|
|
longitude: parseFloat(data.lon),
|
|
boundingBox: data.boundingbox || null,
|
|
placeId: data.place_id || null,
|
|
osmType: data.osm_type || null,
|
|
osmId: data.osm_id || null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
* @returns {Object} Cache statistics
|
|
*/
|
|
function getCacheStats() {
|
|
return {
|
|
size: geocodeCache.size,
|
|
maxSize: 1000, // Could be made configurable
|
|
ttl: CACHE_TTL
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear the geocoding cache
|
|
*/
|
|
function clearCache() {
|
|
geocodeCache.clear();
|
|
logger.info('Geocoding cache cleared');
|
|
}
|
|
|
|
module.exports = {
|
|
reverseGeocode,
|
|
forwardGeocode,
|
|
forwardGeocodeSearch,
|
|
getCacheStats,
|
|
clearCache
|
|
};
|