updates on the addressautocomplete and geocodingservice and home page as been updated
This commit is contained in:
@@ -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] : '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user