Files
doormilxpress_astryx/src/lib/geocodingService.js

1237 lines
50 KiB
JavaScript

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