1064 lines
37 KiB
JavaScript
1064 lines
37 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
|
|
|
|
/**
|
|
* Multi-provider geocoding configuration
|
|
* Providers are tried in order until one succeeds with good confidence
|
|
*/
|
|
// Provider configuration - order matters (higher quality providers first)
|
|
const GEOCODING_PROVIDERS = [
|
|
// Premium provider (when API key is available)
|
|
{
|
|
name: 'Mapbox',
|
|
func: geocodeWithMapbox,
|
|
enabled: () => !!(process.env.MAPBOX_ACCESS_TOKEN || process.env.MAPBOX_API_KEY),
|
|
options: { timeout: 8000, delay: 0 }
|
|
},
|
|
// Free providers (fallbacks)
|
|
{
|
|
name: 'Nominatim',
|
|
func: geocodeWithNominatim,
|
|
enabled: () => true,
|
|
options: { timeout: 5000, delay: 1000 }
|
|
},
|
|
{
|
|
name: 'Photon',
|
|
func: geocodeWithPhoton,
|
|
enabled: () => true,
|
|
options: { timeout: 5000, delay: 500 }
|
|
},
|
|
{
|
|
name: 'LocationIQ',
|
|
func: geocodeWithLocationIQ,
|
|
enabled: () => !!process.env.LOCATIONIQ_API_KEY,
|
|
options: { timeout: 5000, delay: 0 }
|
|
},
|
|
{
|
|
name: 'ArcGIS',
|
|
func: geocodeWithArcGIS,
|
|
enabled: () => true,
|
|
options: { timeout: 8000, delay: 500 }
|
|
}
|
|
];
|
|
|
|
/**
|
|
* Geocode with Nominatim (OpenStreetMap)
|
|
*/
|
|
// Geocoding provider functions
|
|
async function geocodeWithMapbox(address, options = {}) {
|
|
const { timeout = 5000, delay = 0 } = options;
|
|
const apiKey = process.env.MAPBOX_ACCESS_TOKEN || process.env.MAPBOX_API_KEY;
|
|
|
|
if (!apiKey) {
|
|
throw new Error('Mapbox API key not configured');
|
|
}
|
|
|
|
// Rate limiting (Mapbox has generous rate limits)
|
|
if (delay > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
// Parse address components for structured input
|
|
const addressComponents = parseAddressString(address);
|
|
let url;
|
|
|
|
if (addressComponents.hasComponents) {
|
|
// Use structured input for better accuracy
|
|
const params = new URLSearchParams({
|
|
access_token: apiKey,
|
|
limit: 1,
|
|
country: addressComponents.country || 'ca' // Default to Canada
|
|
});
|
|
|
|
if (addressComponents.address_number) params.append('address_number', addressComponents.address_number);
|
|
if (addressComponents.street) params.append('street', addressComponents.street);
|
|
if (addressComponents.place) params.append('place', addressComponents.place);
|
|
if (addressComponents.region) params.append('region', addressComponents.region);
|
|
if (addressComponents.postcode) params.append('postcode', addressComponents.postcode);
|
|
|
|
url = `https://api.mapbox.com/search/geocode/v6/forward?${params.toString()}`;
|
|
} else {
|
|
// Fallback to simple search
|
|
url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${apiKey}&limit=1&country=ca`;
|
|
}
|
|
|
|
logger.info(`Geocoding with Mapbox: ${address}`);
|
|
|
|
try {
|
|
const response = await axios.get(url, { timeout });
|
|
const data = response.data;
|
|
|
|
// Debug: Log the API response structure
|
|
logger.debug(`Mapbox response structure:`, {
|
|
hasFeatures: !!(data.features && data.features.length > 0),
|
|
hasData: !!(data.data && data.data.length > 0),
|
|
featuresLength: data.features?.length || 0,
|
|
dataLength: data.data?.length || 0,
|
|
firstFeature: data.features?.[0] ? Object.keys(data.features[0]) : null,
|
|
firstData: data.data?.[0] ? Object.keys(data.data[0]) : null
|
|
});
|
|
|
|
let result;
|
|
|
|
// Handle direct feature response (newer format)
|
|
if (data.geometry && data.properties) {
|
|
result = data;
|
|
} else if (data.features && data.features.length > 0) {
|
|
// v5 API response format (legacy)
|
|
result = data.features[0];
|
|
} else if (data.data && data.data.length > 0) {
|
|
// v6 API response format (structured)
|
|
result = data.data[0];
|
|
}
|
|
|
|
if (!result) {
|
|
logger.info(`Mapbox returned no results for address: ${address}`);
|
|
return null;
|
|
}
|
|
|
|
// Extract coordinates - try multiple possible locations
|
|
let latitude, longitude;
|
|
|
|
if (result.properties?.coordinates?.latitude && result.properties?.coordinates?.longitude) {
|
|
// New format: properties.coordinates object
|
|
latitude = result.properties.coordinates.latitude;
|
|
longitude = result.properties.coordinates.longitude;
|
|
} else if (result.geometry?.coordinates && Array.isArray(result.geometry.coordinates) && result.geometry.coordinates.length >= 2) {
|
|
// GeoJSON format: geometry.coordinates [lng, lat]
|
|
longitude = result.geometry.coordinates[0];
|
|
latitude = result.geometry.coordinates[1];
|
|
} else if (result.center && Array.isArray(result.center) && result.center.length >= 2) {
|
|
// v5 format: center [lng, lat]
|
|
longitude = result.center[0];
|
|
latitude = result.center[1];
|
|
} else {
|
|
logger.error(`Mapbox result missing valid coordinates:`, {
|
|
hasPropsCoords: !!(result.properties?.coordinates),
|
|
hasGeomCoords: !!(result.geometry?.coordinates),
|
|
hasCenter: !!result.center,
|
|
result: result
|
|
});
|
|
return null;
|
|
}
|
|
|
|
// Extract formatted address
|
|
let formattedAddress = result.properties?.full_address ||
|
|
result.properties?.name_preferred ||
|
|
result.properties?.name ||
|
|
result.place_name ||
|
|
'Unknown Address';
|
|
|
|
// Calculate confidence from match_code if available
|
|
let confidence = 100;
|
|
if (result.properties?.match_code) {
|
|
const matchCode = result.properties.match_code;
|
|
if (matchCode.confidence === 'exact') confidence = 100;
|
|
else if (matchCode.confidence === 'high') confidence = 90;
|
|
else if (matchCode.confidence === 'medium') confidence = 70;
|
|
else if (matchCode.confidence === 'low') confidence = 50;
|
|
else confidence = (result.relevance || 1) * 100;
|
|
} else {
|
|
confidence = (result.relevance || 1) * 100;
|
|
}
|
|
|
|
return {
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
formattedAddress: formattedAddress,
|
|
provider: 'Mapbox',
|
|
confidence: confidence,
|
|
components: extractMapboxComponents(result),
|
|
raw: result
|
|
};
|
|
|
|
// No results found
|
|
logger.info(`Mapbox returned no results for address: ${address}`);
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Mapbox geocoding error:', error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function geocodeWithNominatim(address, options = {}) {
|
|
const { timeout = 5000, delay = 1000 } = options;
|
|
|
|
// Rate limiting for Nominatim (1 request per second)
|
|
if (delay > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1&addressdetails=1`;
|
|
|
|
logger.info(`Geocoding with Nominatim: ${address}`);
|
|
|
|
try {
|
|
const response = await axios.get(url, {
|
|
timeout,
|
|
headers: {
|
|
'User-Agent': 'MapApp/1.0'
|
|
}
|
|
});
|
|
|
|
const data = response.data;
|
|
if (!data || data.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const result = data[0];
|
|
return {
|
|
latitude: parseFloat(result.lat),
|
|
longitude: parseFloat(result.lon),
|
|
formattedAddress: result.display_name,
|
|
provider: 'Nominatim',
|
|
confidence: calculateNominatimConfidence(result),
|
|
components: extractAddressComponents(result.address || {}),
|
|
raw: result
|
|
};
|
|
} catch (error) {
|
|
logger.error('Nominatim geocoding error:', error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Geocode with Photon (OpenStreetMap-based)
|
|
*/
|
|
async function geocodeWithPhoton(address, options = {}) {
|
|
const { timeout = 15000, delay = 500 } = options;
|
|
|
|
// Rate limiting
|
|
if (delay > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
const response = await axios.get('https://photon.komoot.io/api/', {
|
|
params: {
|
|
q: address,
|
|
limit: 1,
|
|
lang: 'en'
|
|
},
|
|
timeout
|
|
});
|
|
|
|
if (!response.data?.features || response.data.features.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const feature = response.data.features[0];
|
|
const coords = feature.geometry.coordinates;
|
|
const props = feature.properties;
|
|
|
|
return {
|
|
provider: 'photon',
|
|
latitude: coords[1],
|
|
longitude: coords[0],
|
|
formattedAddress: buildFormattedAddressFromPhoton(props),
|
|
components: extractPhotonComponents(props),
|
|
confidence: calculatePhotonConfidence(feature),
|
|
raw: feature
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Geocode with LocationIQ (fallback)
|
|
*/
|
|
async function geocodeWithLocationIQ(address, options = {}) {
|
|
const { timeout = 15000, delay = 0 } = options;
|
|
const apiKey = process.env.LOCATIONIQ_API_KEY;
|
|
|
|
// Rate limiting
|
|
if (delay > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
// LocationIQ can work without API key but has rate limits
|
|
const params = {
|
|
format: 'json',
|
|
q: address,
|
|
limit: 1,
|
|
addressdetails: 1,
|
|
countrycodes: 'ca'
|
|
};
|
|
|
|
// Add API key if available for better rate limits
|
|
if (apiKey) {
|
|
params.key = apiKey;
|
|
}
|
|
|
|
const response = await axios.get('https://us1.locationiq.com/v1/search.php', {
|
|
params,
|
|
headers: {
|
|
'User-Agent': 'NocoDB Map Viewer 1.0'
|
|
},
|
|
timeout
|
|
});
|
|
|
|
if (!response.data || response.data.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const data = response.data[0];
|
|
return {
|
|
provider: 'locationiq',
|
|
latitude: parseFloat(data.lat),
|
|
longitude: parseFloat(data.lon),
|
|
formattedAddress: buildFormattedAddress(data.address),
|
|
components: extractAddressComponents(data.address),
|
|
confidence: calculateNominatimConfidence(data), // Similar format to Nominatim
|
|
boundingBox: data.boundingbox,
|
|
raw: data
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Geocode with ArcGIS World Geocoding Service
|
|
*/
|
|
async function geocodeWithArcGIS(address, options = {}) {
|
|
const { timeout = 15000, delay = 500 } = options;
|
|
|
|
// Rate limiting
|
|
if (delay > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
const response = await axios.get('https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates', {
|
|
params: {
|
|
SingleLine: address,
|
|
f: 'json',
|
|
outFields: '*',
|
|
maxLocations: 1,
|
|
countryCode: 'CA'
|
|
},
|
|
timeout
|
|
});
|
|
|
|
if (!response.data?.candidates || response.data.candidates.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = response.data.candidates[0];
|
|
const location = candidate.location;
|
|
const attributes = candidate.attributes;
|
|
|
|
return {
|
|
provider: 'arcgis',
|
|
latitude: location.y,
|
|
longitude: location.x,
|
|
formattedAddress: attributes.LongLabel || candidate.address,
|
|
components: extractArcGISComponents(attributes),
|
|
confidence: candidate.score || 50, // ArcGIS provides score 0-100
|
|
raw: candidate
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper functions for address processing
|
|
*/
|
|
function buildFormattedAddress(addressComponents) {
|
|
if (!addressComponents) return '';
|
|
|
|
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;
|
|
|
|
return formattedAddress.trim().replace(/,$/, '');
|
|
}
|
|
|
|
function buildFormattedAddressFromPhoton(props) {
|
|
let address = '';
|
|
if (props.housenumber) address += props.housenumber + ' ';
|
|
if (props.street) address += props.street + ', ';
|
|
if (props.district) address += props.district + ', ';
|
|
if (props.city) address += props.city + ', ';
|
|
if (props.state) address += props.state + ' ';
|
|
if (props.postcode) address += props.postcode;
|
|
|
|
return address.trim().replace(/,$/, '');
|
|
}
|
|
|
|
function extractAddressComponents(address) {
|
|
// If address is a string, parse it
|
|
if (typeof address === 'string') {
|
|
return parseAddressString(address);
|
|
}
|
|
|
|
// If address is already an object (from Nominatim/LocationIQ response)
|
|
return {
|
|
house_number: address?.house_number || '',
|
|
road: address?.road || '',
|
|
suburb: address?.suburb || address?.neighbourhood || '',
|
|
city: address?.city || address?.town || address?.village || '',
|
|
state: address?.state || address?.province || '',
|
|
postcode: address?.postcode || '',
|
|
country: address?.country || ''
|
|
};
|
|
}
|
|
|
|
function parseAddressString(addressStr) {
|
|
// Parse a string address into components for Mapbox structured input
|
|
const components = {
|
|
address_number: '',
|
|
street: '',
|
|
place: '',
|
|
region: '',
|
|
postcode: '',
|
|
country: 'ca',
|
|
hasComponents: false
|
|
};
|
|
|
|
if (!addressStr || typeof addressStr !== 'string') {
|
|
return components;
|
|
}
|
|
|
|
// Clean up the address string
|
|
const cleanAddress = addressStr.trim();
|
|
|
|
// Basic regex patterns for Canadian addresses
|
|
const patterns = {
|
|
// Match house number at the start
|
|
houseNumber: /^(\d+[A-Za-z]?)\s+(.+)/,
|
|
// Match postal code (Canadian format: A1A 1A1)
|
|
postalCode: /([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)$/i,
|
|
// Match province abbreviations
|
|
province: /,?\s*(AB|BC|MB|NB|NL|NT|NS|NU|ON|PE|QC|SK|YT|Alberta|British Columbia|Manitoba|New Brunswick|Newfoundland|Northwest Territories|Nova Scotia|Nunavut|Ontario|Prince Edward Island|Quebec|Saskatchewan|Yukon)\s*,?\s*CA(?:NADA)?$/i
|
|
};
|
|
|
|
let workingAddress = cleanAddress;
|
|
|
|
// Extract postal code
|
|
const postalMatch = workingAddress.match(patterns.postalCode);
|
|
if (postalMatch) {
|
|
components.postcode = postalMatch[1].replace(/\s/g, '');
|
|
workingAddress = workingAddress.replace(patterns.postalCode, '').trim();
|
|
}
|
|
|
|
// Extract province/region
|
|
const provinceMatch = workingAddress.match(patterns.province);
|
|
if (provinceMatch) {
|
|
components.region = provinceMatch[1];
|
|
workingAddress = workingAddress.replace(patterns.province, '').trim();
|
|
}
|
|
|
|
// Extract house number and street
|
|
const houseMatch = workingAddress.match(patterns.houseNumber);
|
|
if (houseMatch) {
|
|
components.address_number = houseMatch[1];
|
|
workingAddress = houseMatch[2];
|
|
components.hasComponents = true;
|
|
}
|
|
|
|
// The remaining part is likely the street and city
|
|
const parts = workingAddress.split(',').map(p => p.trim()).filter(p => p);
|
|
|
|
if (parts.length > 0) {
|
|
components.street = parts[0];
|
|
components.hasComponents = true;
|
|
}
|
|
|
|
if (parts.length > 1) {
|
|
components.place = parts[1];
|
|
}
|
|
|
|
return components;
|
|
}
|
|
|
|
function extractPhotonComponents(props) {
|
|
return {
|
|
house_number: props.housenumber || '',
|
|
road: props.street || '',
|
|
suburb: props.district || '',
|
|
city: props.city || '',
|
|
state: props.state || '',
|
|
postcode: props.postcode || '',
|
|
country: props.country || ''
|
|
};
|
|
}
|
|
|
|
function extractArcGISComponents(attributes) {
|
|
return {
|
|
house_number: attributes.AddNum || '',
|
|
road: attributes.StName || '',
|
|
suburb: attributes.District || '',
|
|
city: attributes.City || '',
|
|
state: attributes.Region || '',
|
|
postcode: attributes.Postal || '',
|
|
country: attributes.Country || ''
|
|
};
|
|
}
|
|
|
|
function extractMapboxComponents(result) {
|
|
// Universal Mapbox component extractor for multiple API formats
|
|
const components = {
|
|
house_number: '',
|
|
road: '',
|
|
suburb: '',
|
|
city: '',
|
|
state: '',
|
|
postcode: '',
|
|
country: ''
|
|
};
|
|
|
|
// New format: properties.context object
|
|
if (result.properties?.context) {
|
|
const ctx = result.properties.context;
|
|
|
|
components.house_number = ctx.address?.address_number || '';
|
|
components.road = ctx.address?.street_name || ctx.street?.name || '';
|
|
components.suburb = ctx.neighborhood?.name || '';
|
|
components.city = ctx.place?.name || '';
|
|
components.state = ctx.region?.name || '';
|
|
components.postcode = ctx.postcode?.name || '';
|
|
components.country = ctx.country?.name || '';
|
|
|
|
return components;
|
|
}
|
|
|
|
// Legacy v6 format: properties object
|
|
if (result.properties) {
|
|
const props = result.properties;
|
|
components.house_number = props.address_number || '';
|
|
components.road = props.street || '';
|
|
components.suburb = props.neighborhood || '';
|
|
components.city = props.place || '';
|
|
components.state = props.region || '';
|
|
components.postcode = props.postcode || '';
|
|
components.country = props.country || '';
|
|
|
|
return components;
|
|
}
|
|
|
|
// Legacy v5 format: context array
|
|
if (result.context && Array.isArray(result.context)) {
|
|
result.context.forEach(item => {
|
|
const id = item.id || '';
|
|
if (id.startsWith('postcode.')) components.postcode = item.text;
|
|
else if (id.startsWith('place.')) components.city = item.text;
|
|
else if (id.startsWith('region.')) components.state = item.text;
|
|
else if (id.startsWith('country.')) components.country = item.text;
|
|
else if (id.startsWith('neighborhood.')) components.suburb = item.text;
|
|
});
|
|
|
|
// Extract house number from place name (first part)
|
|
if (result.place_name) {
|
|
const match = result.place_name.match(/^(\d+[A-Za-z]?)\s+/);
|
|
if (match) components.house_number = match[1];
|
|
|
|
// Extract road from place name or address
|
|
if (components.house_number) {
|
|
const addressPart = result.place_name.replace(new RegExp(`^${components.house_number}\\s+`), '').split(',')[0];
|
|
components.road = addressPart.trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
return components;
|
|
}
|
|
|
|
function calculateNominatimConfidence(data) {
|
|
// Basic confidence calculation for Nominatim results
|
|
let confidence = 100;
|
|
|
|
if (!data.address?.house_number) confidence -= 20;
|
|
if (!data.address?.road) confidence -= 30;
|
|
if (data.type === 'administrative') confidence -= 25;
|
|
if (data.class === 'place' && data.type === 'suburb') confidence -= 20;
|
|
|
|
return Math.max(confidence, 10);
|
|
}
|
|
|
|
function calculatePhotonConfidence(feature) {
|
|
// Basic confidence for Photon results
|
|
let confidence = 100;
|
|
const props = feature.properties;
|
|
|
|
if (!props.housenumber) confidence -= 20;
|
|
if (!props.street) confidence -= 30;
|
|
if (props.osm_type === 'relation') confidence -= 15;
|
|
|
|
return Math.max(confidence, 10);
|
|
}
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate geocoding result and calculate confidence score
|
|
* @param {string} originalAddress - The original address searched
|
|
* @param {Object} geocodeResult - The geocoding result
|
|
* @returns {Object} Validation result with confidence score
|
|
*/
|
|
function validateGeocodeResult(originalAddress, geocodeResult) {
|
|
const validation = {
|
|
isValid: true,
|
|
confidence: 100,
|
|
warnings: [],
|
|
isMalformed: false
|
|
};
|
|
|
|
if (!geocodeResult || !geocodeResult.formattedAddress) {
|
|
validation.isValid = false;
|
|
validation.confidence = 0;
|
|
return validation;
|
|
}
|
|
|
|
// Extract key components from original address
|
|
const originalLower = originalAddress.toLowerCase();
|
|
const resultLower = geocodeResult.formattedAddress.toLowerCase();
|
|
|
|
// Check for street number presence
|
|
const streetNumberMatch = originalAddress.match(/^\d+/);
|
|
const resultStreetNumber = geocodeResult.components.house_number;
|
|
|
|
if (streetNumberMatch && !resultStreetNumber) {
|
|
validation.warnings.push('Street number not found in result');
|
|
validation.confidence -= 30;
|
|
validation.isMalformed = true;
|
|
} else if (streetNumberMatch && resultStreetNumber) {
|
|
if (streetNumberMatch[0] !== resultStreetNumber) {
|
|
validation.warnings.push('Street number mismatch');
|
|
validation.confidence -= 40;
|
|
validation.isMalformed = true;
|
|
}
|
|
}
|
|
|
|
// Check for street name presence
|
|
const streetNameWords = originalLower
|
|
.replace(/^\d+\s*/, '') // Remove leading number
|
|
.replace(/,.*$/, '') // Remove everything after first comma
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(word => !['nw', 'ne', 'sw', 'se', 'street', 'st', 'avenue', 'ave', 'road', 'rd', 'crescent', 'close'].includes(word));
|
|
|
|
let matchedWords = 0;
|
|
streetNameWords.forEach(word => {
|
|
if (resultLower.includes(word)) {
|
|
matchedWords++;
|
|
}
|
|
});
|
|
|
|
const matchPercentage = streetNameWords.length > 0 ? (matchedWords / streetNameWords.length) * 100 : 0;
|
|
|
|
if (matchPercentage < 50) {
|
|
validation.warnings.push('Poor street name match');
|
|
validation.confidence -= 30;
|
|
validation.isMalformed = true;
|
|
} else if (matchPercentage < 75) {
|
|
validation.warnings.push('Partial street name match');
|
|
validation.confidence -= 15;
|
|
}
|
|
|
|
// Check for generic/fallback results (often indicates geocoding failure)
|
|
const genericIndicators = ['castle downs', 'clover bar', 'downtown', 'city centre'];
|
|
const hasGenericResult = genericIndicators.some(indicator =>
|
|
resultLower.includes(indicator) && !originalLower.includes(indicator)
|
|
);
|
|
|
|
if (hasGenericResult) {
|
|
validation.warnings.push('Result appears to be generic area, not specific address');
|
|
validation.confidence -= 25;
|
|
validation.isMalformed = true;
|
|
}
|
|
|
|
// Final validation
|
|
validation.isValid = validation.confidence >= 50;
|
|
|
|
return validation;
|
|
}
|
|
|
|
/**
|
|
* Multi-provider forward geocode with fallback support
|
|
* @param {string} address - Address to geocode
|
|
* @returns {Promise<Object>} Geocoding result with provider info
|
|
*/
|
|
async function forwardGeocode(address) {
|
|
// Input validation
|
|
if (!address || typeof address !== 'string') {
|
|
logger.warn(`Invalid address provided for geocoding: ${address}`);
|
|
throw new Error('Invalid address: address must be a non-empty string');
|
|
}
|
|
|
|
address = address.trim();
|
|
if (address.length === 0) {
|
|
throw new Error('Invalid address: address cannot be empty');
|
|
}
|
|
|
|
// 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 different address format variations
|
|
const addressVariations = [
|
|
address, // Original
|
|
address.replace(/\s+NW\s*$/i, ' Northwest'), // Expand NW
|
|
address.replace(/\s+Northwest\s*$/i, ' NW'), // Contract Northwest
|
|
address.replace(/,\s*CA\s*$/i, ', Canada'), // Expand CA to Canada
|
|
address.replace(/\s+Street\s+/i, ' St '), // Abbreviate Street
|
|
address.replace(/\s+St\s+/i, ' Street '), // Expand St
|
|
address.replace(/\s+Avenue\s+/i, ' Ave '), // Abbreviate Avenue
|
|
address.replace(/\s+Ave\s+/i, ' Avenue '), // Expand Ave
|
|
];
|
|
|
|
// Provider functions mapping
|
|
const providerFunctions = {
|
|
'Mapbox': geocodeWithMapbox,
|
|
'Nominatim': geocodeWithNominatim,
|
|
'Photon': geocodeWithPhoton,
|
|
'LocationIQ': geocodeWithLocationIQ,
|
|
'ArcGIS': geocodeWithArcGIS
|
|
};
|
|
|
|
let bestResult = null;
|
|
let bestConfidence = 0;
|
|
const allErrors = [];
|
|
|
|
// Try each provider with each address variation
|
|
for (const provider of GEOCODING_PROVIDERS) {
|
|
logger.info(`Trying provider: ${provider.name}`);
|
|
|
|
for (let varIndex = 0; varIndex < addressVariations.length; varIndex++) {
|
|
const addressVariation = addressVariations[varIndex];
|
|
|
|
try {
|
|
logger.info(`${provider.name} - attempt ${varIndex + 1}/${addressVariations.length}: ${addressVariation}`);
|
|
|
|
const providerResult = await providerFunctions[provider.name](addressVariation, provider.options);
|
|
|
|
if (!providerResult) {
|
|
continue; // No results from this provider/variation
|
|
}
|
|
|
|
// Convert to standard format
|
|
const result = {
|
|
fullAddress: providerResult.formattedAddress,
|
|
formattedAddress: providerResult.formattedAddress,
|
|
components: providerResult.components,
|
|
coordinates: {
|
|
lat: providerResult.latitude,
|
|
lng: providerResult.longitude
|
|
},
|
|
latitude: providerResult.latitude,
|
|
longitude: providerResult.longitude,
|
|
provider: providerResult.provider,
|
|
providerConfidence: providerResult.confidence,
|
|
addressVariation: addressVariation,
|
|
variationIndex: varIndex,
|
|
boundingBox: providerResult.boundingBox || null,
|
|
raw: providerResult.raw
|
|
};
|
|
|
|
// Validate the result
|
|
const validation = validateGeocodeResult(address, result);
|
|
result.validation = validation;
|
|
|
|
// Calculate combined confidence (provider confidence + validation confidence)
|
|
const combinedConfidence = Math.round((providerResult.confidence + validation.confidence) / 2);
|
|
result.combinedConfidence = combinedConfidence;
|
|
|
|
logger.info(`${provider.name} result - Provider: ${providerResult.confidence}%, Validation: ${validation.confidence}%, Combined: ${combinedConfidence}%`);
|
|
|
|
// If this is a very high confidence result, use it immediately
|
|
if (combinedConfidence >= 90 && validation.confidence >= 80) {
|
|
logger.info(`High confidence result found with ${provider.name}, using immediately`);
|
|
|
|
// Cache and return the result
|
|
geocodeCache.set(cacheKey, {
|
|
data: result,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// Keep track of the best result so far
|
|
if (combinedConfidence > bestConfidence) {
|
|
bestResult = result;
|
|
bestConfidence = combinedConfidence;
|
|
}
|
|
|
|
// If we have a decent result from the first variation, don't try more variations for this provider
|
|
if (varIndex === 0 && combinedConfidence >= 70) {
|
|
logger.info(`Good result from ${provider.name} with original address, skipping variations`);
|
|
break;
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error(`${provider.name} error with "${addressVariation}": ${error.message}`);
|
|
allErrors.push(`${provider.name}: ${error.message}`);
|
|
|
|
// If rate limited, wait extra time
|
|
if (error.response?.status === 429) {
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
}
|
|
|
|
continue; // Try next variation or provider
|
|
}
|
|
}
|
|
|
|
// If we found a good result with this provider, we can stop trying other providers
|
|
if (bestConfidence >= 75) {
|
|
logger.info(`Acceptable result found with ${provider.name} (${bestConfidence}%), stopping provider search`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If we have any result, return the best one
|
|
if (bestResult) {
|
|
logger.info(`Returning best result from ${bestResult.provider} with ${bestConfidence}% confidence`);
|
|
|
|
// Cache the result
|
|
geocodeCache.set(cacheKey, {
|
|
data: bestResult,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
return bestResult;
|
|
}
|
|
|
|
// All providers failed
|
|
const errorMessage = `All geocoding providers failed: ${allErrors.join('; ')}`;
|
|
logger.error(errorMessage);
|
|
throw new Error('Geocoding failed: No providers could locate this address');
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
};
|