Skip to main content

everymap_providers_mapbox/domain/isoline/
mod.rs

1pub mod types;
2
3use crate::client::MapBoxClient;
4use async_trait::async_trait;
5use everymap_core::domains::isoline::{
6    IsolineOptions, IsolineProvider, IsolineResponse, IsolineResult, RangeType,
7};
8use everymap_core::domains::routing::TransportMode;
9use everymap_core::error::EveryMapResult;
10use everymap_core::types::Coordinate;
11use std::sync::Arc;
12
13pub use types::*;
14
15const ISOCHRONE_BASE_URL: &str = "https://api.mapbox.com";
16
17/// Implementation of IsolineProvider for MapBox Isochrone API.
18pub struct MapBoxIsoline {
19    pub(crate) client: Arc<MapBoxClient>,
20    pub(crate) base_url: String,
21}
22
23impl MapBoxIsoline {
24    pub fn new(client: Arc<MapBoxClient>) -> Self {
25        Self {
26            client,
27            base_url: ISOCHRONE_BASE_URL.to_string(),
28        }
29    }
30
31    pub fn with_base_url(client: Arc<MapBoxClient>, base_url: String) -> Self {
32        Self { client, base_url }
33    }
34}
35
36/// Convert core TransportMode to MapBox profile.
37fn transport_mode_to_profile(mode: &TransportMode) -> &'static str {
38    match mode {
39        TransportMode::Car => "driving",
40        TransportMode::Truck => {
41            log::warn!(
42                "MapBox Isochrone API does not support truck profile, falling back to driving"
43            );
44            "driving"
45        }
46        TransportMode::Pedestrian => "walking",
47        TransportMode::Bicycle => "cycling",
48        TransportMode::Bus => {
49            log::warn!(
50                "MapBox Isochrone API does not support bus profile, falling back to driving"
51            );
52            "driving"
53        }
54        TransportMode::Taxi => {
55            log::warn!(
56                "MapBox Isochrone API does not support taxi profile, falling back to driving"
57            );
58            "driving"
59        }
60        TransportMode::Scooter => {
61            log::warn!(
62                "MapBox Isochrone API does not support scooter profile, falling back to driving"
63            );
64            "driving"
65        }
66        TransportMode::Unknown => "driving",
67    }
68}
69
70fn extract_polygon_coordinates(geom: &serde_json::Value) -> Vec<Coordinate> {
71    // MapBox isochrone geometry is a Polygon: [[[lng, lat], [lng, lat], ...]]
72    geom.as_array()
73        .and_then(|rings| rings.first())
74        .and_then(|ring| ring.as_array())
75        .map(|ring| {
76            ring.iter()
77                .filter_map(|coordinate| {
78                    let arr = coordinate.as_array()?;
79                    if arr.len() >= 2 {
80                        Some(
81                            Coordinate::new(arr[1].as_f64()?, arr[0].as_f64()?)
82                                .unwrap_or(Coordinate::ORIGIN),
83                        )
84                    } else {
85                        None
86                    }
87                })
88                .collect()
89        })
90        .unwrap_or_default()
91}
92
93#[async_trait]
94impl IsolineProvider for MapBoxIsoline {
95    async fn get_isoline(
96        &self,
97        center: &Coordinate,
98        range: f64,
99        options: &IsolineOptions,
100    ) -> EveryMapResult<IsolineResponse> {
101        let profile = options
102            .transport_mode
103            .as_ref()
104            .map(|m| transport_mode_to_profile(m))
105            .unwrap_or("driving");
106        let coords = format!("{},{}", center.lng, center.lat);
107        let url = format!(
108            "{}/isochrone/v1/mapbox/{}/{}",
109            self.base_url, profile, coords
110        );
111
112        let mut params: Vec<(&str, String)> = Vec::new();
113
114        match options.range_type.as_ref().unwrap_or(&RangeType::Time) {
115            RangeType::Time => {
116                // MapBox uses minutes for contours_minutes
117                let minutes = (range / 60.0).round() as u64;
118                params.push(("contours_minutes", minutes.to_string()));
119            }
120            RangeType::Distance => {
121                // MapBox uses meters for contours_meters
122                let meters = range.round() as u64;
123                params.push(("contours_meters", meters.to_string()));
124            }
125            RangeType::Consumption => {
126                return Err(everymap_core::error::EveryMapError::provider(
127                    "mapbox",
128                    "UNSUPPORTED_RANGE_TYPE",
129                    "MapBox Isochrone API does not support consumption-based ranges",
130                ));
131            }
132        }
133
134        if let Some(departure) = &options.departure_time {
135            log::warn!(
136                "MapBox Isochrone API does not support departure_time; \
137                 departure_time ({}) will be ignored",
138                departure
139            );
140        }
141        if !options.avoid.is_empty() {
142            log::warn!(
143                "MapBox Isochrone API does not support avoid restrictions; \
144                 avoid will be ignored"
145            );
146        }
147
148        if let Some(extra) = &options.provider_extra {
149            if let Some(obj) = extra.as_object() {
150                if let Some(v) = obj.get("denoise").and_then(|v| v.as_f64()) {
151                    params.push(("denoise", v.to_string()));
152                }
153                if let Some(v) = obj.get("generalize").and_then(|v| v.as_f64()) {
154                    params.push(("generalize", v.to_string()));
155                }
156            }
157        }
158
159        params.push(("polygons", "true".to_string()));
160
161        let builder = self
162            .client
163            .build_request(reqwest::Method::GET, &url)
164            .query(&params);
165
166        let result: MapBoxIsochroneResponse = self.client.request_json(builder).await?;
167
168        let isolines: Vec<IsolineResult> = result
169            .features
170            .into_iter()
171            .map(|f| {
172                let polygon = f
173                    .geometry
174                    .and_then(|g| g.coordinates)
175                    .map(|coords| extract_polygon_coordinates(&coords))
176                    .unwrap_or_default();
177
178                let range_val = f.properties.and_then(|p| p.contour).map(|c| {
179                    match options.range_type.as_ref().unwrap_or(&RangeType::Time) {
180                        RangeType::Time => (c as f64) * 60.0, // minutes to seconds
181                        RangeType::Distance => c as f64,      // already meters
182                        RangeType::Consumption => c as f64,
183                    }
184                });
185
186                IsolineResult {
187                    polygon,
188                    range: range_val,
189                }
190            })
191            .collect();
192
193        Ok(IsolineResponse {
194            isolines,
195            raw: None,
196        })
197    }
198}