when-am-i / files / src / utils / nearestCity.ts
nearestCity.ts
| 1 | // Offline "near <city>, <country>" lookup for a clicked point. |
| 2 | // |
| 3 | // Cities come from the bundled src/data/cities.json (GeoNames cities ≥ 50k |
| 4 | // people; see scripts/gen-cities.mts). We pick the nearest of those "large" |
| 5 | // cities — so the readout names a recognizable place, per the "closest largest |
| 6 | // city" idea — and resolve the country *name* from its ISO code at runtime via |
| 7 | // Intl.DisplayNames, so no country table is bundled. |
| 8 | |
| 9 | import citiesData from '../data/cities.json'; |
| 10 | |
| 11 | /** [name, ISO-3166 alpha-2 country code, lat, lng]. */ |
| 12 | type CityTuple = [string, string, number, number]; |
| 13 | |
| 14 | // Cast through unknown so tsc doesn't infer a 12k-entry literal tuple type. |
| 15 | const CITIES = (citiesData as unknown as { cities: CityTuple[] }).cities; |
| 16 | |
| 17 | export interface NearestCity { |
| 18 | name: string; |
| 19 | countryCode: string; |
| 20 | /** Resolved English country name (falls back to the code). */ |
| 21 | country: string; |
| 22 | /** Great-circle distance from the queried point, in km. */ |
| 23 | distanceKm: number; |
| 24 | } |
| 25 | |
| 26 | const regionNames = |
| 27 | typeof Intl !== 'undefined' && 'DisplayNames' in Intl |
| 28 | ? new Intl.DisplayNames(['en'], { type: 'region', fallback: 'code' }) |
| 29 | : null; |
| 30 | |
| 31 | /** ISO-3166 alpha-2 → English country name (e.g. "US" → "United States"). */ |
| 32 | export function countryName(code: string): string { |
| 33 | try { |
| 34 | return regionNames?.of(code.toUpperCase()) ?? code; |
| 35 | } catch { |
| 36 | return code; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | const EARTH_RADIUS_KM = 6371; |
| 41 | const toRad = (deg: number) => (deg * Math.PI) / 180; |
| 42 | |
| 43 | function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { |
| 44 | const dLat = toRad(lat2 - lat1); |
| 45 | const dLng = toRad(lng2 - lng1); |
| 46 | const a = |
| 47 | Math.sin(dLat / 2) ** 2 + |
| 48 | Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; |
| 49 | return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a))); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * The nearest large city to (lat, lng), or null if the dataset is empty. |
| 54 | * Ranking uses a cheap equirectangular metric (longitude scaled by cos(lat)), |
| 55 | * then reports the true great-circle distance for the winner. |
| 56 | */ |
| 57 | export function nearestCity(lat: number, lng: number): NearestCity | null { |
| 58 | const cosLat = Math.cos(toRad(lat)); |
| 59 | let best: CityTuple | null = null; |
| 60 | let bestScore = Infinity; |
| 61 | for (const city of CITIES) { |
| 62 | const dLat = city[2] - lat; |
| 63 | const dLng = (city[3] - lng) * cosLat; |
| 64 | const score = dLat * dLat + dLng * dLng; |
| 65 | if (score < bestScore) { |
| 66 | bestScore = score; |
| 67 | best = city; |
| 68 | } |
| 69 | } |
| 70 | if (!best) return null; |
| 71 | return { |
| 72 | name: best[0], |
| 73 | countryCode: best[1], |
| 74 | country: countryName(best[1]), |
| 75 | distanceKm: haversineKm(lat, lng, best[2], best[3]), |
| 76 | }; |
| 77 | } |