1use std::collections::HashMap;
10
11use nodedb_spatial::rtree::{RTree, RTreeEntry};
12use nodedb_types::BoundingBox;
13use nodedb_types::geometry::Geometry;
14
15pub struct SpatialIndexManager {
21 indices: HashMap<(String, String), RTree>,
23 doc_to_entry: HashMap<(String, String), u64>,
26 next_id: u64,
28}
29
30impl SpatialIndexManager {
31 pub fn new() -> Self {
32 Self {
33 indices: HashMap::new(),
34 doc_to_entry: HashMap::new(),
35 next_id: 1,
36 }
37 }
38
39 pub fn index_document(
42 &mut self,
43 collection: &str,
44 field: &str,
45 doc_id: &str,
46 geometry: &Geometry,
47 ) {
48 let key = (collection.to_string(), field.to_string());
49 let doc_key = (collection.to_string(), doc_id.to_string());
50
51 if let Some(old_id) = self.doc_to_entry.remove(&doc_key)
53 && let Some(tree) = self.indices.get_mut(&key)
54 {
55 tree.delete(old_id);
56 }
57
58 let bbox = nodedb_types::geometry_bbox(geometry);
59 let entry_id = self.next_id;
60 self.next_id += 1;
61
62 let tree = self.indices.entry(key).or_default();
63 tree.insert(RTreeEntry { id: entry_id, bbox });
64 self.doc_to_entry.insert(doc_key, entry_id);
65 }
66
67 pub fn remove_document(&mut self, collection: &str, field: &str, doc_id: &str) {
69 let key = (collection.to_string(), field.to_string());
70 let doc_key = (collection.to_string(), doc_id.to_string());
71
72 if let Some(entry_id) = self.doc_to_entry.remove(&doc_key)
73 && let Some(tree) = self.indices.get_mut(&key)
74 {
75 tree.delete(entry_id);
76 }
77 }
78
79 pub fn search(&self, collection: &str, field: &str, query: &BoundingBox) -> Vec<&RTreeEntry> {
81 let key = (collection.to_string(), field.to_string());
82 match self.indices.get(&key) {
83 Some(tree) => tree.search(query),
84 None => Vec::new(),
85 }
86 }
87
88 pub fn nearest(
90 &self,
91 collection: &str,
92 field: &str,
93 lng: f64,
94 lat: f64,
95 k: usize,
96 ) -> Vec<nodedb_spatial::rtree::NnResult> {
97 let key = (collection.to_string(), field.to_string());
98 match self.indices.get(&key) {
99 Some(tree) => tree.nearest(lng, lat, k),
100 None => Vec::new(),
101 }
102 }
103
104 pub fn is_empty(&self) -> bool {
107 self.indices.is_empty()
108 }
109
110 pub fn total_entries(&self) -> usize {
111 self.indices.values().map(|t| t.len()).sum()
112 }
113
114 pub fn collection_count(&self) -> usize {
116 self.indices.len()
117 }
118
119 pub fn checkpoint_all(&self) -> Vec<(String, String, Vec<u8>)> {
123 let mut results = Vec::new();
124 for ((collection, field), tree) in &self.indices {
125 match tree.checkpoint_to_bytes() {
126 Ok(bytes) => results.push((collection.clone(), field.clone(), bytes)),
127 Err(e) => {
128 tracing::error!(
129 collection = %collection,
130 field = %field,
131 error = %e,
132 "spatial index checkpoint failed"
133 );
134 }
135 }
136 }
137 results
138 }
139
140 pub fn restore_all(checkpoints: &[(String, String, Vec<u8>)]) -> Self {
144 let mut manager = Self::new();
145 for (collection, field, bytes) in checkpoints {
146 match RTree::from_checkpoint(bytes) {
147 Ok(tree) => {
148 let max_id = tree.entries().iter().map(|e| e.id).max().unwrap_or(0);
155 if max_id >= manager.next_id {
156 manager.next_id = max_id + 1;
157 }
158
159 for entry in tree.entries() {
163 let doc_key = (collection.clone(), format!("__entry_{}", entry.id));
164 manager.doc_to_entry.insert(doc_key, entry.id);
165 }
166
167 manager
168 .indices
169 .insert((collection.clone(), field.clone()), tree);
170 }
171 Err(e) => {
172 tracing::warn!(
173 collection = %collection,
174 field = %field,
175 error = %e,
176 "spatial index restore failed, will rebuild from documents"
177 );
178 }
179 }
180 }
181 manager
182 }
183
184 pub fn rebuild_from_documents(
189 &mut self,
190 collection: &str,
191 field: &str,
192 documents: &[(String, Geometry)],
193 ) {
194 let entries: Vec<RTreeEntry> = documents
195 .iter()
196 .map(|(doc_id, geom)| {
197 let id = self.next_id;
198 self.next_id += 1;
199 let doc_key = (collection.to_string(), doc_id.clone());
200 self.doc_to_entry.insert(doc_key, id);
201 RTreeEntry {
202 id,
203 bbox: nodedb_types::geometry_bbox(geom),
204 }
205 })
206 .collect();
207
208 let tree = RTree::bulk_load(entries);
209 self.indices
210 .insert((collection.to_string(), field.to_string()), tree);
211 }
212}
213
214impl Default for SpatialIndexManager {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn index_and_search() {
226 let mut mgr = SpatialIndexManager::new();
227 mgr.index_document("places", "location", "doc1", &Geometry::point(10.0, 20.0));
228 mgr.index_document("places", "location", "doc2", &Geometry::point(11.0, 21.0));
229 mgr.index_document("places", "location", "doc3", &Geometry::point(50.0, 50.0));
230
231 let results = mgr.search(
232 "places",
233 "location",
234 &BoundingBox::new(9.0, 19.0, 12.0, 22.0),
235 );
236 assert_eq!(results.len(), 2);
237 }
238
239 #[test]
240 fn upsert_replaces_old_entry() {
241 let mut mgr = SpatialIndexManager::new();
242 mgr.index_document("places", "loc", "doc1", &Geometry::point(10.0, 20.0));
243 mgr.index_document("places", "loc", "doc1", &Geometry::point(50.0, 50.0));
244
245 let old = mgr.search("places", "loc", &BoundingBox::new(9.0, 19.0, 12.0, 22.0));
247 assert!(old.is_empty());
248
249 let new = mgr.search("places", "loc", &BoundingBox::new(49.0, 49.0, 51.0, 51.0));
251 assert_eq!(new.len(), 1);
252 }
253
254 #[test]
255 fn remove_document() {
256 let mut mgr = SpatialIndexManager::new();
257 mgr.index_document("places", "loc", "doc1", &Geometry::point(10.0, 20.0));
258 mgr.remove_document("places", "loc", "doc1");
259
260 let results = mgr.search("places", "loc", &BoundingBox::new(0.0, 0.0, 180.0, 90.0));
261 assert!(results.is_empty());
262 }
263
264 #[test]
265 fn checkpoint_restore_roundtrip() {
266 let mut mgr = SpatialIndexManager::new();
267 for i in 0..50 {
268 mgr.index_document(
269 "buildings",
270 "geom",
271 &format!("b{i}"),
272 &Geometry::point(i as f64 * 0.5, i as f64 * 0.3),
273 );
274 }
275
276 let checkpoints = mgr.checkpoint_all();
277 assert_eq!(checkpoints.len(), 1);
278
279 let restored = SpatialIndexManager::restore_all(&checkpoints);
280 assert_eq!(restored.total_entries(), 50);
281
282 let results = restored.search(
283 "buildings",
284 "geom",
285 &BoundingBox::new(-180.0, -90.0, 180.0, 90.0),
286 );
287 assert_eq!(results.len(), 50);
288 }
289
290 #[test]
291 fn nearest_neighbor() {
292 let mut mgr = SpatialIndexManager::new();
293 mgr.index_document("pois", "loc", "a", &Geometry::point(0.0, 0.0));
294 mgr.index_document("pois", "loc", "b", &Geometry::point(10.0, 10.0));
295 mgr.index_document("pois", "loc", "c", &Geometry::point(1.0, 1.0));
296
297 let nn = mgr.nearest("pois", "loc", 0.5, 0.5, 2);
298 assert_eq!(nn.len(), 2);
299 }
300
301 #[test]
302 fn rebuild_from_documents() {
303 let mut mgr = SpatialIndexManager::new();
304 let docs: Vec<(String, Geometry)> = (0..100)
305 .map(|i| {
306 (
307 format!("d{i}"),
308 Geometry::point(i as f64 * 0.1, i as f64 * 0.1),
309 )
310 })
311 .collect();
312 mgr.rebuild_from_documents("col", "geom", &docs);
313 assert_eq!(mgr.total_entries(), 100);
314 }
315
316 #[test]
317 fn multiple_collections() {
318 let mut mgr = SpatialIndexManager::new();
319 mgr.index_document("a", "loc", "d1", &Geometry::point(0.0, 0.0));
320 mgr.index_document("b", "loc", "d1", &Geometry::point(50.0, 50.0));
321
322 assert_eq!(mgr.collection_count(), 2);
323
324 let a_results = mgr.search("a", "loc", &BoundingBox::new(-1.0, -1.0, 1.0, 1.0));
325 assert_eq!(a_results.len(), 1);
326
327 let b_results = mgr.search("b", "loc", &BoundingBox::new(-1.0, -1.0, 1.0, 1.0));
328 assert!(b_results.is_empty());
329 }
330}