Files
doormilxpress_astryx/src/lib/geocodingService.js

353 lines
11 KiB
JavaScript

/**
* 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;
}