Skip to main content

spatial_narrative/core/
bounds.rs

1//! Geographic and temporal bounds for filtering and queries.
2
3use chrono::{Datelike, Duration, TimeZone};
4use serde::{Deserialize, Serialize};
5
6use crate::core::{Location, Timestamp};
7
8/// Geographic bounding box.
9///
10/// Represents a rectangular region defined by minimum and maximum
11/// latitude and longitude values.
12///
13/// # Examples
14///
15/// ```
16/// use spatial_narrative::core::{GeoBounds, Location};
17///
18/// // Create bounds for the San Francisco Bay Area
19/// let bay_area = GeoBounds::new(37.0, -123.0, 38.5, -121.5);
20///
21/// // Check if a location is within bounds
22/// let sf = Location::new(37.7749, -122.4194);
23/// assert!(bay_area.contains(&sf));
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
26pub struct GeoBounds {
27    /// Minimum latitude (south).
28    pub min_lat: f64,
29    /// Minimum longitude (west).
30    pub min_lon: f64,
31    /// Maximum latitude (north).
32    pub max_lat: f64,
33    /// Maximum longitude (east).
34    pub max_lon: f64,
35}
36
37impl GeoBounds {
38    /// Creates a new bounding box.
39    ///
40    /// # Arguments
41    ///
42    /// * `min_lat` - Southern boundary
43    /// * `min_lon` - Western boundary
44    /// * `max_lat` - Northern boundary
45    /// * `max_lon` - Eastern boundary
46    pub fn new(min_lat: f64, min_lon: f64, max_lat: f64, max_lon: f64) -> Self {
47        Self {
48            min_lat,
49            min_lon,
50            max_lat,
51            max_lon,
52        }
53    }
54
55    /// Creates bounds from two corner locations.
56    pub fn from_corners(sw: &Location, ne: &Location) -> Self {
57        Self::new(sw.lat, sw.lon, ne.lat, ne.lon)
58    }
59
60    /// Creates bounds that contain all given locations.
61    pub fn from_locations<'a>(locations: impl IntoIterator<Item = &'a Location>) -> Option<Self> {
62        let mut iter = locations.into_iter();
63        let first = iter.next()?;
64
65        let mut bounds = Self::new(first.lat, first.lon, first.lat, first.lon);
66
67        for loc in iter {
68            bounds.expand_to_include(loc);
69        }
70
71        Some(bounds)
72    }
73
74    /// Creates bounds centered on a point with given radius in degrees.
75    pub fn from_center_degrees(center: &Location, lat_radius: f64, lon_radius: f64) -> Self {
76        Self::new(
77            center.lat - lat_radius,
78            center.lon - lon_radius,
79            center.lat + lat_radius,
80            center.lon + lon_radius,
81        )
82    }
83
84    /// Checks if a location is within these bounds.
85    pub fn contains(&self, location: &Location) -> bool {
86        location.lat >= self.min_lat
87            && location.lat <= self.max_lat
88            && location.lon >= self.min_lon
89            && location.lon <= self.max_lon
90    }
91
92    /// Checks if these bounds intersect with other bounds.
93    pub fn intersects(&self, other: &GeoBounds) -> bool {
94        self.min_lat <= other.max_lat
95            && self.max_lat >= other.min_lat
96            && self.min_lon <= other.max_lon
97            && self.max_lon >= other.min_lon
98    }
99
100    /// Returns the intersection of two bounds, if any.
101    pub fn intersection(&self, other: &GeoBounds) -> Option<GeoBounds> {
102        if !self.intersects(other) {
103            return None;
104        }
105
106        Some(GeoBounds::new(
107            self.min_lat.max(other.min_lat),
108            self.min_lon.max(other.min_lon),
109            self.max_lat.min(other.max_lat),
110            self.max_lon.min(other.max_lon),
111        ))
112    }
113
114    /// Returns bounds that contain both this and other bounds.
115    pub fn union(&self, other: &GeoBounds) -> GeoBounds {
116        GeoBounds::new(
117            self.min_lat.min(other.min_lat),
118            self.min_lon.min(other.min_lon),
119            self.max_lat.max(other.max_lat),
120            self.max_lon.max(other.max_lon),
121        )
122    }
123
124    /// Expands bounds to include the given location.
125    pub fn expand_to_include(&mut self, location: &Location) {
126        self.min_lat = self.min_lat.min(location.lat);
127        self.max_lat = self.max_lat.max(location.lat);
128        self.min_lon = self.min_lon.min(location.lon);
129        self.max_lon = self.max_lon.max(location.lon);
130    }
131
132    /// Returns the center of the bounds.
133    pub fn center(&self) -> Location {
134        Location::new(
135            (self.min_lat + self.max_lat) / 2.0,
136            (self.min_lon + self.max_lon) / 2.0,
137        )
138    }
139
140    /// Returns the width in degrees (longitude span).
141    pub fn width(&self) -> f64 {
142        self.max_lon - self.min_lon
143    }
144
145    /// Returns the height in degrees (latitude span).
146    pub fn height(&self) -> f64 {
147        self.max_lat - self.min_lat
148    }
149
150    /// Returns the southwest corner.
151    pub fn southwest(&self) -> Location {
152        Location::new(self.min_lat, self.min_lon)
153    }
154
155    /// Returns the northeast corner.
156    pub fn northeast(&self) -> Location {
157        Location::new(self.max_lat, self.max_lon)
158    }
159
160    /// Returns the northwest corner.
161    pub fn northwest(&self) -> Location {
162        Location::new(self.max_lat, self.min_lon)
163    }
164
165    /// Returns the southeast corner.
166    pub fn southeast(&self) -> Location {
167        Location::new(self.min_lat, self.max_lon)
168    }
169
170    /// Converts to a geo-types Rect.
171    pub fn to_geo_rect(&self) -> geo_types::Rect<f64> {
172        geo_types::Rect::new(
173            geo_types::coord! { x: self.min_lon, y: self.min_lat },
174            geo_types::coord! { x: self.max_lon, y: self.max_lat },
175        )
176    }
177}
178
179impl Default for GeoBounds {
180    fn default() -> Self {
181        // World bounds
182        Self::new(-90.0, -180.0, 90.0, 180.0)
183    }
184}
185
186/// Time range for temporal queries.
187///
188/// Represents a span of time with start and end timestamps.
189///
190/// # Examples
191///
192/// ```
193/// use spatial_narrative::core::{TimeRange, Timestamp};
194///
195/// // Create a range for March 2024
196/// let march = TimeRange::month(2024, 3);
197///
198/// // Check if a timestamp is within range
199/// let ts = Timestamp::parse("2024-03-15T12:00:00Z").unwrap();
200/// assert!(march.contains(&ts));
201/// ```
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct TimeRange {
204    /// Start of the range (inclusive).
205    pub start: Timestamp,
206    /// End of the range (inclusive).
207    pub end: Timestamp,
208}
209
210impl TimeRange {
211    /// Creates a new time range.
212    pub fn new(start: Timestamp, end: Timestamp) -> Self {
213        Self { start, end }
214    }
215
216    /// Creates a time range for a specific year.
217    pub fn year(year: i32) -> Self {
218        let start = Timestamp::parse(&format!("{}", year)).unwrap();
219        let end_dt = chrono::NaiveDate::from_ymd_opt(year, 12, 31)
220            .unwrap()
221            .and_hms_opt(23, 59, 59)
222            .unwrap();
223        let end = Timestamp::new(chrono::Utc.from_utc_datetime(&end_dt));
224        Self::new(start, end)
225    }
226
227    /// Creates a time range for a specific month.
228    pub fn month(year: i32, month: u32) -> Self {
229        let start = Timestamp::parse(&format!("{}-{:02}", year, month)).unwrap();
230
231        // Calculate last day of month
232        let next_month = if month == 12 { 1 } else { month + 1 };
233        let next_year = if month == 12 { year + 1 } else { year };
234        let last_day = chrono::NaiveDate::from_ymd_opt(next_year, next_month, 1)
235            .unwrap()
236            .pred_opt()
237            .unwrap()
238            .day();
239
240        let end_dt = chrono::NaiveDate::from_ymd_opt(year, month, last_day)
241            .unwrap()
242            .and_hms_opt(23, 59, 59)
243            .unwrap();
244        let end = Timestamp::new(chrono::Utc.from_utc_datetime(&end_dt));
245
246        Self::new(start, end)
247    }
248
249    /// Creates a time range for a specific day.
250    pub fn day(year: i32, month: u32, day: u32) -> Self {
251        let start = Timestamp::parse(&format!("{}-{:02}-{:02}", year, month, day)).unwrap();
252        let end_dt = chrono::NaiveDate::from_ymd_opt(year, month, day)
253            .unwrap()
254            .and_hms_opt(23, 59, 59)
255            .unwrap();
256        let end = Timestamp::new(chrono::Utc.from_utc_datetime(&end_dt));
257        Self::new(start, end)
258    }
259
260    /// Creates a time range from now going back by the given duration.
261    pub fn last(duration: Duration) -> Self {
262        let end = Timestamp::now();
263        let start = Timestamp::new(end.datetime - duration);
264        Self::new(start, end)
265    }
266
267    /// Creates a time range from now going forward by the given duration.
268    pub fn next(duration: Duration) -> Self {
269        let start = Timestamp::now();
270        let end = Timestamp::new(start.datetime + duration);
271        Self::new(start, end)
272    }
273
274    /// Checks if a timestamp is within this range.
275    pub fn contains(&self, timestamp: &Timestamp) -> bool {
276        timestamp >= &self.start && timestamp <= &self.end
277    }
278
279    /// Checks if this range overlaps with another.
280    pub fn overlaps(&self, other: &TimeRange) -> bool {
281        self.start <= other.end && self.end >= other.start
282    }
283
284    /// Returns the intersection of two ranges, if any.
285    pub fn intersection(&self, other: &TimeRange) -> Option<TimeRange> {
286        if !self.overlaps(other) {
287            return None;
288        }
289
290        let start = if self.start > other.start {
291            self.start.clone()
292        } else {
293            other.start.clone()
294        };
295
296        let end = if self.end < other.end {
297            self.end.clone()
298        } else {
299            other.end.clone()
300        };
301
302        Some(TimeRange::new(start, end))
303    }
304
305    /// Returns a range that spans both this and another range.
306    pub fn union(&self, other: &TimeRange) -> TimeRange {
307        let start = if self.start < other.start {
308            self.start.clone()
309        } else {
310            other.start.clone()
311        };
312
313        let end = if self.end > other.end {
314            self.end.clone()
315        } else {
316            other.end.clone()
317        };
318
319        TimeRange::new(start, end)
320    }
321
322    /// Returns the duration of this range.
323    pub fn duration(&self) -> Duration {
324        self.end.duration_since(&self.start)
325    }
326
327    /// Splits the range into smaller ranges of the given duration.
328    pub fn split(&self, chunk_duration: Duration) -> Vec<TimeRange> {
329        let mut ranges = Vec::new();
330        let mut current_start = self.start.clone();
331
332        while current_start < self.end {
333            let chunk_end = Timestamp::new(current_start.datetime + chunk_duration);
334            let actual_end = if chunk_end > self.end {
335                self.end.clone()
336            } else {
337                chunk_end
338            };
339
340            ranges.push(TimeRange::new(current_start.clone(), actual_end.clone()));
341            current_start = Timestamp::new(actual_end.datetime + Duration::seconds(1));
342        }
343
344        ranges
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_geobounds_contains() {
354        let bounds = GeoBounds::new(37.0, -123.0, 38.5, -121.5);
355
356        let inside = Location::new(37.7749, -122.4194);
357        let outside = Location::new(35.0, -120.0);
358
359        assert!(bounds.contains(&inside));
360        assert!(!bounds.contains(&outside));
361    }
362
363    #[test]
364    fn test_geobounds_intersects() {
365        let bounds1 = GeoBounds::new(0.0, 0.0, 10.0, 10.0);
366        let bounds2 = GeoBounds::new(5.0, 5.0, 15.0, 15.0);
367        let bounds3 = GeoBounds::new(20.0, 20.0, 30.0, 30.0);
368
369        assert!(bounds1.intersects(&bounds2));
370        assert!(!bounds1.intersects(&bounds3));
371    }
372
373    #[test]
374    fn test_geobounds_from_locations() {
375        let locations = vec![
376            Location::new(10.0, 20.0),
377            Location::new(30.0, 40.0),
378            Location::new(20.0, 30.0),
379        ];
380
381        let bounds = GeoBounds::from_locations(&locations).unwrap();
382        assert_eq!(bounds.min_lat, 10.0);
383        assert_eq!(bounds.max_lat, 30.0);
384        assert_eq!(bounds.min_lon, 20.0);
385        assert_eq!(bounds.max_lon, 40.0);
386    }
387
388    #[test]
389    fn test_geobounds_center() {
390        let bounds = GeoBounds::new(0.0, 0.0, 10.0, 10.0);
391        let center = bounds.center();
392        assert_eq!(center.lat, 5.0);
393        assert_eq!(center.lon, 5.0);
394    }
395
396    #[test]
397    fn test_timerange_year() {
398        let range = TimeRange::year(2024);
399        let inside = Timestamp::parse("2024-06-15T12:00:00Z").unwrap();
400        let outside = Timestamp::parse("2023-06-15T12:00:00Z").unwrap();
401
402        assert!(range.contains(&inside));
403        assert!(!range.contains(&outside));
404    }
405
406    #[test]
407    fn test_timerange_month() {
408        let range = TimeRange::month(2024, 3);
409
410        let inside = Timestamp::parse("2024-03-15T12:00:00Z").unwrap();
411        let outside = Timestamp::parse("2024-04-15T12:00:00Z").unwrap();
412
413        assert!(range.contains(&inside));
414        assert!(!range.contains(&outside));
415    }
416
417    #[test]
418    fn test_timerange_overlaps() {
419        let range1 = TimeRange::month(2024, 3);
420        let range2 = TimeRange::new(
421            Timestamp::parse("2024-03-15T00:00:00Z").unwrap(),
422            Timestamp::parse("2024-04-15T00:00:00Z").unwrap(),
423        );
424        let range3 = TimeRange::month(2024, 5);
425
426        assert!(range1.overlaps(&range2));
427        assert!(!range1.overlaps(&range3));
428    }
429
430    #[test]
431    fn test_timerange_duration() {
432        let range = TimeRange::day(2024, 3, 15);
433        let duration = range.duration();
434        // Should be approximately 24 hours minus 1 second
435        assert!(duration.num_hours() >= 23);
436    }
437}