//! Turn-by-turn directions — derive human maneuvers from a [`RouteResult`]. //! //! Each consecutive pair of route segments has a *turn angle* (the signed //! difference of their compass bearings); that angle classifies into a //! [`Maneuver`]. Straight runs are merged so a long road is one step, not dozens. //! The first step is a `Depart` (with a cardinal heading) and the last is //! `Arrive`. Pure geometry over the segments the router already produced. use serde::{Deserialize, Serialize}; use crate::{bearing, Coordinate, RouteResult}; /// A driving/walking maneuver at a step boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Maneuver { Depart, Continue, SlightLeft, SlightRight, Left, Right, SharpLeft, SharpRight, UTurn, Arrive, } impl Maneuver { /// The base instruction verb for this maneuver. pub fn verb(self) -> &'static str { match self { Maneuver::Depart => "Head", Maneuver::Continue => "Continue straight", Maneuver::SlightLeft => "Keep left", Maneuver::SlightRight => "Keep right", Maneuver::Left => "Turn left", Maneuver::Right => "Turn right", Maneuver::SharpLeft => "Take a sharp left", Maneuver::SharpRight => "Take a sharp right", Maneuver::UTurn => "Make a U-turn", Maneuver::Arrive => "Arrive at destination", } } } /// One step of turn-by-turn guidance: the maneuver at its start, the heading /// taken, and how far/long to travel until the next step. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DirectionStep { pub maneuver: Maneuver, pub instruction: String, pub coord: Coordinate, pub bearing_deg: f64, pub distance_m: f64, pub duration_secs: f64, } /// Normalize a bearing delta to the signed turn in `(-180, 180]` /// (positive = right/clockwise, negative = left). fn turn_angle(from_bearing: f64, to_bearing: f64) -> f64 { let mut a = to_bearing - from_bearing; while a <= -180.0 { a += 360.0; } while a > 180.0 { a -= 360.0; } a } /// Classify a signed turn angle into a maneuver. fn classify(angle: f64) -> Maneuver { let m = angle.abs(); let right = angle > 0.0; if m < 20.0 { Maneuver::Continue } else if m < 45.0 { if right { Maneuver::SlightRight } else { Maneuver::SlightLeft } } else if m <= 135.0 { if right { Maneuver::Right } else { Maneuver::Left } } else if m <= 160.0 { if right { Maneuver::SharpRight } else { Maneuver::SharpLeft } } else { Maneuver::UTurn } } /// The 8-wind compass name for a bearing in degrees. pub fn cardinal(bearing_deg: f64) -> &'static str { const DIRS: [&str; 8] = ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"]; let idx = (((bearing_deg % 360.0 + 360.0) % 360.0 + 22.5) / 45.0) as usize % 8; DIRS[idx] } /// Build turn-by-turn directions from a route, merging straight runs into the /// preceding step. Returns an empty list for an empty route. pub fn turn_by_turn(route: &RouteResult) -> Vec { let segs = &route.segments; if segs.is_empty() { return Vec::new(); } let seg_bearing = |i: usize| bearing(&segs[i].from, &segs[i].to); let b0 = seg_bearing(0); let mut steps = Vec::new(); let mut cur = DirectionStep { maneuver: Maneuver::Depart, instruction: format!("Head {}", cardinal(b0)), coord: segs[0].from, bearing_deg: b0, distance_m: 0.0, duration_secs: 0.0, }; for (i, seg) in segs.iter().enumerate() { if i > 0 { let m = classify(turn_angle(seg_bearing(i - 1), seg_bearing(i))); if m != Maneuver::Continue { steps.push(cur); cur = DirectionStep { maneuver: m, instruction: m.verb().to_string(), coord: seg.from, bearing_deg: seg_bearing(i), distance_m: 0.0, duration_secs: 0.0, }; } } cur.distance_m += seg.distance_m; cur.duration_secs += seg.duration_secs; } steps.push(cur); let last = segs.last().unwrap(); steps.push(DirectionStep { maneuver: Maneuver::Arrive, instruction: Maneuver::Arrive.verb().to_string(), coord: last.to, bearing_deg: seg_bearing(segs.len() - 1), distance_m: 0.0, duration_secs: 0.0, }); steps } #[cfg(test)] mod tests { use super::*; use crate::{RouteResult, RouteSegment}; fn seg(from: (f64, f64), to: (f64, f64)) -> RouteSegment { let from = Coordinate::new(from.0, from.1); let to = Coordinate::new(to.0, to.1); RouteSegment { from, to, distance_m: crate::haversine_distance(&from, &to), duration_secs: 10.0, instruction: String::new(), } } fn route(segments: Vec) -> RouteResult { RouteResult { waypoints: Vec::new(), total_distance_m: segments.iter().map(|s| s.distance_m).sum(), total_duration_secs: segments.iter().map(|s| s.duration_secs).sum(), segments, } } #[test] fn cardinals_round_correctly() { assert_eq!(cardinal(0.0), "north"); assert_eq!(cardinal(90.0), "east"); assert_eq!(cardinal(359.0), "north"); assert_eq!(cardinal(45.0), "northeast"); assert_eq!(cardinal(225.0), "southwest"); } #[test] fn straight_run_merges_into_one_step() { // Three colinear segments heading east → Depart + Arrive only. let r = route(vec![ seg((0.0, 0.0), (0.0, 0.01)), seg((0.0, 0.01), (0.0, 0.02)), seg((0.0, 0.02), (0.0, 0.03)), ]); let steps = turn_by_turn(&r); assert_eq!(steps.len(), 2, "merged straight run: depart + arrive"); assert_eq!(steps[0].maneuver, Maneuver::Depart); assert_eq!(steps[1].maneuver, Maneuver::Arrive); // Depart accumulates all three segments' distance. assert!((steps[0].distance_m - r.total_distance_m).abs() < 1e-6); } #[test] fn detects_a_right_turn() { // East, then north → a left turn (bearing 90 → 0 is -90 = left). let east = route(vec![seg((0.0, 0.0), (0.0, 0.01)), seg((0.0, 0.01), (0.01, 0.01))]); let steps = turn_by_turn(&east); assert_eq!(steps.len(), 3); // depart, turn, arrive assert_eq!(steps[1].maneuver, Maneuver::Left); // East, then south → a right turn. let s = route(vec![seg((0.0, 0.0), (0.0, 0.01)), seg((0.0, 0.01), (-0.01, 0.01))]); assert_eq!(turn_by_turn(&s)[1].maneuver, Maneuver::Right); } #[test] fn empty_route_yields_no_steps() { assert!(turn_by_turn(&route(Vec::new())).is_empty()); } }