updates on the addressautocomplete and geocodingservice and home page as been updated

This commit is contained in:
2026-09-28 19:08:17 +05:30
parent 5bf0104b61
commit 05a4cbdf8a
4 changed files with 427 additions and 66 deletions

View File

@@ -27,6 +27,7 @@ const getEnvVar = (key) => {
const OLA_MAPS_KEY = getEnvVar('VITE_OLA_MAPS_API_KEY');
const INDIA_POST_PINCODE_URL = 'https://api.postalpincode.in/pincode';
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';
@@ -41,6 +42,34 @@ const INDIA_BOUNDS = {
};
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 }
];
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';
/**
@@ -109,7 +138,9 @@ export const parseRawCoordinatesOrUrl = (text) => {
return null;
};
const REQUEST_TIMEOUT_MS = 3000;
// 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;
const fetchWithTimeout = (url, options = {}) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -206,7 +237,8 @@ export const saveRecentAddress = (place) => {
/**
* 1. Photon (Komoot OSM) Dynamic Search
*/
async function searchPhoton(query, { bias, limit = 10 } = {}) {
async function searchPhoton(query, options = {}) {
const { bias, limit = 10 } = options;
try {
const { doorNo, cleanQuery } = extractDoorPrefix(query);
const searchQuery = cleanQuery || query;
@@ -215,7 +247,7 @@ async function searchPhoton(query, { bias, limit = 10 } = {}) {
q: searchQuery,
limit: String(limit),
lang: 'en',
bbox: INDIA_BBOX_PHOTON
bbox: options.bbox || INDIA_BBOX_PHOTON
});
if (bias?.lat && bias?.lng) {
@@ -234,7 +266,10 @@ async function searchPhoton(query, { bias, limit = 10 } = {}) {
return data.features
.filter((item) => {
const [lon, lat] = item.geometry?.coordinates || [null, null];
return isWithinIndia(lat, lon);
// 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 p = item.properties || {};
@@ -301,6 +336,9 @@ async function searchNominatim(query, { bias, limit = 8 } = {}) {
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()}`, {
@@ -399,10 +437,107 @@ async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
}
}
/**
* 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();
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 seen = new Set();
const places = offices
.filter((po) => po?.Name && !seen.has(po.Name.toLowerCase()) && seen.add(po.Name.toLowerCase()))
.map((po) => {
const area = po.Name.trim();
const city = (po.District || po.Block || '').trim();
const state = (po.State || '').trim();
return standardizePlace({
formatted_address: `${[area, city, state].filter(Boolean).join(', ')} ${pin}`,
name: area,
suburb: area,
city,
state,
postcode: pin,
provider: 'indiapost',
raw: po
});
});
pincodeCache.set(pin, places);
return places;
} catch {
return [];
}
}
/**
* 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
*/
function deduplicateAndRank(places, bias, limit = 8) {
function deduplicateAndRank(places, bias, limit = 8, pin = '') {
const seen = new Set();
const unique = [];
@@ -417,25 +552,65 @@ function deduplicateAndRank(places, bias, limit = 8) {
unique.push(p);
}
// Sort by bias city or proximity
if (bias?.city) {
const biasCity = bias.city.toLowerCase();
unique.sort((a, b) => {
const aMatches = (a.city || a.formatted_address || '').toLowerCase().includes(biasCity) ? 0 : 1;
const bMatches = (b.city || b.formatted_address || '').toLowerCase().includes(biasCity) ? 0 : 1;
return aMatches - bMatches;
});
} else if (bias?.lat && bias?.lng) {
const bLat = Number(bias.lat);
const bLng = Number(bias.lng);
unique.sort((a, b) => {
const distA = a.latitude && a.longitude ? Math.hypot(a.latitude - bLat, a.longitude - bLng) : 999;
const distB = b.latitude && b.longitude ? Math.hypot(b.latitude - bLat, b.longitude - bLng) : 999;
return distA - distB;
});
}
// Rank: a typed pincode first, then the active city, then distance from the
// hub. City alone used to decide it, so when nothing matched the city name —
// the usual case for a small locality — proximity was never consulted.
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 score = (p) => [
p.provider === 'recent' ? 0 : 1,
pin && String(p.postcode) !== pin ? 1 : 0,
isInSouth(p) ? 0 : 1,
biasCity && !(p.city || p.formatted_address || '').toLowerCase().includes(biasCity) ? 1 : 0,
// Near the hub 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.
hasPoint ? (p.latitude && p.longitude && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG ? 0 : 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;
});
return scored.slice(0, limit).map((x) => x.p);
}
return unique.slice(0, limit);
/** 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 around the hub (when one is chosen) AND around each southern metro.
* The hub's answers lead; the metros are searched regardless, so a Coimbatore
* operator typing a Bengaluru address still gets Bengaluru's matches.
*/
async function searchPhotonSouth(query, options = {}) {
const hubLat = Number(options.bias?.lat);
const hubLng = Number(options.bias?.lng);
const hasHub = Boolean(hubLat && hubLng);
const perCity = Math.max(4, Math.ceil((options.limit || 10) / 2));
const metros = SOUTH_METROS.filter((m) => !hasHub || Math.hypot(m.lat - hubLat, m.lng - hubLng) > 0.5);
const lists = await Promise.all([
...(hasHub ? [searchPhoton(query, { ...options, limit: perCity + 2 })] : []),
...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);
}
/** 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' }));
}
/**
@@ -454,24 +629,77 @@ export async function getAddressSuggestions(query, options = {}) {
if (cleanQuery.length < 2) return [];
// Parallel Execution
const limit = options.limit || 12;
const pinMatch = cleanQuery.match(/(?:^|\D)([1-9][0-9]{5})(?:\D|$)/);
const pinOnly = /^[1-9][0-9]{5}$/.test(cleanQuery);
const collect = (settled) =>
settled.flatMap((res) => (res.status === 'fulfilled' && Array.isArray(res.value) ? res.value : []));
// A bare pincode is answered from the pincode directory first: its areas are
// what the operator is choosing between. OSM's centroid follows as the one
// result that carries a map point.
if (pinOnly) {
const [areas, osm] = await Promise.all([
lookupPincode(cleanQuery),
Promise.allSettled([searchPhotonSouth(cleanQuery, options), searchNominatim(cleanQuery, options)]).then(collect)
]);
return deduplicateAndRank([...areas, ...osm], options.bias, Math.max(limit, areas.length + 1), cleanQuery);
}
const promises = [];
if (OLA_MAPS_KEY) {
promises.push(searchOlaMaps(cleanQuery, options));
}
promises.push(searchPhoton(cleanQuery, options));
promises.push(Promise.resolve(matchRecent(cleanQuery)));
promises.push(searchPhotonSouth(cleanQuery, options));
promises.push(searchNominatim(cleanQuery, options));
// "white villas 641035" — the pincode's areas are useful alongside the text match.
if (pinMatch) promises.push(lookupPincode(pinMatch[1]));
const results = await Promise.allSettled(promises);
const places = [];
let places = collect(await Promise.allSettled(promises));
if (pinMatch) {
// Of the pincode's areas, the one named in the query goes first.
const q = cleanQuery.toLowerCase();
const named = (p) => (p.provider === 'indiapost' && q.includes(p.name.toLowerCase()) ? 0 : 1);
places = places.map((p, i) => ({ p, i })).sort((a, b) => named(a.p) - named(b.p) || a.i - b.i).map((x) => x.p);
}
for (const res of results) {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
places.push(...res.value);
// 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 full phrase comes back thin,
// search the trailing words on their own — Photon's fuzzy match then finds
// the locality even with a spelling slip. "Thin" means few results that
// mention the area at all, not few results: Bangalore villas are plenty.
const words = cleanQuery.replace(/[,]+/g, ' ').split(/\s+/).filter((w) => w.length > 1 && !/^\d+$/.test(w));
if (words.length >= 1) {
const stem = words[words.length - 1].toLowerCase().slice(0, 5);
const mentions = (p) => (p.formatted_address || '').toLowerCase().includes(stem);
const relevant = places.filter(mentions);
if (relevant.length < 2) {
const last = words[words.length - 1];
// One word is the query itself; re-searching it would only repeat it.
const tails = words.length >= 2 ? [words.slice(-2).join(' '), last].filter((t, i, a) => a.indexOf(t) === i) : [];
const variants = localitySpellings(last);
const [extra, guessed] = await Promise.all([
Promise.allSettled(tails.map((t) => searchPhotonSouth(t, { ...options, limit: 6 }))).then(collect),
// Without a hub, guesses are boxed to the South rather than run per metro.
Promise.allSettled(
variants.map((v) =>
searchPhoton(v, { ...options, limit: 4, ...(options.bias?.lat ? {} : { bbox: SOUTH_BBOX_PHOTON }) })
)
).then(collect)
]);
const bLat = Number(options.bias?.lat);
const bLng = Number(options.bias?.lng);
const near = (p) =>
bLat && bLng
? Boolean(p.latitude && p.longitude && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG)
: isInSouth(p);
places = [...relevant, ...guessed.filter(near), ...extra, ...places.filter((p) => !mentions(p))];
}
}
return deduplicateAndRank(places, options.bias, options.limit || 8);
return deduplicateAndRank(places, options.bias, limit, pinMatch ? pinMatch[1] : '');
}
/**