353 lines
11 KiB
JavaScript
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;
|
|
}
|