Skip to main content

spatial_narrative/io/
geojson.rs

1//! GeoJSON format import/export.
2
3use super::format::Format;
4use crate::core::{
5    EventBuilder, Location, Narrative, NarrativeBuilder, SourceRef, SourceType, Timestamp,
6};
7use crate::{Error, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::io::{Read, Write};
11
12/// GeoJSON format handler.
13///
14/// This format handler can import and export narratives in GeoJSON format,
15/// storing events as Features with Point geometries. Temporal and metadata
16/// information is stored in feature properties.
17///
18/// # Example
19///
20/// ```rust
21/// use spatial_narrative::io::{GeoJsonFormat, Format};
22/// use spatial_narrative::prelude::*;
23///
24/// let format = GeoJsonFormat::default();
25///
26/// // Import from GeoJSON
27/// let geojson = r#"{
28///   "type": "FeatureCollection",
29///   "features": [
30///     {
31///       "type": "Feature",
32///       "geometry": {
33///         "type": "Point",
34///         "coordinates": [-74.006, 40.7128]
35///       },
36///       "properties": {
37///         "text": "Something happened",
38///         "timestamp": "2024-01-15T14:30:00Z"
39///       }
40///     }
41///   ]
42/// }"#;
43///
44/// let narrative = format.import_str(geojson).unwrap();
45/// assert_eq!(narrative.events().len(), 1);
46/// ```
47#[derive(Debug, Clone, Default)]
48pub struct GeoJsonFormat {
49    /// Options for import/export behavior
50    pub options: GeoJsonOptions,
51}
52
53/// Configuration options for GeoJSON import/export.
54#[derive(Debug, Clone)]
55pub struct GeoJsonOptions {
56    /// Whether to include event IDs in exported GeoJSON
57    pub include_ids: bool,
58
59    /// Whether to include tags in exported GeoJSON
60    pub include_tags: bool,
61
62    /// Whether to include source references in exported GeoJSON
63    pub include_sources: bool,
64
65    /// Property name for timestamp field
66    pub timestamp_property: String,
67
68    /// Property name for text/description field
69    pub text_property: String,
70}
71
72impl Default for GeoJsonOptions {
73    fn default() -> Self {
74        Self {
75            include_ids: true,
76            include_tags: true,
77            include_sources: true,
78            timestamp_property: "timestamp".to_string(),
79            text_property: "text".to_string(),
80        }
81    }
82}
83
84impl GeoJsonFormat {
85    /// Create a new GeoJSON format handler with default options.
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Create a new GeoJSON format handler with custom options.
91    pub fn with_options(options: GeoJsonOptions) -> Self {
92        Self { options }
93    }
94}
95
96/// Internal structure for GeoJSON FeatureCollection
97#[derive(Debug, Serialize, Deserialize)]
98struct FeatureCollection {
99    #[serde(rename = "type")]
100    type_: String,
101    features: Vec<Feature>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    properties: Option<Map<String, Value>>,
104}
105
106/// Internal structure for GeoJSON Feature
107#[derive(Debug, Serialize, Deserialize)]
108struct Feature {
109    #[serde(rename = "type")]
110    type_: String,
111    geometry: Geometry,
112    properties: Map<String, Value>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    id: Option<Value>,
115}
116
117/// Internal structure for GeoJSON Geometry
118#[derive(Debug, Serialize, Deserialize)]
119struct Geometry {
120    #[serde(rename = "type")]
121    type_: String,
122    coordinates: Vec<f64>,
123}
124
125impl Format for GeoJsonFormat {
126    fn import<R: Read>(&self, reader: R) -> Result<Narrative> {
127        let fc: FeatureCollection = serde_json::from_reader(reader)?;
128
129        if fc.type_ != "FeatureCollection" {
130            return Err(Error::InvalidFormat(
131                "expected GeoJSON FeatureCollection".to_string(),
132            ));
133        }
134
135        let mut builder = NarrativeBuilder::new();
136
137        // Extract narrative-level metadata from FeatureCollection properties
138        if let Some(props) = fc.properties {
139            if let Some(title) = props.get("title").and_then(|v| v.as_str()) {
140                builder = builder.title(title);
141            }
142            if let Some(desc) = props.get("description").and_then(|v| v.as_str()) {
143                builder = builder.description(desc);
144            }
145        }
146
147        // Convert each feature to an event
148        for feature in fc.features {
149            if feature.geometry.type_ != "Point" {
150                continue; // Skip non-point geometries
151            }
152
153            let coords = &feature.geometry.coordinates;
154            if coords.len() < 2 {
155                continue; // Invalid coordinates
156            }
157
158            let lon = coords[0];
159            let lat = coords[1];
160            let mut location = Location::new(lat, lon);
161            if let Some(elev) = coords.get(2).copied() {
162                location.elevation = Some(elev);
163            }
164
165            let props = &feature.properties;
166
167            // Extract timestamp
168            let timestamp = if let Some(ts_str) = props
169                .get(&self.options.timestamp_property)
170                .and_then(|v| v.as_str())
171            {
172                Timestamp::parse(ts_str)
173                    .map_err(|e| Error::InvalidFormat(format!("invalid timestamp: {}", e)))?
174            } else {
175                Timestamp::now() // Default to current time if not specified
176            };
177
178            // Build the event
179            let mut event_builder = EventBuilder::new().location(location).timestamp(timestamp);
180
181            // Extract text/description
182            if let Some(text) = props
183                .get(&self.options.text_property)
184                .and_then(|v| v.as_str())
185            {
186                event_builder = event_builder.text(text);
187            }
188
189            // Extract tags
190            if let Some(tags) = props.get("tags").and_then(|v| v.as_array()) {
191                for tag in tags {
192                    if let Some(tag_str) = tag.as_str() {
193                        event_builder = event_builder.tag(tag_str);
194                    }
195                }
196            }
197
198            // Extract source
199            if let Some(source_obj) = props.get("source").and_then(|v| v.as_object()) {
200                let source_type = source_obj
201                    .get("type")
202                    .and_then(|v| v.as_str())
203                    .and_then(|s| match s.to_lowercase().as_str() {
204                        "article" => Some(SourceType::Article),
205                        "report" => Some(SourceType::Report),
206                        "witness" => Some(SourceType::Witness),
207                        "sensor" => Some(SourceType::Sensor),
208                        _ => None,
209                    })
210                    .unwrap_or(SourceType::Article);
211
212                let mut source = SourceRef::new(source_type);
213                if let Some(url) = source_obj.get("url").and_then(|v| v.as_str()) {
214                    source.url = Some(url.to_string());
215                }
216                if let Some(title) = source_obj.get("title").and_then(|v| v.as_str()) {
217                    source.title = Some(title.to_string());
218                }
219                event_builder = event_builder.source(source);
220            }
221
222            let event = event_builder.build();
223            builder = builder.event(event);
224        }
225
226        Ok(builder.build())
227    }
228
229    fn export<W: Write>(&self, narrative: &Narrative, mut writer: W) -> Result<()> {
230        let mut features = Vec::new();
231
232        for event in narrative.events() {
233            let loc = &event.location;
234            let coords = if let Some(elev) = loc.elevation {
235                vec![loc.lon, loc.lat, elev]
236            } else {
237                vec![loc.lon, loc.lat]
238            };
239
240            let geometry = Geometry {
241                type_: "Point".to_string(),
242                coordinates: coords,
243            };
244
245            let mut properties = Map::new();
246
247            // Add timestamp
248            properties.insert(
249                self.options.timestamp_property.clone(),
250                Value::String(event.timestamp.to_rfc3339()),
251            );
252
253            // Add text if present
254            properties.insert(
255                self.options.text_property.clone(),
256                Value::String(event.text.clone()),
257            );
258
259            // Add tags if enabled and present
260            if self.options.include_tags && !event.tags.is_empty() {
261                let tags: Vec<Value> = event
262                    .tags
263                    .iter()
264                    .map(|t| Value::String(t.clone()))
265                    .collect();
266                properties.insert("tags".to_string(), Value::Array(tags));
267            }
268
269            // Add source if enabled and present
270            if self.options.include_sources && !event.sources.is_empty() {
271                let source = &event.sources[0]; // Use first source
272                let mut source_obj = Map::new();
273                source_obj.insert(
274                    "type".to_string(),
275                    Value::String(source.source_type.to_string()),
276                );
277                if let Some(url) = &source.url {
278                    source_obj.insert("url".to_string(), Value::String(url.clone()));
279                }
280                if let Some(title) = &source.title {
281                    source_obj.insert("title".to_string(), Value::String(title.clone()));
282                }
283                properties.insert("source".to_string(), Value::Object(source_obj));
284            }
285
286            let feature = Feature {
287                type_: "Feature".to_string(),
288                geometry,
289                properties,
290                id: if self.options.include_ids {
291                    Some(Value::String(event.id.to_string()))
292                } else {
293                    None
294                },
295            };
296
297            features.push(feature);
298        }
299
300        // Add narrative-level metadata
301        let mut fc_properties = Map::new();
302        fc_properties.insert("title".to_string(), Value::String(narrative.title.clone()));
303        if let Some(desc) = &narrative.metadata.description {
304            fc_properties.insert("description".to_string(), Value::String(desc.clone()));
305        }
306
307        let fc = FeatureCollection {
308            type_: "FeatureCollection".to_string(),
309            features,
310            properties: if fc_properties.is_empty() {
311                None
312            } else {
313                Some(fc_properties)
314            },
315        };
316
317        serde_json::to_writer_pretty(&mut writer, &fc)?;
318        Ok(())
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::core::Event;
326
327    #[test]
328    fn test_geojson_import_basic() {
329        let geojson = r#"{
330            "type": "FeatureCollection",
331            "features": [
332                {
333                    "type": "Feature",
334                    "geometry": {
335                        "type": "Point",
336                        "coordinates": [-74.006, 40.7128]
337                    },
338                    "properties": {
339                        "text": "Event at NYC",
340                        "timestamp": "2024-01-15T14:30:00Z"
341                    }
342                }
343            ]
344        }"#;
345
346        let format = GeoJsonFormat::new();
347        let narrative = format.import_str(geojson).unwrap();
348
349        assert_eq!(narrative.events().len(), 1);
350        let event = &narrative.events()[0];
351        assert_eq!(event.location.lat, 40.7128);
352        assert_eq!(event.location.lon, -74.006);
353        assert_eq!(event.text.as_str(), "Event at NYC");
354    }
355
356    #[test]
357    fn test_geojson_roundtrip() {
358        let event = Event::builder()
359            .location(Location::new(40.7128, -74.006))
360            .timestamp(Timestamp::parse("2024-01-15T14:30:00Z").unwrap())
361            .text("Test event")
362            .tag("test")
363            .build();
364
365        let narrative = Narrative::builder()
366            .title("Test Narrative")
367            .event(event)
368            .build();
369
370        let format = GeoJsonFormat::new();
371        let exported = format.export_str(&narrative).unwrap();
372        let imported = format.import_str(&exported).unwrap();
373
374        assert_eq!(imported.events().len(), 1);
375        assert_eq!(imported.title, "Test Narrative");
376    }
377
378    #[test]
379    fn test_geojson_with_elevation() {
380        let geojson = r#"{
381            "type": "FeatureCollection",
382            "features": [
383                {
384                    "type": "Feature",
385                    "geometry": {
386                        "type": "Point",
387                        "coordinates": [-122.4194, 37.7749, 100.5]
388                    },
389                    "properties": {
390                        "timestamp": "2024-01-15T14:30:00Z"
391                    }
392                }
393            ]
394        }"#;
395
396        let format = GeoJsonFormat::new();
397        let narrative = format.import_str(geojson).unwrap();
398
399        let event = &narrative.events()[0];
400        assert_eq!(event.location.elevation, Some(100.5));
401    }
402}