/** * Enterprise Geocoding & Address Autocomplete Service * * Supports multi-provider hierarchy: * 1. Ola Maps Places Autocomplete (if VITE_OLA_MAPS_API_KEY is configured) * 2. Google Places API (if VITE_GOOGLE_MAPS_API_KEY is configured) * 3. Photon (Komoot OSM - high-speed, typo-tolerant, sub-100ms autocomplete) * 4. Nominatim (OpenStreetMap standard geocoder with country lock) */ const getEnvVar = (key) => { try { if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env[key]) { return import.meta.env[key]; } } catch {} try { if (typeof process !== 'undefined' && process.env && process.env[key]) { return process.env[key]; } } catch {} return ''; }; const OLA_MAPS_KEY = getEnvVar('VITE_OLA_MAPS_API_KEY'); const PHOTON_API_URL = 'https://photon.komoot.io/api'; const PHOTON_REVERSE_URL = 'https://photon.komoot.io/reverse'; const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse'; /** * Standardize place result into unified format for Doormile order forms */ export const standardizePlace = ({ formatted_address = '', name = '', suburb = '', city = '', state = '', postcode = '', latitude = null, longitude = null, provider = 'unknown', raw = null }) => { const latNum = latitude != null ? Number(latitude) : null; const lngNum = longitude != null ? Number(longitude) : null; return { formatted_address: formatted_address || name, name: name || formatted_address.split(',')[0] || '', suburb: suburb || '', city: city || '', state: state || '', postcode: postcode || '', latitude: Number.isFinite(latNum) ? latNum : null, longitude: Number.isFinite(lngNum) ? lngNum : null, provider, raw, // Google Places / Legacy compatibility interface geometry: { location: { lat: () => (Number.isFinite(latNum) ? latNum : 0), lng: () => (Number.isFinite(lngNum) ? lngNum : 0) } }, address_components: [ ...(suburb ? [{ long_name: suburb, short_name: suburb, types: ['sublocality_level_1', 'sublocality'] }] : []), ...(city ? [{ long_name: city, short_name: city, types: ['locality'] }] : []), ...(state ? [{ long_name: state, short_name: state, types: ['administrative_area_level_1'] }] : []), ...(postcode ? [{ long_name: postcode, short_name: postcode, types: ['postal_code'] }] : []) ] }; }; /** * 1. Photon (Komoot OSM) Search - Fast, typo-tolerant, ideal for real-time keystroke suggestions */ async function searchPhoton(query, { bias, limit = 6 } = {}) { try { const params = new URLSearchParams({ q: query, limit: String(limit), lang: 'en' }); if (bias?.lat && bias?.lng) { params.set('lat', String(bias.lat)); params.set('lon', String(bias.lng)); } const res = await fetch(`${PHOTON_API_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }); if (!res.ok) return []; const data = await res.json(); if (!data?.features || !Array.isArray(data.features)) return []; return data.features.map((item) => { const p = item.properties || {}; const [lon, lat] = item.geometry?.coordinates || [null, null]; const parts = [ p.name, p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', p.district || p.suburb || p.neighbourhood || p.locality, p.city, p.state, p.postcode, p.country ].filter(Boolean); // Remove consecutive duplicates in address string const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx); const formatted = uniqueParts.join(', '); return standardizePlace({ formatted_address: formatted || p.name || '', name: p.name || formatted.split(',')[0], suburb: p.district || p.suburb || p.neighbourhood || p.locality || '', city: p.city || p.county || '', state: p.state || '', postcode: p.postcode || '', latitude: lat, longitude: lon, provider: 'photon', raw: item }); }); } catch (err) { console.warn('[Geocoding] Photon search failed:', err); return []; } } /** * 2. Nominatim Search - Fallback with countrycodes=in & addressdetails */ async function searchNominatim(query, { bias, country = 'in', limit = 5 } = {}) { try { const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: String(limit) }); if (country) { params.set('countrycodes', country.toLowerCase()); } if (bias?.lat && bias?.lng) { const d = 0.5; // ~55km bounding box params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); } const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9', 'User-Agent': 'DoormileConsole/1.0' } }); if (!res.ok) return []; const results = await res.json(); if (!Array.isArray(results)) return []; return results.map((r) => { const addr = r.address || {}; const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || ''; const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || ''; const state = addr.state || ''; const postcode = addr.postcode || ''; return standardizePlace({ formatted_address: r.display_name || '', name: r.display_name?.split(',')[0] || '', suburb, city, state, postcode, latitude: r.lat, longitude: r.lon, provider: 'nominatim', raw: r }); }); } catch (err) { console.warn('[Geocoding] Nominatim search failed:', err); return []; } } /** * 3. Ola Maps Places Autocomplete (if key configured) */ async function searchOlaMaps(query, { bias, limit = 5 } = {}) { if (!OLA_MAPS_KEY) return []; try { const params = new URLSearchParams({ input: query, api_key: OLA_MAPS_KEY }); if (bias?.lat && bias?.lng) { params.set('location', `${bias.lat},${bias.lng}`); params.set('radius', '50000'); } const res = await fetch(`https://api.olamaps.io/places/v1/autocomplete?${params.toString()}`); if (!res.ok) return []; const data = await res.json(); if (!data?.predictions || !Array.isArray(data.predictions)) return []; return data.predictions.slice(0, limit).map((p) => { const lat = p.geometry?.location?.lat; const lng = p.geometry?.location?.lng; return standardizePlace({ formatted_address: p.description || '', name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '', suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '', city: '', state: '', postcode: '', latitude: lat, longitude: lng, provider: 'olamaps', raw: p }); }); } catch (err) { console.warn('[Geocoding] Ola Maps autocomplete failed:', err); return []; } } /** * Primary Address Autocomplete Dispatcher * Queries fastest/best provider first, then falls back seamlessly. */ export async function getAddressSuggestions(query, options = {}) { const cleanQuery = (query || '').trim(); if (!cleanQuery || cleanQuery.length < 2) return []; // 1. Try Ola Maps if API key is present if (OLA_MAPS_KEY) { const olaResults = await searchOlaMaps(cleanQuery, options); if (olaResults.length > 0) return olaResults; } // 2. High-speed Photon OSM autocomplete (sub-100ms, typo tolerant) const photonResults = await searchPhoton(cleanQuery, options); if (photonResults.length > 0) return photonResults; // 3. Fallback to scoped Nominatim const nominatimResults = await searchNominatim(cleanQuery, options); return nominatimResults; } /** * Reverse Geocode: Convert Coordinates to structured address */ export async function reverseGeocode(lat, lng) { if (lat == null || lng == null) return null; // 1. Try Photon reverse first try { const res = await fetch(`${PHOTON_REVERSE_URL}?lat=${lat}&lon=${lng}`, { headers: { Accept: 'application/json' } }); if (res.ok) { const data = await res.json(); if (data?.features?.length > 0) { const item = data.features[0]; const p = item.properties || {}; const parts = [ p.name, p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', p.district || p.suburb || p.neighbourhood || p.locality, p.city, p.state, p.postcode, p.country ].filter(Boolean); const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); return standardizePlace({ formatted_address: formatted, name: p.name || formatted.split(',')[0], suburb: p.district || p.suburb || p.neighbourhood || p.locality || '', city: p.city || p.county || '', state: p.state || '', postcode: p.postcode || '', latitude: lat, longitude: lng, provider: 'photon_reverse' }); } } } catch (e) { console.warn('[Geocoding] Photon reverse failed, trying Nominatim:', e); } // 2. Fallback to Nominatim reverse try { const res = await fetch( `${NOMINATIM_REVERSE_URL}?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`, { headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9', 'User-Agent': 'DoormileConsole/1.0' } } ); if (res.ok) { const r = await res.json(); const addr = r.address || {}; return standardizePlace({ formatted_address: r.display_name || '', name: r.display_name?.split(',')[0] || '', suburb: addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '', city: addr.city || addr.town || addr.village || addr.city_district || addr.county || '', state: addr.state || '', postcode: addr.postcode || '', latitude: lat, longitude: lng, provider: 'nominatim_reverse' }); } } catch (err) { console.error('[Geocoding] Reverse geocode failed completely:', err); } return standardizePlace({ formatted_address: `Location (${Number(lat).toFixed(5)}, ${Number(lng).toFixed(5)})`, name: 'Selected Pin', latitude: lat, longitude: lng, provider: 'coordinates_only' }); } /** * Direct forward geocode for single address string (used by bulk uploads / fallback) */ export async function geocodeAddress(address, options = {}) { const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 }); return suggestions.length > 0 ? suggestions[0] : null; }