Skip to main content

spatial_narrative/index/
spatiotemporal.rs

1//! Combined spatiotemporal indexing.
2//!
3//! This module provides indexing that combines both spatial and temporal
4//! dimensions for efficient queries on events that have both location
5//! and time components.
6//!
7//! # Example
8//!
9//! ```rust
10//! use spatial_narrative::index::SpatiotemporalIndex;
11//! use spatial_narrative::core::{Event, Location, Timestamp, GeoBounds, TimeRange};
12//!
13//! // Create an index for events
14//! let mut index = SpatiotemporalIndex::new();
15//!
16//! // Add events
17//! index.insert(
18//!     "Event 1",
19//!     &Location::new(40.7128, -74.0060),
20//!     &Timestamp::now(),
21//! );
22//!
23//! // Query by both space and time
24//! let bounds = GeoBounds::new(39.0, -75.0, 42.0, -73.0);
25//! let range = TimeRange::year(2024);
26//! let results = index.query(&bounds, &range);
27//! ```
28
29use super::{SpatialIndex, TemporalIndex};
30use crate::core::{GeoBounds, Location, TimeRange, Timestamp};
31
32/// Combined spatiotemporal index for efficient space-time queries.
33///
34/// This index maintains both a spatial R-tree and a temporal B-tree,
35/// enabling queries that filter by both dimensions efficiently.
36#[derive(Debug)]
37pub struct SpatiotemporalIndex<T> {
38    spatial: SpatialIndex<usize>,
39    temporal: TemporalIndex<usize>,
40    items: Vec<T>,
41    locations: Vec<Location>,
42    timestamps: Vec<Timestamp>,
43}
44
45impl<T: Clone> SpatiotemporalIndex<T> {
46    /// Create an empty spatiotemporal index.
47    pub fn new() -> Self {
48        Self {
49            spatial: SpatialIndex::new(),
50            temporal: TemporalIndex::new(),
51            items: Vec::new(),
52            locations: Vec::new(),
53            timestamps: Vec::new(),
54        }
55    }
56
57    /// Create an index from items with location and timestamp extractors.
58    pub fn from_iter<I, L, Ts>(iter: I, location_fn: L, timestamp_fn: Ts) -> Self
59    where
60        I: IntoIterator<Item = T>,
61        L: Fn(&T) -> &Location,
62        Ts: Fn(&T) -> &Timestamp,
63    {
64        let mut index = Self::new();
65        for item in iter {
66            let loc = location_fn(&item).clone();
67            let ts = timestamp_fn(&item).clone();
68            index.insert(item, &loc, &ts);
69        }
70        index
71    }
72
73    /// Insert an item with its location and timestamp.
74    pub fn insert(&mut self, item: T, location: &Location, timestamp: &Timestamp) {
75        let idx = self.items.len();
76
77        self.items.push(item);
78        self.locations.push(location.clone());
79        self.timestamps.push(timestamp.clone());
80
81        self.spatial.insert(idx, location);
82        self.temporal.insert(idx, timestamp);
83    }
84
85    /// Query items within both spatial bounds and time range.
86    pub fn query(&self, bounds: &GeoBounds, range: &TimeRange) -> Vec<&T> {
87        // Get spatial candidates
88        let spatial_indices: std::collections::HashSet<usize> = self
89            .spatial
90            .query_bounds(bounds)
91            .into_iter()
92            .copied()
93            .collect();
94
95        // Get temporal candidates
96        let temporal_indices: std::collections::HashSet<usize> = self
97            .temporal
98            .query_range(range)
99            .into_iter()
100            .copied()
101            .collect();
102
103        // Intersect the results
104        spatial_indices
105            .intersection(&temporal_indices)
106            .map(|&i| &self.items[i])
107            .collect()
108    }
109
110    /// Query items within spatial bounds only.
111    pub fn query_spatial(&self, bounds: &GeoBounds) -> Vec<&T> {
112        self.spatial
113            .query_bounds(bounds)
114            .into_iter()
115            .map(|&i| &self.items[i])
116            .collect()
117    }
118
119    /// Query items within time range only.
120    pub fn query_temporal(&self, range: &TimeRange) -> Vec<&T> {
121        self.temporal
122            .query_range(range)
123            .into_iter()
124            .map(|&i| &self.items[i])
125            .collect()
126    }
127
128    /// Find k nearest items to a point within a time range.
129    pub fn nearest_in_range(&self, lat: f64, lon: f64, k: usize, range: &TimeRange) -> Vec<&T> {
130        // Get temporal candidates first
131        let temporal_indices: std::collections::HashSet<usize> = self
132            .temporal
133            .query_range(range)
134            .into_iter()
135            .copied()
136            .collect();
137
138        // Get spatial nearest and filter by temporal
139        self.spatial
140            .nearest(lat, lon, k * 2) // Get more candidates to account for filtering
141            .into_iter()
142            .filter(|&i| temporal_indices.contains(i))
143            .take(k)
144            .map(|&i| &self.items[i])
145            .collect()
146    }
147
148    /// Returns the number of indexed items.
149    pub fn len(&self) -> usize {
150        self.items.len()
151    }
152
153    /// Returns true if the index is empty.
154    pub fn is_empty(&self) -> bool {
155        self.items.is_empty()
156    }
157
158    /// Get all items in the index.
159    pub fn items(&self) -> &[T] {
160        &self.items
161    }
162
163    /// Get the geographic bounds of all indexed items.
164    pub fn bounds(&self) -> Option<GeoBounds> {
165        if self.locations.is_empty() {
166            return None;
167        }
168        GeoBounds::from_locations(&self.locations)
169    }
170
171    /// Get the time range of all indexed items.
172    pub fn time_range(&self) -> Option<TimeRange> {
173        self.temporal.time_range()
174    }
175}
176
177impl<T: Clone> Default for SpatiotemporalIndex<T> {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183/// Specification for generating a heatmap grid.
184#[derive(Debug, Clone)]
185pub struct GridSpec {
186    /// Number of cells in latitude direction
187    pub lat_cells: usize,
188    /// Number of cells in longitude direction
189    pub lon_cells: usize,
190    /// Bounds of the grid
191    pub bounds: GeoBounds,
192}
193
194impl GridSpec {
195    /// Create a new grid specification.
196    pub fn new(bounds: GeoBounds, lat_cells: usize, lon_cells: usize) -> Self {
197        Self {
198            bounds,
199            lat_cells,
200            lon_cells,
201        }
202    }
203
204    /// Create a grid with approximately square cells.
205    pub fn square_cells(bounds: GeoBounds, target_cells: usize) -> Self {
206        let lat_range = bounds.max_lat - bounds.min_lat;
207        let lon_range = bounds.max_lon - bounds.min_lon;
208        let aspect = lon_range / lat_range;
209
210        let lat_cells = ((target_cells as f64 / aspect).sqrt() as usize).max(1);
211        let lon_cells = ((target_cells as f64 * aspect).sqrt() as usize).max(1);
212
213        Self {
214            bounds,
215            lat_cells,
216            lon_cells,
217        }
218    }
219
220    /// Get the cell size in degrees.
221    pub fn cell_size(&self) -> (f64, f64) {
222        let lat_size = (self.bounds.max_lat - self.bounds.min_lat) / self.lat_cells as f64;
223        let lon_size = (self.bounds.max_lon - self.bounds.min_lon) / self.lon_cells as f64;
224        (lat_size, lon_size)
225    }
226}
227
228/// Result of a heatmap computation.
229#[derive(Debug, Clone)]
230pub struct Heatmap {
231    /// The grid specification used
232    pub grid: GridSpec,
233    /// Count of items in each cell (row-major order)
234    pub counts: Vec<usize>,
235    /// Maximum count in any cell
236    pub max_count: usize,
237}
238
239impl Heatmap {
240    /// Get the count at a specific cell.
241    pub fn get(&self, lat_idx: usize, lon_idx: usize) -> usize {
242        if lat_idx >= self.grid.lat_cells || lon_idx >= self.grid.lon_cells {
243            return 0;
244        }
245        self.counts[lat_idx * self.grid.lon_cells + lon_idx]
246    }
247
248    /// Get the normalized value (0.0 to 1.0) at a cell.
249    pub fn get_normalized(&self, lat_idx: usize, lon_idx: usize) -> f64 {
250        if self.max_count == 0 {
251            return 0.0;
252        }
253        self.get(lat_idx, lon_idx) as f64 / self.max_count as f64
254    }
255
256    /// Convert to a 2D vector for easier manipulation.
257    pub fn to_grid(&self) -> Vec<Vec<usize>> {
258        self.counts
259            .chunks(self.grid.lon_cells)
260            .map(|row| row.to_vec())
261            .collect()
262    }
263}
264
265impl<T: Clone> SpatiotemporalIndex<T> {
266    /// Generate a heatmap from the indexed items.
267    pub fn heatmap(&self, grid: GridSpec) -> Heatmap {
268        let mut counts = vec![0usize; grid.lat_cells * grid.lon_cells];
269        let (lat_size, lon_size) = grid.cell_size();
270
271        for loc in &self.locations {
272            if !grid.bounds.contains(loc) {
273                continue;
274            }
275
276            let lat_idx = ((loc.lat - grid.bounds.min_lat) / lat_size) as usize;
277            let lon_idx = ((loc.lon - grid.bounds.min_lon) / lon_size) as usize;
278
279            let lat_idx = lat_idx.min(grid.lat_cells - 1);
280            let lon_idx = lon_idx.min(grid.lon_cells - 1);
281
282            counts[lat_idx * grid.lon_cells + lon_idx] += 1;
283        }
284
285        let max_count = counts.iter().copied().max().unwrap_or(0);
286
287        Heatmap {
288            grid,
289            counts,
290            max_count,
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn make_timestamp(day: u32) -> Timestamp {
300        Timestamp::parse(&format!("2024-01-{:02}T12:00:00Z", day)).unwrap()
301    }
302
303    #[test]
304    fn test_spatiotemporal_index_new() {
305        let index: SpatiotemporalIndex<String> = SpatiotemporalIndex::new();
306        assert!(index.is_empty());
307    }
308
309    #[test]
310    fn test_spatiotemporal_index_insert() {
311        let mut index = SpatiotemporalIndex::new();
312        index.insert("NYC", &Location::new(40.7128, -74.0060), &make_timestamp(1));
313        index.insert("LA", &Location::new(34.0522, -118.2437), &make_timestamp(5));
314
315        assert_eq!(index.len(), 2);
316    }
317
318    #[test]
319    fn test_spatiotemporal_query() {
320        let mut index = SpatiotemporalIndex::new();
321        index.insert(
322            "NYC Jan 1",
323            &Location::new(40.7128, -74.0060),
324            &make_timestamp(1),
325        );
326        index.insert(
327            "NYC Jan 15",
328            &Location::new(40.7128, -74.0060),
329            &make_timestamp(15),
330        );
331        index.insert(
332            "LA Jan 1",
333            &Location::new(34.0522, -118.2437),
334            &make_timestamp(1),
335        );
336        index.insert(
337            "LA Jan 15",
338            &Location::new(34.0522, -118.2437),
339            &make_timestamp(15),
340        );
341
342        // Query: East Coast in first week
343        let bounds = GeoBounds::new(35.0, -80.0, 45.0, -70.0);
344        let range = TimeRange::new(make_timestamp(1), make_timestamp(7));
345
346        let results = index.query(&bounds, &range);
347        assert_eq!(results.len(), 1);
348        assert_eq!(*results[0], "NYC Jan 1");
349    }
350
351    #[test]
352    fn test_heatmap_generation() {
353        let mut index: SpatiotemporalIndex<&str> = SpatiotemporalIndex::new();
354
355        // Add clustered events
356        for i in 0..10 {
357            index.insert(
358                "NYC area",
359                &Location::new(40.7 + (i as f64 * 0.01), -74.0 + (i as f64 * 0.01)),
360                &make_timestamp(1),
361            );
362        }
363        for i in 0..5 {
364            index.insert(
365                "LA area",
366                &Location::new(34.0 + (i as f64 * 0.01), -118.2 + (i as f64 * 0.01)),
367                &make_timestamp(1),
368            );
369        }
370
371        let bounds = GeoBounds::new(30.0, -125.0, 45.0, -70.0);
372        let grid = GridSpec::new(bounds, 10, 10);
373        let heatmap = index.heatmap(grid);
374
375        assert!(heatmap.max_count > 0);
376    }
377}