1#![allow(dead_code)]
12#![allow(clippy::too_many_arguments)]
13
14#[derive(Debug)]
19pub enum GeoError {
20 ParseError(String),
22 NotFound(String),
24 UnsupportedCrs(String),
26 Io(String),
28}
29
30impl std::fmt::Display for GeoError {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 GeoError::ParseError(m) => write!(f, "parse error: {m}"),
34 GeoError::NotFound(m) => write!(f, "not found: {m}"),
35 GeoError::UnsupportedCrs(m) => write!(f, "unsupported CRS: {m}"),
36 GeoError::Io(m) => write!(f, "I/O error: {m}"),
37 }
38 }
39}
40
41impl std::error::Error for GeoError {}
42
43pub type GeoResult<T> = Result<T, GeoError>;
45
46#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct LonLat {
53 pub lon: f64,
55 pub lat: f64,
57}
58
59impl LonLat {
60 #[must_use]
62 pub fn new(lon: f64, lat: f64) -> Self {
63 Self { lon, lat }
64 }
65
66 #[must_use]
68 pub fn haversine_m(&self, other: &LonLat) -> f64 {
69 const R: f64 = 6_371_000.0; let dlat = (other.lat - self.lat).to_radians();
71 let dlon = (other.lon - self.lon).to_radians();
72 let lat1 = self.lat.to_radians();
73 let lat2 = other.lat.to_radians();
74 let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
75 let c = 2.0 * a.sqrt().asin();
76 R * c
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct LonLatElev {
83 pub lon: f64,
85 pub lat: f64,
87 pub elev: f64,
89}
90
91impl LonLatElev {
92 #[must_use]
94 pub fn new(lon: f64, lat: f64, elev: f64) -> Self {
95 Self { lon, lat, elev }
96 }
97
98 #[must_use]
100 pub fn to_lonlat(&self) -> LonLat {
101 LonLat::new(self.lon, self.lat)
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq)]
111pub struct BoundingBox {
112 pub min_lon: f64,
114 pub min_lat: f64,
116 pub max_lon: f64,
118 pub max_lat: f64,
120}
121
122impl BoundingBox {
123 #[must_use]
125 pub fn new(min_lon: f64, min_lat: f64, max_lon: f64, max_lat: f64) -> Self {
126 Self {
127 min_lon,
128 min_lat,
129 max_lon,
130 max_lat,
131 }
132 }
133
134 #[must_use]
136 pub fn contains(&self, pt: LonLat) -> bool {
137 pt.lon >= self.min_lon
138 && pt.lon <= self.max_lon
139 && pt.lat >= self.min_lat
140 && pt.lat <= self.max_lat
141 }
142
143 #[must_use]
145 pub fn intersects(&self, other: &BoundingBox) -> bool {
146 !(other.min_lon > self.max_lon
147 || other.max_lon < self.min_lon
148 || other.min_lat > self.max_lat
149 || other.max_lat < self.min_lat)
150 }
151
152 pub fn expand_to_include(&mut self, pt: LonLat) {
154 if pt.lon < self.min_lon {
155 self.min_lon = pt.lon;
156 }
157 if pt.lon > self.max_lon {
158 self.max_lon = pt.lon;
159 }
160 if pt.lat < self.min_lat {
161 self.min_lat = pt.lat;
162 }
163 if pt.lat > self.max_lat {
164 self.max_lat = pt.lat;
165 }
166 }
167
168 #[must_use]
170 pub fn centre(&self) -> LonLat {
171 LonLat::new(
172 (self.min_lon + self.max_lon) / 2.0,
173 (self.min_lat + self.max_lat) / 2.0,
174 )
175 }
176
177 #[must_use]
179 pub fn width_deg(&self) -> f64 {
180 self.max_lon - self.min_lon
181 }
182
183 #[must_use]
185 pub fn height_deg(&self) -> f64 {
186 self.max_lat - self.min_lat
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum CrsCode {
197 Epsg4326,
199 Epsg3857,
201 Utm {
203 zone: u8,
205 north: bool,
207 },
208 Custom(String),
210}
211
212impl std::fmt::Display for CrsCode {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match self {
215 CrsCode::Epsg4326 => write!(f, "EPSG:4326"),
216 CrsCode::Epsg3857 => write!(f, "EPSG:3857"),
217 CrsCode::Utm { zone, north } => {
218 let hemi = if *north { "N" } else { "S" };
219 write!(f, "UTM Zone {zone}{hemi}")
220 }
221 CrsCode::Custom(s) => write!(f, "{s}"),
222 }
223 }
224}
225
226#[derive(Debug, Clone)]
228pub struct CrsMetadata {
229 pub code: CrsCode,
231 pub name: String,
233 pub datum: String,
235 pub units: String,
237}
238
239impl CrsMetadata {
240 #[must_use]
242 pub fn wgs84() -> Self {
243 Self {
244 code: CrsCode::Epsg4326,
245 name: "WGS 84".to_string(),
246 datum: "WGS 84".to_string(),
247 units: "degree".to_string(),
248 }
249 }
250
251 #[must_use]
253 pub fn web_mercator() -> Self {
254 Self {
255 code: CrsCode::Epsg3857,
256 name: "WGS 84 / Pseudo-Mercator".to_string(),
257 datum: "WGS 84".to_string(),
258 units: "metre".to_string(),
259 }
260 }
261
262 #[must_use]
264 pub fn to_wkt2(&self) -> String {
265 format!(
266 r#"GEOGCRS["{}",DATUM["{}"],CS[ellipsoidal,2],AXIS["longitude",east],AXIS["latitude",north],UNIT["{}",1]]"#,
267 self.name, self.datum, self.units
268 )
269 }
270}
271
272#[derive(Debug, Clone, PartialEq)]
278pub enum GeoGeometry {
279 Point(LonLat),
281 Point3D(LonLatElev),
283 LineString(Vec<LonLat>),
285 Polygon {
287 exterior: Vec<LonLat>,
289 holes: Vec<Vec<LonLat>>,
291 },
292 MultiPoint(Vec<LonLat>),
294 MultiLineString(Vec<Vec<LonLat>>),
296 MultiPolygon(Vec<(Vec<LonLat>, Vec<Vec<LonLat>>)>),
298 GeometryCollection(Vec<GeoGeometry>),
300}
301
302impl GeoGeometry {
303 #[must_use]
307 pub fn bounding_box(&self) -> Option<BoundingBox> {
308 let pts = self.collect_lonlat();
309 if pts.is_empty() {
310 return None;
311 }
312 let mut bb = BoundingBox::new(pts[0].lon, pts[0].lat, pts[0].lon, pts[0].lat);
313 for p in &pts[1..] {
314 bb.expand_to_include(*p);
315 }
316 Some(bb)
317 }
318
319 #[must_use]
321 pub fn collect_lonlat(&self) -> Vec<LonLat> {
322 match self {
323 GeoGeometry::Point(p) => vec![*p],
324 GeoGeometry::Point3D(p) => vec![p.to_lonlat()],
325 GeoGeometry::LineString(pts) => pts.clone(),
326 GeoGeometry::Polygon { exterior, holes } => {
327 let mut v = exterior.clone();
328 for h in holes {
329 v.extend_from_slice(h);
330 }
331 v
332 }
333 GeoGeometry::MultiPoint(pts) => pts.clone(),
334 GeoGeometry::MultiLineString(lines) => lines.iter().flatten().copied().collect(),
335 GeoGeometry::MultiPolygon(polys) => polys
336 .iter()
337 .flat_map(|(ext, holes)| {
338 let mut v = ext.clone();
339 for h in holes {
340 v.extend_from_slice(h);
341 }
342 v
343 })
344 .collect(),
345 GeoGeometry::GeometryCollection(gs) => {
346 gs.iter().flat_map(|g| g.collect_lonlat()).collect()
347 }
348 }
349 }
350
351 #[must_use]
353 pub fn type_str(&self) -> &'static str {
354 match self {
355 GeoGeometry::Point(_) | GeoGeometry::Point3D(_) => "Point",
356 GeoGeometry::LineString(_) => "LineString",
357 GeoGeometry::Polygon { .. } => "Polygon",
358 GeoGeometry::MultiPoint(_) => "MultiPoint",
359 GeoGeometry::MultiLineString(_) => "MultiLineString",
360 GeoGeometry::MultiPolygon(_) => "MultiPolygon",
361 GeoGeometry::GeometryCollection(_) => "GeometryCollection",
362 }
363 }
364}
365
366#[must_use]
372pub fn lonlat_to_json(p: LonLat) -> String {
373 format!("[{:.6},{:.6}]", p.lon, p.lat)
374}
375
376#[must_use]
378pub fn ring_to_json(pts: &[LonLat]) -> String {
379 let items: Vec<String> = pts.iter().map(|p| lonlat_to_json(*p)).collect();
380 format!("[{}]", items.join(","))
381}
382
383#[must_use]
385pub fn geometry_to_geojson(g: &GeoGeometry) -> String {
386 match g {
387 GeoGeometry::Point(p) => {
388 format!(r#"{{"type":"Point","coordinates":{}}}"#, lonlat_to_json(*p))
389 }
390 GeoGeometry::Point3D(p) => {
391 format!(
392 r#"{{"type":"Point","coordinates":[{:.6},{:.6},{:.6}]}}"#,
393 p.lon, p.lat, p.elev
394 )
395 }
396 GeoGeometry::LineString(pts) => {
397 let coords: Vec<String> = pts.iter().map(|p| lonlat_to_json(*p)).collect();
398 format!(
399 r#"{{"type":"LineString","coordinates":[{}]}}"#,
400 coords.join(",")
401 )
402 }
403 GeoGeometry::Polygon { exterior, holes } => {
404 let mut rings = vec![ring_to_json(exterior)];
405 for h in holes {
406 rings.push(ring_to_json(h));
407 }
408 format!(
409 r#"{{"type":"Polygon","coordinates":[{}]}}"#,
410 rings.join(",")
411 )
412 }
413 GeoGeometry::MultiPoint(pts) => {
414 let coords: Vec<String> = pts.iter().map(|p| lonlat_to_json(*p)).collect();
415 format!(
416 r#"{{"type":"MultiPoint","coordinates":[{}]}}"#,
417 coords.join(",")
418 )
419 }
420 GeoGeometry::MultiLineString(lines) => {
421 let ls: Vec<String> = lines
422 .iter()
423 .map(|line| {
424 let cs: Vec<String> = line.iter().map(|p| lonlat_to_json(*p)).collect();
425 format!("[{}]", cs.join(","))
426 })
427 .collect();
428 format!(
429 r#"{{"type":"MultiLineString","coordinates":[{}]}}"#,
430 ls.join(",")
431 )
432 }
433 GeoGeometry::MultiPolygon(polys) => {
434 let ps: Vec<String> = polys
435 .iter()
436 .map(|(ext, holes)| {
437 let mut rings = vec![ring_to_json(ext)];
438 for h in holes {
439 rings.push(ring_to_json(h));
440 }
441 format!("[{}]", rings.join(","))
442 })
443 .collect();
444 format!(
445 r#"{{"type":"MultiPolygon","coordinates":[{}]}}"#,
446 ps.join(",")
447 )
448 }
449 GeoGeometry::GeometryCollection(gs) => {
450 let inner: Vec<String> = gs.iter().map(geometry_to_geojson).collect();
451 format!(
452 r#"{{"type":"GeometryCollection","geometries":[{}]}}"#,
453 inner.join(",")
454 )
455 }
456 }
457}
458
459#[derive(Debug, Clone, PartialEq)]
465pub struct GeoProperty {
466 pub key: String,
468 pub value: String,
470}
471
472impl GeoProperty {
473 #[must_use]
475 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
476 Self {
477 key: key.into(),
478 value: value.into(),
479 }
480 }
481
482 #[must_use]
484 pub fn to_json_pair(&self) -> String {
485 format!(r#""{}":{}"#, self.key, json_string_or_number(&self.value))
486 }
487}
488
489fn json_string_or_number(s: &str) -> String {
491 if s.parse::<f64>().is_ok() {
492 s.to_string()
493 } else {
494 format!(r#""{s}""#)
495 }
496}
497
498#[derive(Debug, Clone)]
500pub struct GeoFeature {
501 pub id: Option<String>,
503 pub geometry: Option<GeoGeometry>,
505 pub properties: Vec<GeoProperty>,
507}
508
509impl GeoFeature {
510 #[must_use]
512 pub fn new(geometry: GeoGeometry) -> Self {
513 Self {
514 id: None,
515 geometry: Some(geometry),
516 properties: Vec::new(),
517 }
518 }
519
520 #[must_use]
522 pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
523 self.properties.push(GeoProperty::new(key, value));
524 self
525 }
526
527 #[must_use]
529 pub fn to_geojson(&self) -> String {
530 let geom_str = match &self.geometry {
531 Some(g) => geometry_to_geojson(g),
532 None => "null".to_string(),
533 };
534 let props: Vec<String> = self.properties.iter().map(|p| p.to_json_pair()).collect();
535 let id_str = match &self.id {
536 Some(id) => format!(r#","id":"{}""#, id),
537 None => String::new(),
538 };
539 format!(
540 r#"{{"type":"Feature"{id_str},"geometry":{geom_str},"properties":{{{}}}}}"#,
541 props.join(",")
542 )
543 }
544
545 #[must_use]
547 pub fn bounding_box(&self) -> Option<BoundingBox> {
548 self.geometry.as_ref().and_then(|g| g.bounding_box())
549 }
550}
551
552#[derive(Debug, Clone, Default)]
554pub struct FeatureCollection {
555 pub features: Vec<GeoFeature>,
557 pub crs: Option<CrsMetadata>,
559}
560
561impl FeatureCollection {
562 #[must_use]
564 pub fn new() -> Self {
565 Self::default()
566 }
567
568 pub fn add(&mut self, feature: GeoFeature) {
570 self.features.push(feature);
571 }
572
573 #[must_use]
575 pub fn len(&self) -> usize {
576 self.features.len()
577 }
578
579 #[must_use]
581 pub fn is_empty(&self) -> bool {
582 self.features.is_empty()
583 }
584
585 #[must_use]
587 pub fn query_bbox(&self, bbox: &BoundingBox) -> Vec<&GeoFeature> {
588 self.features
589 .iter()
590 .filter(|f| {
591 f.bounding_box()
592 .map(|bb| bb.intersects(bbox))
593 .unwrap_or(false)
594 })
595 .collect()
596 }
597
598 #[must_use]
600 pub fn to_geojson(&self) -> String {
601 let feats: Vec<String> = self.features.iter().map(|f| f.to_geojson()).collect();
602 format!(
603 r#"{{"type":"FeatureCollection","features":[{}]}}"#,
604 feats.join(",")
605 )
606 }
607
608 #[must_use]
610 pub fn total_bounding_box(&self) -> Option<BoundingBox> {
611 let mut all_pts: Vec<LonLat> = self
612 .features
613 .iter()
614 .flat_map(|f| {
615 f.geometry
616 .as_ref()
617 .map(|g| g.collect_lonlat())
618 .unwrap_or_default()
619 })
620 .collect();
621 if all_pts.is_empty() {
622 return None;
623 }
624 let first = all_pts.remove(0);
625 let mut bb = BoundingBox::new(first.lon, first.lat, first.lon, first.lat);
626 for p in all_pts {
627 bb.expand_to_include(p);
628 }
629 Some(bb)
630 }
631}
632
633#[must_use]
639pub fn geometry_to_wkt(g: &GeoGeometry) -> String {
640 match g {
641 GeoGeometry::Point(p) => format!("POINT ({:.6} {:.6})", p.lon, p.lat),
642 GeoGeometry::Point3D(p) => format!("POINT Z ({:.6} {:.6} {:.6})", p.lon, p.lat, p.elev),
643 GeoGeometry::LineString(pts) => {
644 let coords = pts_to_wkt_coords(pts);
645 format!("LINESTRING ({coords})")
646 }
647 GeoGeometry::Polygon { exterior, holes } => {
648 let mut rings = vec![format!("({})", pts_to_wkt_coords(exterior))];
649 for h in holes {
650 rings.push(format!("({})", pts_to_wkt_coords(h)));
651 }
652 format!("POLYGON ({})", rings.join(","))
653 }
654 GeoGeometry::MultiPoint(pts) => {
655 let coords: Vec<String> = pts
656 .iter()
657 .map(|p| format!("({:.6} {:.6})", p.lon, p.lat))
658 .collect();
659 format!("MULTIPOINT ({})", coords.join(","))
660 }
661 GeoGeometry::MultiLineString(lines) => {
662 let ls: Vec<String> = lines
663 .iter()
664 .map(|l| format!("({})", pts_to_wkt_coords(l)))
665 .collect();
666 format!("MULTILINESTRING ({})", ls.join(","))
667 }
668 GeoGeometry::MultiPolygon(polys) => {
669 let ps: Vec<String> = polys
670 .iter()
671 .map(|(ext, holes)| {
672 let mut rings = vec![format!("({})", pts_to_wkt_coords(ext))];
673 for h in holes {
674 rings.push(format!("({})", pts_to_wkt_coords(h)));
675 }
676 format!("({})", rings.join(","))
677 })
678 .collect();
679 format!("MULTIPOLYGON ({})", ps.join(","))
680 }
681 GeoGeometry::GeometryCollection(gs) => {
682 let inner: Vec<String> = gs.iter().map(geometry_to_wkt).collect();
683 format!("GEOMETRYCOLLECTION ({})", inner.join(","))
684 }
685 }
686}
687
688fn pts_to_wkt_coords(pts: &[LonLat]) -> String {
689 pts.iter()
690 .map(|p| format!("{:.6} {:.6}", p.lon, p.lat))
691 .collect::<Vec<_>>()
692 .join(",")
693}
694
695pub fn parse_wkt_point(wkt: &str) -> GeoResult<GeoGeometry> {
700 let s = wkt.trim();
701 if !s.to_uppercase().starts_with("POINT") {
702 return Err(GeoError::ParseError(format!("expected POINT, got: {s}")));
703 }
704 let inner = s
705 .find('(')
706 .and_then(|i| {
707 let rest = &s[i + 1..];
708 rest.rfind(')').map(|j| &rest[..j])
709 })
710 .ok_or_else(|| GeoError::ParseError("missing parentheses".to_string()))?;
711 let parts: Vec<f64> = inner
712 .split_whitespace()
713 .map(|tok| tok.parse::<f64>())
714 .collect::<Result<_, _>>()
715 .map_err(|e| GeoError::ParseError(e.to_string()))?;
716 match parts.as_slice() {
717 [lon, lat] => Ok(GeoGeometry::Point(LonLat::new(*lon, *lat))),
718 [lon, lat, elev] => Ok(GeoGeometry::Point3D(LonLatElev::new(*lon, *lat, *elev))),
719 _ => Err(GeoError::ParseError(
720 "expected 2 or 3 coordinates".to_string(),
721 )),
722 }
723}
724
725#[derive(Debug, Clone, Copy)]
731pub struct MercatorProjection {
732 pub earth_radius_m: f64,
734}
735
736impl MercatorProjection {
737 #[must_use]
739 pub fn web_mercator() -> Self {
740 Self {
741 earth_radius_m: 6_378_137.0,
742 }
743 }
744
745 #[must_use]
749 pub fn forward(&self, pt: LonLat) -> [f64; 2] {
750 let lat_clamp = pt.lat.clamp(-85.051_129, 85.051_129);
751 let x = self.earth_radius_m * pt.lon.to_radians();
752 let y = self.earth_radius_m
753 * ((std::f64::consts::FRAC_PI_4 + lat_clamp.to_radians() / 2.0).tan()).ln();
754 [x, y]
755 }
756
757 #[must_use]
759 pub fn inverse(&self, xy: [f64; 2]) -> LonLat {
760 let lon = xy[0].to_degrees() / self.earth_radius_m;
761 let lat = (2.0 * (xy[1] / self.earth_radius_m).exp().atan() - std::f64::consts::FRAC_PI_2)
762 .to_degrees();
763 LonLat::new(lon, lat)
764 }
765
766 #[must_use]
768 pub fn forward_all(&self, pts: &[LonLat]) -> Vec<[f64; 2]> {
769 pts.iter().map(|p| self.forward(*p)).collect()
770 }
771}
772
773#[derive(Debug, Clone)]
779pub struct DemRaster {
780 pub bbox: BoundingBox,
782 pub cols: usize,
784 pub rows: usize,
786 pub data: Vec<f32>,
788 pub nodata: f32,
790}
791
792impl DemRaster {
793 #[must_use]
795 pub fn zeros(bbox: BoundingBox, cols: usize, rows: usize) -> Self {
796 Self {
797 bbox,
798 cols,
799 rows,
800 data: vec![0.0_f32; cols * rows],
801 nodata: -9999.0,
802 }
803 }
804
805 #[must_use]
807 pub fn cell_width_deg(&self) -> f64 {
808 bbox_cell_size(self.bbox.min_lon, self.bbox.max_lon, self.cols)
809 }
810
811 #[must_use]
813 pub fn cell_height_deg(&self) -> f64 {
814 bbox_cell_size(self.bbox.min_lat, self.bbox.max_lat, self.rows)
815 }
816
817 #[must_use]
822 pub fn sample(&self, pt: LonLat) -> Option<f32> {
823 if !self.bbox.contains(pt) {
824 return None;
825 }
826 let fx = (pt.lon - self.bbox.min_lon) / self.cell_width_deg();
827 let fy = (pt.lat - self.bbox.min_lat) / self.cell_height_deg();
828 let col = fx.floor() as usize;
829 let row = fy.floor() as usize;
830 let col = col.min(self.cols - 1);
831 let row = row.min(self.rows - 1);
832 let v = self.data[row * self.cols + col];
833 if v == self.nodata { None } else { Some(v) }
834 }
835
836 #[must_use]
838 pub fn min_elevation(&self) -> Option<f32> {
839 self.data
840 .iter()
841 .copied()
842 .filter(|&v| v != self.nodata)
843 .reduce(f32::min)
844 }
845
846 #[must_use]
848 pub fn max_elevation(&self) -> Option<f32> {
849 self.data
850 .iter()
851 .copied()
852 .filter(|&v| v != self.nodata)
853 .reduce(f32::max)
854 }
855
856 #[must_use]
858 pub fn to_ascii_grid(&self) -> String {
859 let mut s = String::new();
860 s.push_str(&format!("ncols {}\n", self.cols));
861 s.push_str(&format!("nrows {}\n", self.rows));
862 s.push_str(&format!("xllcorner {:.6}\n", self.bbox.min_lon));
863 s.push_str(&format!("yllcorner {:.6}\n", self.bbox.min_lat));
864 s.push_str(&format!("cellsize {:.6}\n", self.cell_width_deg()));
865 s.push_str(&format!("NODATA_value {:.1}\n", self.nodata));
866 for row in (0..self.rows).rev() {
867 let row_vals: Vec<String> = (0..self.cols)
868 .map(|c| format!("{:.1}", self.data[row * self.cols + c]))
869 .collect();
870 s.push_str(&row_vals.join(" "));
871 s.push('\n');
872 }
873 s
874 }
875}
876
877fn bbox_cell_size(lo: f64, hi: f64, n: usize) -> f64 {
878 if n == 0 { 0.0 } else { (hi - lo) / n as f64 }
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
887pub struct TileId {
888 pub zoom: u8,
890 pub x: u32,
892 pub y: u32,
894}
895
896impl TileId {
897 #[must_use]
899 pub fn new(zoom: u8, x: u32, y: u32) -> Self {
900 Self { zoom, x, y }
901 }
902
903 #[must_use]
905 pub fn from_lonlat(pt: LonLat, zoom: u8) -> Self {
906 let n = 2_u32.pow(zoom as u32) as f64;
907 let x = ((pt.lon + 180.0) / 360.0 * n).floor() as u32;
908 let lat_rad = pt.lat.to_radians();
909 let y = ((1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0
910 * n)
911 .floor() as u32;
912 Self::new(zoom, x, y)
913 }
914
915 #[must_use]
917 pub fn bbox(&self) -> BoundingBox {
918 let n = 2_u32.pow(self.zoom as u32) as f64;
919 let min_lon = self.x as f64 / n * 360.0 - 180.0;
920 let max_lon = (self.x + 1) as f64 / n * 360.0 - 180.0;
921 let lat_north = tile_lat(self.y, n);
922 let lat_south = tile_lat(self.y + 1, n);
923 BoundingBox::new(min_lon, lat_south, max_lon, lat_north)
924 }
925
926 #[must_use]
928 pub fn children(&self) -> [TileId; 4] {
929 let z = self.zoom + 1;
930 let x2 = self.x * 2;
931 let y2 = self.y * 2;
932 [
933 TileId::new(z, x2, y2),
934 TileId::new(z, x2 + 1, y2),
935 TileId::new(z, x2, y2 + 1),
936 TileId::new(z, x2 + 1, y2 + 1),
937 ]
938 }
939}
940
941fn tile_lat(y: u32, n: f64) -> f64 {
942 let sinh_val = std::f64::consts::PI * (1.0 - 2.0 * y as f64 / n);
943 sinh_val.sinh().atan().to_degrees()
944}
945
946#[derive(Debug, Clone)]
948pub struct TerrainTile {
949 pub id: TileId,
951 pub elevation: Vec<f32>,
953 pub size: usize,
955}
956
957impl TerrainTile {
958 #[must_use]
960 pub fn empty(id: TileId, size: usize) -> Self {
961 Self {
962 id,
963 elevation: vec![0.0; size * size],
964 size,
965 }
966 }
967
968 #[must_use]
970 pub fn sample_uv(&self, u: f64, v: f64) -> f32 {
971 let col = (u.clamp(0.0, 1.0) * (self.size - 1) as f64).round() as usize;
972 let row = (v.clamp(0.0, 1.0) * (self.size - 1) as f64).round() as usize;
973 self.elevation[row * self.size + col]
974 }
975}
976
977const GEOHASH_CHARS: &[u8] = b"0123456789bcdefghjkmnpqrstuvwxyz";
982
983#[must_use]
987pub fn geohash_encode(pt: LonLat, precision: usize) -> String {
988 assert!((1..=12).contains(&precision), "precision must be 1–12");
989 let mut lat_range = (-90.0_f64, 90.0_f64);
990 let mut lon_range = (-180.0_f64, 180.0_f64);
991 let mut bits_total = precision * 5;
992 let mut result = Vec::with_capacity(precision);
993 let mut idx: u8 = 0;
994 let mut bit = 0u8;
995 let mut is_lon = true;
996 while bits_total > 0 {
997 if is_lon {
998 let mid = (lon_range.0 + lon_range.1) / 2.0;
999 if pt.lon >= mid {
1000 idx = (idx << 1) | 1;
1001 lon_range.0 = mid;
1002 } else {
1003 idx <<= 1;
1004 lon_range.1 = mid;
1005 }
1006 } else {
1007 let mid = (lat_range.0 + lat_range.1) / 2.0;
1008 if pt.lat >= mid {
1009 idx = (idx << 1) | 1;
1010 lat_range.0 = mid;
1011 } else {
1012 idx <<= 1;
1013 lat_range.1 = mid;
1014 }
1015 }
1016 is_lon = !is_lon;
1017 bit += 1;
1018 bits_total -= 1;
1019 if bit == 5 {
1020 result.push(GEOHASH_CHARS[idx as usize] as char);
1021 idx = 0;
1022 bit = 0;
1023 }
1024 }
1025 result.into_iter().collect()
1026}
1027
1028pub fn geohash_decode(hash: &str) -> GeoResult<(LonLat, f64, f64)> {
1034 let mut lat_range = (-90.0_f64, 90.0_f64);
1035 let mut lon_range = (-180.0_f64, 180.0_f64);
1036 let mut is_lon = true;
1037 for ch in hash.chars() {
1038 let idx = GEOHASH_CHARS
1039 .iter()
1040 .position(|&c| c as char == ch)
1041 .ok_or_else(|| GeoError::ParseError(format!("invalid geohash char '{ch}'")))?;
1042 for bit in (0..5).rev() {
1043 let b = (idx >> bit) & 1;
1044 if is_lon {
1045 let mid = (lon_range.0 + lon_range.1) / 2.0;
1046 if b == 1 {
1047 lon_range.0 = mid;
1048 } else {
1049 lon_range.1 = mid;
1050 }
1051 } else {
1052 let mid = (lat_range.0 + lat_range.1) / 2.0;
1053 if b == 1 {
1054 lat_range.0 = mid;
1055 } else {
1056 lat_range.1 = mid;
1057 }
1058 }
1059 is_lon = !is_lon;
1060 }
1061 }
1062 let lon = (lon_range.0 + lon_range.1) / 2.0;
1063 let lat = (lat_range.0 + lat_range.1) / 2.0;
1064 let lon_err = (lon_range.1 - lon_range.0) / 2.0;
1065 let lat_err = (lat_range.1 - lat_range.0) / 2.0;
1066 Ok((LonLat::new(lon, lat), lon_err, lat_err))
1067}
1068
1069#[derive(Debug, Clone)]
1075pub struct SpatialEntry {
1076 pub bbox: BoundingBox,
1078 pub id: usize,
1080}
1081
1082#[derive(Debug, Clone, Default)]
1087pub struct SpatialIndex {
1088 entries: Vec<SpatialEntry>,
1089}
1090
1091impl SpatialIndex {
1092 #[must_use]
1094 pub fn new() -> Self {
1095 Self::default()
1096 }
1097
1098 pub fn insert(&mut self, bbox: BoundingBox, id: usize) {
1100 self.entries.push(SpatialEntry { bbox, id });
1101 }
1102
1103 #[must_use]
1105 pub fn query(&self, query: &BoundingBox) -> Vec<usize> {
1106 self.entries
1107 .iter()
1108 .filter(|e| e.bbox.intersects(query))
1109 .map(|e| e.id)
1110 .collect()
1111 }
1112
1113 #[must_use]
1115 pub fn len(&self) -> usize {
1116 self.entries.len()
1117 }
1118
1119 #[must_use]
1121 pub fn is_empty(&self) -> bool {
1122 self.entries.is_empty()
1123 }
1124
1125 pub fn clear(&mut self) {
1127 self.entries.clear();
1128 }
1129}
1130
1131#[must_use]
1133pub fn index_feature_collection(fc: &FeatureCollection) -> SpatialIndex {
1134 let mut idx = SpatialIndex::new();
1135 for (i, f) in fc.features.iter().enumerate() {
1136 if let Some(bb) = f.bounding_box() {
1137 idx.insert(bb, i);
1138 }
1139 }
1140 idx
1141}
1142
1143#[derive(Debug, Clone, PartialEq, Eq)]
1149pub enum DbfFieldType {
1150 Character,
1152 Numeric,
1154 Logical,
1156 Date,
1158}
1159
1160#[derive(Debug, Clone)]
1162pub struct DbfFieldDescriptor {
1163 pub name: String,
1165 pub field_type: DbfFieldType,
1167 pub length: u8,
1169 pub decimals: u8,
1171}
1172
1173impl DbfFieldDescriptor {
1174 #[must_use]
1176 pub fn character(name: impl Into<String>, length: u8) -> Self {
1177 Self {
1178 name: name.into(),
1179 field_type: DbfFieldType::Character,
1180 length,
1181 decimals: 0,
1182 }
1183 }
1184
1185 #[must_use]
1187 pub fn numeric(name: impl Into<String>, length: u8, decimals: u8) -> Self {
1188 Self {
1189 name: name.into(),
1190 field_type: DbfFieldType::Numeric,
1191 length,
1192 decimals,
1193 }
1194 }
1195}
1196
1197#[derive(Debug, Clone, Default)]
1199pub struct DbfRecord {
1200 pub values: Vec<String>,
1202 pub deleted: bool,
1204}
1205
1206impl DbfRecord {
1207 #[must_use]
1209 pub fn new(values: Vec<String>) -> Self {
1210 Self {
1211 values,
1212 deleted: false,
1213 }
1214 }
1215}
1216
1217#[derive(Debug, Clone)]
1219pub struct DbfTable {
1220 pub fields: Vec<DbfFieldDescriptor>,
1222 pub records: Vec<DbfRecord>,
1224}
1225
1226impl DbfTable {
1227 #[must_use]
1229 pub fn new(fields: Vec<DbfFieldDescriptor>) -> Self {
1230 Self {
1231 fields,
1232 records: Vec::new(),
1233 }
1234 }
1235
1236 pub fn append(&mut self, record: DbfRecord) -> GeoResult<()> {
1238 if record.values.len() != self.fields.len() {
1239 return Err(GeoError::ParseError(format!(
1240 "expected {} values, got {}",
1241 self.fields.len(),
1242 record.values.len()
1243 )));
1244 }
1245 self.records.push(record);
1246 Ok(())
1247 }
1248
1249 #[must_use]
1251 pub fn record_count(&self) -> usize {
1252 self.records.iter().filter(|r| !r.deleted).count()
1253 }
1254
1255 #[must_use]
1257 pub fn field_index(&self, name: &str) -> Option<usize> {
1258 self.fields.iter().position(|f| f.name == name)
1259 }
1260
1261 #[must_use]
1263 pub fn get_value(&self, record_idx: usize, field_name: &str) -> Option<&str> {
1264 let col = self.field_index(field_name)?;
1265 self.records.get(record_idx).map(|r| r.values[col].as_str())
1266 }
1267
1268 #[must_use]
1270 pub fn to_csv(&self) -> String {
1271 let header: Vec<&str> = self.fields.iter().map(|f| f.name.as_str()).collect();
1272 let mut lines = vec![header.join(",")];
1273 for rec in &self.records {
1274 if !rec.deleted {
1275 lines.push(rec.values.join(","));
1276 }
1277 }
1278 lines.join("\n")
1279 }
1280}
1281
1282#[derive(Debug, Clone)]
1288pub struct PointShapefile {
1289 pub points: Vec<LonLat>,
1291 pub attributes: DbfTable,
1293}
1294
1295impl PointShapefile {
1296 #[must_use]
1298 pub fn new(fields: Vec<DbfFieldDescriptor>) -> Self {
1299 Self {
1300 points: Vec::new(),
1301 attributes: DbfTable::new(fields),
1302 }
1303 }
1304
1305 pub fn add_point(&mut self, pt: LonLat, values: Vec<String>) -> GeoResult<()> {
1307 self.points.push(pt);
1308 self.attributes.append(DbfRecord::new(values))
1309 }
1310
1311 #[must_use]
1313 pub fn to_geojson(&self) -> String {
1314 let mut fc = FeatureCollection::new();
1315 for (i, pt) in self.points.iter().enumerate() {
1316 let mut feat = GeoFeature::new(GeoGeometry::Point(*pt));
1317 for (fi, field) in self.attributes.fields.iter().enumerate() {
1318 if let Some(rec) = self.attributes.records.get(i) {
1319 feat = feat.with_property(&field.name, &rec.values[fi]);
1320 }
1321 }
1322 fc.add(feat);
1323 }
1324 fc.to_geojson()
1325 }
1326}
1327
1328#[must_use]
1335pub fn dem_contour_lines(dem: &DemRaster, level: f32) -> Vec<Vec<LonLat>> {
1336 let mut lines = Vec::new();
1337 let cw = dem.cell_width_deg();
1338 let ch = dem.cell_height_deg();
1339 for row in 0..dem.rows.saturating_sub(1) {
1340 for col in 0..dem.cols.saturating_sub(1) {
1341 let v00 = dem.data[row * dem.cols + col];
1342 let v10 = dem.data[row * dem.cols + col + 1];
1343 let v01 = dem.data[(row + 1) * dem.cols + col];
1344 let v11 = dem.data[(row + 1) * dem.cols + col + 1];
1345 let pts = marching_square_pts(v00, v10, v01, v11, level, col, row, cw, ch, dem);
1347 if pts.len() >= 2 {
1348 lines.push(pts);
1349 }
1350 }
1351 }
1352 lines
1353}
1354
1355#[allow(clippy::too_many_arguments)]
1356fn marching_square_pts(
1357 v00: f32,
1358 v10: f32,
1359 v01: f32,
1360 v11: f32,
1361 level: f32,
1362 col: usize,
1363 row: usize,
1364 cw: f64,
1365 ch: f64,
1366 dem: &DemRaster,
1367) -> Vec<LonLat> {
1368 let mut pts = Vec::new();
1369 let lon0 = dem.bbox.min_lon + col as f64 * cw;
1370 let lat0 = dem.bbox.min_lat + row as f64 * ch;
1371 if (v00 < level) != (v10 < level) {
1373 let t = (level - v00) / (v10 - v00);
1374 pts.push(LonLat::new(lon0 + t as f64 * cw, lat0));
1375 }
1376 if (v00 < level) != (v01 < level) {
1378 let t = (level - v00) / (v01 - v00);
1379 pts.push(LonLat::new(lon0, lat0 + t as f64 * ch));
1380 }
1381 if (v01 < level) != (v11 < level) {
1383 let t = (level - v01) / (v11 - v01);
1384 pts.push(LonLat::new(lon0 + t as f64 * cw, lat0 + ch));
1385 }
1386 if (v10 < level) != (v11 < level) {
1388 let t = (level - v10) / (v11 - v10);
1389 pts.push(LonLat::new(lon0 + cw, lat0 + t as f64 * ch));
1390 }
1391 pts
1392}
1393
1394#[must_use]
1403pub fn extract_json_string(json: &str, key: &str) -> Option<String> {
1404 let pattern = format!(r#""{key}":""#);
1405 let start = json.find(&pattern)? + pattern.len();
1406 let rest = &json[start..];
1407 let end = rest.find('"')?;
1408 Some(rest[..end].to_string())
1409}
1410
1411pub fn parse_geojson_point(json: &str) -> GeoResult<LonLat> {
1413 let coord_key = r#""coordinates":["#;
1415 let start = json
1416 .find(coord_key)
1417 .ok_or_else(|| GeoError::ParseError("no coordinates key".to_string()))?
1418 + coord_key.len();
1419 let rest = &json[start..];
1420 let end = rest
1421 .find(']')
1422 .ok_or_else(|| GeoError::ParseError("no closing bracket".to_string()))?;
1423 let coords: Vec<f64> = rest[..end]
1424 .split(',')
1425 .map(|s| s.trim().parse::<f64>())
1426 .collect::<Result<_, _>>()
1427 .map_err(|e| GeoError::ParseError(e.to_string()))?;
1428 match coords.as_slice() {
1429 [lon, lat] => Ok(LonLat::new(*lon, *lat)),
1430 [lon, lat, _] => Ok(LonLat::new(*lon, *lat)),
1431 _ => Err(GeoError::ParseError(
1432 "unexpected coordinate count".to_string(),
1433 )),
1434 }
1435}
1436
1437#[cfg(test)]
1442mod tests {
1443 use super::*;
1444
1445 #[test]
1448 fn test_lonlat_new() {
1449 let p = LonLat::new(13.4050, 52.5200);
1450 assert!((p.lon - 13.4050).abs() < 1e-10);
1451 assert!((p.lat - 52.5200).abs() < 1e-10);
1452 }
1453
1454 #[test]
1455 fn test_haversine_same_point() {
1456 let p = LonLat::new(0.0, 0.0);
1457 assert!(p.haversine_m(&p) < 1e-6);
1458 }
1459
1460 #[test]
1461 fn test_haversine_equator() {
1462 let a = LonLat::new(0.0, 0.0);
1464 let b = LonLat::new(1.0, 0.0);
1465 let d = a.haversine_m(&b);
1466 assert!((d - 111_195.0).abs() < 200.0);
1467 }
1468
1469 #[test]
1472 fn test_bbox_contains() {
1473 let bb = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
1474 assert!(bb.contains(LonLat::new(5.0, 5.0)));
1475 assert!(!bb.contains(LonLat::new(11.0, 5.0)));
1476 }
1477
1478 #[test]
1479 fn test_bbox_intersects() {
1480 let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
1481 let b = BoundingBox::new(5.0, 5.0, 15.0, 15.0);
1482 assert!(a.intersects(&b));
1483 let c = BoundingBox::new(11.0, 0.0, 20.0, 10.0);
1484 assert!(!a.intersects(&c));
1485 }
1486
1487 #[test]
1488 fn test_bbox_centre() {
1489 let bb = BoundingBox::new(-10.0, -10.0, 10.0, 10.0);
1490 let c = bb.centre();
1491 assert!((c.lon).abs() < 1e-10);
1492 assert!((c.lat).abs() < 1e-10);
1493 }
1494
1495 #[test]
1496 fn test_bbox_expand() {
1497 let mut bb = BoundingBox::new(0.0, 0.0, 1.0, 1.0);
1498 bb.expand_to_include(LonLat::new(5.0, 5.0));
1499 assert!((bb.max_lon - 5.0).abs() < 1e-10);
1500 assert!((bb.max_lat - 5.0).abs() < 1e-10);
1501 }
1502
1503 #[test]
1506 fn test_geometry_type_str() {
1507 let g = GeoGeometry::Point(LonLat::new(0.0, 0.0));
1508 assert_eq!(g.type_str(), "Point");
1509 }
1510
1511 #[test]
1512 fn test_collect_lonlat_polygon() {
1513 let g = GeoGeometry::Polygon {
1514 exterior: vec![
1515 LonLat::new(0.0, 0.0),
1516 LonLat::new(1.0, 0.0),
1517 LonLat::new(1.0, 1.0),
1518 LonLat::new(0.0, 0.0),
1519 ],
1520 holes: vec![],
1521 };
1522 assert_eq!(g.collect_lonlat().len(), 4);
1523 }
1524
1525 #[test]
1526 fn test_bounding_box_linestring() {
1527 let g = GeoGeometry::LineString(vec![LonLat::new(-1.0, -1.0), LonLat::new(1.0, 1.0)]);
1528 let bb = g.bounding_box().unwrap();
1529 assert!((bb.min_lon - (-1.0)).abs() < 1e-10);
1530 assert!((bb.max_lat - 1.0).abs() < 1e-10);
1531 }
1532
1533 #[test]
1536 fn test_geometry_to_geojson_point() {
1537 let g = GeoGeometry::Point(LonLat::new(13.405, 52.52));
1538 let s = geometry_to_geojson(&g);
1539 assert!(s.contains("\"type\":\"Point\""));
1540 assert!(s.contains("13.405000"));
1541 }
1542
1543 #[test]
1544 fn test_geometry_to_geojson_linestring() {
1545 let g = GeoGeometry::LineString(vec![LonLat::new(0.0, 0.0), LonLat::new(1.0, 1.0)]);
1546 let s = geometry_to_geojson(&g);
1547 assert!(s.contains("\"type\":\"LineString\""));
1548 }
1549
1550 #[test]
1551 fn test_feature_collection_to_geojson() {
1552 let mut fc = FeatureCollection::new();
1553 fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(0.0, 0.0))));
1554 let s = fc.to_geojson();
1555 assert!(s.contains("\"type\":\"FeatureCollection\""));
1556 }
1557
1558 #[test]
1559 fn test_feature_with_properties() {
1560 let f = GeoFeature::new(GeoGeometry::Point(LonLat::new(10.0, 20.0)))
1561 .with_property("name", "test")
1562 .with_property("value", "42");
1563 let s = f.to_geojson();
1564 assert!(s.contains("\"name\":\"test\""));
1565 assert!(s.contains("\"value\":42"));
1566 }
1567
1568 #[test]
1569 fn test_query_bbox_filters() {
1570 let mut fc = FeatureCollection::new();
1571 fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(5.0, 5.0))));
1572 fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(50.0, 50.0))));
1573 let query = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
1574 let results = fc.query_bbox(&query);
1575 assert_eq!(results.len(), 1);
1576 }
1577
1578 #[test]
1581 fn test_geometry_to_wkt_point() {
1582 let g = GeoGeometry::Point(LonLat::new(10.0, 20.0));
1583 let s = geometry_to_wkt(&g);
1584 assert!(s.starts_with("POINT ("));
1585 assert!(s.contains("10.000000"));
1586 }
1587
1588 #[test]
1589 fn test_parse_wkt_point_2d() {
1590 let g = parse_wkt_point("POINT (10.5 20.5)").unwrap();
1591 if let GeoGeometry::Point(p) = g {
1592 assert!((p.lon - 10.5).abs() < 1e-6);
1593 assert!((p.lat - 20.5).abs() < 1e-6);
1594 } else {
1595 panic!("expected Point");
1596 }
1597 }
1598
1599 #[test]
1600 fn test_parse_wkt_point_3d() {
1601 let g = parse_wkt_point("POINT Z (1.0 2.0 3.0)").unwrap();
1602 assert!(matches!(g, GeoGeometry::Point3D(_)));
1603 }
1604
1605 #[test]
1606 fn test_parse_wkt_invalid() {
1607 assert!(parse_wkt_point("LINESTRING (0 0, 1 1)").is_err());
1608 }
1609
1610 #[test]
1613 fn test_mercator_forward_origin() {
1614 let m = MercatorProjection::web_mercator();
1615 let xy = m.forward(LonLat::new(0.0, 0.0));
1616 assert!(xy[0].abs() < 1e-6);
1617 assert!(xy[1].abs() < 1e-6);
1618 }
1619
1620 #[test]
1621 fn test_mercator_round_trip() {
1622 let m = MercatorProjection::web_mercator();
1623 let orig = LonLat::new(30.0, 45.0);
1624 let xy = m.forward(orig);
1625 let back = m.inverse(xy);
1626 assert!((back.lon - orig.lon).abs() < 1e-6);
1627 assert!((back.lat - orig.lat).abs() < 1e-4);
1628 }
1629
1630 #[test]
1633 fn test_dem_zeros_min_max() {
1634 let dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 10, 10);
1635 assert_eq!(dem.min_elevation(), Some(0.0));
1636 assert_eq!(dem.max_elevation(), Some(0.0));
1637 }
1638
1639 #[test]
1640 fn test_dem_sample_inside() {
1641 let mut dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 10, 10);
1642 dem.data[55] = 100.0;
1643 let v = dem.sample(LonLat::new(0.05, 0.05));
1645 assert!(v.is_some());
1646 }
1647
1648 #[test]
1649 fn test_dem_sample_outside() {
1650 let dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 10, 10);
1651 let v = dem.sample(LonLat::new(5.0, 5.0));
1652 assert!(v.is_none());
1653 }
1654
1655 #[test]
1656 fn test_dem_ascii_grid_header() {
1657 let dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 4, 4);
1658 let s = dem.to_ascii_grid();
1659 assert!(s.contains("ncols 4"));
1660 assert!(s.contains("nrows 4"));
1661 }
1662
1663 #[test]
1666 fn test_tile_from_lonlat_zoom0() {
1667 let t = TileId::from_lonlat(LonLat::new(0.0, 0.0), 0);
1668 assert_eq!(t.zoom, 0);
1669 assert_eq!(t.x, 0);
1670 assert_eq!(t.y, 0);
1671 }
1672
1673 #[test]
1674 fn test_tile_bbox_width() {
1675 let t = TileId::new(1, 0, 0);
1676 let bb = t.bbox();
1677 assert!((bb.width_deg() - 180.0).abs() < 0.001);
1679 }
1680
1681 #[test]
1682 fn test_tile_children_count() {
1683 let t = TileId::new(2, 1, 1);
1684 let kids = t.children();
1685 assert_eq!(kids.len(), 4);
1686 }
1687
1688 #[test]
1689 fn test_terrain_tile_sample_uv() {
1690 let mut tile = TerrainTile::empty(TileId::new(0, 0, 0), 4);
1691 tile.elevation[5] = 500.0;
1692 let _ = tile.sample_uv(0.5, 0.5);
1694 }
1695
1696 #[test]
1699 fn test_geohash_encode_length() {
1700 let h = geohash_encode(LonLat::new(13.4050, 52.5200), 7);
1701 assert_eq!(h.len(), 7);
1702 }
1703
1704 #[test]
1705 fn test_geohash_decode_round_trip() {
1706 let orig = LonLat::new(10.0, 50.0);
1707 let hash = geohash_encode(orig, 9);
1708 let (decoded, lon_err, lat_err) = geohash_decode(&hash).unwrap();
1709 assert!((decoded.lon - orig.lon).abs() < lon_err + 1e-5);
1710 assert!((decoded.lat - orig.lat).abs() < lat_err + 1e-5);
1711 }
1712
1713 #[test]
1714 fn test_geohash_decode_invalid() {
1715 assert!(geohash_decode("invalid!").is_err());
1716 }
1717
1718 #[test]
1721 fn test_spatial_index_query() {
1722 let mut idx = SpatialIndex::new();
1723 idx.insert(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 0);
1724 idx.insert(BoundingBox::new(10.0, 10.0, 20.0, 20.0), 1);
1725 let results = idx.query(&BoundingBox::new(0.5, 0.5, 2.0, 2.0));
1726 assert!(results.contains(&0));
1727 assert!(!results.contains(&1));
1728 }
1729
1730 #[test]
1731 fn test_spatial_index_empty() {
1732 let idx = SpatialIndex::new();
1733 assert!(idx.is_empty());
1734 }
1735
1736 #[test]
1737 fn test_index_feature_collection() {
1738 let mut fc = FeatureCollection::new();
1739 fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(5.0, 5.0))));
1740 fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(15.0, 15.0))));
1741 let idx = index_feature_collection(&fc);
1742 assert_eq!(idx.len(), 2);
1743 }
1744
1745 #[test]
1748 fn test_dbf_append_ok() {
1749 let mut tbl = DbfTable::new(vec![DbfFieldDescriptor::character("name", 50)]);
1750 tbl.append(DbfRecord::new(vec!["hello".to_string()]))
1751 .unwrap();
1752 assert_eq!(tbl.record_count(), 1);
1753 }
1754
1755 #[test]
1756 fn test_dbf_append_mismatch() {
1757 let mut tbl = DbfTable::new(vec![DbfFieldDescriptor::character("name", 50)]);
1758 let result = tbl.append(DbfRecord::new(vec!["a".to_string(), "b".to_string()]));
1759 assert!(result.is_err());
1760 }
1761
1762 #[test]
1763 fn test_dbf_get_value() {
1764 let mut tbl = DbfTable::new(vec![
1765 DbfFieldDescriptor::character("city", 50),
1766 DbfFieldDescriptor::numeric("pop", 10, 0),
1767 ]);
1768 tbl.append(DbfRecord::new(vec![
1769 "Berlin".to_string(),
1770 "3700000".to_string(),
1771 ]))
1772 .unwrap();
1773 assert_eq!(tbl.get_value(0, "city"), Some("Berlin"));
1774 }
1775
1776 #[test]
1777 fn test_dbf_to_csv() {
1778 let mut tbl = DbfTable::new(vec![DbfFieldDescriptor::character("x", 10)]);
1779 tbl.append(DbfRecord::new(vec!["foo".to_string()])).unwrap();
1780 let csv = tbl.to_csv();
1781 assert!(csv.contains("x\nfoo"));
1782 }
1783}