Large update to geo-coding functions in order to support better matching of street addresses. Added premium mapbox option
This commit is contained in:
@@ -30,11 +30,586 @@ setInterval(() => {
|
||||
}, 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
|
||||
* 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}`;
|
||||
@@ -168,11 +743,106 @@ async function forwardGeocodeSearch(address, limit = 5) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward geocode address to get coordinates
|
||||
* 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
|
||||
* @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()}`;
|
||||
|
||||
@@ -183,58 +853,138 @@ async function forwardGeocode(address) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add delay to respect rate limits - increase delay for batch processing
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
// 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}`);
|
||||
|
||||
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');
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Process the first result
|
||||
const result = processGeocodeResponse(response.data[0]);
|
||||
// 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: result,
|
||||
data: bestResult,
|
||||
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}`);
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user