1use std::{collections::BTreeSet, path::Path, sync::Arc};
2
3use anyhow::Result;
4use serde::Serialize;
5
6use crate::{
7 pack::{ContextRecord, PackReader, RecordId},
8 record::{AddressComponents, InterpolationAddressComponents, InterpolationRange, Layer},
9 spatial_index::SpatialIndexReader,
10};
11
12const ADDRESS_RADIUS_M: f64 = 30.0;
13const INTERPOLATION_RADIUS_M: f64 = 30.0;
14const STREET_RADIUS_M: f64 = 30.0;
15const CONTEXT_RADIUS_M: f64 = 50_000.0;
16const CANDIDATE_LIMIT: usize = 16;
17
18#[derive(Debug)]
19pub struct PackReverseGeocoder {
20 pack: Arc<PackReader>,
21 spatial: SpatialIndexReader,
22}
23
24#[derive(Debug, Clone, Copy)]
25pub struct ReverseGeocodeOptions {
26 pub lon: f64,
27 pub lat: f64,
28}
29
30#[derive(Debug, Serialize, PartialEq)]
31pub struct ReverseGeocodeResponse {
32 pub lon: f64,
33 pub lat: f64,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub result: Option<ReverseGeocodeResult>,
36}
37
38#[derive(Debug, Serialize, PartialEq)]
39pub struct ReverseGeocodeResult {
40 pub match_kind: ReverseMatchKind,
41 pub label: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub primary_record_id: Option<RecordId>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub id: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub layer: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub distance_m: Option<f64>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub point: Option<ReversePoint>,
52 pub context: ReverseContext,
53 pub evidence: ReverseEvidence,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
57#[serde(rename_all = "snake_case")]
58pub enum ReverseMatchKind {
59 ExplicitAddress,
60 EstimatedAddress,
61 NearestStreet,
62 ContextOnly,
63}
64
65#[derive(Debug, Clone, Serialize, PartialEq)]
66pub struct ReversePoint {
67 pub lon: f64,
68 pub lat: f64,
69 pub precision: ReversePointPrecision,
70}
71
72#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
73#[serde(rename_all = "snake_case")]
74pub enum ReversePointPrecision {
75 Point,
76 Estimated,
77 Street,
78 Context,
79}
80
81#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
82pub struct ReverseContext {
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub street: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub place: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub postcode: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub neighbourhood: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub locality: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub district: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub region: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub country: Option<String>,
99}
100
101#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
102pub struct ReverseEvidence {
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub explicit_address_record_id: Option<RecordId>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub interpolation_record_id: Option<RecordId>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub street_record_id: Option<RecordId>,
109 #[serde(skip_serializing_if = "Vec::is_empty")]
110 pub context_record_ids: Vec<RecordId>,
111}
112
113impl PackReverseGeocoder {
114 pub fn open(pack_path: impl AsRef<Path>) -> Result<Self> {
115 Self::from_pack(Arc::new(PackReader::open(pack_path)?))
116 }
117
118 pub fn from_pack(pack: Arc<PackReader>) -> Result<Self> {
120 let spatial = SpatialIndexReader::open(pack.container(), pack.records().clone())?;
121 Ok(Self { pack, spatial })
122 }
123
124 pub fn reverse(&self, options: ReverseGeocodeOptions) -> Result<ReverseGeocodeResponse> {
125 let mut context = ReverseContext::default();
126 let mut context_record_ids = Vec::new();
127
128 let mut result = self.explicit_address(options, &mut context, &mut context_record_ids)?;
129 if result.is_none() {
130 result = self.estimated_address(options, &mut context, &mut context_record_ids)?;
131 }
132 if result.is_none() {
133 result = self.nearest_street(options, &mut context, &mut context_record_ids)?;
134 }
135 if result.is_none() {
136 result = self.context_only(options, &mut context, &mut context_record_ids)?;
137 }
138
139 Ok(ReverseGeocodeResponse {
140 lon: options.lon,
141 lat: options.lat,
142 result,
143 })
144 }
145
146 fn explicit_address(
147 &self,
148 options: ReverseGeocodeOptions,
149 context: &mut ReverseContext,
150 context_record_ids: &mut Vec<RecordId>,
151 ) -> Result<Option<ReverseGeocodeResult>> {
152 let Some(candidate) = self
153 .spatial
154 .point_candidates(
155 options.lon,
156 options.lat,
157 Layer::Address,
158 ADDRESS_RADIUS_M,
159 1,
160 )?
161 .into_iter()
162 .next()
163 else {
164 return Ok(None);
165 };
166 let Some(address) = self.pack.address(candidate.record_id)? else {
167 return Ok(None);
168 };
169
170 self.enrich_record_context(candidate.record_id, context, context_record_ids)?;
171 apply_address_context(context, &address.address);
172 self.enrich_context(options, context, context_record_ids)?;
173
174 Ok(Some(ReverseGeocodeResult {
175 match_kind: ReverseMatchKind::ExplicitAddress,
176 label: address.label(),
177 primary_record_id: Some(candidate.record_id),
178 id: Some(address.id()),
179 layer: Some("address".to_string()),
180 distance_m: Some(candidate.distance_m),
181 point: Some(ReversePoint {
182 lon: candidate.lon,
183 lat: candidate.lat,
184 precision: ReversePointPrecision::Point,
185 }),
186 context: context.clone(),
187 evidence: ReverseEvidence {
188 explicit_address_record_id: Some(candidate.record_id),
189 context_record_ids: context_record_ids.clone(),
190 ..ReverseEvidence::default()
191 },
192 }))
193 }
194
195 fn estimated_address(
196 &self,
197 options: ReverseGeocodeOptions,
198 context: &mut ReverseContext,
199 context_record_ids: &mut Vec<RecordId>,
200 ) -> Result<Option<ReverseGeocodeResult>> {
201 for candidate in self.spatial.segment_candidates(
202 options.lon,
203 options.lat,
204 Layer::Interpolation,
205 INTERPOLATION_RADIUS_M,
206 CANDIDATE_LIMIT,
207 )? {
208 let Some(interpolation) = self.pack.interpolation(candidate.record_id)? else {
209 continue;
210 };
211 let number = estimated_number(&interpolation.interpolation, candidate.fraction);
212 self.enrich_record_context(candidate.record_id, context, context_record_ids)?;
213 apply_interpolation_context(context, &interpolation.address);
214 self.enrich_context(options, context, context_record_ids)?;
215 let primary = estimated_primary_label(number, &interpolation.address)
216 .unwrap_or_else(|| format!("{} {}", number, interpolation.name()));
217
218 return Ok(Some(ReverseGeocodeResult {
219 match_kind: ReverseMatchKind::EstimatedAddress,
220 label: compose_label(&primary, context),
221 primary_record_id: Some(candidate.record_id),
222 id: Some(interpolation.id()),
223 layer: Some("interpolation".to_string()),
224 distance_m: Some(candidate.distance_m),
225 point: Some(ReversePoint {
226 lon: candidate.closest_lon,
227 lat: candidate.closest_lat,
228 precision: ReversePointPrecision::Estimated,
229 }),
230 context: context.clone(),
231 evidence: ReverseEvidence {
232 interpolation_record_id: Some(candidate.record_id),
233 context_record_ids: context_record_ids.clone(),
234 ..ReverseEvidence::default()
235 },
236 }));
237 }
238
239 Ok(None)
240 }
241
242 fn nearest_street(
243 &self,
244 options: ReverseGeocodeOptions,
245 context: &mut ReverseContext,
246 context_record_ids: &mut Vec<RecordId>,
247 ) -> Result<Option<ReverseGeocodeResult>> {
248 let Some(candidate) = self
249 .spatial
250 .segment_candidates(options.lon, options.lat, Layer::Street, STREET_RADIUS_M, 1)?
251 .into_iter()
252 .next()
253 else {
254 return Ok(None);
255 };
256 let Some(street) = self.pack.street(candidate.record_id)? else {
257 return Ok(None);
258 };
259
260 set_if_missing(&mut context.street, street.name.clone());
261 self.enrich_record_context(candidate.record_id, context, context_record_ids)?;
262 self.enrich_context(options, context, context_record_ids)?;
263
264 Ok(Some(ReverseGeocodeResult {
265 match_kind: ReverseMatchKind::NearestStreet,
266 label: compose_label(&street.label(), context),
267 primary_record_id: Some(candidate.record_id),
268 id: Some(street.id()),
269 layer: Some("street".to_string()),
270 distance_m: Some(candidate.distance_m),
271 point: Some(ReversePoint {
272 lon: candidate.closest_lon,
273 lat: candidate.closest_lat,
274 precision: ReversePointPrecision::Street,
275 }),
276 context: context.clone(),
277 evidence: ReverseEvidence {
278 street_record_id: Some(candidate.record_id),
279 context_record_ids: context_record_ids.clone(),
280 ..ReverseEvidence::default()
281 },
282 }))
283 }
284
285 fn context_only(
286 &self,
287 options: ReverseGeocodeOptions,
288 context: &mut ReverseContext,
289 context_record_ids: &mut Vec<RecordId>,
290 ) -> Result<Option<ReverseGeocodeResult>> {
291 self.enrich_context(options, context, context_record_ids)?;
292 let Some(primary_record_id) = context_record_ids.first().copied() else {
293 return Ok(None);
294 };
295 let Some(record) = self.pack.context_record(primary_record_id)? else {
296 return Ok(None);
297 };
298 let Some(label) = context_only_label(context).or_else(|| Some(record.label.clone())) else {
299 return Ok(None);
300 };
301
302 Ok(Some(ReverseGeocodeResult {
303 match_kind: ReverseMatchKind::ContextOnly,
304 label,
305 primary_record_id: Some(primary_record_id),
306 id: Some(record.id),
307 layer: Some(record.layer),
308 distance_m: None,
309 point: record.point.map(context_point),
310 context: context.clone(),
311 evidence: ReverseEvidence {
312 context_record_ids: context_record_ids.clone(),
313 ..ReverseEvidence::default()
314 },
315 }))
316 }
317
318 fn enrich_context(
319 &self,
320 options: ReverseGeocodeOptions,
321 context: &mut ReverseContext,
322 context_record_ids: &mut Vec<RecordId>,
323 ) -> Result<()> {
324 let mut seen = context_record_ids.iter().copied().collect::<BTreeSet<_>>();
325 for candidate in self.spatial.context_candidates(
326 options.lon,
327 options.lat,
328 CONTEXT_RADIUS_M,
329 CANDIDATE_LIMIT,
330 )? {
331 if !seen.insert(candidate.record_id) {
332 continue;
333 }
334 if let Some(record) = self.pack.context_record(candidate.record_id)?
335 && apply_context_record(context, &record)
336 {
337 context_record_ids.push(candidate.record_id);
338 }
339 }
340 Ok(())
341 }
342
343 fn enrich_record_context(
344 &self,
345 record_id: RecordId,
346 context: &mut ReverseContext,
347 context_record_ids: &mut Vec<RecordId>,
348 ) -> Result<()> {
349 let Some(boundary_context) = self.pack.boundary_context(record_id)? else {
350 return Ok(());
351 };
352 let mut seen = context_record_ids.iter().copied().collect::<BTreeSet<_>>();
353 for context_record_id in boundary_context.admin_context.parent_record_ids() {
354 if !seen.insert(context_record_id) {
355 continue;
356 }
357 if let Some(record) = self.pack.context_record(context_record_id)?
358 && apply_context_record(context, &record)
359 {
360 context_record_ids.push(context_record_id);
361 }
362 }
363 Ok(())
364 }
365}
366
367fn apply_address_context(context: &mut ReverseContext, address: &AddressComponents) {
368 set_option_if_missing(&mut context.street, address.street.clone());
369 set_option_if_missing(&mut context.place, address.place.clone());
370 set_option_if_missing(&mut context.postcode, address.postcode.clone());
371 set_option_if_missing(&mut context.locality, address.locality.clone());
372 set_option_if_missing(&mut context.region, address.region.clone());
373 set_option_if_missing(&mut context.country, address.country.clone());
374}
375
376fn apply_interpolation_context(
377 context: &mut ReverseContext,
378 address: &InterpolationAddressComponents,
379) {
380 set_option_if_missing(&mut context.street, address.street.clone());
381 set_option_if_missing(&mut context.place, address.place.clone());
382 set_option_if_missing(&mut context.postcode, address.postcode.clone());
383 set_option_if_missing(&mut context.locality, address.locality.clone());
384 set_option_if_missing(&mut context.region, address.region.clone());
385 set_option_if_missing(&mut context.country, address.country.clone());
386}
387
388fn apply_context_record(context: &mut ReverseContext, record: &ContextRecord) -> bool {
389 match record.layer.as_str() {
390 "postcode" => set_option_if_missing(&mut context.postcode, record.postcode.clone()),
391 "neighbourhood" => set_if_missing(&mut context.neighbourhood, record.name.clone()),
392 "locality" => set_if_missing(&mut context.locality, record.name.clone()),
393 "district" => set_if_missing(&mut context.district, record.name.clone()),
394 "region" => set_if_missing(&mut context.region, record.name.clone()),
395 "country" => set_if_missing(&mut context.country, record.name.clone()),
396 "place" => set_if_missing(&mut context.place, record.name.clone()),
397 _ => false,
398 }
399}
400
401fn estimated_number(range: &InterpolationRange, fraction: f64) -> u32 {
402 let fraction = fraction.clamp(0.0, 1.0);
403 let span = range.end.saturating_sub(range.start);
404 if span == 0 || range.step == 0 {
405 return range.start;
406 }
407 let raw = range.start as f64 + fraction * span as f64;
408 let step_index = ((raw - range.start as f64) / range.step as f64).round() as u32;
409 (range.start + step_index * range.step).min(range.end)
410}
411
412fn estimated_primary_label(
413 number: u32,
414 address: &InterpolationAddressComponents,
415) -> Option<String> {
416 address
417 .street
418 .as_deref()
419 .or(address.place.as_deref())
420 .map(|street_or_place| format!("{number} {street_or_place}"))
421}
422
423fn compose_label(primary: &str, context: &ReverseContext) -> String {
424 let mut parts = vec![primary.to_string()];
425 for part in [
426 context.place.as_deref(),
427 context.neighbourhood.as_deref(),
428 context.locality.as_deref(),
429 context.district.as_deref(),
430 context.region.as_deref(),
431 context.postcode.as_deref(),
432 context.country.as_deref(),
433 ]
434 .into_iter()
435 .flatten()
436 {
437 if !parts.iter().any(|existing| same_text(existing, part)) {
438 parts.push(part.to_string());
439 }
440 }
441 parts.join(", ")
442}
443
444fn context_only_label(context: &ReverseContext) -> Option<String> {
445 [
446 context.place.as_deref(),
447 context.neighbourhood.as_deref(),
448 context.locality.as_deref(),
449 context.district.as_deref(),
450 context.region.as_deref(),
451 context.postcode.as_deref(),
452 context.country.as_deref(),
453 ]
454 .into_iter()
455 .flatten()
456 .next()
457 .map(|primary| compose_label(primary, context))
458}
459
460fn context_point(point: crate::pack::RecordPoint) -> ReversePoint {
461 ReversePoint {
462 lon: point.lon,
463 lat: point.lat,
464 precision: ReversePointPrecision::Context,
465 }
466}
467
468fn set_option_if_missing(target: &mut Option<String>, value: Option<String>) -> bool {
469 if let Some(value) = value {
470 set_if_missing(target, value)
471 } else {
472 false
473 }
474}
475
476fn set_if_missing(target: &mut Option<String>, value: String) -> bool {
477 if target.is_none() && !value.trim().is_empty() {
478 *target = Some(value);
479 true
480 } else {
481 false
482 }
483}
484
485fn same_text(left: &str, right: &str) -> bool {
486 left.trim().eq_ignore_ascii_case(right.trim())
487}
488
489#[cfg(test)]
490mod tests {
491 use std::{
492 fs,
493 time::{SystemTime, UNIX_EPOCH},
494 };
495
496 use geojson::{Geometry, GeometryValue};
497
498 use crate::{
499 context::AdminContextTuple,
500 pack::{PackWriter, RecordContext},
501 record::{
502 AddressRecord, LocationPrecision, OsmObjectType, PlaceLayer, PlaceRecord, Record,
503 SourceProvenance, StreetRecord, point_geometry,
504 },
505 };
506
507 use super::*;
508
509 #[test]
510 fn reverse_prefers_explicit_address() {
511 let temp_dir = temp_pack_dir("explicit");
512 let mut writer = PackWriter::create(&temp_dir).expect("writer");
513 writer.write(&street_record().into(), None).expect("street");
514 writer
515 .write(&address_record().into(), None)
516 .expect("address");
517 writer.finish().expect("finish");
518
519 let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
520 let response = geocoder
521 .reverse(ReverseGeocodeOptions {
522 lon: -79.0,
523 lat: 43.0,
524 })
525 .expect("reverse");
526 let result = response.result.expect("result");
527
528 assert_eq!(result.match_kind, ReverseMatchKind::ExplicitAddress);
529 assert_eq!(result.primary_record_id, Some(1));
530 assert_eq!(result.evidence.explicit_address_record_id, Some(1));
531
532 let _ = fs::remove_dir_all(temp_dir);
533 }
534
535 #[test]
536 fn reverse_estimates_address_from_interpolation() {
537 let temp_dir = temp_pack_dir("interpolation");
538 let mut writer = PackWriter::create(&temp_dir).expect("writer");
539 writer
540 .write(&interpolation_record().into(), None)
541 .expect("interpolation");
542 writer.finish().expect("finish");
543
544 let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
545 let response = geocoder
546 .reverse(ReverseGeocodeOptions {
547 lon: -79.00001,
548 lat: 43.0005,
549 })
550 .expect("reverse");
551 let result = response.result.expect("result");
552
553 assert_eq!(result.match_kind, ReverseMatchKind::EstimatedAddress);
554 assert!(result.label.starts_with("50 Queen Street"));
555 assert_eq!(result.evidence.interpolation_record_id, Some(0));
556
557 let _ = fs::remove_dir_all(temp_dir);
558 }
559
560 #[test]
561 fn reverse_falls_back_to_street() {
562 let temp_dir = temp_pack_dir("street");
563 let mut writer = PackWriter::create(&temp_dir).expect("writer");
564 writer.write(&street_record().into(), None).expect("street");
565 writer.finish().expect("finish");
566
567 let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
568 let response = geocoder
569 .reverse(ReverseGeocodeOptions {
570 lon: -79.00001,
571 lat: 43.0005,
572 })
573 .expect("reverse");
574 let result = response.result.expect("result");
575
576 assert_eq!(result.match_kind, ReverseMatchKind::NearestStreet);
577 assert!(result.label.starts_with("Queen Street"));
578 assert_eq!(result.evidence.street_record_id, Some(0));
579
580 let _ = fs::remove_dir_all(temp_dir);
581 }
582
583 #[test]
584 fn reverse_prefers_materialized_boundary_context_over_source_locality() {
585 let temp_dir = temp_pack_dir("boundary-context");
586 let mut writer = PackWriter::create(&temp_dir).expect("writer");
587 let locality_id = writer
588 .write(&Record::Place(PlaceLayer::Locality, place_record()), None)
589 .expect("place");
590 let mut address = address_record();
591 address.address.locality = Some("North York".to_string());
592 writer
593 .write(
594 &address.into(),
595 Some(RecordContext {
596 admin_context: AdminContextTuple {
597 locality_record_id: Some(locality_id),
598 ..AdminContextTuple::default()
599 },
600 flags: 0,
601 }),
602 )
603 .expect("address");
604 writer.finish().expect("finish");
605
606 let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
607 let response = geocoder
608 .reverse(ReverseGeocodeOptions {
609 lon: -79.0,
610 lat: 43.0,
611 })
612 .expect("reverse");
613 let result = response.result.expect("result");
614
615 assert_eq!(result.context.locality.as_deref(), Some("Toronto"));
616 assert_eq!(result.evidence.context_record_ids, vec![locality_id]);
617
618 let _ = fs::remove_dir_all(temp_dir);
619 }
620
621 fn temp_pack_dir(label: &str) -> std::path::PathBuf {
622 let nanos = SystemTime::now()
623 .duration_since(UNIX_EPOCH)
624 .expect("clock")
625 .as_nanos();
626 std::env::temp_dir().join(format!(
627 "open-geocode-reverse-{label}-{}-{nanos}",
628 std::process::id()
629 ))
630 }
631
632 fn address_record() -> AddressRecord {
633 AddressRecord {
634 address: AddressComponents {
635 number: "10".to_string(),
636 street: Some("Queen Street".to_string()),
637 place: None,
638 unit: None,
639 locality: Some("Toronto".to_string()),
640 region: Some("Ontario".to_string()),
641 postcode: None,
642 country: Some("Canada".to_string()),
643 },
644 geometry: point_geometry(-79.0, 43.0),
645 location_precision: LocationPrecision::Point,
646 source: SourceProvenance::osm(OsmObjectType::Node, 10),
647 }
648 }
649
650 fn place_record() -> PlaceRecord {
651 PlaceRecord {
652 name: "Toronto".to_string(),
653 place_type: "admin_level:8".to_string(),
654 geometry: point_geometry(-79.0, 43.0),
655 source: SourceProvenance::osm(OsmObjectType::Relation, 99),
656 }
657 }
658
659 fn interpolation_record() -> crate::record::InterpolationRecord {
660 crate::record::InterpolationRecord {
661 address: InterpolationAddressComponents {
662 street: Some("Queen Street".to_string()),
663 place: None,
664 locality: Some("Toronto".to_string()),
665 region: Some("Ontario".to_string()),
666 postcode: None,
667 country: Some("Canada".to_string()),
668 },
669 interpolation: InterpolationRange {
670 kind: "even".to_string(),
671 start: 2,
672 end: 98,
673 step: 2,
674 },
675 anchor_node_ids: [2, 98],
676 geometry: line_geometry(),
677 representative_point: [-79.0, 43.0005],
678 source: SourceProvenance::osm(OsmObjectType::Way, 20),
679 }
680 }
681
682 fn street_record() -> StreetRecord {
683 StreetRecord {
684 name: "Queen Street".to_string(),
685 geometry: line_geometry(),
686 representative_point: [-79.0, 43.0005],
687 source: SourceProvenance::osm(OsmObjectType::Way, 30),
688 }
689 }
690
691 fn line_geometry() -> Geometry {
692 Geometry::new(GeometryValue::LineString {
693 coordinates: vec![vec![-79.0, 43.0].into(), vec![-79.0, 43.001].into()],
694 })
695 }
696}