603 lines
16 KiB
JavaScript

const axios = require('axios');
const logger = require('../utils/logger');
// Cache for geocoding results
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);
/**
* Alberta bounding box for validation
* Ensures geocoded points are actually in Alberta
*/
const ALBERTA_BOUNDS = {
north: 60.0,
south: 49.0,
east: -110.0,
west: -120.0
};
/**
* Multi-provider geocoding - tries providers in order until success
* Premium providers first (when API key available), then free fallbacks
*/
const GEOCODING_PROVIDERS = [
{
name: 'Mapbox',
func: geocodeWithMapbox,
enabled: () => !!process.env.MAPBOX_ACCESS_TOKEN,
options: { timeout: 10000, delay: 0 }
},
{
name: 'Nominatim',
func: geocodeWithNominatim,
enabled: () => true,
options: { timeout: 10000, delay: 1000 }
},
{
name: 'Photon',
func: geocodeWithPhoton,
enabled: () => true,
options: { timeout: 10000, delay: 500 }
},
{
name: 'ArcGIS',
func: geocodeWithArcGIS,
enabled: () => true,
options: { timeout: 10000, delay: 500 }
}
];
/**
* Geocode with Mapbox (premium provider)
*/
async function geocodeWithMapbox(address, options = {}) {
const { timeout = 10000 } = options;
const apiKey = process.env.MAPBOX_ACCESS_TOKEN;
if (!apiKey) {
throw new Error('Mapbox API key not configured');
}
logger.info(`Geocoding with Mapbox: ${address}`);
try {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json`;
const response = await axios.get(url, {
params: {
access_token: apiKey,
limit: 1,
country: 'ca',
types: 'address,poi,place'
},
timeout
});
const data = response.data;
if (!data.features || data.features.length === 0) {
return null;
}
const result = data.features[0];
const [longitude, latitude] = result.center;
// Extract address components from context
const components = extractMapboxComponents(result);
return {
latitude,
longitude,
formattedAddress: result.place_name,
provider: 'Mapbox',
confidence: Math.round((result.relevance || 0.5) * 100),
components,
raw: result
};
} catch (error) {
logger.error('Mapbox geocoding error:', error.message);
throw error;
}
}
/**
* Geocode with Nominatim (OpenStreetMap)
*/
async function geocodeWithNominatim(address, options = {}) {
const { timeout = 10000, delay = 1000 } = options;
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
const url = `https://nominatim.openstreetmap.org/search`;
logger.info(`Geocoding with Nominatim: ${address}`);
try {
const response = await axios.get(url, {
params: {
format: 'json',
q: address,
limit: 1,
addressdetails: 1,
countrycodes: 'ca'
},
headers: {
'User-Agent': 'FreeAlbertaFood/1.0 (https://freealberta.org)'
},
timeout
});
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 = 10000, delay = 500 } = options;
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
logger.info(`Geocoding with Photon: ${address}`);
try {
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 {
latitude: coords[1],
longitude: coords[0],
formattedAddress: buildFormattedAddress(props),
provider: 'Photon',
confidence: calculatePhotonConfidence(feature),
components: extractPhotonComponents(props),
raw: feature
};
} catch (error) {
logger.error('Photon geocoding error:', error.message);
throw error;
}
}
/**
* Geocode with ArcGIS World Geocoding Service (free tier)
*/
async function geocodeWithArcGIS(address, options = {}) {
const { timeout = 10000, delay = 500 } = options;
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
logger.info(`Geocoding with ArcGIS: ${address}`);
try {
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 {
latitude: location.y,
longitude: location.x,
formattedAddress: attributes.LongLabel || candidate.address,
provider: 'ArcGIS',
confidence: candidate.score || 50,
components: extractArcGISComponents(attributes),
raw: candidate
};
} catch (error) {
logger.error('ArcGIS geocoding error:', error.message);
throw error;
}
}
/**
* Validate that coordinates are within Alberta
*/
function isInAlberta(lat, lng) {
return lat >= ALBERTA_BOUNDS.south &&
lat <= ALBERTA_BOUNDS.north &&
lng >= ALBERTA_BOUNDS.west &&
lng <= ALBERTA_BOUNDS.east;
}
/**
* Validate geocoding result against original address
*/
function validateGeocodeResult(originalAddress, result) {
const validation = {
isValid: true,
confidence: result.confidence || 50,
warnings: []
};
if (!result || !result.latitude || !result.longitude) {
validation.isValid = false;
validation.confidence = 0;
return validation;
}
// Check if result is in Alberta
if (!isInAlberta(result.latitude, result.longitude)) {
validation.warnings.push('Result is outside Alberta');
validation.confidence -= 50;
validation.isValid = false;
}
// Check for street number match if original has one
const originalNumber = originalAddress.match(/^(\d+)/);
if (originalNumber && result.components) {
if (!result.components.house_number) {
validation.warnings.push('Street number not found in result');
validation.confidence -= 25;
} else if (result.components.house_number !== originalNumber[1]) {
validation.warnings.push('Street number mismatch');
validation.confidence -= 30;
}
}
// Penalize results that are just city-level (no street)
if (result.components && !result.components.road && !result.components.house_number) {
validation.warnings.push('Result is city-level only, not street address');
validation.confidence -= 20;
}
validation.confidence = Math.max(validation.confidence, 0);
validation.isValid = validation.confidence >= 30;
return validation;
}
/**
* Forward geocode address to coordinates
*/
async function forwardGeocode(address) {
if (!address || typeof address !== 'string' || address.trim().length === 0) {
throw new Error('Invalid address');
}
address = address.trim();
const cacheKey = `addr:${address.toLowerCase()}`;
// Check cache
const cached = geocodeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
logger.debug(`Geocoding cache hit for ${address}`);
return cached.data;
}
// Build address variations for Alberta addresses
const variations = buildAddressVariations(address);
let bestResult = null;
let bestValidation = null;
let bestScore = 0;
for (const provider of GEOCODING_PROVIDERS) {
if (!provider.enabled()) continue;
logger.info(`Trying provider: ${provider.name}`);
for (const variation of variations) {
try {
const result = await provider.func(variation, provider.options);
if (!result) continue;
// Validate the result
const validation = validateGeocodeResult(address, result);
const score = (result.confidence + validation.confidence) / 2;
logger.debug(`${provider.name} result: confidence=${result.confidence}, validation=${validation.confidence}, score=${score}`);
if (score > bestScore) {
bestResult = result;
bestValidation = validation;
bestScore = score;
// If we have a high-confidence match, return immediately
if (score >= 85 && validation.isValid) {
bestResult.validation = validation;
bestResult.combinedConfidence = score;
geocodeCache.set(cacheKey, { data: bestResult, timestamp: Date.now() });
logger.info(`High-confidence result from ${provider.name}: ${score}`);
return bestResult;
}
}
} catch (error) {
logger.warn(`${provider.name} failed for "${variation}": ${error.message}`);
}
}
// If we have a good result from this provider, stop trying more
if (bestScore >= 70) {
logger.info(`Good result from ${provider.name}, stopping search`);
break;
}
}
if (bestResult) {
bestResult.validation = bestValidation;
bestResult.combinedConfidence = bestScore;
geocodeCache.set(cacheKey, { data: bestResult, timestamp: Date.now() });
logger.info(`Best result: ${bestResult.provider} with score ${bestScore}`);
return bestResult;
}
throw new Error('Could not geocode address');
}
/**
* Build address variations for geocoding attempts
*/
function buildAddressVariations(address) {
const variations = new Set();
// Original address
variations.add(address);
// Add Alberta/Canada if not present
if (!address.toLowerCase().includes('alberta') && !address.toLowerCase().includes(', ab')) {
variations.add(`${address}, Alberta, Canada`);
variations.add(`${address}, AB, Canada`);
}
// Expand quadrant abbreviations (common in Calgary/Edmonton)
const quadrantExpansions = {
' NW': ' Northwest',
' NE': ' Northeast',
' SW': ' Southwest',
' SE': ' Southeast'
};
for (const [abbrev, full] of Object.entries(quadrantExpansions)) {
if (address.toUpperCase().includes(abbrev)) {
variations.add(address.replace(new RegExp(abbrev, 'gi'), full));
}
if (address.includes(full)) {
variations.add(address.replace(new RegExp(full, 'gi'), abbrev.trim()));
}
}
// Expand/contract street type abbreviations
const streetTypes = {
' St ': ' Street ',
' St.': ' Street',
' Ave ': ' Avenue ',
' Ave.': ' Avenue',
' Rd ': ' Road ',
' Rd.': ' Road',
' Dr ': ' Drive ',
' Dr.': ' Drive',
' Cres ': ' Crescent ',
' Cres.': ' Crescent',
' Blvd ': ' Boulevard ',
' Blvd.': ' Boulevard'
};
for (const [abbrev, full] of Object.entries(streetTypes)) {
if (address.includes(abbrev)) {
variations.add(address.replace(abbrev, full));
}
if (address.includes(full)) {
variations.add(address.replace(full, abbrev.replace('.', '')));
}
}
return Array.from(variations).slice(0, 6); // Limit to 6 variations
}
/**
* Reverse geocode coordinates to address
*/
async function reverseGeocode(lat, lng) {
const cacheKey = `rev:${lat.toFixed(6)},${lng.toFixed(6)}`;
const cached = geocodeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const response = await axios.get('https://nominatim.openstreetmap.org/reverse', {
params: {
format: 'json',
lat,
lon: lng,
zoom: 18,
addressdetails: 1
},
headers: {
'User-Agent': 'FreeAlbertaFood/1.0 (https://freealberta.org)'
},
timeout: 10000
});
const result = {
formattedAddress: response.data.display_name,
components: extractAddressComponents(response.data.address || {}),
latitude: parseFloat(response.data.lat),
longitude: parseFloat(response.data.lon)
};
geocodeCache.set(cacheKey, { data: result, timestamp: Date.now() });
return result;
} catch (error) {
logger.error('Reverse geocoding error:', error.message);
throw error;
}
}
// Helper functions
function extractAddressComponents(address) {
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 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 extractMapboxComponents(result) {
const components = {
house_number: '',
road: '',
suburb: '',
city: '',
state: '',
postcode: '',
country: ''
};
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 and street from place_name
if (result.place_name) {
const match = result.place_name.match(/^(\d+[A-Za-z]?)\s+(.+?),/);
if (match) {
components.house_number = match[1];
components.road = match[2];
}
}
return components;
}
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 buildFormattedAddress(props) {
const parts = [];
if (props.housenumber) parts.push(props.housenumber);
if (props.street) parts.push(props.street);
if (props.city) parts.push(props.city);
if (props.state) parts.push(props.state);
if (props.postcode) parts.push(props.postcode);
return parts.join(', ');
}
function calculateNominatimConfidence(data) {
let confidence = 100;
if (!data.address?.house_number) confidence -= 20;
if (!data.address?.road) confidence -= 30;
if (data.type === 'administrative') confidence -= 25;
return Math.max(confidence, 10);
}
function calculatePhotonConfidence(feature) {
let confidence = 100;
const props = feature.properties;
if (!props.housenumber) confidence -= 20;
if (!props.street) confidence -= 30;
return Math.max(confidence, 10);
}
function getCacheStats() {
return { size: geocodeCache.size, ttl: CACHE_TTL };
}
function clearCache() {
geocodeCache.clear();
}
module.exports = {
forwardGeocode,
reverseGeocode,
getCacheStats,
clearCache
};