Skip to main content

pysochrone/
poi.rs

1use crate::cache;
2use crate::error::OsmGraphError;
3use crate::graph::XmlData;
4use crate::overpass;
5use geo::{Contains, Coord, LineString, Point, Polygon};
6use std::collections::HashMap;
7
8pub struct Poi {
9    pub id: i64,
10    pub lat: f64,
11    pub lon: f64,
12    pub tags: HashMap<String, String>,
13}
14
15fn create_poi_query(bbox: &str) -> String {
16    format!(
17        "[out:xml];(\
18         node[\"tourism\"]({bbox});\
19         node[\"historic\"]({bbox});\
20         node[\"natural\"~\"peak|waterfall|cave_entrance|beach|hot_spring\"]({bbox});\
21         node[\"amenity\"~\"restaurant|fast_food|cafe|bar|pub|biergarten|ice_cream|food_court|\
22         museum|theatre|cinema|arts_centre|library|place_of_worship|spa|swimming_pool\"]({bbox});\
23         node[\"leisure\"~\"park|nature_reserve|garden|sports_centre|fitness_centre\"]({bbox});\
24         node[\"shop\"~\"bakery|deli|chocolate|wine|cheese|mall|department_store\"]({bbox});\
25         );out;"
26    )
27}
28
29/// Extract a `south,west,north,east` Overpass bbox from an isochrone polygon.
30/// Internal coordinate convention: x = lat, y = lon.
31fn bbox_from_polygon(polygon: &Polygon<f64>) -> String {
32    let mut min_lat = f64::MAX;
33    let mut max_lat = f64::MIN;
34    let mut min_lon = f64::MAX;
35    let mut max_lon = f64::MIN;
36    for coord in polygon.exterior().coords() {
37        min_lat = min_lat.min(coord.x);
38        max_lat = max_lat.max(coord.x);
39        min_lon = min_lon.min(coord.y);
40        max_lon = max_lon.max(coord.y);
41    }
42    format!("{},{},{},{}", min_lat, min_lon, max_lat, max_lon)
43}
44
45/// Parse a GeoJSON geometry string (as produced by `polygon_to_geojson_string`)
46/// back into a `geo::Polygon<f64>` using the library's internal x=lat, y=lon convention.
47pub fn parse_isochrone(geojson_str: &str) -> Result<Polygon<f64>, OsmGraphError> {
48    let gj: geojson::GeoJson = geojson_str
49        .parse()
50        .map_err(|_| OsmGraphError::InvalidInput("invalid GeoJSON".into()))?;
51    let rings = match gj {
52        geojson::GeoJson::Geometry(geom) => match geom.value {
53            geojson::Value::Polygon(rings) => rings,
54            _ => return Err(OsmGraphError::InvalidInput("expected Polygon geometry".into())),
55        },
56        _ => return Err(OsmGraphError::InvalidInput("expected a GeoJSON Geometry, not a Feature or FeatureCollection".into())),
57    };
58    // GeoJSON coords are [lon, lat]; internal convention is x=lat, y=lon.
59    let exterior: Vec<Coord<f64>> = rings[0]
60        .iter()
61        .map(|c| Coord { x: c[1], y: c[0] })
62        .collect();
63    Ok(Polygon::new(LineString::from(exterior), vec![]))
64}
65
66pub async fn fetch_pois_within(polygon: &Polygon<f64>) -> Result<Vec<Poi>, OsmGraphError> {
67    let bbox = bbox_from_polygon(polygon);
68    let query = create_poi_query(&bbox);
69
70    let xml = if let Some(cached) = cache::check_xml_cache(&query)? {
71        cached
72    } else if let Some(disk) = cache::check_disk_xml_cache(&query) {
73        cache::insert_into_xml_cache(query.clone(), disk.clone())?;
74        disk
75    } else {
76        let fetched = overpass::make_request("https://overpass-api.de/api/interpreter", &query).await?;
77        cache::write_disk_xml_cache(&query, &fetched);
78        cache::insert_into_xml_cache(query.clone(), fetched.clone())?;
79        fetched
80    };
81
82    let data: XmlData = quick_xml::de::from_str(&xml)?;
83
84    let pois = data
85        .nodes
86        .into_iter()
87        .filter(|n| polygon.contains(&Point::new(n.lat, n.lon)))
88        .map(|n| Poi {
89            id: n.id,
90            lat: n.lat,
91            lon: n.lon,
92            tags: n.tags.into_iter().map(|t| (t.key, t.value)).collect(),
93        })
94        .collect();
95
96    Ok(pois)
97}
98
99pub fn pois_to_geojson(pois: &[Poi]) -> String {
100    let features: Vec<geojson::Feature> = pois
101        .iter()
102        .map(|poi| {
103            // GeoJSON spec: [longitude, latitude]
104            let geometry = geojson::Geometry::new(geojson::Value::Point(vec![poi.lon, poi.lat]));
105            let props: geojson::JsonObject = poi
106                .tags
107                .iter()
108                .map(|(k, v)| (k.clone(), geojson::JsonValue::String(v.clone())))
109                .collect();
110            geojson::Feature {
111                geometry: Some(geometry),
112                properties: Some(props),
113                ..Default::default()
114            }
115        })
116        .collect();
117
118    geojson::GeoJson::FeatureCollection(geojson::FeatureCollection {
119        features,
120        bbox: None,
121        foreign_members: None,
122    })
123    .to_string()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_parse_isochrone_valid() {
132        // GeoJSON uses [lon, lat]; internal convention is x=lat, y=lon
133        let geojson = r#"{"type":"Polygon","coordinates":[[[11.0,48.0],[11.1,48.0],[11.05,48.1],[11.0,48.0]]]}"#;
134        let polygon = parse_isochrone(geojson).unwrap();
135        let first = polygon.exterior().coords().next().unwrap();
136        // [11.0, 48.0] → x=48.0 (lat), y=11.0 (lon)
137        assert!((first.x - 48.0).abs() < 1e-9, "x should be lat (48.0), got {}", first.x);
138        assert!((first.y - 11.0).abs() < 1e-9, "y should be lon (11.0), got {}", first.y);
139    }
140
141    #[test]
142    fn test_parse_isochrone_invalid_json() {
143        let result = parse_isochrone("not valid json");
144        assert!(matches!(result, Err(crate::error::OsmGraphError::InvalidInput(_))));
145    }
146
147    #[test]
148    fn test_parse_isochrone_wrong_geometry_type() {
149        let geojson = r#"{"type":"Point","coordinates":[11.0,48.0]}"#;
150        let result = parse_isochrone(geojson);
151        assert!(matches!(result, Err(crate::error::OsmGraphError::InvalidInput(_))));
152    }
153
154    #[test]
155    fn test_pois_to_geojson_empty() {
156        let json = pois_to_geojson(&[]);
157        let gj: geojson::GeoJson = json.parse().unwrap();
158        if let geojson::GeoJson::FeatureCollection(fc) = gj {
159            assert_eq!(fc.features.len(), 0);
160        } else {
161            panic!("expected FeatureCollection");
162        }
163    }
164
165    #[test]
166    fn test_pois_to_geojson_coordinate_order() {
167        // GeoJSON spec: coordinates must be [longitude, latitude]
168        let poi = Poi { id: 1, lat: 48.0, lon: 11.0, tags: HashMap::new() };
169        let json = pois_to_geojson(&[poi]);
170        let gj: geojson::GeoJson = json.parse().unwrap();
171        if let geojson::GeoJson::FeatureCollection(fc) = gj {
172            let geom = fc.features[0].geometry.as_ref().unwrap();
173            if let geojson::Value::Point(coords) = &geom.value {
174                assert!((coords[0] - 11.0).abs() < 1e-9, "first coord should be lon");
175                assert!((coords[1] - 48.0).abs() < 1e-9, "second coord should be lat");
176            } else {
177                panic!("expected Point geometry");
178            }
179        } else {
180            panic!("expected FeatureCollection");
181        }
182    }
183
184    #[test]
185    fn test_pois_to_geojson_tags_as_properties() {
186        let mut tags = HashMap::new();
187        tags.insert("tourism".to_string(), "museum".to_string());
188        let poi = Poi { id: 1, lat: 48.0, lon: 11.0, tags };
189        let json = pois_to_geojson(&[poi]);
190        let gj: geojson::GeoJson = json.parse().unwrap();
191        if let geojson::GeoJson::FeatureCollection(fc) = gj {
192            let props = fc.features[0].properties.as_ref().unwrap();
193            assert_eq!(props["tourism"], geojson::JsonValue::String("museum".to_string()));
194        } else {
195            panic!("expected FeatureCollection");
196        }
197    }
198}