/** * Minimal geohash encode/decode/neighbors implementation. * * Firestore has no native geo-radius query, so nearby-driver lookups use * geohash prefix matching: encode the search point to a geohash cell, query * that cell plus its 8 neighbors (covers a driver sitting just across a cell * boundary), then filter the merged results down to the real circle with a * haversine check. This is a well-known, compact algorithm (base32 * bit-interleaving) — not worth pulling an external dependency for. */ const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"; export function encodeGeohash(lat: number, lng: number, precision = 9): string { const latRange: [number, number] = [-90, 90]; const lngRange: [number, number] = [-180, 180]; let hash = ""; let bit = 0; let ch = 0; let evenBit = true; while (hash.length < precision) { if (evenBit) { const mid = (lngRange[0] + lngRange[1]) / 2; if (lng >= mid) { ch |= 1 << (4 - bit); lngRange[0] = mid; } else { lngRange[1] = mid; } } else { const mid = (latRange[0] + latRange[1]) / 2; if (lat >= mid) { ch |= 1 << (4 - bit); latRange[0] = mid; } else { latRange[1] = mid; } } evenBit = !evenBit; if (bit < 4) { bit++; } else { hash += BASE32[ch]; bit = 0; ch = 0; } } return hash; } interface GeohashBounds { latMin: number; latMax: number; lngMin: number; lngMax: number; } function decodeGeohashBounds(hash: string): GeohashBounds { let latRange: [number, number] = [-90, 90]; let lngRange: [number, number] = [-180, 180]; let evenBit = true; for (const c of hash) { const idx = BASE32.indexOf(c); if (idx === -1) throw new Error(`Invalid geohash character: ${c}`); for (let n = 4; n >= 0; n--) { const bitN = (idx >> n) & 1; if (evenBit) { const mid = (lngRange[0] + lngRange[1]) / 2; lngRange = bitN === 1 ? [mid, lngRange[1]] : [lngRange[0], mid]; } else { const mid = (latRange[0] + latRange[1]) / 2; latRange = bitN === 1 ? [mid, latRange[1]] : [latRange[0], mid]; } evenBit = !evenBit; } } return {latMin: latRange[0], latMax: latRange[1], lngMin: lngRange[0], lngMax: lngRange[1]}; } /** * Returns the geohash cell plus its 8 neighbors (9 cells total) at the same * precision as `hash`, covering a generous area around the point so a radius * query doesn't miss a driver sitting just across a cell boundary. */ export function geohashNeighbors(hash: string): string[] { const precision = hash.length; const bounds = decodeGeohashBounds(hash); const latStep = bounds.latMax - bounds.latMin; const lngStep = bounds.lngMax - bounds.lngMin; const centerLat = (bounds.latMin + bounds.latMax) / 2; const centerLng = (bounds.lngMin + bounds.lngMax) / 2; const cells = new Set(); for (let dLat = -1; dLat <= 1; dLat++) { for (let dLng = -1; dLng <= 1; dLng++) { const lat = clampLat(centerLat + dLat * latStep); const lng = wrapLng(centerLng + dLng * lngStep); cells.add(encodeGeohash(lat, lng, precision)); } } return Array.from(cells); } function clampLat(lat: number): number { return Math.max(-90, Math.min(90, lat)); } function wrapLng(lng: number): number { let l = lng; while (l > 180) l -= 360; while (l < -180) l += 360; return l; } /** Haversine distance in meters between two lat/lng points. */ export function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { const earthRadiusM = 6371000; const toRad = (deg: number) => (deg * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return earthRadiusM * 2 * Math.asin(Math.sqrt(a)); }