Skip to main content

open_geocode/
spatial_index.rs

1//! H3 spatial index over the record store.
2//!
3//! The index maps H3 cells to record references. It stores no coordinates of
4//! its own: point references are record ids and segment references are
5//! `(record id, segment index)`, both resolved against the record store, which
6//! already holds every point and line. Because records are in Hilbert order,
7//! the ids inside one cell are close together and their deltas are tiny.
8//!
9//! Two indexes share one layout: a fine one (H3 resolution 11) over addresses,
10//! places, postcodes and road segments, and a coarse one (resolution 6) over
11//! context points for wide-radius admin lookups.
12//!
13//! ```text
14//! spatial/<name>/index  cell_count u64, block_count u64,
15//!                       then per block of 16 cells: first_cell u64, data_offset u64,
16//!                       then the data length u64
17//! spatial/<name>/data   per cell: varint cell delta (0 for a block's first cell),
18//!                       varint refs length, refs:
19//!                       varint point count, delta-coded record ids,
20//!                       varint segment count, delta-coded segment refs
21//! ```
22
23use std::{cmp::Ordering, collections::HashMap, io::Write, sync::Arc};
24
25use anyhow::{Context, Result, bail};
26use geojson::GeometryValue;
27use h3o::{CellIndex, LatLng, Resolution};
28use serde::Serialize;
29
30use crate::{
31    container::{Bytes, Container, ContainerWriter},
32    extsort::{ExternalSorter, Scratch, SortStats, Spill},
33    memory::MemoryBudget,
34    pack::RecordId,
35    record::{Layer, Record},
36    records::{RecordsReader, dequantize},
37    util::codec::{get_u64, put_u64, read_u64_le},
38};
39
40pub const SPATIAL_VERSION: u32 = 2;
41const FINE: &str = "spatial/fine";
42const CONTEXT: &str = "spatial/context";
43
44const H3_FINE_RESOLUTION: Resolution = Resolution::Eleven;
45const H3_CONTEXT_RESOLUTION: Resolution = Resolution::Six;
46const H3_SEGMENT_SAMPLE_DIVISOR: f64 = 2.0;
47const H3_RADIUS_EXTRA_RING: u32 = 1;
48const H3_MAX_QUERY_K: u32 = 128;
49const EARTH_RADIUS_M: f64 = 6_371_008.8;
50const CELLS_PER_BLOCK: u64 = 16;
51const SEGMENT_INDEX_BITS: u32 = 16;
52const KIND_POINT: u8 = 0;
53const KIND_SEGMENT: u8 = 1;
54
55#[derive(Debug, Clone, Serialize, PartialEq)]
56pub struct PointCandidate {
57    pub record_id: RecordId,
58    pub layer: Layer,
59    pub lon: f64,
60    pub lat: f64,
61    pub distance_m: f64,
62}
63
64#[derive(Debug, Clone, Serialize, PartialEq)]
65pub struct SegmentCandidate {
66    pub record_id: RecordId,
67    pub layer: Layer,
68    pub closest_lon: f64,
69    pub closest_lat: f64,
70    pub distance_m: f64,
71    pub fraction: f64,
72}
73
74/// One (cell, reference) pair produced while records are written.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
76pub struct CellRef {
77    cell: u64,
78    kind: u8,
79    reference: u64,
80}
81
82impl Spill for CellRef {
83    fn encode(&self, out: &mut Vec<u8>) {
84        (self.cell, self.kind, self.reference).encode(out);
85    }
86
87    fn decode(input: &mut &[u8]) -> Result<Self> {
88        let (cell, kind, reference) = <(u64, u8, u64)>::decode(input)?;
89        Ok(Self {
90            cell,
91            kind,
92            reference,
93        })
94    }
95}
96
97/// Index pairs for one record, computed off the write path in parallel.
98#[derive(Debug, Default)]
99pub struct RecordCells {
100    fine: Vec<CellRef>,
101    context: Vec<CellRef>,
102    points: u64,
103    segments: u64,
104}
105
106impl RecordCells {
107    pub fn for_record(record_id: RecordId, record: &Record) -> Result<Self> {
108        let mut cells = Self::default();
109        match record {
110            Record::Street(_) | Record::Interpolation(_) => {
111                let GeometryValue::LineString { coordinates } = &record.geometry().value else {
112                    return Ok(cells);
113                };
114                let positions = coordinates
115                    .iter()
116                    .filter_map(|position| match position.as_slice() {
117                        [lon, lat, ..] if lon.is_finite() && lat.is_finite() => Some([*lon, *lat]),
118                        _ => None,
119                    })
120                    .collect::<Vec<_>>();
121                for (index, pair) in positions.windows(2).enumerate() {
122                    if haversine_m(pair[0][0], pair[0][1], pair[1][0], pair[1][1]) <= f64::EPSILON {
123                        continue;
124                    }
125                    let index = u64::try_from(index)?;
126                    if index >= 1 << SEGMENT_INDEX_BITS {
127                        bail!("line record {} has too many segments to index", record.id());
128                    }
129                    let reference = record_id << SEGMENT_INDEX_BITS | index;
130                    for cell in segment_cells(pair[0], pair[1], H3_FINE_RESOLUTION)? {
131                        cells.fine.push(CellRef {
132                            cell,
133                            kind: KIND_SEGMENT,
134                            reference,
135                        });
136                    }
137                    cells.segments += 1;
138                }
139            }
140            _ => {
141                let Some([lon, lat]) = record.display_point() else {
142                    return Ok(cells);
143                };
144                cells.fine.push(CellRef {
145                    cell: cell_id(lon, lat, H3_FINE_RESOLUTION)?,
146                    kind: KIND_POINT,
147                    reference: record_id,
148                });
149                if record.layer().is_context() {
150                    cells.context.push(CellRef {
151                        cell: cell_id(lon, lat, H3_CONTEXT_RESOLUTION)?,
152                        kind: KIND_POINT,
153                        reference: record_id,
154                    });
155                }
156                cells.points += 1;
157            }
158        }
159        Ok(cells)
160    }
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
164pub struct SpatialCommit {
165    pub point_count: u64,
166    pub segment_count: u64,
167    pub cell_count: u64,
168    pub context_cell_count: u64,
169    pub fine_pairs: SortStats,
170    pub context_pairs: SortStats,
171}
172
173pub struct SpatialIndexWriter {
174    fine: ExternalSorter<CellRef>,
175    context: ExternalSorter<CellRef>,
176    points: u64,
177    segments: u64,
178}
179
180impl SpatialIndexWriter {
181    pub fn new(scratch: &Arc<Scratch>, budget: &Arc<MemoryBudget>, share_bytes: usize) -> Self {
182        Self {
183            fine: ExternalSorter::new(scratch, "spatial-fine", budget, share_bytes),
184            context: ExternalSorter::new(scratch, "spatial-context", budget, share_bytes / 8),
185            points: 0,
186            segments: 0,
187        }
188    }
189
190    pub fn add(&mut self, cells: RecordCells) -> Result<()> {
191        for pair in cells.fine {
192            self.fine.push(pair)?;
193        }
194        for pair in cells.context {
195            self.context.push(pair)?;
196        }
197        self.points += cells.points;
198        self.segments += cells.segments;
199        Ok(())
200    }
201
202    pub fn finish(self, pack: &mut ContainerWriter) -> Result<SpatialCommit> {
203        let fine_pairs = self.fine.stats();
204        let context_pairs = self.context.stats();
205        let cell_count = write_cell_index(pack, FINE, self.fine)?;
206        let context_cell_count = write_cell_index(pack, CONTEXT, self.context)?;
207        Ok(SpatialCommit {
208            point_count: self.points,
209            segment_count: self.segments,
210            cell_count,
211            context_cell_count,
212            fine_pairs,
213            context_pairs,
214        })
215    }
216}
217
218fn write_cell_index(
219    pack: &mut ContainerWriter,
220    name: &str,
221    pairs: ExternalSorter<CellRef>,
222) -> Result<u64> {
223    let mut blocks: Vec<(u64, u64)> = Vec::new();
224    let mut cell_count = 0u64;
225    let mut data_len = 0u64;
226    let mut previous_cell = 0u64;
227    let mut current: Option<u64> = None;
228    let mut points = Vec::new();
229    let mut segments = Vec::new();
230    let mut entry = Vec::new();
231    let mut refs = Vec::new();
232
233    pack.begin(&format!("{name}/data"), SPATIAL_VERSION)?;
234    let mut flush = |pack: &mut ContainerWriter,
235                     cell: u64,
236                     points: &mut Vec<u64>,
237                     segments: &mut Vec<u64>|
238     -> Result<()> {
239        if cell_count % CELLS_PER_BLOCK == 0 {
240            blocks.push((cell, data_len));
241            previous_cell = cell;
242        }
243        refs.clear();
244        put_delta_list(&mut refs, points);
245        put_delta_list(&mut refs, segments);
246        entry.clear();
247        put_u64(&mut entry, cell - previous_cell);
248        put_u64(&mut entry, refs.len() as u64);
249        pack.write_all(&entry)?;
250        pack.write_all(&refs)?;
251        data_len += (entry.len() + refs.len()) as u64;
252        previous_cell = cell;
253        cell_count += 1;
254        points.clear();
255        segments.clear();
256        Ok(())
257    };
258    let mut last: Option<CellRef> = None;
259    for pair in pairs.finish()? {
260        let pair = pair?;
261        if last == Some(pair) {
262            continue;
263        }
264        last = Some(pair);
265        if current.is_some_and(|cell| cell != pair.cell) {
266            flush(
267                pack,
268                current.expect("current cell"),
269                &mut points,
270                &mut segments,
271            )?;
272        }
273        current = Some(pair.cell);
274        match pair.kind {
275            KIND_POINT => points.push(pair.reference),
276            _ => segments.push(pair.reference),
277        }
278    }
279    if let Some(cell) = current {
280        flush(pack, cell, &mut points, &mut segments)?;
281    }
282    pack.end()?;
283
284    let mut index = Vec::with_capacity(24 + blocks.len() * 16);
285    index.extend_from_slice(&cell_count.to_le_bytes());
286    index.extend_from_slice(&(blocks.len() as u64).to_le_bytes());
287    for (first_cell, offset) in &blocks {
288        index.extend_from_slice(&first_cell.to_le_bytes());
289        index.extend_from_slice(&offset.to_le_bytes());
290    }
291    index.extend_from_slice(&data_len.to_le_bytes());
292    pack.add(&format!("{name}/index"), SPATIAL_VERSION, &index)?;
293    Ok(cell_count)
294}
295
296fn put_delta_list(out: &mut Vec<u8>, values: &[u64]) {
297    put_u64(out, values.len() as u64);
298    let mut previous = 0;
299    for value in values {
300        put_u64(out, value - previous);
301        previous = *value;
302    }
303}
304
305fn get_delta_list(input: &mut &[u8]) -> Result<Vec<u64>> {
306    let count = usize::try_from(get_u64(input)?)?;
307    if count > input.len() {
308        bail!("spatial reference list is truncated");
309    }
310    let mut values = Vec::with_capacity(count);
311    let mut previous = 0u64;
312    for _ in 0..count {
313        previous = previous
314            .checked_add(get_u64(input)?)
315            .context("spatial reference overflows")?;
316        values.push(previous);
317    }
318    Ok(values)
319}
320
321#[derive(Clone)]
322struct CellTable {
323    index: Bytes,
324    data: Bytes,
325    block_count: u64,
326}
327
328struct CellRefs {
329    points: Vec<u64>,
330    segments: Vec<u64>,
331}
332
333impl CellTable {
334    fn open(container: &Container, name: &str) -> Result<Self> {
335        let index = container.section(&format!("{name}/index"), SPATIAL_VERSION)?;
336        let data = container.section(&format!("{name}/data"), SPATIAL_VERSION)?;
337        let cell_count = read_u64_le(&index, 0).context("spatial index is truncated")?;
338        let block_count = read_u64_le(&index, 8).context("spatial index is truncated")?;
339        if block_count != cell_count.div_ceil(CELLS_PER_BLOCK)
340            || index.len() as u64 != 24 + block_count * 16
341            || read_u64_le(&index, index.len() - 8) != Some(data.len() as u64)
342        {
343            bail!("spatial index {name} is inconsistent with its data");
344        }
345        Ok(Self {
346            index,
347            data,
348            block_count,
349        })
350    }
351
352    fn block(&self, block: u64) -> (u64, u64) {
353        let offset = 16 + block as usize * 16;
354        (
355            read_u64_le(&self.index, offset).expect("validated index"),
356            read_u64_le(&self.index, offset + 8).expect("validated index"),
357        )
358    }
359
360    fn refs(&self, cell: u64) -> Result<Option<CellRefs>> {
361        // Last block whose first cell is <= cell.
362        let (mut low, mut high) = (0, self.block_count);
363        while low < high {
364            let mid = low + (high - low) / 2;
365            if self.block(mid).0 <= cell {
366                low = mid + 1;
367            } else {
368                high = mid;
369            }
370        }
371        let Some(block) = low.checked_sub(1) else {
372            return Ok(None);
373        };
374        let (first_cell, start) = self.block(block);
375        let end = if block + 1 < self.block_count {
376            self.block(block + 1).1
377        } else {
378            self.data.len() as u64
379        };
380        let mut input = self
381            .data
382            .get(start as usize..end as usize)
383            .context("spatial block is out of range")?;
384        let mut current = first_cell;
385        while !input.is_empty() {
386            current += get_u64(&mut input)?;
387            let len = usize::try_from(get_u64(&mut input)?)?;
388            let mut refs = input.get(..len).context("spatial cell is truncated")?;
389            input = &input[len..];
390            match current.cmp(&cell) {
391                Ordering::Less => continue,
392                Ordering::Greater => return Ok(None),
393                Ordering::Equal => {
394                    return Ok(Some(CellRefs {
395                        points: get_delta_list(&mut refs)?,
396                        segments: get_delta_list(&mut refs)?,
397                    }));
398                }
399            }
400        }
401        Ok(None)
402    }
403}
404
405#[derive(Clone)]
406pub struct SpatialIndexReader {
407    fine: CellTable,
408    context: CellTable,
409    records: RecordsReader,
410}
411
412impl std::fmt::Debug for SpatialIndexReader {
413    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        formatter
415            .debug_struct("SpatialIndexReader")
416            .field("fine_blocks", &self.fine.block_count)
417            .field("context_blocks", &self.context.block_count)
418            .finish()
419    }
420}
421
422struct LineCache {
423    points: Vec<[f64; 2]>,
424    /// Cumulative length at each vertex, in metres.
425    lengths: Vec<f64>,
426}
427
428impl SpatialIndexReader {
429    pub fn open(container: &Container, records: RecordsReader) -> Result<Self> {
430        Ok(Self {
431            fine: CellTable::open(container, FINE)?,
432            context: CellTable::open(container, CONTEXT)?,
433            records,
434        })
435    }
436
437    pub fn point_candidates(
438        &self,
439        lon: f64,
440        lat: f64,
441        layer: Layer,
442        radius_m: f64,
443        limit: usize,
444    ) -> Result<Vec<PointCandidate>> {
445        let candidates = self.collect_points(
446            &self.fine,
447            H3_FINE_RESOLUTION,
448            lon,
449            lat,
450            radius_m,
451            limit,
452            |candidate| candidate == layer,
453        )?;
454        Ok(closest_candidates(candidates, limit))
455    }
456
457    pub fn context_candidates(
458        &self,
459        lon: f64,
460        lat: f64,
461        radius_m: f64,
462        limit: usize,
463    ) -> Result<Vec<PointCandidate>> {
464        let candidates = self.collect_points(
465            &self.context,
466            H3_CONTEXT_RESOLUTION,
467            lon,
468            lat,
469            radius_m,
470            limit,
471            Layer::is_context,
472        )?;
473        Ok(closest_candidates(candidates, limit))
474    }
475
476    /// Whether any context point of `layer` within `radius_m` satisfies
477    /// `matches`, scanning rings outward and stopping at the first match.
478    /// `None` when there is no point of that layer within the radius at all.
479    pub fn any_context_point(
480        &self,
481        lon: f64,
482        lat: f64,
483        layer: Layer,
484        radius_m: f64,
485        mut matches: impl FnMut(RecordId) -> Result<bool>,
486    ) -> Result<Option<bool>> {
487        let Ok(lat_lng) = LatLng::new(lat, lon) else {
488            return Ok(None);
489        };
490        let mut cells = lat_lng
491            .to_cell(H3_CONTEXT_RESOLUTION)
492            .grid_disk_distances::<Vec<(CellIndex, u32)>>(disk_k(H3_CONTEXT_RESOLUTION, radius_m));
493        cells.sort_unstable_by_key(|(cell, k)| (*k, u64::from(*cell)));
494        let mut seen = false;
495        for (cell, _) in cells {
496            let Some(refs) = self.context.refs(u64::from(cell))? else {
497                continue;
498            };
499            for record_id in refs.points {
500                let (point_layer, point_lon, point_lat) = self.records.point(record_id)?;
501                if point_layer != layer || haversine_m(lon, lat, point_lon, point_lat) > radius_m {
502                    continue;
503                }
504                seen = true;
505                if matches(record_id)? {
506                    return Ok(Some(true));
507                }
508            }
509        }
510        Ok(seen.then_some(false))
511    }
512
513    pub fn segment_candidates(
514        &self,
515        lon: f64,
516        lat: f64,
517        layer: Layer,
518        radius_m: f64,
519        limit: usize,
520    ) -> Result<Vec<SegmentCandidate>> {
521        let mut seen = std::collections::BTreeSet::new();
522        let mut lines: HashMap<RecordId, Option<LineCache>> = HashMap::new();
523        let mut candidates = Vec::new();
524        for cell in query_cells(lon, lat, H3_FINE_RESOLUTION, radius_m) {
525            let Some(refs) = self.fine.refs(cell)? else {
526                continue;
527            };
528            for reference in refs.segments {
529                if !seen.insert(reference) {
530                    continue;
531                }
532                let record_id = reference >> SEGMENT_INDEX_BITS;
533                let segment = (reference & ((1 << SEGMENT_INDEX_BITS) - 1)) as usize;
534                let line = match lines.entry(record_id) {
535                    std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
536                    std::collections::hash_map::Entry::Vacant(entry) => {
537                        entry.insert(if self.records.header(record_id)?.layer == layer {
538                            self.records.line(record_id)?.map(line_cache)
539                        } else {
540                            None
541                        })
542                    }
543                };
544                let Some(line) = line.as_ref() else {
545                    continue;
546                };
547                let (Some(start), Some(end)) =
548                    (line.points.get(segment), line.points.get(segment + 1))
549                else {
550                    bail!("segment reference {reference} is out of range");
551                };
552                let projection = project_to_segment_m(lon, lat, *start, *end);
553                if projection.distance_m <= radius_m {
554                    let total = *line.lengths.last().expect("line has vertices");
555                    let start_fraction = line.lengths[segment] / total;
556                    let end_fraction = line.lengths[segment + 1] / total;
557                    candidates.push(SegmentCandidate {
558                        record_id,
559                        layer,
560                        closest_lon: projection.lon,
561                        closest_lat: projection.lat,
562                        distance_m: projection.distance_m,
563                        fraction: start_fraction + projection.t * (end_fraction - start_fraction),
564                    });
565                }
566            }
567        }
568        Ok(closest_candidates(candidates, limit))
569    }
570
571    /// Points within `radius_m`, scanning H3 rings outward from the query
572    /// cell. With a `limit`, the scan stops as soon as the `limit` closest hits
573    /// are certain: once the disk scanned so far covers a radius beyond the
574    /// `limit`-th distance, no unscanned point can be closer. Dense areas (a
575    /// city centre full of postcodes) then cost a few rings instead of the
576    /// whole radius.
577    #[allow(clippy::too_many_arguments)]
578    fn collect_points(
579        &self,
580        index: &CellTable,
581        resolution: Resolution,
582        lon: f64,
583        lat: f64,
584        radius_m: f64,
585        limit: usize,
586        accepts: impl Fn(Layer) -> bool,
587    ) -> Result<Vec<PointCandidate>> {
588        let Ok(lat_lng) = LatLng::new(lat, lon) else {
589            return Ok(Vec::new());
590        };
591        let max_k = disk_k(resolution, radius_m);
592        let mut cells = lat_lng
593            .to_cell(resolution)
594            .grid_disk_distances::<Vec<(CellIndex, u32)>>(max_k);
595        cells.sort_unstable_by_key(|(cell, k)| (*k, u64::from(*cell)));
596
597        let mut candidates = Vec::new();
598        let mut next = 0;
599        for k in 0..=max_k {
600            while let Some(&(cell, ring)) = cells.get(next)
601                && ring == k
602            {
603                next += 1;
604                let Some(refs) = index.refs(u64::from(cell))? else {
605                    continue;
606                };
607                for record_id in refs.points {
608                    let (layer, point_lon, point_lat) = self.records.point(record_id)?;
609                    if !accepts(layer) {
610                        continue;
611                    }
612                    let distance_m = haversine_m(lon, lat, point_lon, point_lat);
613                    if distance_m <= radius_m {
614                        candidates.push(PointCandidate {
615                            record_id,
616                            layer,
617                            lon: point_lon,
618                            lat: point_lat,
619                            distance_m,
620                        });
621                    }
622                }
623            }
624            if limit > 0 && candidates.len() >= limit && k < max_k {
625                // `disk_k` scans `ceil(r / edge) + H3_RADIUS_EXTRA_RING` rings to
626                // cover radius r, so the rings scanned so far cover this radius.
627                let covered_m =
628                    f64::from(k.saturating_sub(H3_RADIUS_EXTRA_RING)) * resolution.edge_length_m();
629                let mut distances = candidates
630                    .iter()
631                    .map(|candidate| candidate.distance_m)
632                    .collect::<Vec<_>>();
633                let (_, kth, _) = distances.select_nth_unstable_by(limit - 1, f64::total_cmp);
634                if *kth < covered_m {
635                    break;
636                }
637            }
638        }
639        Ok(candidates)
640    }
641}
642
643fn line_cache(points: Vec<[i32; 2]>) -> LineCache {
644    let points = points
645        .into_iter()
646        .map(|[lon, lat]| [dequantize(lon), dequantize(lat)])
647        .collect::<Vec<_>>();
648    let mut lengths = Vec::with_capacity(points.len());
649    let mut total = 0.0;
650    lengths.push(0.0);
651    for pair in points.windows(2) {
652        total += haversine_m(pair[0][0], pair[0][1], pair[1][0], pair[1][1]);
653        lengths.push(total);
654    }
655    LineCache { points, lengths }
656}
657
658fn cell_id(lon: f64, lat: f64, resolution: Resolution) -> Result<u64> {
659    let lat_lng = LatLng::new(lat, lon).context("invalid coordinate for h3 cell")?;
660    Ok(u64::from(lat_lng.to_cell(resolution)))
661}
662
663/// Rings around the query cell that cover `radius_m`.
664fn disk_k(resolution: Resolution, radius_m: f64) -> u32 {
665    if radius_m <= 0.0 {
666        H3_RADIUS_EXTRA_RING
667    } else {
668        ((radius_m / resolution.edge_length_m()).ceil() as u32)
669            .saturating_add(H3_RADIUS_EXTRA_RING)
670            .min(H3_MAX_QUERY_K)
671    }
672}
673
674fn query_cells(lon: f64, lat: f64, resolution: Resolution, radius_m: f64) -> Vec<u64> {
675    let Ok(lat_lng) = LatLng::new(lat, lon) else {
676        return Vec::new();
677    };
678    let mut cells = lat_lng
679        .to_cell(resolution)
680        .grid_disk::<Vec<CellIndex>>(disk_k(resolution, radius_m))
681        .into_iter()
682        .map(u64::from)
683        .collect::<Vec<_>>();
684    // Ascending cell ids read the index front to back.
685    cells.sort_unstable();
686    cells
687}
688
689fn segment_cells(start: [f64; 2], end: [f64; 2], resolution: Resolution) -> Result<Vec<u64>> {
690    let length = haversine_m(start[0], start[1], end[0], end[1]);
691    let step = (resolution.edge_length_m() / H3_SEGMENT_SAMPLE_DIVISOR).max(1.0);
692    let sample_count = ((length / step).ceil() as usize).max(1);
693    let mut cells = Vec::with_capacity(sample_count + 1);
694    for sample in 0..=sample_count {
695        let t = sample as f64 / sample_count as f64;
696        let lon = start[0] + t * (end[0] - start[0]);
697        let lat = start[1] + t * (end[1] - start[1]);
698        cells.push(cell_id(lon, lat, resolution)?);
699    }
700    cells.sort_unstable();
701    cells.dedup();
702    Ok(cells)
703}
704
705struct SegmentProjection {
706    lon: f64,
707    lat: f64,
708    distance_m: f64,
709    t: f64,
710}
711
712fn project_to_segment_m(lon: f64, lat: f64, start: [f64; 2], end: [f64; 2]) -> SegmentProjection {
713    let (sx, sy) = local_xy_m(start[0], start[1], lon, lat);
714    let (ex, ey) = local_xy_m(end[0], end[1], lon, lat);
715    let vx = ex - sx;
716    let vy = ey - sy;
717    let length_2 = vx * vx + vy * vy;
718    let t = if length_2 <= f64::EPSILON {
719        0.0
720    } else {
721        (-(sx * vx + sy * vy) / length_2).clamp(0.0, 1.0)
722    };
723    let x = sx + t * vx;
724    let y = sy + t * vy;
725    SegmentProjection {
726        lon: start[0] + t * (end[0] - start[0]),
727        lat: start[1] + t * (end[1] - start[1]),
728        distance_m: (x * x + y * y).sqrt(),
729        t,
730    }
731}
732
733fn local_xy_m(lon: f64, lat: f64, origin_lon: f64, origin_lat: f64) -> (f64, f64) {
734    let x = (lon - origin_lon).to_radians() * EARTH_RADIUS_M * origin_lat.to_radians().cos();
735    let y = (lat - origin_lat).to_radians() * EARTH_RADIUS_M;
736    (x, y)
737}
738
739pub(crate) fn haversine_m(a_lon: f64, a_lat: f64, b_lon: f64, b_lat: f64) -> f64 {
740    let d_lat = (b_lat - a_lat).to_radians();
741    let d_lon = (b_lon - a_lon).to_radians();
742    let a_lat = a_lat.to_radians();
743    let b_lat = b_lat.to_radians();
744    let sin_d_lat = (d_lat / 2.0).sin();
745    let sin_d_lon = (d_lon / 2.0).sin();
746    let h = sin_d_lat * sin_d_lat + a_lat.cos() * b_lat.cos() * sin_d_lon * sin_d_lon;
747    2.0 * EARTH_RADIUS_M * h.sqrt().asin()
748}
749
750trait CandidateDistance {
751    fn distance_m(&self) -> f64;
752}
753
754impl CandidateDistance for PointCandidate {
755    fn distance_m(&self) -> f64 {
756        self.distance_m
757    }
758}
759
760impl CandidateDistance for SegmentCandidate {
761    fn distance_m(&self) -> f64 {
762        self.distance_m
763    }
764}
765
766fn compare_distance<T: CandidateDistance>(left: &T, right: &T) -> Ordering {
767    left.distance_m()
768        .partial_cmp(&right.distance_m())
769        .unwrap_or(Ordering::Equal)
770}
771
772fn closest_candidates<T: CandidateDistance>(mut candidates: Vec<T>, limit: usize) -> Vec<T> {
773    if limit > 0 && candidates.len() > limit {
774        // Select indices so equal-distance candidates retain their original order,
775        // including ties at the cutoff. Only the retained candidates are sorted.
776        let mut indices: Vec<usize> = (0..candidates.len()).collect();
777        indices.select_nth_unstable_by(limit - 1, |&left, &right| {
778            compare_distance(&candidates[left], &candidates[right]).then_with(|| left.cmp(&right))
779        });
780        let cutoff_index = indices[limit - 1];
781        let cutoff_distance = candidates[cutoff_index].distance_m();
782        let mut index = 0;
783        candidates.retain(|candidate| {
784            let keep = candidate
785                .distance_m()
786                .partial_cmp(&cutoff_distance)
787                .unwrap_or(Ordering::Equal)
788                .then_with(|| index.cmp(&cutoff_index))
789                != Ordering::Greater;
790            index += 1;
791            keep
792        });
793    }
794    candidates.sort_by(compare_distance);
795    candidates
796}
797
798#[cfg(test)]
799mod tests {
800    use geojson::Geometry;
801
802    use super::*;
803    use crate::{
804        record::{
805            AddressComponents, AddressRecord, DerivedSourceProvenance, LocationPrecision,
806            OsmObjectType, PostcodeRecord, SourceProvenance, StreetRecord, point_geometry,
807        },
808        records::RecordsWriter,
809    };
810
811    #[test]
812    fn closest_candidates_matches_stable_full_sort() {
813        for count in [0, 1, 8, 1_000] {
814            let candidates: Vec<_> = (0..count)
815                .map(|id| PointCandidate {
816                    record_id: id,
817                    layer: Layer::Address,
818                    lon: 0.0,
819                    lat: 0.0,
820                    // Repeated, unsorted distances exercise ties at the cutoff.
821                    distance_m: ((id * 37) % 23) as f64,
822                })
823                .collect();
824            for limit in [0, 1, 3, 5, 8, 999, 1_000, 1_001] {
825                let mut expected = candidates.clone();
826                expected.sort_by(compare_distance);
827                if limit > 0 {
828                    expected.truncate(limit);
829                }
830                let actual = closest_candidates(candidates.clone(), limit);
831                assert_eq!(
832                    actual.iter().map(|hit| hit.record_id).collect::<Vec<_>>(),
833                    expected.iter().map(|hit| hit.record_id).collect::<Vec<_>>(),
834                    "count={count}, limit={limit}",
835                );
836            }
837        }
838    }
839
840    fn address(lon: f64, lat: f64, object_id: i64) -> Record {
841        Record::Address(AddressRecord {
842            address: AddressComponents {
843                number: "10".to_string(),
844                street: Some("King Street".to_string()),
845                place: None,
846                unit: None,
847                locality: None,
848                region: None,
849                postcode: None,
850                country: None,
851            },
852            geometry: point_geometry(lon, lat),
853            location_precision: LocationPrecision::Point,
854            source: SourceProvenance::osm(OsmObjectType::Node, object_id),
855        })
856    }
857
858    /// Build a Pack holding only records and the spatial index, with a sorter
859    /// budget small enough to force spilling.
860    fn build(records: &[Record]) -> (SpatialIndexReader, SpatialCommit) {
861        let root =
862            std::env::temp_dir().join(format!("open-geocode-spatial-{}", uuid::Uuid::new_v4()));
863        let scratch = Scratch::create(root.join("scratch")).expect("scratch");
864        let mut writer =
865            RecordsWriter::create(&scratch, &MemoryBudget::unlimited()).expect("records");
866        let mut spatial = SpatialIndexWriter::new(&scratch, &MemoryBudget::unlimited(), 64);
867        for record in records {
868            let id = writer.write(record, None).expect("write");
869            spatial
870                .add(RecordCells::for_record(id, record).expect("cells"))
871                .expect("add");
872        }
873        let path = root.join("pack.ogp");
874        let mut pack = ContainerWriter::create(&path).expect("pack");
875        writer.finish(&mut pack).expect("records finish");
876        let commit = spatial.finish(&mut pack).expect("spatial finish");
877        pack.finish().expect("pack finish");
878        let container = Container::open(&path).expect("open");
879        let records = RecordsReader::open(&container).expect("records reader");
880        (
881            SpatialIndexReader::open(&container, records).expect("spatial reader"),
882            commit,
883        )
884    }
885
886    #[test]
887    fn indexes_and_queries_address_points() {
888        let mut records = vec![address(-79.0, 43.0, 7)];
889        for id in 8..1_008 {
890            records.push(address(
891                -79.0,
892                43.0 + ((id * 37) % 100) as f64 * 0.000001,
893                id,
894            ));
895        }
896        let (reader, commit) = build(&records);
897        assert!(commit.fine_pairs.runs > 0, "the tiny budget must spill");
898        assert_eq!(commit.point_count, 1_001);
899
900        let hits = reader
901            .point_candidates(-79.0, 43.0, Layer::Address, 5.0, 1)
902            .expect("hits");
903        assert_eq!(hits.len(), 1);
904        assert_eq!(hits[0].record_id, 0);
905
906        let all = reader
907            .point_candidates(-79.0, 43.0, Layer::Address, 100.0, 0)
908            .expect("all");
909        let limited = reader
910            .point_candidates(-79.0, 43.0, Layer::Address, 100.0, 5)
911            .expect("limited");
912        assert_eq!(all.len(), 1_001);
913        assert_eq!(
914            limited.iter().map(|hit| hit.record_id).collect::<Vec<_>>(),
915            all.iter()
916                .take(5)
917                .map(|hit| hit.record_id)
918                .collect::<Vec<_>>(),
919        );
920        assert!(
921            reader
922                .point_candidates(-79.0, 43.0, Layer::Postcode, 100.0, 0)
923                .expect("other layer")
924                .is_empty()
925        );
926    }
927
928    #[test]
929    fn early_stopping_ring_scan_matches_the_full_scan() {
930        // Postcodes scattered over ~60 km, denser near the centre.
931        let mut state = 0x2545_f491_4f6c_dd1du64;
932        let mut next = || {
933            state ^= state << 13;
934            state ^= state >> 7;
935            state ^= state << 17;
936            (state % 1_000_000) as f64 / 1_000_000.0 - 0.5
937        };
938        let records = (0..2_000)
939            .map(|index| {
940                let spread = if index % 3 == 0 { 0.6 } else { 0.05 };
941                Record::Postcode(PostcodeRecord {
942                    postcode: format!("P{index}"),
943                    geometry: point_geometry(-79.4 + next() * spread, 43.7 + next() * spread),
944                    source: DerivedSourceProvenance::osm_address_records(1),
945                })
946            })
947            .collect::<Vec<_>>();
948        let (reader, _) = build(&records);
949        for (lon, lat) in [(-79.4, 43.7), (-79.6, 43.9), (-79.1, 43.5), (-78.0, 43.0)] {
950            for limit in [1, 16, 100] {
951                let early = reader
952                    .context_candidates(lon, lat, 50_000.0, limit)
953                    .expect("early");
954                let mut full = reader
955                    .context_candidates(lon, lat, 50_000.0, 0)
956                    .expect("full");
957                full.truncate(limit);
958                let distances = |hits: &[PointCandidate]| {
959                    hits.iter().map(|hit| hit.distance_m).collect::<Vec<_>>()
960                };
961                assert_eq!(
962                    distances(&early),
963                    distances(&full),
964                    "{lon},{lat} limit {limit}"
965                );
966            }
967        }
968    }
969
970    #[test]
971    fn indexes_context_points_with_coarse_cells() {
972        let (reader, _) = build(&[Record::Postcode(PostcodeRecord {
973            postcode: "M5V".to_string(),
974            geometry: point_geometry(-79.4, 43.6),
975            source: DerivedSourceProvenance::osm_address_records(1),
976        })]);
977        let hits = reader
978            .context_candidates(-79.39, 43.6, 5_000.0, 5)
979            .expect("hits");
980        assert_eq!(hits.len(), 1);
981        assert_eq!(hits[0].record_id, 0);
982        assert_eq!(hits[0].layer, Layer::Postcode);
983    }
984
985    #[test]
986    fn indexes_line_segments_with_fraction() {
987        let street = |object_id: i64, lon: f64| {
988            Record::Street(StreetRecord {
989                name: "King Street".to_string(),
990                geometry: Geometry::new(GeometryValue::LineString {
991                    coordinates: vec![
992                        vec![lon, 43.0].into(),
993                        vec![lon, 43.0005].into(),
994                        vec![lon, 43.001].into(),
995                    ],
996                }),
997                representative_point: [lon, 43.0005],
998                source: SourceProvenance::osm(OsmObjectType::Way, object_id),
999            })
1000        };
1001        let (reader, commit) = build(&[street(1, -79.0), street(2, -78.99)]);
1002        assert_eq!(commit.segment_count, 4);
1003        let hits = reader
1004            .segment_candidates(-79.00001, 43.00075, Layer::Street, 5.0, 1)
1005            .expect("hits");
1006        assert_eq!(hits.len(), 1);
1007        assert_eq!(hits[0].record_id, 0);
1008        assert!(
1009            (hits[0].fraction - 0.75).abs() < 0.01,
1010            "{}",
1011            hits[0].fraction
1012        );
1013        assert!(
1014            reader
1015                .segment_candidates(-79.00001, 43.00075, Layer::Interpolation, 5.0, 1)
1016                .expect("other layer")
1017                .is_empty()
1018        );
1019    }
1020}