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
29fn 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
45pub 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 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 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 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 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 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}