1use crate::algorithms::trunc_float;
43use crate::models::{CourseOverGround, GeographicCoordinate, Route, UserLocation};
44use geo::{coord, Bearing, Densify, Geodesic, Haversine, LineString, Point};
45use polyline::decode_polyline;
46
47#[cfg(any(test, feature = "wasm-bindgen"))]
48use serde::{Deserialize, Serialize};
49
50#[cfg(feature = "wasm-bindgen")]
51use wasm_bindgen::{prelude::*, JsValue};
52
53#[cfg(feature = "wasm-bindgen")]
54use tsify::Tsify;
55
56#[cfg(all(feature = "std", not(feature = "web-time")))]
57use std::time::SystemTime;
58
59#[cfg(feature = "web-time")]
60use web_time::SystemTime;
61
62#[cfg(feature = "alloc")]
63use alloc::{
64 string::{String, ToString},
65 vec::Vec,
66};
67
68#[derive(Debug)]
69#[cfg_attr(feature = "std", derive(thiserror::Error))]
70#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
71#[cfg_attr(feature = "wasm-bindgen", derive(Serialize, Deserialize, Tsify))]
72#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
73pub enum SimulationError {
74 #[cfg_attr(feature = "std", error("Failed to parse polyline: {error}."))]
75 PolylineError { error: String },
77 #[cfg_attr(feature = "std", error("Not enough points (expected at least two)."))]
78 NotEnoughPoints,
80}
81
82#[derive(Clone, PartialEq, Debug)]
85#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
86#[cfg_attr(any(feature = "wasm-bindgen", test), derive(Serialize, Deserialize))]
87#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
88#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
89pub enum LocationBias {
90 Left(f64),
93
94 Right(f64),
97
98 Random(f64),
105
106 None,
111}
112
113#[derive(Clone, PartialEq)]
115#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
116#[cfg_attr(any(feature = "wasm-bindgen", test), derive(Serialize, Deserialize))]
117#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
118#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
119pub struct LocationSimulationState {
120 pub current_location: UserLocation,
121 remaining_locations: Vec<GeographicCoordinate>,
122 bias: LocationBias,
123}
124
125#[cfg_attr(feature = "uniffi", uniffi::export)]
129pub fn location_simulation_from_coordinates(
130 coordinates: &[GeographicCoordinate],
131 resample_distance: Option<f64>,
132 bias: LocationBias,
133) -> Result<LocationSimulationState, SimulationError> {
134 if let Some((current, rest)) = coordinates.split_first() {
135 if let Some(next) = rest.first() {
136 let (jittered_current, bearing) = add_lateral_offset(*current, *next, &bias);
137
138 let accuracy = match bias {
139 LocationBias::None => 0.0,
140 LocationBias::Left(m) | LocationBias::Right(m) | LocationBias::Random(m) => m,
141 };
142
143 let current_location = UserLocation {
144 coordinates: jittered_current,
145 horizontal_accuracy: accuracy,
146 course_over_ground: Some(CourseOverGround::new(bearing, Some(5))),
147 timestamp: SystemTime::now(),
148 speed: None,
149 };
150 let remaining_locations = if let Some(distance) = resample_distance {
151 let coords: Vec<_> = rest
153 .iter()
154 .map(|coord| {
155 coord! {
156 x: coord.lng,
157 y: coord.lat
158 }
159 })
160 .collect();
161 let linestring: LineString = coords.into();
162 let densified_linestring = Haversine.densify(&linestring, distance);
163 densified_linestring
164 .points()
165 .map(|point| GeographicCoordinate {
166 lat: trunc_float(point.y(), 6),
171 lng: trunc_float(point.x(), 6),
172 })
173 .collect()
174 } else {
175 Vec::from(rest)
176 };
177
178 Ok(LocationSimulationState {
179 current_location,
180 remaining_locations,
181 bias,
182 })
183 } else {
184 Err(SimulationError::NotEnoughPoints)
185 }
186 } else {
187 Err(SimulationError::NotEnoughPoints)
188 }
189}
190
191#[cfg_attr(feature = "uniffi", uniffi::export)]
195pub fn location_simulation_from_route(
196 route: &Route,
197 resample_distance: Option<f64>,
198 bias: LocationBias,
199) -> Result<LocationSimulationState, SimulationError> {
200 location_simulation_from_coordinates(&route.geometry, resample_distance, bias)
203}
204
205#[cfg_attr(feature = "uniffi", uniffi::export)]
209pub fn location_simulation_from_polyline(
210 polyline: &str,
211 precision: u32,
212 resample_distance: Option<f64>,
213 bias: LocationBias,
214) -> Result<LocationSimulationState, SimulationError> {
215 let linestring =
216 decode_polyline(polyline, precision).map_err(|error| SimulationError::PolylineError {
217 error: error.to_string(),
218 })?;
219 let coordinates: Vec<_> = linestring
220 .coords()
221 .map(|c| GeographicCoordinate::from(*c))
222 .collect();
223 location_simulation_from_coordinates(&coordinates, resample_distance, bias)
224}
225
226fn add_lateral_offset(
227 current: GeographicCoordinate,
228 next: GeographicCoordinate,
229 bias: &LocationBias,
230) -> (GeographicCoordinate, f64) {
231 let current_point = Point::from(current);
232 let next_point = Point::from(next);
233 let bearing = Geodesic.bearing(current_point, next_point);
234
235 match bias {
236 LocationBias::None => (current, bearing),
237 LocationBias::Left(meters) | LocationBias::Right(meters) | LocationBias::Random(meters) => {
238 let sign = match bias {
239 LocationBias::Left(_) => -1.0,
240 LocationBias::Right(_) => 1.0,
241 LocationBias::Random(_) => {
242 if rand::random() {
243 1.0
244 } else {
245 -1.0
246 }
247 }
248 LocationBias::None => unreachable!(),
249 };
250
251 let lateral_bearing_rad = (bearing + sign * 90.0).to_radians();
253
254 let offset_deg = meters / 111_111.0;
256
257 let lat_offset = offset_deg * lateral_bearing_rad.cos();
258 let lng_offset = offset_deg * lateral_bearing_rad.sin();
259
260 (
261 GeographicCoordinate {
262 lat: current.lat + lat_offset,
263 lng: current.lng + lng_offset,
264 },
265 bearing,
266 )
267 }
268 }
269}
270
271#[cfg_attr(feature = "uniffi", uniffi::export)]
280pub fn advance_location_simulation(state: &LocationSimulationState) -> LocationSimulationState {
281 if let Some((next_coordinate, rest)) = state.remaining_locations.split_first() {
282 let (jittered_next, bearing) = add_lateral_offset(
283 *next_coordinate,
284 if let Some(future) = rest.first() {
285 *future
286 } else {
287 *next_coordinate
288 },
289 &state.bias,
290 );
291
292 let accuracy = match state.bias {
293 LocationBias::None => 0.0,
294 LocationBias::Left(m) | LocationBias::Right(m) | LocationBias::Random(m) => m,
295 };
296
297 let next_location = UserLocation {
298 coordinates: jittered_next,
299 horizontal_accuracy: accuracy,
300 course_over_ground: Some(CourseOverGround::new(bearing, Some(5))),
301 timestamp: SystemTime::now(),
302 speed: None,
303 };
304
305 LocationSimulationState {
306 current_location: next_location,
307 remaining_locations: Vec::from(rest),
308 bias: state.bias.clone(),
309 }
310 } else {
311 state.clone()
312 }
313}
314
315#[cfg(feature = "wasm-bindgen")]
317#[wasm_bindgen(js_name = locationSimulationFromCoordinates)]
318pub fn js_location_simulation_from_coordinates(
319 coordinates: JsValue,
320 resample_distance: Option<f64>,
321 bias: LocationBias,
322) -> Result<JsValue, JsValue> {
323 let coordinates: Vec<GeographicCoordinate> = serde_wasm_bindgen::from_value(coordinates)
324 .map_err(|error| JsValue::from_str(&error.to_string()))?;
325
326 location_simulation_from_coordinates(&coordinates, resample_distance, bias)
327 .map(|state| serde_wasm_bindgen::to_value(&state).unwrap())
328 .map_err(|error| JsValue::from_str(&error.to_string()))
329}
330
331#[cfg(feature = "wasm-bindgen")]
333#[wasm_bindgen(js_name = locationSimulationFromRoute)]
334pub fn js_location_simulation_from_route(
335 route: JsValue,
336 resample_distance: Option<f64>,
337 bias: LocationBias,
338) -> Result<JsValue, JsValue> {
339 let route: Route = serde_wasm_bindgen::from_value(route)
340 .map_err(|error| JsValue::from_str(&error.to_string()))?;
341
342 location_simulation_from_route(&route, resample_distance, bias)
343 .map(|state| serde_wasm_bindgen::to_value(&state).unwrap())
344 .map_err(|error| JsValue::from_str(&error.to_string()))
345}
346
347#[cfg(feature = "wasm-bindgen")]
349#[wasm_bindgen(js_name = locationSimulationFromPolyline)]
350pub fn js_location_simulation_from_polyline(
351 polyline: &str,
352 precision: u32,
353 resample_distance: Option<f64>,
354 bias: LocationBias,
355) -> Result<JsValue, JsValue> {
356 location_simulation_from_polyline(polyline, precision, resample_distance, bias)
357 .map(|state| serde_wasm_bindgen::to_value(&state).unwrap())
358 .map_err(|error| JsValue::from_str(&error.to_string()))
359}
360
361#[cfg(feature = "wasm-bindgen")]
363#[wasm_bindgen(js_name = advanceLocationSimulation)]
364pub fn js_advance_location_simulation(state: JsValue) -> JsValue {
365 let state: LocationSimulationState = serde_wasm_bindgen::from_value(state).unwrap();
366 let new_state = advance_location_simulation(&state);
367 serde_wasm_bindgen::to_value(&new_state).unwrap()
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::algorithms::snap_user_location_to_line;
374 use geo::{Distance, Haversine};
375 use rstest::rstest;
376
377 #[rstest]
378 #[case(None)]
379 #[case(Some(10.0))]
380 fn advance_to_next_location(#[case] resample_distance: Option<f64>) {
381 let mut state = location_simulation_from_coordinates(
382 &[
383 GeographicCoordinate { lng: 0.0, lat: 0.0 },
384 GeographicCoordinate {
385 lng: 0.0001,
386 lat: 0.0001,
387 },
388 GeographicCoordinate {
389 lng: 0.0002,
390 lat: 0.0002,
391 },
392 GeographicCoordinate {
393 lng: 0.0003,
394 lat: 0.0003,
395 },
396 ],
397 resample_distance,
398 LocationBias::None,
399 )
400 .expect("Unable to initialize simulation");
401
402 let mut states = vec![state.clone()];
404 loop {
405 let new_state = advance_location_simulation(&state);
406 if new_state == state {
407 break;
408 }
409 state = new_state;
410 states.push(state.clone());
411 }
412
413 insta::assert_yaml_snapshot!(format!("{:?}", resample_distance), states);
414 }
415
416 #[test]
417 fn state_from_polyline() {
418 let state = location_simulation_from_polyline(
419 "wzvmrBxalf|GcCrX}A|Nu@jI}@pMkBtZ{@x^_Afj@Inn@`@veB",
420 6,
421 None,
422 LocationBias::None,
423 )
424 .expect("Unable to parse polyline");
425 insta::assert_yaml_snapshot!(state);
426 }
427
428 #[test]
429 fn test_extended_interpolation_simulation() {
430 let polyline = r#"umrefAzifwgF?yJf@?|C@?sJ?iL@_BBqD@cDzh@L|@?jBuDjCCl@u@^f@nB?|ABd@s@r@_AAiBBiC@kAlAHrEQ|F@pCNpA?pAAfB?~CkAtXsGRXlDw@rCo@jBc@SwAKoDr@}GLyAJ}AEs@]qBs@gE_@qC?aBBqAVkBZwBLmAFcBG_DOuB?}A^wAjA}Av@eBJoAAyA[sBbCUhAEIoCdAaCd@{@Fer@@ae@?aD?o[Ny@Vk@Sg@C_FCcDT[S_@Ow@F}oCXoAVe@_@e@?mE?cDNm@Og@Ok@Ck^N_BRu@a@OJqFFyDV[a@kAIkSLcF|AgNb@{@U_@JaEN}ETW[cA\_TbAkm@P_H\sE`AgFrCkKlAuGrEo\n@_B|@[~sBa@pAc@|AAh`Aa@jGEnGCrh@AfiAAjAx@TW`DO|CK\mEZ?~LBzBA|_@GtA?zPGlKQ?op@?uO@ggA?wE@uFEwXEyOCeFAkMAsKIot@?_FEoYAsI?yC?eH?}C?}GAy]Bux@Aog@AmKCmFC}YA}WVgBRu@vAaBlC{CxDCR?h@AhHQvGApDA|BAhHA`DC|GGzFDlM@jNA|J?bAkBtACvAArCClINfDdAfFGzW[|HI`FE@eMhHEt^KpJE"#;
431 let max_distance = 10.0;
432 let mut state =
433 location_simulation_from_polyline(polyline, 6, Some(max_distance), LocationBias::None)
434 .expect("Unable to create initial state");
435 let original_linestring = decode_polyline(polyline, 6).expect("Unable to decode polyline");
436
437 let mut states = vec![state.clone()];
439 loop {
440 let new_state = advance_location_simulation(&state);
441 if new_state == state {
442 break;
443 }
444
445 let current_point: Point = state.current_location.into();
447 let next_point: Point = new_state.current_location.into();
448 let distance = Haversine.distance(current_point, next_point);
449 assert!(
451 distance <= max_distance + 7.0,
452 "Expected consecutive points to be <= {max_distance}m apart; was {distance}m"
453 );
454
455 let snapped =
456 snap_user_location_to_line(new_state.current_location, &original_linestring);
457 let snapped_point: Point = snapped.coordinates.into();
458 let distance = Haversine.distance(next_point, snapped_point);
459 assert!(
460 distance <= max_distance,
461 "Expected snapped point to be on the line; was {distance}m away"
462 );
463
464 state = new_state;
465 states.push(state.clone());
466 }
467
468 assert_eq!(
470 state.current_location.coordinates,
471 original_linestring
472 .points()
473 .last()
474 .expect("Expected at least one point")
475 .into()
476 );
477 insta::assert_yaml_snapshot!(states);
478 }
479
480 #[rstest]
481 #[case(LocationBias::None)]
482 #[case(LocationBias::Left(4.0))]
483 #[case(LocationBias::Right(4.0))]
484 #[case(LocationBias::Random(4.0))]
485 fn test_location_bias(#[case] bias: LocationBias) {
486 let coordinates = vec![
487 GeographicCoordinate { lng: 0.0, lat: 0.0 },
488 GeographicCoordinate {
489 lng: 0.0001,
490 lat: 0.0001,
491 },
492 GeographicCoordinate {
493 lng: 0.0002,
494 lat: 0.0002,
495 },
496 ];
497
498 let state = location_simulation_from_coordinates(&coordinates, None, bias.clone())
499 .expect("Failed to create simulation");
500
501 if matches!(bias, LocationBias::None) {
502 assert_eq!(state.current_location.coordinates, coordinates[0]);
503 return;
504 }
505
506 let expected_meters = match bias {
507 LocationBias::Left(m) | LocationBias::Right(m) | LocationBias::Random(m) => m,
508 LocationBias::None => unreachable!(),
509 };
510
511 let original_point: Point = coordinates[0].into();
512 let offset_point: Point = state.current_location.coordinates.into();
513 let distance = Haversine.distance(original_point, offset_point);
514
515 assert!(
516 (distance - expected_meters).abs() < 0.1,
517 "Expected offset of {expected_meters}m but got {distance}m"
518 );
519 }
520
521 #[test]
522 fn test_bias_consistency() {
523 let coordinates = vec![
524 GeographicCoordinate { lng: 0.0, lat: 0.0 },
525 GeographicCoordinate {
526 lng: 0.0001,
527 lat: 0.0001,
528 },
529 GeographicCoordinate {
530 lng: 0.0002,
531 lat: 0.0002,
532 },
533 GeographicCoordinate {
534 lng: 0.0003,
535 lat: 0.0003,
536 },
537 ];
538
539 let mut state =
540 location_simulation_from_coordinates(&coordinates, None, LocationBias::Random(4.0))
541 .expect("Failed to create simulation");
542
543 let first_point: Point = state.current_location.coordinates.into();
544 let first_original: Point = coordinates[0].into();
545 let initial_distance = Haversine.distance(first_point, first_original);
546
547 while let Some((next, _)) = state.remaining_locations.split_first() {
548 let new_state = advance_location_simulation(&state);
549 if new_state == state {
550 break;
551 }
552
553 let current_point: Point = new_state.current_location.coordinates.into();
554 let original_point: Point = (*next).into();
555 let distance = Haversine.distance(current_point, original_point);
556
557 assert!(
558 (distance - initial_distance).abs() < 0.1,
559 "Bias distance changed from {initial_distance}m to {distance}m"
560 );
561
562 state = new_state;
563 }
564 }
565}