1237 lines
50 KiB
JavaScript
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;
|
|
}
|