nearest-city.ts123 lines · main
1/**
2 * Offline nearest-city fallback when MaxMind returns country + lat/lon but no city.
3 * Self-hosted only — no third-party HTTP geocoding (same rule as lib/geoip.ts).
4 *
5 * Dense coverage for Belgium (ops / common users); major world cities for everyone else.
6 */
7
8export type NearestCity = {
9 name: string;
10 region: string | null;
11 country: string;
12 lat: number;
13 lon: number;
14};
15
16/** Curated cities used only when MaxMind city is empty. */
17const CITIES: NearestCity[] = [
18 // Belgium (dense — flndrn / mavi ops)
19 { name: 'Brussels', region: 'Brussels', country: 'Belgium', lat: 50.8503, lon: 4.3517 },
20 { name: 'Ghent', region: 'East Flanders', country: 'Belgium', lat: 51.0543, lon: 3.7174 },
21 { name: 'Antwerp', region: 'Antwerp', country: 'Belgium', lat: 51.2194, lon: 4.4025 },
22 { name: 'Bruges', region: 'West Flanders', country: 'Belgium', lat: 51.2093, lon: 3.2247 },
23 { name: 'Leuven', region: 'Flemish Brabant', country: 'Belgium', lat: 50.8798, lon: 4.7005 },
24 { name: 'Liège', region: 'Liège', country: 'Belgium', lat: 50.6326, lon: 5.5797 },
25 { name: 'Namur', region: 'Namur', country: 'Belgium', lat: 50.4674, lon: 4.872 },
26 { name: 'Charleroi', region: 'Hainaut', country: 'Belgium', lat: 50.4108, lon: 4.4446 },
27 { name: 'Mons', region: 'Hainaut', country: 'Belgium', lat: 50.4542, lon: 3.9561 },
28 { name: 'Mechelen', region: 'Antwerp', country: 'Belgium', lat: 51.0259, lon: 4.4776 },
29 { name: 'Aalst', region: 'East Flanders', country: 'Belgium', lat: 50.9378, lon: 4.0409 },
30 { name: 'Kortrijk', region: 'West Flanders', country: 'Belgium', lat: 50.827, lon: 3.2648 },
31 { name: 'Ostend', region: 'West Flanders', country: 'Belgium', lat: 51.2154, lon: 2.9286 },
32 { name: 'Hasselt', region: 'Limburg', country: 'Belgium', lat: 50.9307, lon: 5.3325 },
33 // Neighbours / major EU
34 { name: 'Amsterdam', region: 'North Holland', country: 'Netherlands', lat: 52.3676, lon: 4.9041 },
35 { name: 'Rotterdam', region: 'South Holland', country: 'Netherlands', lat: 51.9244, lon: 4.4777 },
36 { name: 'Paris', region: 'Île-de-France', country: 'France', lat: 48.8566, lon: 2.3522 },
37 { name: 'Lille', region: 'Hauts-de-France', country: 'France', lat: 50.6292, lon: 3.0573 },
38 { name: 'London', region: 'England', country: 'United Kingdom', lat: 51.5074, lon: -0.1278 },
39 { name: 'Berlin', region: 'Berlin', country: 'Germany', lat: 52.52, lon: 13.405 },
40 { name: 'Cologne', region: 'North Rhine-Westphalia', country: 'Germany', lat: 50.9375, lon: 6.9603 },
41 { name: 'Frankfurt', region: 'Hesse', country: 'Germany', lat: 50.1109, lon: 8.6821 },
42 { name: 'Munich', region: 'Bavaria', country: 'Germany', lat: 48.1351, lon: 11.582 },
43 { name: 'Luxembourg City', region: null, country: 'Luxembourg', lat: 49.6116, lon: 6.1319 },
44 { name: 'Madrid', region: 'Madrid', country: 'Spain', lat: 40.4168, lon: -3.7038 },
45 { name: 'Barcelona', region: 'Catalonia', country: 'Spain', lat: 41.3874, lon: 2.1686 },
46 { name: 'Rome', region: 'Lazio', country: 'Italy', lat: 41.9028, lon: 12.4964 },
47 { name: 'Milan', region: 'Lombardy', country: 'Italy', lat: 45.4642, lon: 9.19 },
48 { name: 'Vienna', region: 'Vienna', country: 'Austria', lat: 48.2082, lon: 16.3738 },
49 { name: 'Zurich', region: 'Zurich', country: 'Switzerland', lat: 47.3769, lon: 8.5417 },
50 { name: 'Geneva', region: 'Geneva', country: 'Switzerland', lat: 46.2044, lon: 6.1432 },
51 { name: 'Dublin', region: 'Leinster', country: 'Ireland', lat: 53.3498, lon: -6.2603 },
52 { name: 'Stockholm', region: 'Stockholm', country: 'Sweden', lat: 59.3293, lon: 18.0686 },
53 { name: 'Oslo', region: 'Oslo', country: 'Norway', lat: 59.9139, lon: 10.7522 },
54 { name: 'Copenhagen', region: 'Capital Region', country: 'Denmark', lat: 55.6761, lon: 12.5683 },
55 { name: 'Warsaw', region: 'Masovian', country: 'Poland', lat: 52.2297, lon: 21.0122 },
56 { name: 'Prague', region: 'Prague', country: 'Czechia', lat: 50.0755, lon: 14.4378 },
57 { name: 'Lisbon', region: 'Lisbon', country: 'Portugal', lat: 38.7223, lon: -9.1393 },
58 { name: 'Athens', region: 'Attica', country: 'Greece', lat: 37.9838, lon: 23.7275 },
59 { name: 'Istanbul', region: 'Istanbul', country: 'Turkey', lat: 41.0082, lon: 28.9784 },
60 // Americas / APAC (common)
61 { name: 'New York', region: 'New York', country: 'United States', lat: 40.7128, lon: -74.006 },
62 { name: 'Los Angeles', region: 'California', country: 'United States', lat: 34.0522, lon: -118.2437 },
63 { name: 'Chicago', region: 'Illinois', country: 'United States', lat: 41.8781, lon: -87.6298 },
64 { name: 'San Francisco', region: 'California', country: 'United States', lat: 37.7749, lon: -122.4194 },
65 { name: 'Toronto', region: 'Ontario', country: 'Canada', lat: 43.6532, lon: -79.3832 },
66 { name: 'Vancouver', region: 'British Columbia', country: 'Canada', lat: 49.2827, lon: -123.1207 },
67 { name: 'Mexico City', region: 'Mexico City', country: 'Mexico', lat: 19.4326, lon: -99.1332 },
68 { name: 'São Paulo', region: 'São Paulo', country: 'Brazil', lat: -23.5505, lon: -46.6333 },
69 { name: 'Buenos Aires', region: 'Buenos Aires', country: 'Argentina', lat: -34.6037, lon: -58.3816 },
70 { name: 'Tokyo', region: 'Tokyo', country: 'Japan', lat: 35.6762, lon: 139.6503 },
71 { name: 'Seoul', region: 'Seoul', country: 'South Korea', lat: 37.5665, lon: 126.978 },
72 { name: 'Singapore', region: null, country: 'Singapore', lat: 1.3521, lon: 103.8198 },
73 { name: 'Hong Kong', region: null, country: 'Hong Kong', lat: 22.3193, lon: 114.1694 },
74 { name: 'Sydney', region: 'New South Wales', country: 'Australia', lat: -33.8688, lon: 151.2093 },
75 { name: 'Melbourne', region: 'Victoria', country: 'Australia', lat: -37.8136, lon: 144.9631 },
76 { name: 'Auckland', region: 'Auckland', country: 'New Zealand', lat: -36.8509, lon: 174.7645 },
77 { name: 'Dubai', region: 'Dubai', country: 'United Arab Emirates', lat: 25.2048, lon: 55.2708 },
78 { name: 'Mumbai', region: 'Maharashtra', country: 'India', lat: 19.076, lon: 72.8777 },
79 { name: 'Delhi', region: 'Delhi', country: 'India', lat: 28.7041, lon: 77.1025 },
80 { name: 'Bangalore', region: 'Karnataka', country: 'India', lat: 12.9716, lon: 77.5946 },
81 { name: 'Johannesburg', region: 'Gauteng', country: 'South Africa', lat: -26.2041, lon: 28.0473 },
82 { name: 'Lagos', region: 'Lagos', country: 'Nigeria', lat: 6.5244, lon: 3.3792 },
83 { name: 'Cairo', region: 'Cairo', country: 'Egypt', lat: 30.0444, lon: 31.2357 },
84];
85
86function haversineKm(
87 lat1: number,
88 lon1: number,
89 lat2: number,
90 lon2: number,
91): number {
92 const R = 6371;
93 const toRad = (d: number) => (d * Math.PI) / 180;
94 const dLat = toRad(lat2 - lat1);
95 const dLon = toRad(lon2 - lon1);
96 const a =
97 Math.sin(dLat / 2) ** 2 +
98 Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
99 return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
100}
101
102/**
103 * Nearest curated city within maxKm of the MaxMind coordinate.
104 * Returns null if nothing is close enough (avoids wild guesses).
105 */
106export function nearestCityFromCoords(
107 lat: number,
108 lon: number,
109 maxKm = 120,
110): NearestCity | null {
111 if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
112 let best: NearestCity | null = null;
113 let bestKm = Infinity;
114 for (const c of CITIES) {
115 const d = haversineKm(lat, lon, c.lat, c.lon);
116 if (d < bestKm) {
117 bestKm = d;
118 best = c;
119 }
120 }
121 if (!best || bestKm > maxKm) return null;
122 return best;
123}