Skip to main content

solverforge_maps/routing/
network.rs

1//! Road network graph core types and routing.
2
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5
6use super::algo::{astar, kosaraju_scc};
7use super::coord::Coord;
8use super::error::RoutingError;
9use super::geo::{coord_key, haversine_distance};
10use super::graph::{DiGraph, EdgeIdx, NodeIdx};
11use super::spatial::{KdTree, Point2D, Segment, SegmentIndex};
12
13#[derive(Debug, Clone)]
14pub struct NodeData {
15    pub lat: f64,
16    pub lng: f64,
17}
18
19impl NodeData {
20    #[inline]
21    pub fn coord(&self) -> Coord {
22        Coord::new(self.lat, self.lng)
23    }
24}
25
26#[derive(Debug, Clone)]
27pub struct EdgeData {
28    pub travel_time_s: f64,
29    pub distance_m: f64,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct RouteResult {
34    pub duration_seconds: i64,
35    pub distance_meters: f64,
36    pub geometry: Vec<Coord>,
37}
38
39impl RouteResult {
40    /// Simplify the route geometry using Douglas-Peucker algorithm.
41    ///
42    /// # Arguments
43    /// * `tolerance_m` - Tolerance in meters; points within this distance of
44    ///   the simplified line are removed.
45    pub fn simplify(mut self, tolerance_m: f64) -> Self {
46        if self.geometry.len() <= 2 {
47            return self;
48        }
49
50        self.geometry = douglas_peucker(&self.geometry, tolerance_m);
51        self
52    }
53}
54
55#[derive(Debug, Clone, Copy, Default)]
56pub struct SnappedCoord {
57    pub original: Coord,
58    pub snapped: Coord,
59    pub snap_distance_m: f64,
60    pub(crate) node_index: NodeIdx,
61}
62
63/// Data stored in the point spatial index.
64#[derive(Debug, Clone, Copy)]
65struct SpatialPoint {
66    node_index: NodeIdx,
67}
68
69/// Data stored in the segment spatial index.
70#[derive(Debug, Clone, Copy)]
71struct SpatialSegment {
72    from_node: NodeIdx,
73    to_node: NodeIdx,
74    edge_index: EdgeIdx,
75}
76
77#[derive(Debug, Clone, Copy)]
78pub struct EdgeSnappedLocation {
79    pub original: Coord,
80    pub snapped: Coord,
81    pub snap_distance_m: f64,
82    pub(crate) edge_index: EdgeIdx,
83    pub(crate) position: f64,
84    pub(crate) from_node: NodeIdx,
85    pub(crate) to_node: NodeIdx,
86}
87
88#[derive(Debug, Clone, Copy)]
89struct EdgeTraversal {
90    node: NodeIdx,
91    time_s: f64,
92    distance_m: f64,
93}
94
95pub struct RoadNetwork {
96    pub(super) graph: DiGraph<NodeData, EdgeData>,
97    pub(super) coord_to_node: HashMap<(i64, i64), NodeIdx>,
98    spatial_index: Option<KdTree<SpatialPoint>>,
99    edge_spatial_index: Option<SegmentIndex<SpatialSegment>>,
100    max_speed_mps: f64,
101}
102
103impl RoadNetwork {
104    pub fn new() -> Self {
105        Self {
106            graph: DiGraph::new(),
107            coord_to_node: HashMap::new(),
108            spatial_index: None,
109            edge_spatial_index: None,
110            max_speed_mps: 0.0,
111        }
112    }
113
114    pub(super) fn get_or_create_node(&mut self, lat: f64, lng: f64) -> NodeIdx {
115        let key = coord_key(lat, lng);
116        if let Some(&idx) = self.coord_to_node.get(&key) {
117            idx
118        } else {
119            let idx = self.graph.add_node(NodeData { lat, lng });
120            self.coord_to_node.insert(key, idx);
121            idx
122        }
123    }
124
125    pub(super) fn add_node_at(&mut self, lat: f64, lng: f64) -> NodeIdx {
126        let idx = self.graph.add_node(NodeData { lat, lng });
127        let key = coord_key(lat, lng);
128        self.coord_to_node.insert(key, idx);
129        idx
130    }
131
132    pub(super) fn add_edge(&mut self, from: NodeIdx, to: NodeIdx, data: EdgeData) {
133        self.record_edge_speed(&data);
134        self.graph.add_edge(from, to, data);
135    }
136
137    pub(super) fn add_edge_by_index(
138        &mut self,
139        from: usize,
140        to: usize,
141        travel_time_s: f64,
142        distance_m: f64,
143    ) {
144        let from_idx = NodeIdx::new(from);
145        let to_idx = NodeIdx::new(to);
146        let data = EdgeData {
147            travel_time_s,
148            distance_m,
149        };
150        self.record_edge_speed(&data);
151        self.graph.add_edge(from_idx, to_idx, data);
152    }
153
154    pub(super) fn build_spatial_index(&mut self) {
155        // Build point index for nodes
156        let points: Vec<(Point2D, SpatialPoint)> = self
157            .graph
158            .node_indices()
159            .filter_map(|node_idx| {
160                let node = self.graph.node_weight(node_idx)?;
161                Some((
162                    Point2D::new(node.lat, node.lng),
163                    SpatialPoint {
164                        node_index: node_idx,
165                    },
166                ))
167            })
168            .collect();
169
170        self.spatial_index = Some(KdTree::from_items(points));
171
172        // Build segment index for edges
173        let segments: Vec<(Segment, SpatialSegment)> = self
174            .graph
175            .edge_indices()
176            .filter_map(|edge_idx| {
177                let (from_node, to_node) = self.graph.edge_endpoints(edge_idx)?;
178                let from_data = self.graph.node_weight(from_node)?;
179                let to_data = self.graph.node_weight(to_node)?;
180                Some((
181                    Segment::new(
182                        Point2D::new(from_data.lat, from_data.lng),
183                        Point2D::new(to_data.lat, to_data.lng),
184                    ),
185                    SpatialSegment {
186                        from_node,
187                        to_node,
188                        edge_index: edge_idx,
189                    },
190                ))
191            })
192            .collect();
193
194        self.edge_spatial_index = Some(SegmentIndex::bulk_load(segments));
195    }
196
197    fn record_edge_speed(&mut self, edge: &EdgeData) {
198        if edge.travel_time_s <= 0.0 || edge.distance_m <= 0.0 {
199            return;
200        }
201
202        let speed_mps = edge.distance_m / edge.travel_time_s;
203        if speed_mps.is_finite() {
204            self.max_speed_mps = self.max_speed_mps.max(speed_mps);
205        }
206    }
207
208    fn distance_lower_bound_between(&self, from: NodeIdx, to: NodeIdx) -> f64 {
209        let Some(from_node) = self.graph.node_weight(from) else {
210            return 0.0;
211        };
212        let Some(to_node) = self.graph.node_weight(to) else {
213            return 0.0;
214        };
215        haversine_distance(from_node.coord(), to_node.coord())
216    }
217
218    fn time_lower_bound_between(&self, from: NodeIdx, to: NodeIdx) -> f64 {
219        if !self.max_speed_mps.is_finite() || self.max_speed_mps <= 0.0 {
220            return 0.0;
221        }
222
223        self.distance_lower_bound_between(from, to) / self.max_speed_mps
224    }
225
226    fn node_path_geometry(&self, path: &[NodeIdx]) -> Vec<Coord> {
227        path.iter()
228            .filter_map(|&idx| self.graph.node_weight(idx).map(|n| n.coord()))
229            .collect()
230    }
231
232    fn node_path_metrics(&self, path: &[NodeIdx]) -> (f64, f64) {
233        let mut distance = 0.0;
234        let mut time = 0.0;
235
236        for window in path.windows(2) {
237            if let Some(edge) = self.graph.find_edge(window[0], window[1]) {
238                if let Some(weight) = self.graph.edge_weight(edge) {
239                    distance += weight.distance_m;
240                    time += weight.travel_time_s;
241                }
242            }
243        }
244
245        (distance, time)
246    }
247
248    fn route_result_from_node_path(&self, path: &[NodeIdx], duration_seconds: i64) -> RouteResult {
249        let geometry = self.node_path_geometry(path);
250        let (distance, _) = self.node_path_metrics(path);
251
252        RouteResult {
253            duration_seconds,
254            distance_meters: distance,
255            geometry,
256        }
257    }
258
259    /// Iterate over all nodes as (lat, lng) pairs.
260    pub fn nodes_iter(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
261        self.graph
262            .node_indices()
263            .filter_map(|idx| self.graph.node_weight(idx).map(|n| (n.lat, n.lng)))
264    }
265
266    /// Iterate over all edges as (from_idx, to_idx, travel_time_s, distance_m) tuples.
267    pub fn edges_iter(&self) -> impl Iterator<Item = (usize, usize, f64, f64)> + '_ {
268        self.graph.edge_indices().filter_map(|idx| {
269            let (from, to) = self.graph.edge_endpoints(idx)?;
270            let weight = self.graph.edge_weight(idx)?;
271            Some((
272                from.index(),
273                to.index(),
274                weight.travel_time_s,
275                weight.distance_m,
276            ))
277        })
278    }
279
280    /// Snap a coordinate to the nearest node in the road network.
281    ///
282    /// Returns `None` if the network is empty.
283    pub fn snap_to_road(&self, coord: Coord) -> Option<NodeIdx> {
284        self.spatial_index
285            .as_ref()?
286            .nearest_neighbor(&Point2D::new(coord.lat, coord.lng))
287            .map(|p| p.node_index)
288    }
289
290    /// Snap a coordinate to the road network with detailed information.
291    ///
292    /// Returns a `SnappedCoord` containing both original and snapped coordinates,
293    /// the snap distance, and an internal node index for routing.
294    pub fn snap_to_road_detailed(&self, coord: Coord) -> Result<SnappedCoord, RoutingError> {
295        let tree = self
296            .spatial_index
297            .as_ref()
298            .ok_or(RoutingError::SnapFailed {
299                coord,
300                nearest_distance_m: None,
301            })?;
302
303        let query = Point2D::new(coord.lat, coord.lng);
304        let (nearest, _dist_sq) =
305            tree.nearest_neighbor_with_distance(&query)
306                .ok_or(RoutingError::SnapFailed {
307                    coord,
308                    nearest_distance_m: None,
309                })?;
310
311        // Get the actual coordinates from the graph node
312        let node_data =
313            self.graph
314                .node_weight(nearest.node_index)
315                .ok_or(RoutingError::SnapFailed {
316                    coord,
317                    nearest_distance_m: None,
318                })?;
319
320        let snapped = Coord::new(node_data.lat, node_data.lng);
321        let snap_distance_m = haversine_distance(coord, snapped);
322
323        Ok(SnappedCoord {
324            original: coord,
325            snapped,
326            snap_distance_m,
327            node_index: nearest.node_index,
328        })
329    }
330
331    /// Snap a coordinate to the nearest road segment (edge-based snapping).
332    ///
333    /// This is production-grade snapping that projects the coordinate onto the
334    /// nearest road segment rather than snapping to the nearest intersection.
335    pub fn snap_to_edge(&self, coord: Coord) -> Result<EdgeSnappedLocation, RoutingError> {
336        let index = self
337            .edge_spatial_index
338            .as_ref()
339            .ok_or(RoutingError::SnapFailed {
340                coord,
341                nearest_distance_m: None,
342            })?;
343
344        let query = Point2D::new(coord.lat, coord.lng);
345        let (segment, seg_data, proj_point, _dist_sq) =
346            index
347                .nearest_segment(&query)
348                .ok_or(RoutingError::SnapFailed {
349                    coord,
350                    nearest_distance_m: None,
351                })?;
352
353        // Compute position along segment
354        let (_, position) = segment.project_point(&query);
355        let snapped = Coord::new(proj_point.x, proj_point.y);
356        let snap_distance_m = haversine_distance(coord, snapped);
357
358        Ok(EdgeSnappedLocation {
359            original: coord,
360            snapped,
361            snap_distance_m,
362            edge_index: seg_data.edge_index,
363            position,
364            from_node: seg_data.from_node,
365            to_node: seg_data.to_node,
366        })
367    }
368
369    /// Find a route between two coordinates.
370    ///
371    /// This method snaps both coordinates to the nearest road-network nodes,
372    /// then runs the public travel-time search over those snapped nodes.
373    /// The current implementation passes a zero heuristic to `astar`, so its
374    /// behavior is equivalent to Dijkstra's algorithm.
375    pub fn route(&self, from: Coord, to: Coord) -> Result<RouteResult, RoutingError> {
376        let start_snap = self.snap_to_road_detailed(from)?;
377        let end_snap = self.snap_to_road_detailed(to)?;
378
379        self.route_snapped(&start_snap, &end_snap)
380    }
381
382    /// Find a route between two edge-snapped locations.
383    ///
384    /// Use this when start and end should stay on their containing road
385    /// segments instead of being snapped all the way to graph nodes.
386    pub fn route_edge_snapped(
387        &self,
388        from: &EdgeSnappedLocation,
389        to: &EdgeSnappedLocation,
390    ) -> Result<RouteResult, RoutingError> {
391        let from_edge = self
392            .graph
393            .edge_weight(from.edge_index)
394            .ok_or(RoutingError::NoPath {
395                from: from.original,
396                to: to.original,
397            })?;
398        let to_edge = self
399            .graph
400            .edge_weight(to.edge_index)
401            .ok_or(RoutingError::NoPath {
402                from: from.original,
403                to: to.original,
404            })?;
405
406        // Same-edge travel is only valid in the edge's forward direction.
407        if from.edge_index == to.edge_index {
408            if to.position < from.position {
409                return Err(RoutingError::NoPath {
410                    from: from.original,
411                    to: to.original,
412                });
413            }
414
415            let segment_time = from_edge.travel_time_s;
416            let segment_dist = from_edge.distance_m;
417            let travel_fraction = to.position - from.position;
418            return Ok(RouteResult {
419                duration_seconds: (segment_time * travel_fraction).round() as i64,
420                distance_meters: segment_dist * travel_fraction,
421                geometry: vec![from.snapped, to.snapped],
422            });
423        }
424
425        let start_exit = EdgeTraversal {
426            node: from.to_node,
427            time_s: from_edge.travel_time_s * (1.0 - from.position),
428            distance_m: from_edge.distance_m * (1.0 - from.position),
429        };
430        let end_entry = EdgeTraversal {
431            node: to.from_node,
432            time_s: to_edge.travel_time_s * to.position,
433            distance_m: to_edge.distance_m * to.position,
434        };
435
436        let best_result = if start_exit.node == end_entry.node {
437            Some((start_exit.time_s + end_entry.time_s, vec![start_exit.node]))
438        } else {
439            astar(
440                &self.graph,
441                start_exit.node,
442                |n| n == end_entry.node,
443                |e| e.travel_time_s,
444                |n| self.time_lower_bound_between(n, end_entry.node),
445            )
446            .map(|(path_cost, path)| (start_exit.time_s + path_cost + end_entry.time_s, path))
447        };
448
449        match best_result {
450            Some((total_cost, path)) => {
451                let mut geometry = vec![from.snapped];
452                for coord in self.node_path_geometry(&path) {
453                    if geometry.last().copied() != Some(coord) {
454                        geometry.push(coord);
455                    }
456                }
457                if geometry.last().copied() != Some(to.snapped) {
458                    geometry.push(to.snapped);
459                }
460
461                let (path_distance, _) = self.node_path_metrics(&path);
462                let distance = start_exit.distance_m + path_distance + end_entry.distance_m;
463
464                Ok(RouteResult {
465                    duration_seconds: total_cost.round() as i64,
466                    distance_meters: distance,
467                    geometry,
468                })
469            }
470            None => Err(RoutingError::NoPath {
471                from: from.original,
472                to: to.original,
473            }),
474        }
475    }
476
477    /// Find a route between two node-snapped coordinates.
478    ///
479    /// This expects `SnappedCoord` values produced by `snap_to_road_detailed`
480    /// and returns geometry along graph nodes, not projected edge endpoints.
481    pub fn route_snapped(
482        &self,
483        from: &SnappedCoord,
484        to: &SnappedCoord,
485    ) -> Result<RouteResult, RoutingError> {
486        if from.node_index == to.node_index {
487            return Ok(RouteResult {
488                duration_seconds: 0,
489                distance_meters: 0.0,
490                geometry: vec![from.original, to.original],
491            });
492        }
493
494        let result = astar(
495            &self.graph,
496            from.node_index,
497            |n| n == to.node_index,
498            |e| e.travel_time_s,
499            |n| self.time_lower_bound_between(n, to.node_index),
500        );
501
502        match result {
503            Some((cost, path)) => Ok(self.route_result_from_node_path(&path, cost.round() as i64)),
504            None => Err(RoutingError::NoPath {
505                from: from.original,
506                to: to.original,
507            }),
508        }
509    }
510
511    /// Find a route between two coordinates with an explicit optimization objective.
512    ///
513    /// Like `route`, this method snaps to the nearest graph nodes first and
514    /// uses admissible straight-line lower bounds for both objectives.
515    pub fn route_with(
516        &self,
517        from: Coord,
518        to: Coord,
519        objective: Objective,
520    ) -> Result<RouteResult, RoutingError> {
521        let start_snap = self.snap_to_road_detailed(from)?;
522        let end_snap = self.snap_to_road_detailed(to)?;
523
524        if start_snap.node_index == end_snap.node_index {
525            return Ok(RouteResult {
526                duration_seconds: 0,
527                distance_meters: 0.0,
528                geometry: vec![from, to],
529            });
530        }
531
532        let result = match objective {
533            Objective::Time => astar(
534                &self.graph,
535                start_snap.node_index,
536                |n| n == end_snap.node_index,
537                |e| e.travel_time_s,
538                |n| self.time_lower_bound_between(n, end_snap.node_index),
539            ),
540            Objective::Distance => astar(
541                &self.graph,
542                start_snap.node_index,
543                |n| n == end_snap.node_index,
544                |e| e.distance_m,
545                |n| self.distance_lower_bound_between(n, end_snap.node_index),
546            ),
547        };
548
549        match result {
550            Some((_, path)) => {
551                let (distance, time) = self.node_path_metrics(&path);
552                Ok(RouteResult {
553                    duration_seconds: time.round() as i64,
554                    distance_meters: distance,
555                    geometry: self.node_path_geometry(&path),
556                })
557            }
558            None => Err(RoutingError::NoPath { from, to }),
559        }
560    }
561
562    pub fn node_count(&self) -> usize {
563        self.graph.node_count()
564    }
565
566    pub fn edge_count(&self) -> usize {
567        self.graph.edge_count()
568    }
569
570    pub fn strongly_connected_components(&self) -> usize {
571        kosaraju_scc(&self.graph).len()
572    }
573
574    pub fn largest_component_fraction(&self) -> f64 {
575        let sccs = kosaraju_scc(&self.graph);
576        if sccs.is_empty() {
577            return 0.0;
578        }
579        let largest = sccs.iter().map(|c| c.len()).max().unwrap_or(0);
580        let total = self.graph.node_count();
581        if total == 0 {
582            0.0
583        } else {
584            largest as f64 / total as f64
585        }
586    }
587
588    pub fn is_strongly_connected(&self) -> bool {
589        self.strongly_connected_components() == 1
590    }
591
592    /// Filter the network to keep only the largest strongly connected component.
593    pub fn filter_to_largest_scc(&mut self) {
594        let sccs = kosaraju_scc(&self.graph);
595        if sccs.len() <= 1 {
596            return; // Already connected or empty
597        }
598
599        // Find the largest SCC
600        let largest_scc: HashSet<NodeIdx> = sccs
601            .into_iter()
602            .max_by_key(|scc| scc.len())
603            .unwrap_or_default()
604            .into_iter()
605            .collect();
606
607        // Retain only nodes in the largest SCC
608        self.graph.retain_nodes(|n, _| largest_scc.contains(&n));
609
610        self.rebuild_coord_to_node();
611        self.build_spatial_index();
612    }
613
614    fn rebuild_coord_to_node(&mut self) {
615        self.coord_to_node.clear();
616        for idx in self.graph.node_indices() {
617            if let Some(node) = self.graph.node_weight(idx) {
618                let key = coord_key(node.lat, node.lng);
619                self.coord_to_node.insert(key, idx);
620            }
621        }
622    }
623}
624
625impl Default for RoadNetwork {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631impl RoadNetwork {
632    /// Build a network from raw node/edge data (for testing).
633    ///
634    /// # Arguments
635    /// * `nodes` - Slice of (lat, lng) tuples for each node
636    /// * `edges` - Slice of (from_idx, to_idx, time_s, dist_m) tuples
637    ///
638    /// # Example
639    /// ```
640    /// use solverforge_maps::RoadNetwork;
641    ///
642    /// let nodes = &[(40.0, -75.0), (39.8, -75.2)];
643    /// let edges = &[(0, 1, 60.0, 1000.0), (1, 0, 60.0, 1000.0)];
644    /// let network = RoadNetwork::from_test_data(nodes, edges);
645    ///
646    /// assert_eq!(network.node_count(), 2);
647    /// assert_eq!(network.edge_count(), 2);
648    /// ```
649    pub fn from_test_data(nodes: &[(f64, f64)], edges: &[(usize, usize, f64, f64)]) -> Self {
650        let mut network = Self::new();
651
652        // Add all nodes
653        for &(lat, lng) in nodes {
654            network.add_node_at(lat, lng);
655        }
656
657        // Add all edges
658        for &(from, to, time_s, dist_m) in edges {
659            network.add_edge_by_index(from, to, time_s, dist_m);
660        }
661
662        // Build spatial index for snapping
663        network.build_spatial_index();
664
665        network
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::{NodeIdx, Objective, RoadNetwork};
672    use crate::routing::Coord;
673
674    #[test]
675    fn time_routing_uses_non_zero_admissible_heuristic() {
676        let nodes = &[(0.0, 0.0), (0.0, 0.01), (0.01, 0.0), (0.01, 0.01)];
677        let edges = &[
678            (0, 1, 200.0, 1_200.0),
679            (1, 3, 200.0, 1_200.0),
680            (0, 2, 50.0, 1_200.0),
681            (2, 3, 50.0, 1_200.0),
682            (1, 0, 200.0, 1_200.0),
683            (3, 1, 200.0, 1_200.0),
684            (2, 0, 50.0, 1_200.0),
685            (3, 2, 50.0, 1_200.0),
686        ];
687        let network = RoadNetwork::from_test_data(nodes, edges);
688
689        let result = network
690            .route(Coord::new(0.0, 0.0), Coord::new(0.01, 0.01))
691            .expect("time route should exist");
692
693        assert_eq!(result.duration_seconds, 100);
694        assert_eq!(result.distance_meters, 2_400.0);
695        assert_eq!(
696            result.geometry,
697            vec![
698                Coord::new(0.0, 0.0),
699                Coord::new(0.01, 0.0),
700                Coord::new(0.01, 0.01),
701            ]
702        );
703        assert!(network.time_lower_bound_between(NodeIdx(0), NodeIdx(3)) > 0.0);
704    }
705
706    #[test]
707    fn distance_routing_uses_non_zero_admissible_heuristic() {
708        let nodes = &[(0.0, 0.0), (0.0, 0.02), (0.01, 0.0), (0.01, 0.02)];
709        let edges = &[
710            (0, 1, 40.0, 3_000.0),
711            (1, 3, 40.0, 3_000.0),
712            (0, 2, 90.0, 900.0),
713            (2, 3, 90.0, 900.0),
714            (1, 0, 40.0, 3_000.0),
715            (3, 1, 40.0, 3_000.0),
716            (2, 0, 90.0, 900.0),
717            (3, 2, 90.0, 900.0),
718        ];
719        let network = RoadNetwork::from_test_data(nodes, edges);
720
721        let result = network
722            .route_with(
723                Coord::new(0.0, 0.0),
724                Coord::new(0.01, 0.02),
725                Objective::Distance,
726            )
727            .expect("distance route should exist");
728
729        assert_eq!(result.distance_meters, 1_800.0);
730        assert_eq!(result.duration_seconds, 180);
731        assert_eq!(
732            result.geometry,
733            vec![
734                Coord::new(0.0, 0.0),
735                Coord::new(0.01, 0.0),
736                Coord::new(0.01, 0.02),
737            ]
738        );
739        assert!(network.distance_lower_bound_between(NodeIdx(0), NodeIdx(3)) > 0.0);
740    }
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
744pub enum Objective {
745    Time,
746    Distance,
747}
748
749/// Douglas-Peucker line simplification algorithm.
750fn douglas_peucker(points: &[Coord], tolerance_m: f64) -> Vec<Coord> {
751    if points.len() <= 2 {
752        return points.to_vec();
753    }
754
755    let first = points[0];
756    let last = points[points.len() - 1];
757    let mut max_dist = 0.0;
758    let mut max_idx = 0;
759
760    for (i, point) in points.iter().enumerate().skip(1).take(points.len() - 2) {
761        let dist = perpendicular_distance(*point, first, last);
762        if dist > max_dist {
763            max_dist = dist;
764            max_idx = i;
765        }
766    }
767
768    if max_dist > tolerance_m {
769        let mut left = douglas_peucker(&points[..=max_idx], tolerance_m);
770        let right = douglas_peucker(&points[max_idx..], tolerance_m);
771
772        left.pop();
773        left.extend(right);
774        left
775    } else {
776        vec![first, last]
777    }
778}
779
780fn perpendicular_distance(point: Coord, line_start: Coord, line_end: Coord) -> f64 {
781    let dx = line_end.lng - line_start.lng;
782    let dy = line_end.lat - line_start.lat;
783
784    let line_length_sq = dx * dx + dy * dy;
785    if line_length_sq < f64::EPSILON {
786        return haversine_distance(point, line_start);
787    }
788
789    let t =
790        ((point.lng - line_start.lng) * dx + (point.lat - line_start.lat) * dy) / line_length_sq;
791    let t = t.clamp(0.0, 1.0);
792
793    let projected = Coord::new(line_start.lat + t * dy, line_start.lng + t * dx);
794
795    haversine_distance(point, projected)
796}