fuzzyworld

when-am-i / files / src / utils / nearestCity.ts

nearestCity.ts

Blame: the commit that last changed each line · file · history

998b11ce 2026-06-21 Pavel Safronov1// 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
9import citiesData from '../data/cities.json';
10
11/** [name, ISO-3166 alpha-2 country code, lat, lng]. */
12type CityTuple = [string, string, number, number];
13
14// Cast through unknown so tsc doesn't infer a 12k-entry literal tuple type.
15const CITIES = (citiesData as unknown as { cities: CityTuple[] }).cities;
16
17export 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
26const 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"). */
32export function countryName(code: string): string {
33 try {
34 return regionNames?.of(code.toUpperCase()) ?? code;
35 } catch {
36 return code;
37 }
38}
39
40const EARTH_RADIUS_KM = 6371;
41const toRad = (deg: number) => (deg * Math.PI) / 180;
42
43function 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 */
57export 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}