/** * Enterprise Dynamic Geocoding & Address Autocomplete Service for DoorMile * * 100% Dynamic, Functional Geocoding Pipeline: * 1. Door / Flat / Plot Prefix Parser * 2. Google Maps URL & Raw Coordinates Paste Parser * 3. 6-Digit Indian Pincode Auto-Resolver * 4. India-Scoped High-Speed Photon (OSM) with Bounding Box * 5. India-Locked Nominatim Search (countrycodes=in) * 6. Recent Addresses Local Storage Manager * 7. Automatic Landmark Extractor */ 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 INDIA_POST_PINCODE_URL = 'https://api.postalpincode.in/pincode'; const INDIA_POST_POSTOFFICE_URL = 'https://api.postalpincode.in/postoffice'; 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'; // Geographic boundary of India (minLon, minLat, maxLon, maxLat) const INDIA_BOUNDS = { minLat: 6.5, maxLat: 37.5, minLng: 68.0, maxLng: 97.5 }; const INDIA_BBOX_PHOTON = '68.0,6.5,97.5,37.5'; // Doormile operates in the South. Tamil Nadu, Karnataka, Kerala, Andhra, // Telangana and Puducherry all sit inside this box (Hyderabad at 17.4N). const SOUTH_BOUNDS = { minLat: 8.0, maxLat: 19.95, minLng: 74.0, maxLng: 84.8 }; const SOUTH_BBOX_PHOTON = `${SOUTH_BOUNDS.minLng},${SOUTH_BOUNDS.minLat},${SOUTH_BOUNDS.maxLng},${SOUTH_BOUNDS.maxLat}`; const SOUTH_STATES = ['tamil nadu', 'karnataka', 'kerala', 'andhra pradesh', 'telangana', 'puducherry', 'pondicherry']; // With no hub selected, the query is searched around each of these at once. // Photon ranks by closeness to the point it is given, so one unbiased search // for "Indiranagar" answers from Maharashtra; three biased ones return // Bengaluru's, Chennai's and Coimbatore's. Other southern cities are covered // by one more search boxed to the South. const SOUTH_METROS = [ { city: 'Coimbatore', lat: 11.0168, lng: 76.9558 }, { city: 'Bengaluru', lat: 12.9716, lng: 77.5946 }, { city: 'Chennai', lat: 13.0827, lng: 80.2707 } ]; /** * City scope: confine suggestions to the city of the hub/location an order is * being booked from. A bias only ranked Coimbatore first and let Bengaluru and * Chennai fill the rest of the list; a scope drops them. * * `pins` are the city's pincode prefixes, which is how India Post areas (no * coordinates) are held to the city. Hub scoping elsewhere in the console is * pincode-based on the same prefixes ('641%' for Coimbatore). */ const CITY_REGIONS = [ { city: 'Coimbatore', names: ['coimbatore', 'kovai'], state: 'tamil nadu', lat: 11.0168, lng: 76.9558, pins: ['641', '642'] }, { city: 'Bengaluru', names: ['bengaluru', 'bangalore'], state: 'karnataka', lat: 12.9716, lng: 77.5946, pins: ['560', '561', '562'] }, { city: 'Chennai', names: ['chennai', 'madras'], state: 'tamil nadu', lat: 13.0827, lng: 80.2707, pins: ['600', '601', '602', '603'] }, { city: 'Hyderabad', names: ['hyderabad', 'secunderabad', 'cyberabad'], state: 'telangana', lat: 17.385, lng: 78.4867, pins: ['500', '501', '502'] } ]; // ~45 km each way: the metro and its outskirts (Karamadai, Hoskote, // Guduvanchery, Shamshabad), not the next city. const SCOPE_RADIUS_DEG = 0.4; /** * The scope for a hub/location, or null when it can't be placed. Matches a * known city by name, else by being within ~65 km of one; an unknown city is * scoped around the location's own point and pincode prefix. */ export function buildCityScope({ latitude, longitude, city, pincode } = {}) { const lat = Number(latitude); const lng = Number(longitude); const hasPoint = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0; const name = String(city || '').trim().toLowerCase(); const region = (name && CITY_REGIONS.find((r) => r.names.some((n) => name.includes(n)))) || (hasPoint && CITY_REGIONS.find((r) => Math.hypot(r.lat - lat, r.lng - lng) <= 0.6)) || null; const ownPrefix = /^\d{6}$/.test(String(pincode || '')) ? String(pincode).slice(0, 3) : ''; if (region) { return { city: region.city, lat: region.lat, lng: region.lng, radiusDeg: SCOPE_RADIUS_DEG, pins: [...new Set([...region.pins, ...(ownPrefix ? [ownPrefix] : [])])], names: region.names, state: region.state }; } if (!hasPoint) return null; return { city: String(city || '').trim(), lat, lng, radiusDeg: SCOPE_RADIUS_DEG, pins: ownPrefix ? [ownPrefix] : [], names: name ? [name] : [] }; } /** Photon's bbox for a scope. */ const scopeBbox = (s) => `${s.lng - s.radiusDeg},${s.lat - s.radiusDeg},${s.lng + s.radiusDeg},${s.lat + s.radiusDeg}`; /** Inside the scope: by point when the place has one, else by pincode, else by city name. */ export function isInScope(place, scope) { if (!scope) return true; // The box around Coimbatore reaches Palakkad in Kerala; Bengaluru's reaches // Hosur in Tamil Nadu. A place in another state is another city. const state = String(place?.state || '').toLowerCase(); if (scope.state && state && state !== scope.state) return false; const lat = Number(place?.latitude); const lng = Number(place?.longitude); if (Number.isFinite(lat) && Number.isFinite(lng) && lat && lng) { return Math.abs(lat - scope.lat) <= scope.radiusDeg && Math.abs(lng - scope.lng) <= scope.radiusDeg; } const pin = String(place?.postcode || ''); if (/^\d{6}$/.test(pin) && scope.pins.length) return scope.pins.includes(pin.slice(0, 3)); const city = String(place?.city || '').toLowerCase(); return Boolean(city) && scope.names.some((n) => city.includes(n)); } export const isInSouth = (place) => { const state = String(place?.state || '').toLowerCase(); if (state) return SOUTH_STATES.includes(state); const lat = Number(place?.latitude); const lng = Number(place?.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lng) || (!lat && !lng)) { return SOUTH_STATES.some((st) => String(place?.formatted_address || '').toLowerCase().includes(st)); } return lat >= SOUTH_BOUNDS.minLat && lat <= SOUTH_BOUNDS.maxLat && lng >= SOUTH_BOUNDS.minLng && lng <= SOUTH_BOUNDS.maxLng; }; const RECENT_ADDRESSES_STORAGE_KEY = 'doormile_recent_addresses'; /** * Strict boundary validator ensuring suggestions stay inside India */ export const isWithinIndia = (lat, lon) => { const nLat = Number(lat); const nLon = Number(lon); if (!Number.isFinite(nLat) || !Number.isFinite(nLon)) return false; return ( nLat >= INDIA_BOUNDS.minLat && nLat <= INDIA_BOUNDS.maxLat && nLon >= INDIA_BOUNDS.minLng && nLon <= INDIA_BOUNDS.maxLng ); }; /** * Extracts door / flat / plot / house number prefix */ export const extractDoorPrefix = (text) => { if (!text) return { doorNo: '', cleanQuery: '' }; const trimmed = text.trim(); const match = trimmed.match( /^((?:no\.?|d\.?no\.?|flat|door|plot|#|house|shop)\s*[\w\d\-\/]+)(?:,\s*|\s+)(.+)$/i ); if (match) { return { doorNo: match[1].trim(), cleanQuery: match[2].trim() }; } // A bare leading number: "12/A MG Road", "4-2-17, Peelamedu", "12 Gandhi St". // Not a pincode (6 digits alone) and not "100 Feet Road", which is a road. const bare = trimmed.match(/^(\d{1,5}[a-z]?(?:\s*[/-]\s*[\da-z]+)*)(?:\s*,\s*|\s+)(?!(?:feet|ft)\b)(.+)$/i); if (bare && !/^\d{6}$/.test(bare[1])) { return { doorNo: bare[1].replace(/\s+/g, ''), cleanQuery: bare[2].trim() }; } return { doorNo: '', cleanQuery: trimmed }; }; /** * Extracts landmark cues from an address string (e.g. "Opposite Inorbit Mall", "Near Metro Pillar 12") */ export const extractLandmark = (address) => { if (!address) return ''; const match = address.match(/(?:near|opp\.?|opposite|behind|beside|next to|adj\.?|adjacent to)\s+([^,]+)/i); return match ? match[0].trim() : ''; }; /** * Detects and parses pasted Google Maps links or raw coordinates * e.g. "https://maps.google.com/?q=17.4483,78.3915" or "17.4483, 78.3915" */ export const parseRawCoordinatesOrUrl = (text) => { if (!text) return null; const clean = text.trim(); // 1. Raw Coordinates regex: "17.4483, 78.3915" or "17.4483 78.3915" const coordMatch = clean.match(/^(-?\d{1,2}\.\d+)[,\s]+(-?\d{1,3}\.\d+)$/); if (coordMatch) { const lat = Number(coordMatch[1]); const lng = Number(coordMatch[2]); if (isWithinIndia(lat, lng)) return { lat, lng }; } // 2. Google Maps URL parser: contains q=lat,lng or @lat,lng const urlCoordMatch = clean.match(/(?:q=|@|destination=)(-?\d{1,2}\.\d+)[,\s]+(-?\d{1,3}\.\d+)/); if (urlCoordMatch) { const lat = Number(urlCoordMatch[1]); const lng = Number(urlCoordMatch[2]); if (isWithinIndia(lat, lng)) return { lat, lng }; } return null; }; // Nominatim routinely takes 3-5s from India. At 3s its answers were being // aborted and the dropdown showed Photon's alone — or nothing at all. const REQUEST_TIMEOUT_MS = 6000; // `signal` is the caller's: the search box aborts it when the operator types // on, so a stale search stops using the free services' request allowance. // Photon's public server slows to 7-8s per request when it throttles (seen on // 2026-09-28). At the shared 6s every Photon answer was abandoned and searches // came back empty; the search box now shows the faster sources first, so // Photon can be given longer to arrive. const PHOTON_TIMEOUT_MS = 10000; const fetchWithTimeout = (url, options = {}, signal, timeoutMs = REQUEST_TIMEOUT_MS) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const onAbort = () => controller.abort(); if (signal?.aborted) controller.abort(); signal?.addEventListener?.('abort', onAbort); return fetch(url, { ...options, signal: controller.signal }).finally(() => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); }); }; /** * Area and city, the way an operator would write them. * * OSM files Coimbatore's areas under ward numbers ("Ward 24") and zones * ("North Zone"), so the Suburb field was being filled with "Ward 41". Its city * is sometimes a taluk ("RS Puram … Perur") or missing (Vadavalli), while the * county is reliably " North/South/East/West". These read the real area * and city out of whatever the geocoder returned. */ const ADMIN_NOISE = /^(ward\s*(no\.?)?\s*\d+|(north|south|east|west|central)\s+zone|zone\s*\d+)$/i; const cleanArea = (value) => { // "Ward 9 Ramanthapur" (Hyderabad) is the area Ramanthapur with its ward in front. const v = String(value || '').trim().replace(/^ward\s*(no\.?)?\s*\d+\s+(?=\S)/i, ''); return v && !ADMIN_NOISE.test(v) ? v : ''; }; const cityFromCounty = (county) => { const c = String(county || '').trim(); const directional = c.match(/^(.+?)\s+(north|south|east|west|central|urban|rural)$/i); if (directional) return directional[1]; return c.replace(/\s+(taluk|taluka|district|mandal)$/i, ''); }; const AREA_KINDS = new Set(['suburb', 'neighbourhood', 'quarter', 'locality', 'hamlet', 'village', 'residential', 'isolated_dwelling']); const TOWN_KINDS = new Set(['city', 'town', 'municipality']); /** * Standardize place result into unified format */ 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-Maps-shaped accessors, for the call sites that still read a place // the way the Places API returned one. // // These return null — not 0 — when there is no coordinate, and that is a // fix rather than a style choice. Returning 0 made this object contradict // itself: `.latitude` above says null while `.geometry.location.lat()` // said 0, so which answer a caller got depended on which spelling it // happened to read. MultipleOrders read the accessor, so a suggestion with // no coordinates became a drop pinned at (0, 0); the pre-submit guard // tested `== null` and waved it through; the distance calculation then // routed Coimbatore to the Gulf of Guinea, fell back to Haversine, and // billed roughly 11,000 km — a number the backend honours verbatim // because the console sent it as `finalprice`. // // null propagates as "no pin", which every guard already handles. geometry: { location: { lat: () => (Number.isFinite(latNum) && latNum !== 0 ? latNum : null), lng: () => (Number.isFinite(lngNum) && lngNum !== 0 ? lngNum : null) } }, 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'] }] : []) ] }; }; /** * Recent Addresses Local Storage Management */ export const getRecentAddresses = (limit = 5) => { try { const saved = localStorage.getItem(RECENT_ADDRESSES_STORAGE_KEY); if (!saved) return []; const list = JSON.parse(saved); return Array.isArray(list) ? list.slice(0, limit) : []; } catch { return []; } }; export const saveRecentAddress = (place) => { if (!place || !place.formatted_address || !place.latitude || !place.longitude) return; try { const list = getRecentAddresses(10); const filtered = list.filter( (item) => item.formatted_address !== place.formatted_address && Math.hypot(item.latitude - place.latitude, item.longitude - place.longitude) > 0.002 ); filtered.unshift(place); localStorage.setItem(RECENT_ADDRESSES_STORAGE_KEY, JSON.stringify(filtered.slice(0, 8))); } catch {} }; /** * One Photon feature → a place, with the area and city an operator would write. * Shared by search and reverse geocoding so both fill the form the same way. */ function photonToPlace(item, { doorNo = '', latitude, longitude, provider }) { const p = item.properties || {}; const kind = p.osm_value || ''; const isArea = AREA_KINDS.has(kind) || p.type === 'locality' || p.type === 'district'; const isTown = TOWN_KINDS.has(kind) || (p.type === 'city' && !AREA_KINDS.has(kind)); // The area: the place itself when it is one; else the locality a landmark or // street sits in; never a ward number. const area = (isArea ? cleanArea(p.name) : '') || cleanArea(p.locality) || cleanArea(p.suburb) || cleanArea(p.neighbourhood) || cleanArea(p.district); // City: " North/South" county beats a taluk in `city` ("Perur"). const countyCity = cityFromCounty(p.county); const city = (/\s(north|south|east|west|central)$/i.test(p.county || '') ? countyCity : '') || p.city || (isTown ? p.name : '') || countyCity || ''; const house = doorNo || p.housenumber || ''; const title = house ? `${house}, ${p.name || p.street || ''}`.replace(/,\s*$/, '') : p.name || ''; const parts = [ title, p.street && p.street !== p.name ? `${!doorNo && p.housenumber ? `${p.housenumber} ` : ''}${p.street}` : '', area !== p.name ? area : '', city !== p.name ? city : '', p.state || '', p.postcode || '' ].filter(Boolean); const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); const place = standardizePlace({ formatted_address: formatted || title, name: title || formatted.split(',')[0], suburb: area, city, state: p.state || '', postcode: p.postcode || '', latitude, longitude, provider, raw: item }); // Bus stops, schools, police stations: real matches, but eight of them for // "Vadavalli" left no room for any other locality. Ranking caps these. // By what the place is, not whether it has a door number: "34-35, // Gandhimanagar Police Station" has one and outranked Gandhi Nagar itself. const poiKey = ['amenity', 'shop', 'tourism', 'leisure', 'office', 'railway', 'historic', 'craft', 'healthcare', 'man_made']; place.isLandmark = !isArea && !isTown && kind !== 'residential' && (poiKey.includes(p.osm_key) || (p.osm_key === 'highway' && kind === 'bus_stop') || (p.type === 'house' && !p.housenumber)); return place; } /** * 1. Photon (Komoot OSM) Dynamic Search */ async function searchPhoton(query, options = {}) { const { bias, limit = 10 } = options; try { const { doorNo, cleanQuery } = extractDoorPrefix(query); const searchQuery = cleanQuery || query; const params = new URLSearchParams({ q: searchQuery, limit: String(limit), lang: 'en', bbox: options.bbox || INDIA_BBOX_PHOTON }); if (bias?.lat && bias?.lng) { params.set('lat', String(bias.lat)); params.set('lon', String(bias.lng)); } const res = await fetchWithTimeout( `${PHOTON_API_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }, options.signal, PHOTON_TIMEOUT_MS ); if (!res.ok) return []; const data = await res.json(); if (!data?.features || !Array.isArray(data.features)) return []; return data.features .filter((item) => { const [lon, lat] = item.geometry?.coordinates || [null, null]; // The India box also covers Pakistani Punjab and Nepal; "white villas" // was answering from Lahore. Photon says the country outright. const cc = item.properties?.countrycode; return isWithinIndia(lat, lon) && (!cc || cc === 'IN'); }) .map((item) => { const [lon, lat] = item.geometry?.coordinates || [null, null]; return photonToPlace(item, { doorNo, latitude: lat, longitude: lon, provider: 'photon' }); }); } catch (err) { return []; } } /** * One Nominatim result → a place. Its display_name chains every admin level * ("…North Zone, Coimbatore, Coimbatore North, Coimbatore, Tamil Nadu, India"), * so the address is rebuilt from the parts an operator would write. */ function nominatimToPlace(r, { doorNo = '', provider, latitude = r.lat, longitude = r.lon }) { const addr = r.address || {}; const area = cleanArea(addr.suburb) || cleanArea(addr.neighbourhood) || cleanArea(addr.quarter) || cleanArea(addr.residential) || cleanArea(addr.hamlet) || cleanArea(addr.village) || ''; const city = addr.city || addr.town || cityFromCounty(addr.county) || addr.state_district || addr.village || ''; const name = r.name || (r.display_name || '').split(',')[0] || ''; const title = doorNo ? `${doorNo}, ${name}` : name; const parts = [ title, addr.road && addr.road !== name ? addr.road : '', area !== name ? area : '', city !== name ? city : '', addr.state || '', addr.postcode || '' ].filter(Boolean); const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); const place = standardizePlace({ formatted_address: formatted || r.display_name || '', name: title, suburb: area, city, state: addr.state || '', postcode: addr.postcode || '', latitude, longitude, provider, raw: r }); place.isLandmark = (['amenity', 'shop', 'tourism', 'leisure', 'office', 'building', 'railway'].includes(r.class) && r.type !== 'residential') || (r.class === 'highway' && r.type === 'bus_stop'); return place; } /** * 2. Nominatim Dynamic Search */ async function searchNominatim(query, { bias, limit = 8, signal, scope } = {}) { try { const { doorNo, cleanQuery } = extractDoorPrefix(query); const searchQuery = cleanQuery || query; const isPincode = /^[1-9][0-9]{5}$/.test(searchQuery.trim()); const params = new URLSearchParams({ format: 'json', addressdetails: '1', limit: String(limit), countrycodes: 'in' }); if (isPincode) { params.set('postalcode', searchQuery.trim()); } else { params.set('q', searchQuery); } if (scope && !isPincode) { // Held to the city, not just preferring it. const r = scope.radiusDeg; params.set('viewbox', `${scope.lng - r},${scope.lat + r},${scope.lng + r},${scope.lat - r}`); params.set('bounded', '1'); } else if (bias?.lat && bias?.lng) { const d = 0.8; params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); } else { // A preference, not a fence (no `bounded`): southern matches rank first. params.set('viewbox', `${SOUTH_BOUNDS.minLng},${SOUTH_BOUNDS.maxLat},${SOUTH_BOUNDS.maxLng},${SOUTH_BOUNDS.minLat}`); } const res = await fetchWithTimeout( `${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9', 'User-Agent': 'DoormileConsole/2.0' } }, signal ); if (!res.ok) return []; const results = await res.json(); if (!Array.isArray(results)) return []; return results .filter((r) => isWithinIndia(r.lat, r.lon)) .map((r) => nominatimToPlace(r, { doorNo, provider: 'nominatim' })); } catch (err) { return []; } } /** * 3. Ola Maps Places Autocomplete */ 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 fetchWithTimeout(`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 .filter((p) => { const lat = p.geometry?.location?.lat; const lng = p.geometry?.location?.lng; return isWithinIndia(lat, lng); }) .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) { return []; } } /** * 4. India Post pincode directory * * OSM knows a pincode's rough centre but not the localities inside it, and * small Indian localities are exactly what OSM is missing. India Post lists * every post office under a pincode with its district and state, which is * what a typist with only a pincode needs: pick the area, get city and state. * * These places carry NO coordinates on purpose. A post office name is an area, * not a door; a centroid pin would be kilometres off and CreateOrder prices a * drop from its pin. null is "no pin yet", which every caller already handles. */ const pincodeCache = new Map(); const postOfficeNameCache = new Map(); // Words that end many place names but are no place on their own: looking one // up by name returns hundreds of unrelated villages across four states. const GENERIC_AREA_WORDS = new Set([ 'puram', 'nagar', 'palayam', 'palaiyam', 'pudur', 'puthur', 'patti', 'pettai', 'kottai', 'kulam', 'halli', 'palya', 'pakkam', 'bakkam', 'road', 'street', 'salai', 'colony', 'layout', 'main', 'cross', 'north', 'south', 'east', 'west', 'avenue', 'lane', 'phase', 'stage', 'block', 'sector', 'extension', 'extn', 'villas', 'villa', 'apartments', 'apartment', 'residency', 'enclave', 'gardens', 'garden', 'towers', 'tower', 'city', 'town', 'village', 'post', 'taluk', 'district', 'india', 'tamil', 'nadu', 'karnataka', 'kerala' ]); /** India Post rows → area-level places (no coordinates; see above). */ const postOfficesToPlaces = (offices, { southOnly = false } = {}) => { const seen = new Set(); return (offices || []) .filter((po) => { if (!po?.Name || !/^\d{6}$/.test(String(po.Pincode || ''))) return false; if (southOnly && !SOUTH_STATES.includes(String(po.State || '').toLowerCase())) return false; const key = `${po.Name.toLowerCase()}|${po.Pincode}`; if (seen.has(key)) return false; seen.add(key); return true; }) .map((po) => { const area = po.Name.trim(); const city = (po.District || po.Block || '').trim(); const state = (po.State || '').trim(); const pin = String(po.Pincode); return standardizePlace({ formatted_address: `${[area, city, state].filter(Boolean).join(', ')} ${pin}`, name: area, suburb: area, city, state, postcode: pin, provider: 'indiapost', raw: po }); }); }; export async function lookupPincode(pincode) { const pin = String(pincode || '').trim(); if (!/^[1-9][0-9]{5}$/.test(pin)) return []; if (pincodeCache.has(pin)) return pincodeCache.get(pin); try { const res = await fetchWithTimeout(`${INDIA_POST_PINCODE_URL}/${pin}`); if (!res.ok) return []; const data = await res.json(); const offices = Array.isArray(data) && data[0]?.Status === 'Success' ? data[0].PostOffice || [] : []; const places = postOfficesToPlaces(offices); pincodeCache.set(pin, places); return places; } catch { return []; } } /** * Post offices by area name, southern states only. * * OSM finds well-known localities (every one of 20 Bengaluru and 20 Chennai * outskirts tested) but not small villages: Alandurai and Malumichampatti near * Coimbatore are not places in OSM at all. India Post lists them, with the * pincode, district and state. Area-level only, like lookupPincode. */ export async function lookupPostOfficeByName(name) { const q = String(name || '').trim().toLowerCase(); if (q.length < 4 || /\d/.test(q) || GENERIC_AREA_WORDS.has(q)) return []; if (postOfficeNameCache.has(q)) return postOfficeNameCache.get(q); try { const res = await fetchWithTimeout(`${INDIA_POST_POSTOFFICE_URL}/${encodeURIComponent(q)}`); if (!res.ok) return []; const data = await res.json(); const offices = Array.isArray(data) && data[0]?.Status === 'Success' ? data[0].PostOffice || [] : []; // The API matches anywhere in the name ("puram" → Puramannur, Kerala; // "Idikarai" → Pulidikarai, Dharmapuri). Only names that start with what // was typed are what the operator meant. const places = postOfficesToPlaces(offices, { southOnly: true }) .filter((p) => p.name.toLowerCase().replace(/[^a-z]/g, '').startsWith(q.replace(/[^a-z]/g, ''))) .slice(0, 6); postOfficeNameCache.set(q, places); return places; } catch { return []; } } /** * The rough centre of a pincode, for checking that a geocoded address is * where its pincode says. Bulk rows "Saravanampatti 641035" and "Alandurai * 641101" were pinned in Trichy and Chennai: same names, other districts. */ const pincodeCentreCache = new Map(); async function getPincodeCentre(pin, signal) { if (!/^[1-9][0-9]{5}$/.test(pin || '')) return null; if (pincodeCentreCache.has(pin)) return pincodeCentreCache.get(pin); try { const params = new URLSearchParams({ format: 'json', limit: '1', countrycodes: 'in', postalcode: pin }); const res = await fetchWithTimeout( `${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json', 'User-Agent': 'DoormileConsole/2.0' } }, signal ); if (!res.ok) return null; const [first] = await res.json(); const centre = first && isWithinIndia(first.lat, first.lon) ? { lat: Number(first.lat), lng: Number(first.lon) } : null; pincodeCentreCache.set(pin, centre); return centre; } catch { return null; } } // A pincode area in the South spans at most ~20 km; 0.25 degrees is ~27 km. const PINCODE_RADIUS_DEG = 0.25; /** * Spelling variants for a South Indian locality name. * * OSM search has no typo tolerance for these, and they are spelt a dozen ways. * The commonest slip is the nasal before the suffix: people type * "manikarapalayam" for Maniyakaranpalayam / Maniyakaram Palayam, and only * "manikaranpalayam" or "manikarampalayam" finds it. We have no paid geocoder * (no Ola or Google key), so the search tries the few spellings a local would. */ // Tamil (-palayam, -pudur, -pakkam), Kannada (-halli, -palya) and Telugu // (-guda, -peta) endings. Longest first, so "palaiyam" wins over "palayam". const LOCALITY_SUFFIXES = [ 'palaiyam', 'palayam', 'puthur', 'pudur', 'puram', 'patti', 'patty', 'pettai', 'kottai', 'kulam', 'valasu', 'kuppam', 'pakkam', 'bakkam', 'halli', 'hally', 'palya', 'palaya', 'nagar', 'guda', 'peta', 'pet' ]; const SUFFIX_SWAPS = { palayam: 'palaiyam', palaiyam: 'palayam', patti: 'patty', patty: 'patti', pudur: 'puthur', puthur: 'pudur', halli: 'hally', hally: 'halli', palya: 'palaya', palaya: 'palya', pakkam: 'bakkam', bakkam: 'pakkam', pettai: 'pet', pet: 'pettai' }; // The nasal slip ("manikarapalayam" for Maniyakaranpalayam) happens on Tamil endings. const NASAL_SUFFIXES = new Set(['palayam', 'palaiyam', 'pudur', 'puthur', 'puram', 'patti', 'pettai', 'kottai', 'kulam', 'pakkam', 'bakkam']); export function localitySpellings(word) { const w = String(word || '').toLowerCase().replace(/[^a-z]/g, ''); const out = new Set(); const suffix = LOCALITY_SUFFIXES.find((sfx) => w.endsWith(sfx) && w.length > sfx.length + 2); if (suffix) { const stem = w.slice(0, -suffix.length); if (NASAL_SUFFIXES.has(suffix) && /[aeiou]$/.test(stem)) { out.add(`${stem}n${suffix}`); out.add(`${stem}m${suffix}`); } else if (NASAL_SUFFIXES.has(suffix) && /[mn]$/.test(stem)) { out.add(`${stem.slice(0, -1)}${suffix}`); out.add(`${stem.slice(0, -1)}${stem.endsWith('m') ? 'n' : 'm'}${suffix}`); } if (SUFFIX_SWAPS[suffix]) out.add(`${stem}${SUFFIX_SWAPS[suffix]}`); } // Long vowels are spelt both ways: "Peelamedu" / "Pilamedu", "Pooja" / "Puja". const flat = w.replace(/aa/g, 'a').replace(/ee/g, 'i').replace(/oo/g, 'u'); if (flat.length > 3) out.add(flat); out.delete(w); return [...out].slice(0, 4); } // Variant searches are guesses; keep only what lands near the hub, or they // bring back same-named villages from the other end of the state. const VARIANT_RADIUS_DEG = 0.6; // ~65 km /** * Deduplicates results and ranks by active city or geographic proximity */ // Consonant skeleton of a name, so the spellings of one Tamil/Kannada place // compare equal: Saravanampatti / Sarvanampatti / Saravanampatty → "srvnmpt". // Vowels after the first letter, y (patti/patty) and doubled letters drop out. const skeleton = (s) => String(s || '') .toLowerCase() .replace(/[^a-z]/g, '') .replace(/(?!^)[aeiouy]/g, '') .replace(/(.)\1+/g, '$1'); function deduplicateAndRank(places, bias, limit = 8, pin = '', word = '', { preferAreas = true } = {}) { // `word` is the typed area ("saravanampatti"). OSM spells it "Sarvanampatti", // so a plain prefix check ranked the police station named with the typed // spelling above the area itself. const stem = word.slice(0, 4); const wordSkel = skeleton(word).slice(0, 5); const seen = new Set(); const unique = []; for (const p of places) { if (!p || !p.formatted_address) continue; const coordKey = p.latitude && p.longitude ? `${Number(p.latitude).toFixed(3)},${Number(p.longitude).toFixed(3)}` : p.formatted_address.toLowerCase().slice(0, 35); if (seen.has(coordKey)) continue; seen.add(coordKey); unique.push(p); } // Rank, most important first: // recent pick · typed pincode · in the South · names the typed area · // in the hub's city · within ~65 km of the hub · (geocoding) not a landmark. // Near-or-not rather than raw distance: raw distance sorted every Bengaluru // match below every Coimbatore one and the limit cut them off. Within each // bucket the providers' own relevance order stands. const biasCity = (bias?.city || '').toLowerCase(); const bLat = Number(bias?.lat); const bLng = Number(bias?.lng); const hasPoint = Number.isFinite(bLat) && Number.isFinite(bLng) && bLat !== 0 && bLng !== 0; const namesArea = (p) => (p.name || '').toLowerCase().replace(/\s+/g, '').includes(stem) || (p.suburb || '').toLowerCase().includes(stem) || (wordSkel.length >= 4 && (skeleton(p.name).includes(wordSkel) || skeleton(p.suburb).includes(wordSkel))); const score = (p) => [ p.provider === 'recent' ? 0 : 1, pin && String(p.postcode) !== pin ? 1 : 0, isInSouth(p) ? 0 : 1, stem && !namesArea(p) ? 1 : 0, biasCity && !(p.city || p.formatted_address || '').toLowerCase().includes(biasCity) ? 1 : 0, hasPoint ? (p.latitude && p.longitude && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG ? 0 : 1) : 0, // The area before a landmark named after it: "Indiranagar 560038" should // land on Indiranagar, not the clinic, and Enter on "saravanampatti" took // the police station. Last key, so a typed landmark name still wins // through the names-the-typed-area key above. preferAreas && p.isLandmark ? 1 : 0 ]; const scored = unique.map((p, i) => ({ p, i, k: score(p) })); scored.sort((a, b) => { for (let j = 0; j < a.k.length; j += 1) if (a.k[j] !== b.k[j]) return a.k[j] - b.k[j]; return a.i - b.i; }); // At most three landmarks (bus stops, schools, stations) while there is // anything else to show: "Vadavalli" returned eight and no other locality. const kept = []; const overflow = []; let landmarks = 0; for (const { p } of scored) { if (p.isLandmark && landmarks >= MAX_LANDMARKS) { overflow.push(p); } else { if (p.isLandmark) landmarks += 1; kept.push(p); } } return [...kept, ...overflow].slice(0, limit); } const MAX_LANDMARKS = 3; /** Round-robin merge, so each city's best match shows before any city's fifth. */ const interleave = (lists) => { const out = []; const longest = Math.max(0, ...lists.map((l) => l.length)); for (let i = 0; i < longest; i += 1) lists.forEach((l) => l[i] && out.push(l[i])); return out; }; /** * Photon for the search box. * With a hub: around the hub, plus once across the South (2 requests), so a * Coimbatore operator typing a Bengaluru address still sees Bengaluru. * Without one: around Coimbatore, Bengaluru and Chennai, plus the South box * (4 requests); one unbiased search for "Indiranagar" answers from * Maharashtra. * It was up to 19 requests for one search once the spelling fallback ran, * which is what got the public server throttling us. */ async function searchPhotonSouth(query, options = {}) { const hasHub = Boolean(Number(options.bias?.lat) && Number(options.bias?.lng)); const perCity = Math.max(4, Math.ceil((options.limit || 10) / 2)); const lists = await Promise.all([ ...(hasHub ? [searchPhoton(query, { ...options, limit: perCity + 2 })] : SOUTH_METROS.map((m) => searchPhoton(query, { ...options, bias: { lat: m.lat, lng: m.lng }, limit: perCity }))), searchPhoton(query, { ...options, bias: undefined, bbox: SOUTH_BBOX_PHOTON, limit: perCity }) ]); return interleave(lists); } /** One Photon search: near the hub if there is one, else boxed to the South. */ const searchPhotonOnce = (query, options = {}) => searchPhoton(query, options.bias?.lat ? options : { ...options, bbox: SOUTH_BBOX_PHOTON }); /** Addresses this browser picked before whose text contains the query. */ function matchRecent(query, max = 3) { const q = query.toLowerCase(); return getRecentAddresses(10) .filter((r) => (r.formatted_address || '').toLowerCase().includes(q)) .slice(0, max) .map((r) => standardizePlace({ ...r, provider: 'recent' })); } // Typing back over the same text, or reopening a form, repeats searches. Each // costs several requests against free services that throttle, so answers are // kept for ten minutes. const SUGGESTION_TTL_MS = 10 * 60 * 1000; const suggestionCache = new Map(); const cacheGet = (key) => { const hit = suggestionCache.get(key); if (!hit || Date.now() - hit.at > SUGGESTION_TTL_MS) return null; return hit.value; }; const cacheSet = (key, value) => { if (suggestionCache.size > 200) suggestionCache.delete(suggestionCache.keys().next().value); suggestionCache.set(key, { at: Date.now(), value }); }; const collectSettled = (settled) => settled.flatMap((res) => (res.status === 'fulfilled' && Array.isArray(res.value) ? res.value : [])); const hasCoords = (p) => Boolean(p?.latitude && p?.longitude); const normName = (s) => String(s || '').toLowerCase().replace(/[^a-z]/g, ''); /** The words of a query that name places: no door number, pincode or digits. */ function queryWords(textQuery) { const { cleanQuery: noDoor } = extractDoorPrefix(textQuery); const words = (noDoor || textQuery).replace(/,+/g, ' ').split(/\s+/).filter((w) => w.length > 1 && !/\d/.test(w)); const last = words[words.length - 1] || ''; return { words, last, stem: last.toLowerCase().slice(0, 4) }; } /** * India Post areas are shown only where they add something: not when the map * already has that area, and — with a hub chosen — only the hub's district * when it has any ("Vadavalli" also named villages in Andhra and Erode). */ function usefulPostAreas(areas, mapPlaces, bias) { const onMap = new Set(mapPlaces.filter(hasCoords).map((p) => `${normName(p.name)}|${normName(p.city)}`)); const fresh = areas.filter((a) => !onMap.has(`${normName(a.name)}|${normName(a.city)}`)); const city = normName(bias?.city); const local = city ? fresh.filter((a) => normName(a.city) === city) : []; return (local.length ? local : fresh).slice(0, 3); } /** * Primary Address Autocomplete Dispatcher * * Two modes, because two callers want different things: * 'suggest' (default) — the search box. Wide: several Photon searches, * recent picks, India Post areas (which have no map point), spelling * guesses. `onPartial(results)` receives the fast sources (recents, * Nominatim, India Post, under ~2s) before Photon, which can take 7-8s * when its public server throttles. * 'geocode' — geocodeAddress, for bulk upload and the assistant, which take * the top answer and price from its pin. See geocodeOne. */ export async function getAddressSuggestions(query, options = {}) { const cleanQuery = (query || '').trim(); if (!cleanQuery) return []; const mode = options.mode === 'geocode' ? 'geocode' : 'suggest'; const { signal, onPartial } = options; // Check if user pasted coordinates or Google Maps link directly const parsedCoords = parseRawCoordinatesOrUrl(cleanQuery); if (parsedCoords) { const directPlace = await reverseGeocode(parsedCoords.lat, parsedCoords.lng); if (directPlace) return [directPlace]; } if (cleanQuery.length < 2) return []; const limit = options.limit || 12; const biasKey = `${options.bias?.lat ?? ''},${options.bias?.lng ?? ''},${options.bias?.city ?? ''}`; const scopeKey = options.scope ? `${options.scope.city}@${options.scope.lat},${options.scope.lng}` : ''; const cacheKey = `${mode}|${limit}|${cleanQuery.toLowerCase()}|${biasKey}|${scopeKey}`; const cached = cacheGet(cacheKey); if (cached) return cached; const pinMatch = cleanQuery.match(/(?:^|\D)([1-9][0-9]{5})(?:\D|$)/); const pin = pinMatch ? pinMatch[1] : ''; const pinOnly = /^[1-9][0-9]{5}$/.test(cleanQuery); // The pincode ranks the results; sent inside the text it only confuses OSM. const textQuery = pin && !pinOnly ? cleanQuery.replace(pin, ' ').replace(/[\s,]+$/, '').replace(/\s{2,}/g, ' ').trim() : cleanQuery; const opts = { ...options, signal, onPartial: undefined }; const result = mode === 'geocode' ? await geocodeOne(textQuery, pin, pinOnly, opts, limit) : await suggest(textQuery, pin, pinOnly, opts, limit, onPartial); // An aborted search returns whatever had finished; that is not the answer. if (!signal?.aborted) cacheSet(cacheKey, result); return result; } /** * "gandhi nagar coimbatore": the last word names a city we serve. Search the * rest inside that city. Treated as the area word, "coimbatore" ranked * Coimbatore Aerodrome and Coimbatore Bazaar above Gandhi Nagar; and it is how * an operator on a Hyderabad hub reaches a Coimbatore drop without a click. */ /** The served city named at the end of the text, if any ("…, bangalore" → "Bengaluru"). */ export function cityNamedIn(text) { return splitCityHint(text)?.scope?.city || ''; } function splitCityHint(text) { const m = String(text || '').match(/^(.*\S)[\s,]+([a-z]+)\s*$/i); if (!m) return null; const word = m[2].toLowerCase(); const region = CITY_REGIONS.find((r) => r.names.includes(word)); if (!region || m[1].replace(/[\s,]/g, '').length < 3) return null; return { rest: m[1].replace(/[\s,]+$/, ''), scope: buildCityScope({ city: region.city }) }; } async function suggest(textQuery, pin, pinOnly, opts, limit, onPartial) { const hint = pinOnly ? null : splitCityHint(textQuery); if (hint) { textQuery = hint.rest; opts = { ...opts, scope: hint.scope, bias: { lat: hint.scope.lat, lng: hint.scope.lng, city: hint.scope.city } }; } const { words, last, stem } = queryWords(textQuery); const partial = (list) => { if (onPartial && !opts.signal?.aborted && list.length) onPartial(list); }; // A city scope (Create Order's selected hub location) holds every source to // that city. A typed pincode is an explicit choice and is never scoped. const scope = pinOnly ? null : opts.scope || null; const inScope = (list) => (scope ? list.filter((p) => isInScope(p, scope)) : list); // Rank around the scope's city, not the location's name ("RS Puram Kitchen"). const bias = scope ? { lat: scope.lat, lng: scope.lng, city: scope.city } : opts.bias; const photonWide = (q, o) => scope ? searchPhoton(q, { ...o, bias: { lat: scope.lat, lng: scope.lng }, bbox: scopeBbox(scope), limit: Math.max(o.limit || 10, 10) }) : searchPhotonSouth(q, o); const photonOne = (q, o) => scope ? searchPhoton(q, { ...o, bias: { lat: scope.lat, lng: scope.lng }, bbox: scopeBbox(scope) }) : searchPhotonOnce(q, o); if (pinOnly) { // A bare pincode: its areas are what the operator is choosing between; // OSM's centroid follows as the one result that carries a map point. const photonP = searchPhotonOnce(textQuery, opts); const [areas, nomi] = await Promise.all([lookupPincode(textQuery), searchNominatim(textQuery, opts)]); const size = Math.max(limit, areas.length + 1); partial(deduplicateAndRank([...areas, ...nomi], opts.bias, size, textQuery)); const osm = [...nomi, ...(await photonP)]; return deduplicateAndRank([...areas, ...osm], opts.bias, size, textQuery); } // Photon starts first and is awaited last: it is the slowest source. const photonP = photonWide(textQuery, opts); const olaP = OLA_MAPS_KEY ? searchOlaMaps(textQuery, opts) : Promise.resolve([]); const [recent, nomi, byName, byPin] = await Promise.all([ Promise.resolve(inScope(matchRecent(textQuery))), searchNominatim(textQuery, opts), // Small villages OSM lacks (Alandurai, Malumichampatti): India Post by name. last.length >= 4 ? lookupPostOfficeByName(last) : Promise.resolve([]), // "white villas 641035": the pincode's areas alongside the text match. pin ? lookupPincode(pin) : Promise.resolve([]) ]); // The pincode's area named in the query always shows; other India Post areas // only where the map places found so far don't already cover them. const postAreas = (mapPlaces) => { const named = pin ? byPin.filter((a) => textQuery.toLowerCase().includes(a.name.toLowerCase())) : []; const others = inScope([...byName, ...byPin.filter((a) => !named.includes(a))]); return [...named, ...usefulPostAreas(others, mapPlaces, bias)]; }; partial(deduplicateAndRank([...recent, ...inScope(nomi), ...postAreas([...recent, ...nomi])], bias, limit, pin, last.toLowerCase())); let map = inScope([...collectSettled(await Promise.allSettled([olaP, photonP])), ...nomi]); // OSM matches whole phrases poorly: "white villas manikarapalayam" finds a // villa estate in Bangalore and nothing in the locality that was typed. // Indian addresses end with the area, so when the pinned results barely // mention it, search the trailing words alone and a few spellings of it. // One Photon request each (was a four-city fan-out each). const mentions = (p) => (p.formatted_address || '').toLowerCase().includes(stem); const relevant = map.filter(mentions); if (last && relevant.filter(hasCoords).length < 2 && !opts.signal?.aborted) { // Show what the map found while the guesses run. partial(deduplicateAndRank([...recent, ...map, ...postAreas([...recent, ...map])], bias, limit, pin, last.toLowerCase())); // The area word alone and its spellings. Not the last two words: "villas // manikarapalayam" is no better than the full line and costs a request // on a server that may already be throttling. const tries = [words.length >= 2 ? last : '', ...localitySpellings(last).slice(0, 3)].filter(Boolean); const extra = inScope(collectSettled(await Promise.allSettled(tries.map((t) => photonOne(t, { ...opts, limit: 5 }))))); const bLat = Number(opts.bias?.lat); const bLng = Number(opts.bias?.lng); const near = (p) => scope ? isInScope(p, scope) : bLat && bLng ? Boolean(hasCoords(p) && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG) : isInSouth(p); const guessed = extra.filter((p) => mentions(p) || near(p)); map = [...relevant, ...guessed, ...map.filter((p) => !mentions(p))]; } return deduplicateAndRank([...recent, ...map, ...postAreas([...recent, ...map])], bias, limit, pin, last.toLowerCase()); } /** * geocodeAddress's search: one address in, the best place WITH a pin out. * * It used to share the search box's wide search, and a bulk row carrying a * pincode came back as a pincode area with no pin, after ~6s and ~8 * requests. Now: * - Nominatim first (answers in under a second, and one lookup per row is * within its policy; the bulk flow already spaces rows out). Photon only * when Nominatim has nothing usable. * - With a pincode, only places within ~27 km of that pincode's centre: * same-named villages in other districts are refused. * - An area beats a landmark that happens to contain its name. * - No recent picks and no India Post areas (neither is this address's pin). */ async function geocodeOne(textQuery, pin, pinOnly, opts, limit) { const { words, last, stem } = queryWords(textQuery); const centre = pin ? await getPincodeCentre(pin, opts.signal) : null; const inPincode = (p) => hasCoords(p) && (!centre || String(p.postcode) === pin || Math.hypot(p.latitude - centre.lat, p.longitude - centre.lng) <= PINCODE_RADIUS_DEG); const rank = (list) => deduplicateAndRank(list.filter(inPincode), opts.bias, limit, pin, last.toLowerCase(), { preferAreas: true }); const first = rank(await searchNominatim(textQuery, opts)); if (first.length && (pinOnly || !stem || first[0].formatted_address.toLowerCase().includes(stem))) return first; const photon = await searchPhotonOnce(textQuery, centre ? { ...opts, bias: centre } : opts); let found = rank([...first, ...photon]); if (!found.length && last && !opts.signal?.aborted) { // The full line matched nothing near the pincode: try the area on its own // and its likeliest spellings, still held to the pincode. const tries = [words.length >= 2 ? words.slice(-2).join(' ') : '', ...localitySpellings(last).slice(0, 2), last] .filter((t, i, a) => t && a.indexOf(t) === i); const more = collectSettled( await Promise.allSettled(tries.map((t) => searchPhotonOnce(t, centre ? { ...opts, bias: centre, limit: 5 } : { ...opts, limit: 5 }))) ); found = rank(more); } return found; } /** * Dynamic Reverse Geocoding */ export async function reverseGeocode(lat, lon) { if (lat == null || lon == null) return null; const nLat = Number(lat); const nLon = Number(lon); if (!isWithinIndia(nLat, nLon)) return null; // 1. Nominatim reverse. One lookup per dropped pin is within its policy and // it answers in under a second; Photon's reverse took over 6s when its // public server throttled (2026-09-28), so the pin sat on "Identifying // street address…" until Photon timed out and this ran anyway. try { const res = await fetchWithTimeout( `${NOMINATIM_REVERSE_URL}?lat=${nLat}&lon=${nLon}&format=json&zoom=18&addressdetails=1`, { headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9', 'User-Agent': 'DoormileConsole/2.0' } } ); if (res.ok) { const r = await res.json(); if (r && !r.error) { // The pin stays where it was dropped; only the address is read. return nominatimToPlace(r, { provider: 'nominatim_reverse', latitude: nLat, longitude: nLon }); } } } catch {} // 2. Photon reverse, when Nominatim has nothing or is down. try { const res = await fetchWithTimeout( `${PHOTON_REVERSE_URL}?lat=${nLat}&lon=${nLon}`, { headers: { Accept: 'application/json' } }, undefined, PHOTON_TIMEOUT_MS ); if (res.ok) { const data = await res.json(); if (data?.features?.length > 0) { return photonToPlace(data.features[0], { latitude: nLat, longitude: nLon, provider: 'photon_reverse' }); } } } catch {} return standardizePlace({ formatted_address: `Location (${nLat.toFixed(5)}, ${nLon.toFixed(5)})`, name: 'Selected Pin', latitude: nLat, longitude: nLon, provider: 'coordinates_only' }); } /** * Direct forward geocode */ export async function geocodeAddress(address, options = {}) { const suggestions = await getAddressSuggestions(address, { ...options, limit: 1, mode: 'geocode' }); return suggestions.length > 0 ? suggestions[0] : null; }