Skip to main content

smriti/services/
geocoding.rs

1//! Offline reverse geocoding service.
2
3use std::path::Path;
4
5use rusqlite::{params, Connection, OpenFlags, Result as SqliteResult};
6
7/// A geocoding result.
8#[derive(Debug, Clone)]
9pub struct GeocodingResult {
10    pub city: String,
11    pub country: String,
12}
13
14/// Offline geocoding service using GeoNames data.
15pub struct GeocodingService {
16    conn: Connection,
17}
18
19impl GeocodingService {
20    pub fn new<P: AsRef<Path>>(db_path: P) -> SqliteResult<Self> {
21        let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
22        conn.execute_batch(
23            r#"
24            PRAGMA query_only = ON;
25            PRAGMA cache_size = -10000;
26            PRAGMA mmap_size = 268435456;
27            "#,
28        )?;
29        Ok(Self { conn })
30    }
31
32    /// Hard cutoff (km) for the city match. Without this, sparse
33    /// regions in cities1000 can route a photo's coordinates to a city
34    /// hundreds of km away (e.g., a small admin region in Tibet
35    /// becoming the "nearest" match for photos taken in Goa). 100 km is
36    /// the empirical sweet spot — covers photos shot from rural areas
37    /// near a city while still rejecting cross-country mis-attributions.
38    const MAX_CITY_DISTANCE_KM: f64 = 100.0;
39
40    pub fn reverse_geocode(&self, lat: f64, lon: f64) -> Option<GeocodingResult> {
41        if !Self::is_valid_coordinate(lat, lon) {
42            return None;
43        }
44
45        // ±1° (~110 km) covers the cutoff at most latitudes; expand
46        // once to ±2° to catch edge cases where the photo sits exactly
47        // between cells. Any further-out match exceeds the haversine
48        // cutoff and gets filtered out anyway.
49        self.search_bounding_box(lat, lon, 1.0)
50            .or_else(|| self.search_bounding_box(lat, lon, 2.0))
51    }
52
53    /// Priority bucket for a GeoNames `feature_code`. Lower number =
54    /// better match. We strongly prefer admin-seat codes (state/
55    /// district / sub-district HQs) over plain "PPL" entries, because
56    /// upstream GeoNames data sometimes tags suburbs / neighbourhoods
57    /// with metro-wide populations — e.g. Rasapudipalem (PPL) is a
58    /// neighbourhood of Visakhapatnam (PPLA2) but carries population
59    /// 1.7 M in `cities1000.txt`. Ranking by feature_code first picks
60    /// the user-recognisable name regardless.
61    fn feature_priority(code: &str) -> u8 {
62        match code {
63            "PPLC" => 0,  // country capital
64            "PPLA" => 1,  // state capital
65            "PPLA2" => 2, // district seat
66            "PPLA3" => 3,
67            "PPLA4" => 4,
68            "PPLG" => 5, // seat of government
69            "PPL" => 6,  // generic populated place
70            _ => 7,      // PPLX / PPLL / PPLS / etc.
71        }
72    }
73
74    fn search_bounding_box(&self, lat: f64, lon: f64, radius_deg: f64) -> Option<GeocodingResult> {
75        let min_lat = (lat - radius_deg).max(-90.0);
76        let max_lat = (lat + radius_deg).min(90.0);
77        let lon_ranges = longitude_ranges(lon, radius_deg);
78
79        // We DO still apply a population floor, but only as a sanity
80        // gate against extremely small entries. Real ranking is by
81        // `feature_priority(feature_code)` + distance — see comments
82        // there. 100 k stays as the floor for the same reason it was
83        // chosen originally: rural photos without a major town nearby
84        // should fall through to the "Approx. lat/lng" UI fallback
85        // rather than getting tagged with a village no one recognises.
86        let mut cities: Vec<(String, String, String, f64, f64, String)> = Vec::new();
87        for (min_lon, max_lon) in lon_ranges {
88            let mut stmt = self
89                .conn
90                .prepare(
91                    r#"
92                SELECT
93                    ascii_name,
94                    country_name,
95                    country_code,
96                    latitude,
97                    longitude,
98                    COALESCE(feature_code, '')
99                FROM cities
100                WHERE latitude BETWEEN ?1 AND ?2
101                  AND longitude BETWEEN ?3 AND ?4
102                  AND population >= 100000
103                LIMIT 200
104                "#,
105                )
106                .ok()?;
107
108            let mut rows = stmt
109                .query_map(params![min_lat, max_lat, min_lon, max_lon], |row| {
110                    Ok((
111                        row.get(0)?,
112                        row.get(1)?,
113                        row.get(2)?,
114                        row.get(3)?,
115                        row.get(4)?,
116                        row.get(5)?,
117                    ))
118                })
119                .ok()?
120                .collect::<rusqlite::Result<Vec<_>>>()
121                .ok()?;
122            cities.append(&mut rows);
123        }
124
125        // Walk every candidate and keep the best one according to
126        // (feature_priority asc, distance asc). Anything farther than
127        // MAX_CITY_DISTANCE_KM is filtered upfront so it can't win.
128        let mut best: Option<(GeocodingResult, u8, f64)> = None;
129
130        for (city_name, country_name, _country_code, city_lat, city_lon, feature_code) in cities {
131            let distance = Self::haversine_distance(lat, lon, city_lat, city_lon);
132            if distance > Self::MAX_CITY_DISTANCE_KM {
133                continue;
134            }
135            let priority = Self::feature_priority(&feature_code);
136            let candidate = GeocodingResult {
137                city: city_name,
138                country: country_name,
139            };
140            let take = match &best {
141                None => true,
142                Some((_, p, d)) => (priority, distance) < (*p, *d),
143            };
144            if take {
145                best = Some((candidate, priority, distance));
146            }
147        }
148
149        best.map(|(r, _, _)| r)
150    }
151
152    fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
153        const EARTH_RADIUS_KM: f64 = 6371.0;
154
155        let lat1_rad = lat1.to_radians();
156        let lat2_rad = lat2.to_radians();
157        let delta_lat = (lat2 - lat1).to_radians();
158        let delta_lon = (lon2 - lon1).to_radians();
159
160        let a = (delta_lat / 2.0).sin().powi(2)
161            + lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2);
162        let c = 2.0 * a.sqrt().asin();
163        EARTH_RADIUS_KM * c
164    }
165
166    fn is_valid_coordinate(lat: f64, lon: f64) -> bool {
167        if !((-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon)) {
168            return false;
169        }
170        !((lat.abs() < 0.001) && (lon.abs() < 0.001))
171    }
172}
173
174fn longitude_ranges(lon: f64, radius_deg: f64) -> Vec<(f64, f64)> {
175    let min_lon = lon - radius_deg;
176    let max_lon = lon + radius_deg;
177    if min_lon < -180.0 {
178        vec![(min_lon + 360.0, 180.0), (-180.0, max_lon)]
179    } else if max_lon > 180.0 {
180        vec![(min_lon, 180.0), (-180.0, max_lon - 360.0)]
181    } else {
182        vec![(min_lon, max_lon)]
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_haversine_distance() {
192        let distance = GeocodingService::haversine_distance(35.6762, 139.6503, 40.7128, -74.0060);
193        assert!((distance - 10850.0).abs() < 200.0);
194    }
195
196    #[test]
197    fn test_valid_coordinates() {
198        assert!(GeocodingService::is_valid_coordinate(35.6762, 139.6503));
199        assert!(!GeocodingService::is_valid_coordinate(0.0, 0.0));
200        assert!(!GeocodingService::is_valid_coordinate(91.0, 0.0));
201    }
202}