//! Offline reverse geocoding: a GPS position → Sublocation / City / State / Country, from the //! GeoNames dump (CC-BY 4.0, "GeoNames, geonames.org"). See README.md. //! //! Sources: GeoNames dump readme (prose); haversine (textbook); cell index own design. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod unzip; use std::collections::HashMap; use std::path::Path; use serde::Serialize; /// Lookups farther than this from any place find nothing. pub const MAX_KM: f64 = 50.0; /// The files the engine downloads (the first comes zipped as `cities15000.zip`). pub const GEONAMES_FILES: [&str; 3] = ["cities15000.txt", "admin1CodesASCII.txt", "countryInfo.txt"]; /// The zip holding [`GEONAMES_FILES`]`[0]`. pub const GEONAMES_ZIP: &str = "cities15000.zip"; pub const GEONAMES_BASE: &str = "https://download.geonames.org/export/dump/"; pub const GEONAMES_ATTRIBUTION: &str = "Place names: GeoNames (geonames.org), CC-BY 4.0"; /// Places kept from a dataset (cities15000 has ~33 000; cities500 ~200 000). const MAX_PLACES: usize = 2_000_000; /// Largest name kept (GeoNames names are short; anything longer is junk). const MAX_NAME: usize = 200; const EARTH_RADIUS_M: f64 = 6_371_008.8; #[derive(Debug, Clone, PartialEq, Eq)] pub enum GeocodeError { /// The place names aren't there (not downloaded yet) or can't be read. Missing(String), /// The data is not what it should be. Invalid(String), } impl std::fmt::Display for GeocodeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { GeocodeError::Missing(e) | GeocodeError::Invalid(e) => f.write_str(e), } } } impl std::error::Error for GeocodeError {} /// A position in degrees. #[derive(Clone, Copy, Debug, PartialEq)] pub struct LatLon { pub lat: f64, pub lon: f64, } impl LatLon { pub fn new(lat: f64, lon: f64) -> LatLon { LatLon { lat, lon } } /// Finite and on the globe. pub fn is_valid(self) -> bool { self.lat.is_finite() && self.lon.is_finite() && self.lat.abs() <= 90.0 && self.lon.abs() <= 180.0 } } /// Great-circle distance in metres (haversine). NaN in, NaN out. pub fn distance_m(a: LatLon, b: LatLon) -> f64 { let (p1, p2) = (a.lat.to_radians(), b.lat.to_radians()); let dp = p2 - p1; let dl = (b.lon - a.lon).to_radians(); let h = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2); 2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin() } /// What a lookup fills in (empty strings: unknown). #[derive(Clone, Debug, Default, PartialEq, Serialize)] pub struct Place { /// Below city level. GeoNames' city list has none, so offline lookups leave it empty. pub sublocation: String, pub city: String, pub state: String, pub country: String, /// ISO 3166-1 alpha-2, upper case. pub iso: String, /// Metres from the position to the place. pub distance: f64, } #[derive(Clone, Debug)] struct City { name: String, at: LatLon, country: String, admin1: String, } /// An offline GeoNames index: places bucketed by 1° cells. #[derive(Clone, Debug, Default)] pub struct GeoNames { cities: Vec, cells: HashMap<(i32, i32), Vec>, admin1: HashMap, countries: HashMap, } fn cell(p: LatLon) -> (i32, i32) { // valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range (p.lat.floor() as i32, p.lon.floor() as i32) } fn short(s: &str) -> String { s.trim().chars().take(MAX_NAME).collect() } impl GeoNames { /// From the dump's text: `cities` (tab-separated, the `cities*.txt` layout), and optionally /// `admin1CodesASCII.txt` and `countryInfo.txt`. Bad lines are skipped. pub fn parse(cities: &str, admin1: Option<&str>, countries: Option<&str>) -> GeoNames { let mut g = GeoNames::default(); for line in cities.lines() { if g.cities.len() >= MAX_PLACES { break; } let f: Vec<&str> = line.split('\t').collect(); // columns: 1 name, 4 latitude, 5 longitude, 8 country code, 10 admin1 code let (Some(name), Some(lat), Some(lon), Some(cc)) = (f.get(1), f.get(4), f.get(5), f.get(8)) else { continue }; let (Ok(lat), Ok(lon)) = (lat.trim().parse::(), lon.trim().parse::()) else { continue }; let at = LatLon::new(lat, lon); let name = short(name); if !at.is_valid() || name.is_empty() { continue; } let Ok(i) = u32::try_from(g.cities.len()) else { break }; g.cells.entry(cell(at)).or_default().push(i); g.cities.push(City { name, at, country: short(cc).to_ascii_uppercase(), admin1: f.get(10).map(|s| short(s)).unwrap_or_default() }); } for line in admin1.unwrap_or("").lines() { let mut f = line.split('\t'); if let (Some(code), Some(name)) = (f.next(), f.next()) { g.admin1.insert(short(code), short(name)); } } for line in countries.unwrap_or("").lines().filter(|l| !l.starts_with('#')) { let f: Vec<&str> = line.split('\t').collect(); if let (Some(iso), Some(name)) = (f.first(), f.get(4)) { g.countries.insert(short(iso).to_ascii_uppercase(), short(name)); } } g } /// Load from a folder holding [`GEONAMES_FILES`] (the last two are optional: without them /// states stay blank and countries are ISO codes). pub fn load(dir: &Path) -> Result { let read = |n: &str| std::fs::read(dir.join(n)).map(|b| String::from_utf8_lossy(&b).into_owned()); let cities = read(GEONAMES_FILES[0]).map_err(|e| GeocodeError::Missing(format!("the place names aren't downloaded ({e})")))?; let g = GeoNames::parse(&cities, read(GEONAMES_FILES[1]).ok().as_deref(), read(GEONAMES_FILES[2]).ok().as_deref()); if g.is_empty() { return Err(GeocodeError::Invalid("the place names file holds no places".into())); } Ok(g) } pub fn len(&self) -> usize { self.cities.len() } pub fn is_empty(&self) -> bool { self.cities.is_empty() } /// The nearest place within [`MAX_KM`]; `None` for an invalid position or open country. pub fn reverse(&self, p: LatLon) -> Option { if !p.is_valid() { return None; } let (cy, cx) = cell(p); let mut best: Option<(f64, &City)> = None; // ±1° covers 50 km everywhere except very near the poles (where there are no cities) for dy in -1..=1 { for dx in -1..=1 { let mut x = cx + dx; if x < -180 { x += 360; } else if x >= 180 { x -= 360; } for &i in self.cells.get(&(cy + dy, x)).into_iter().flatten() { let Some(c) = self.cities.get(i as usize) else { continue }; let d = distance_m(p, c.at); if best.is_none_or(|(b, _)| d < b) { best = Some((d, c)); } } } } let (d, c) = best?; (d <= MAX_KM * 1000.0).then(|| Place { sublocation: String::new(), city: c.name.clone(), state: self.admin1.get(&format!("{}.{}", c.country, c.admin1)).cloned().unwrap_or_default(), country: self.countries.get(&c.country).cloned().unwrap_or_else(|| c.country.clone()), iso: c.country.clone(), distance: d.round(), }) } } #[cfg(test)] mod tests { use super::*; const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\ 2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n\ bad line\n\ 1\tNowhere\tx\t\tNaN\t0\tP\tP\tXX\n\ 2\tFar\tx\t\t500\t0\tP\tP\tXX\n\ 3\tInf\tx\t\tinf\t-inf\tP\tP\tXX\n"; #[test] fn nearest_place() { let g = GeoNames::parse(CITIES, Some("FR.11\tÎle-de-France\nGB.ENG\tEngland\n"), Some("#ISO\tx\nFR\tFRA\t250\tFR\tFrance\n")); assert_eq!(g.len(), 2); let p = g.reverse(LatLon::new(48.86, 2.34)).unwrap(); assert_eq!((p.city.as_str(), p.state.as_str(), p.country.as_str(), p.iso.as_str()), ("Paris", "Île-de-France", "France", "FR")); assert!(p.distance > 0.0 && p.distance < 2000.0); // no countryInfo row: the ISO code stands in let l = g.reverse(LatLon::new(51.5, -0.1)).unwrap(); assert_eq!((l.city.as_str(), l.state.as_str(), l.country.as_str()), ("London", "England", "GB")); // open sea assert!(g.reverse(LatLon::new(0.0, 0.0)).is_none()); } #[test] fn hostile_positions_find_nothing() { let g = GeoNames::parse(CITIES, None, None); for (la, lo) in [(f64::NAN, 0.0), (0.0, f64::NAN), (f64::INFINITY, 0.0), (91.0, 0.0), (0.0, -180.5), (-1e300, 1e300)] { assert!(g.reverse(LatLon::new(la, lo)).is_none(), "{la}, {lo}"); } // the poles and the antimeridian are fine assert!(g.reverse(LatLon::new(90.0, 180.0)).is_none()); assert!(g.reverse(LatLon::new(-90.0, -180.0)).is_none()); assert!(GeoNames::default().reverse(LatLon::new(48.86, 2.34)).is_none()); } #[test] fn across_the_antimeridian() { let fiji = GeoNames::parse("1\tX\tX\t\t-17.0\t179.9\tP\tP\tFJ\t\t\n", None, None); assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X"); } #[test] fn junk_text_parses_to_nothing() { let g = GeoNames::parse("\t\t\t\t\t\n\u{0}\t\u{feff}\n", Some("\n\t\n"), Some("x")); assert!(g.is_empty()); let long = format!("1\t{}\tx\t\t1\t1\tP\tP\tXX\n", "a".repeat(10_000)); assert_eq!(GeoNames::parse(&long, None, None).reverse(LatLon::new(1.0, 1.0)).unwrap().city.len(), MAX_NAME); } #[test] fn load_from_a_folder() { let dir = std::env::temp_dir().join(format!("lightcraft-geocode-load-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Missing(_)))); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(GEONAMES_FILES[0]), "junk").unwrap(); assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Invalid(_)))); std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap(); std::fs::write(dir.join(GEONAMES_FILES[2]), "GB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap(); let g = GeoNames::load(&dir).unwrap(); assert_eq!(g.reverse(LatLon::new(51.5, -0.1)).unwrap().country, "United Kingdom"); let _ = std::fs::remove_dir_all(&dir); } }