when-am-i / files / src / utils / nearestCity.ts
nearestCity.ts
998b11ce 2026-06-21 Pavel Safronov | 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 | } |