use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use once_cell::sync::Lazy;
use rstar::{RTree, RTreeObject, AABB};
use crate::{
ActivityMetrics, Bounds, FrequentSection, GpsPoint, MatchConfig, RouteGroup, RoutePerformance,
RoutePerformanceResult, RouteSignature, SectionConfig, SectionLap, SectionPerformanceRecord,
SectionPerformanceResult,
};
#[cfg(not(feature = "parallel"))]
use crate::group_signatures;
#[cfg(feature = "parallel")]
use crate::{group_incremental, group_signatures_parallel};
#[derive(Debug, Clone)]
pub struct ActivityData {
pub id: String,
pub coords: Vec<GpsPoint>,
pub sport_type: String,
pub bounds: Option<Bounds>,
}
#[derive(Debug, Clone)]
pub struct ActivityBounds {
pub activity_id: String,
pub min_lat: f64,
pub max_lat: f64,
pub min_lng: f64,
pub max_lng: f64,
}
impl RTreeObject for ActivityBounds {
type Envelope = AABB<[f64; 2]>;
fn envelope(&self) -> Self::Envelope {
AABB::from_corners([self.min_lng, self.min_lat], [self.max_lng, self.max_lat])
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "ffi", derive(uniffi::Enum))]
pub enum EngineEvent {
ActivitiesChanged,
GroupsChanged,
SectionsChanged,
}
pub struct RouteEngine {
activities: HashMap<String, ActivityData>,
signatures: HashMap<String, RouteSignature>,
groups: Vec<RouteGroup>,
sections: Vec<FrequentSection>,
spatial_index: RTree<ActivityBounds>,
consensus_cache: HashMap<String, Vec<GpsPoint>>,
route_names: HashMap<String, String>,
dirty_signatures: HashSet<String>,
new_signatures: HashSet<String>,
groups_dirty: bool,
sections_dirty: bool,
spatial_dirty: bool,
match_config: MatchConfig,
section_config: SectionConfig,
activity_metrics: HashMap<String, ActivityMetrics>,
time_streams: HashMap<String, Vec<u32>>,
}
impl RouteEngine {
pub fn new() -> Self {
Self {
activities: HashMap::new(),
signatures: HashMap::new(),
groups: Vec::new(),
sections: Vec::new(),
spatial_index: RTree::new(),
consensus_cache: HashMap::new(),
route_names: HashMap::new(),
dirty_signatures: HashSet::new(),
new_signatures: HashSet::new(),
groups_dirty: false,
sections_dirty: false,
spatial_dirty: false,
match_config: MatchConfig::default(),
section_config: SectionConfig::default(),
activity_metrics: HashMap::new(),
time_streams: HashMap::new(),
}
}
pub fn with_config(match_config: MatchConfig, section_config: SectionConfig) -> Self {
Self {
match_config,
section_config,
..Self::new()
}
}
pub fn add_activity(&mut self, id: String, coords: Vec<GpsPoint>, sport_type: String) {
let bounds = Bounds::from_points(&coords);
let activity = ActivityData {
id: id.clone(),
coords,
sport_type,
bounds,
};
self.activities.insert(id.clone(), activity);
self.dirty_signatures.insert(id);
self.groups_dirty = true;
self.sections_dirty = true;
self.spatial_dirty = true;
}
pub fn add_activity_flat(&mut self, id: String, flat_coords: &[f64], sport_type: String) {
let coords: Vec<GpsPoint> = flat_coords
.chunks_exact(2)
.map(|chunk| GpsPoint::new(chunk[0], chunk[1]))
.collect();
self.add_activity(id, coords, sport_type);
}
pub fn add_activities_flat(
&mut self,
activity_ids: &[String],
all_coords: &[f64],
offsets: &[u32],
sport_types: &[String],
) {
for (i, id) in activity_ids.iter().enumerate() {
let start = offsets[i] as usize;
let end = offsets
.get(i + 1)
.map(|&o| o as usize)
.unwrap_or(all_coords.len() / 2);
let coords: Vec<GpsPoint> = (start..end)
.filter_map(|j| {
let idx = j * 2;
if idx + 1 < all_coords.len() {
Some(GpsPoint::new(all_coords[idx], all_coords[idx + 1]))
} else {
None
}
})
.collect();
let sport = sport_types.get(i).cloned().unwrap_or_default();
self.add_activity(id.clone(), coords, sport);
}
}
pub fn remove_activity(&mut self, id: &str) {
self.activities.remove(id);
self.signatures.remove(id);
self.dirty_signatures.remove(id);
self.new_signatures.remove(id);
self.new_signatures.clear();
self.groups.clear();
self.consensus_cache.clear(); self.groups_dirty = true;
self.sections_dirty = true;
self.spatial_dirty = true;
}
pub fn remove_activities(&mut self, ids: &[String]) {
for id in ids {
self.activities.remove(id);
self.signatures.remove(id);
self.dirty_signatures.remove(id);
self.new_signatures.remove(id);
}
if !ids.is_empty() {
self.new_signatures.clear();
self.groups.clear();
self.consensus_cache.clear();
self.groups_dirty = true;
self.sections_dirty = true;
self.spatial_dirty = true;
}
}
pub fn clear(&mut self) {
self.activities.clear();
self.signatures.clear();
self.groups.clear();
self.sections.clear();
self.spatial_index = RTree::new();
self.consensus_cache.clear();
self.dirty_signatures.clear();
self.new_signatures.clear();
self.groups_dirty = false;
self.sections_dirty = false;
self.spatial_dirty = false;
self.activity_metrics.clear();
self.time_streams.clear();
}
pub fn get_activity_ids(&self) -> Vec<String> {
self.activities.keys().cloned().collect()
}
pub fn activity_count(&self) -> usize {
self.activities.len()
}
pub fn has_activity(&self, id: &str) -> bool {
self.activities.contains_key(id)
}
fn ensure_signatures(&mut self) {
if self.dirty_signatures.is_empty() {
return;
}
let dirty_ids: Vec<String> = self.dirty_signatures.drain().collect();
for id in dirty_ids {
if let Some(activity) = self.activities.get(&id) {
if let Some(sig) =
RouteSignature::from_points(&activity.id, &activity.coords, &self.match_config)
{
self.signatures.insert(id.clone(), sig);
self.new_signatures.insert(id);
}
}
}
}
pub fn get_signature(&mut self, id: &str) -> Option<&RouteSignature> {
if self.dirty_signatures.contains(id) {
self.ensure_signatures();
}
self.signatures.get(id)
}
pub fn get_all_signatures(&mut self) -> Vec<&RouteSignature> {
self.ensure_signatures();
self.signatures.values().collect()
}
pub fn get_signature_points_json(&mut self, id: &str) -> String {
if let Some(sig) = self.get_signature(id) {
serde_json::to_string(&sig.points).unwrap_or_else(|_| "[]".to_string())
} else {
"[]".to_string()
}
}
pub fn get_signatures_for_group_json(&mut self, group_id: &str) -> String {
self.ensure_groups();
let activity_ids: Vec<String> = self
.groups
.iter()
.find(|g| g.group_id == group_id)
.map(|g| g.activity_ids.clone())
.unwrap_or_default();
let mut result: std::collections::HashMap<String, Vec<GpsPoint>> =
std::collections::HashMap::new();
for id in &activity_ids {
if let Some(sig) = self.get_signature(id) {
result.insert(id.clone(), sig.points.clone());
}
}
serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
}
fn ensure_groups(&mut self) {
if !self.groups_dirty {
return;
}
self.ensure_signatures();
#[cfg(feature = "parallel")]
{
let can_use_incremental = !self.groups.is_empty()
&& !self.new_signatures.is_empty()
&& self.signatures.len() > self.new_signatures.len();
if can_use_incremental {
let new_sigs: Vec<RouteSignature> = self
.new_signatures
.iter()
.filter_map(|id| self.signatures.get(id).cloned())
.collect();
let existing_sigs: Vec<RouteSignature> = self
.signatures
.iter()
.filter(|(id, _)| !self.new_signatures.contains(*id))
.map(|(_, sig)| sig.clone())
.collect();
self.groups =
group_incremental(&new_sigs, &self.groups, &existing_sigs, &self.match_config);
} else {
let signatures: Vec<RouteSignature> = self.signatures.values().cloned().collect();
self.groups = group_signatures_parallel(&signatures, &self.match_config);
}
}
#[cfg(not(feature = "parallel"))]
{
let signatures: Vec<RouteSignature> = self.signatures.values().cloned().collect();
self.groups = group_signatures(&signatures, &self.match_config);
}
self.new_signatures.clear();
for group in &mut self.groups {
if let Some(activity) = self.activities.get(&group.representative_id) {
group.sport_type = activity.sport_type.clone();
}
if let Some(name) = self.route_names.get(&group.group_id) {
group.custom_name = Some(name.clone());
}
}
self.groups_dirty = false;
}
pub fn get_groups(&mut self) -> &[RouteGroup] {
self.ensure_groups();
&self.groups
}
pub fn set_route_name(&mut self, route_id: &str, name: &str) {
if name.is_empty() {
self.route_names.remove(route_id);
if let Some(group) = self.groups.iter_mut().find(|g| g.group_id == route_id) {
group.custom_name = None;
}
} else {
self.route_names
.insert(route_id.to_string(), name.to_string());
if let Some(group) = self.groups.iter_mut().find(|g| g.group_id == route_id) {
group.custom_name = Some(name.to_string());
}
}
}
pub fn get_route_name(&self, route_id: &str) -> Option<&String> {
self.route_names.get(route_id)
}
pub fn get_group_for_activity(&mut self, activity_id: &str) -> Option<&RouteGroup> {
self.ensure_groups();
self.groups
.iter()
.find(|g| g.activity_ids.contains(&activity_id.to_string()))
}
pub fn get_groups_json(&mut self) -> String {
self.ensure_groups();
serde_json::to_string(&self.groups).unwrap_or_else(|_| "[]".to_string())
}
fn ensure_sections(&mut self) {
if !self.sections_dirty {
return;
}
self.ensure_groups();
let tracks: Vec<(String, Vec<GpsPoint>)> = self
.activities
.values()
.map(|a| (a.id.clone(), a.coords.clone()))
.collect();
let sport_map: HashMap<String, String> = self
.activities
.values()
.map(|a| (a.id.clone(), a.sport_type.clone()))
.collect();
self.sections = crate::sections::detect_sections_from_tracks(
&tracks,
&sport_map,
&self.groups,
&self.section_config,
);
self.sections_dirty = false;
}
pub fn get_sections(&mut self) -> &[FrequentSection] {
self.ensure_sections();
&self.sections
}
pub fn get_sections_for_sport(&mut self, sport_type: &str) -> Vec<&FrequentSection> {
self.ensure_sections();
self.sections
.iter()
.filter(|s| s.sport_type == sport_type)
.collect()
}
pub fn get_sections_json(&mut self) -> String {
self.ensure_sections();
serde_json::to_string(&self.sections).unwrap_or_else(|_| "[]".to_string())
}
fn ensure_spatial_index(&mut self) {
if !self.spatial_dirty {
return;
}
let bounds: Vec<ActivityBounds> = self
.activities
.values()
.filter_map(|a| {
a.bounds.map(|b| ActivityBounds {
activity_id: a.id.clone(),
min_lat: b.min_lat,
max_lat: b.max_lat,
min_lng: b.min_lng,
max_lng: b.max_lng,
})
})
.collect();
self.spatial_index = RTree::bulk_load(bounds);
self.spatial_dirty = false;
}
pub fn query_viewport(&mut self, bounds: &Bounds) -> Vec<String> {
self.ensure_spatial_index();
let search_bounds = AABB::from_corners(
[bounds.min_lng, bounds.min_lat],
[bounds.max_lng, bounds.max_lat],
);
self.spatial_index
.locate_in_envelope_intersecting(&search_bounds)
.map(|b| b.activity_id.clone())
.collect()
}
pub fn query_viewport_raw(
&mut self,
min_lat: f64,
max_lat: f64,
min_lng: f64,
max_lng: f64,
) -> Vec<String> {
self.query_viewport(&Bounds {
min_lat,
max_lat,
min_lng,
max_lng,
})
}
pub fn find_nearby(&mut self, lat: f64, lng: f64, radius_degrees: f64) -> Vec<String> {
self.query_viewport_raw(
lat - radius_degrees,
lat + radius_degrees,
lng - radius_degrees,
lng + radius_degrees,
)
}
pub fn get_consensus_route(&mut self, group_id: &str) -> Option<Vec<GpsPoint>> {
if let Some(cached) = self.consensus_cache.get(group_id) {
return Some(cached.clone());
}
self.ensure_groups();
let group = self.groups.iter().find(|g| g.group_id == group_id)?;
if group.activity_ids.is_empty() {
return None;
}
let tracks: Vec<&Vec<GpsPoint>> = group
.activity_ids
.iter()
.filter_map(|id| self.activities.get(id).map(|a| &a.coords))
.collect();
if tracks.is_empty() {
return None;
}
let consensus = self.compute_medoid_track(&tracks);
self.consensus_cache
.insert(group_id.to_string(), consensus.clone());
Some(consensus)
}
fn compute_medoid_track(&self, tracks: &[&Vec<GpsPoint>]) -> Vec<GpsPoint> {
if tracks.is_empty() {
return vec![];
}
if tracks.len() == 1 {
return tracks[0].clone();
}
let mut best_idx = 0;
let mut best_total_dist = f64::MAX;
for (i, track_i) in tracks.iter().enumerate() {
let total_dist: f64 = tracks
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, track_j)| self.track_distance(track_i, track_j))
.sum();
if total_dist < best_total_dist {
best_total_dist = total_dist;
best_idx = i;
}
}
tracks[best_idx].clone()
}
fn track_distance(&self, track1: &[GpsPoint], track2: &[GpsPoint]) -> f64 {
if track1.is_empty() || track2.is_empty() {
return f64::MAX;
}
let sample_size = 20.min(track1.len().min(track2.len()));
let step1 = track1.len() / sample_size;
let step2 = track2.len() / sample_size;
let sampled1: Vec<&GpsPoint> = (0..sample_size).map(|i| &track1[i * step1]).collect();
let sampled2: Vec<&GpsPoint> = (0..sample_size).map(|i| &track2[i * step2]).collect();
let amd: f64 = sampled1
.iter()
.map(|p1| {
sampled2
.iter()
.map(|p2| crate::geo_utils::haversine_distance(p1, p2))
.fold(f64::MAX, f64::min)
})
.sum::<f64>()
/ sample_size as f64;
amd
}
pub fn set_match_config(&mut self, config: MatchConfig) {
self.match_config = config;
self.dirty_signatures = self.activities.keys().cloned().collect();
self.new_signatures.clear();
self.groups.clear();
self.groups_dirty = true;
self.sections_dirty = true;
}
pub fn set_section_config(&mut self, config: SectionConfig) {
self.section_config = config;
self.sections_dirty = true;
}
pub fn get_match_config(&self) -> &MatchConfig {
&self.match_config
}
pub fn get_section_config(&self) -> &SectionConfig {
&self.section_config
}
pub fn get_all_activity_bounds_info(&self) -> Vec<ActivityBoundsInfo> {
self.activities
.values()
.filter_map(|activity| {
let bounds = activity.bounds?;
let distance = self.compute_track_distance(&activity.coords);
Some(ActivityBoundsInfo {
id: activity.id.clone(),
bounds: [
[bounds.min_lat, bounds.min_lng],
[bounds.max_lat, bounds.max_lng],
],
activity_type: activity.sport_type.clone(),
distance,
})
})
.collect()
}
pub fn get_all_activity_bounds_json(&self) -> String {
let info = self.get_all_activity_bounds_info();
serde_json::to_string(&info).unwrap_or_else(|_| "[]".to_string())
}
pub fn get_all_signatures_info(&mut self) -> std::collections::HashMap<String, SignatureInfo> {
self.ensure_signatures();
self.signatures
.iter()
.map(|(id, sig)| {
(
id.clone(),
SignatureInfo {
points: sig.points.clone(),
center: sig.center,
},
)
})
.collect()
}
pub fn get_all_signatures_json(&mut self) -> String {
let info = self.get_all_signatures_info();
serde_json::to_string(&info).unwrap_or_else(|_| "{}".to_string())
}
fn compute_track_distance(&self, coords: &[GpsPoint]) -> f64 {
if coords.len() < 2 {
return 0.0;
}
coords
.windows(2)
.map(|pair| crate::geo_utils::haversine_distance(&pair[0], &pair[1]))
.sum()
}
pub fn stats(&mut self) -> EngineStats {
self.ensure_groups();
self.ensure_sections();
EngineStats {
activity_count: self.activities.len() as u32,
signature_count: self.signatures.len() as u32,
group_count: self.groups.len() as u32,
section_count: self.sections.len() as u32,
cached_consensus_count: self.consensus_cache.len() as u32,
}
}
pub fn set_activity_metrics(&mut self, metrics: Vec<ActivityMetrics>) {
for m in metrics {
self.activity_metrics.insert(m.activity_id.clone(), m);
}
}
pub fn set_activity_metric(&mut self, metric: ActivityMetrics) {
self.activity_metrics
.insert(metric.activity_id.clone(), metric);
}
pub fn get_activity_metrics(&self, activity_id: &str) -> Option<&ActivityMetrics> {
self.activity_metrics.get(activity_id)
}
pub fn get_route_performances(
&mut self,
route_group_id: &str,
current_activity_id: Option<&str>,
) -> RoutePerformanceResult {
self.ensure_groups();
let group = match self.groups.iter().find(|g| g.group_id == route_group_id) {
Some(g) => g,
None => {
return RoutePerformanceResult {
performances: vec![],
best: None,
current_rank: None,
}
}
};
let mut performances: Vec<RoutePerformance> = group
.activity_ids
.iter()
.filter_map(|id| {
let metrics = self.activity_metrics.get(id)?;
let speed = if metrics.moving_time > 0 {
metrics.distance / metrics.moving_time as f64
} else {
0.0
};
Some(RoutePerformance {
activity_id: id.clone(),
name: metrics.name.clone(),
date: metrics.date,
speed,
duration: metrics.elapsed_time,
moving_time: metrics.moving_time,
distance: metrics.distance,
elevation_gain: metrics.elevation_gain,
avg_hr: metrics.avg_hr,
avg_power: metrics.avg_power,
is_current: current_activity_id == Some(id.as_str()),
direction: "same".to_string(),
match_percentage: 100.0,
})
})
.collect();
performances.sort_by_key(|p| p.date);
let best = performances
.iter()
.max_by(|a, b| {
a.speed
.partial_cmp(&b.speed)
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned();
let current_rank = current_activity_id.and_then(|current_id| {
let mut by_speed = performances.clone();
by_speed.sort_by(|a, b| {
b.speed
.partial_cmp(&a.speed)
.unwrap_or(std::cmp::Ordering::Equal)
});
by_speed
.iter()
.position(|p| p.activity_id == current_id)
.map(|idx| (idx + 1) as u32)
});
RoutePerformanceResult {
performances,
best,
current_rank,
}
}
pub fn get_route_performances_json(
&mut self,
route_group_id: &str,
current_activity_id: Option<&str>,
) -> String {
let result = self.get_route_performances(route_group_id, current_activity_id);
serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
}
pub fn set_time_stream(&mut self, activity_id: String, times: Vec<u32>) {
self.time_streams.insert(activity_id, times);
}
pub fn set_time_streams_flat(
&mut self,
activity_ids: &[String],
all_times: &[u32],
offsets: &[u32],
) {
for (i, activity_id) in activity_ids.iter().enumerate() {
let start = offsets[i] as usize;
let end = offsets
.get(i + 1)
.map(|&o| o as usize)
.unwrap_or(all_times.len());
let times = all_times[start..end].to_vec();
self.time_streams.insert(activity_id.clone(), times);
}
}
pub fn get_section_performances(&mut self, section_id: &str) -> SectionPerformanceResult {
self.ensure_sections();
let section = match self.sections.iter().find(|s| s.id == section_id) {
Some(s) => s,
None => {
return SectionPerformanceResult {
records: vec![],
best_record: None,
}
}
};
let mut portions_by_activity: HashMap<&str, Vec<&crate::SectionPortion>> = HashMap::new();
for portion in §ion.activity_portions {
portions_by_activity
.entry(&portion.activity_id)
.or_default()
.push(portion);
}
let mut records: Vec<SectionPerformanceRecord> = portions_by_activity
.iter()
.filter_map(|(activity_id, portions)| {
let metrics = self.activity_metrics.get(*activity_id)?;
let times = self.time_streams.get(*activity_id)?;
let laps: Vec<SectionLap> = portions
.iter()
.enumerate()
.filter_map(|(i, portion)| {
let start_idx = portion.start_index as usize;
let end_idx = portion.end_index as usize;
if start_idx >= times.len() || end_idx >= times.len() {
return None;
}
let lap_time = (times[end_idx] as f64 - times[start_idx] as f64).abs();
if lap_time <= 0.0 {
return None;
}
let pace = portion.distance_meters / lap_time;
Some(SectionLap {
id: format!("{}_lap{}", activity_id, i),
activity_id: activity_id.to_string(),
time: lap_time,
pace,
distance: portion.distance_meters,
direction: portion.direction.clone(),
start_index: portion.start_index,
end_index: portion.end_index,
})
})
.collect();
if laps.is_empty() {
return None;
}
let best_time = laps.iter().map(|l| l.time).fold(f64::MAX, f64::min);
let best_pace = laps.iter().map(|l| l.pace).fold(0.0f64, f64::max);
let avg_time = laps.iter().map(|l| l.time).sum::<f64>() / laps.len() as f64;
let avg_pace = laps.iter().map(|l| l.pace).sum::<f64>() / laps.len() as f64;
Some(SectionPerformanceRecord {
activity_id: activity_id.to_string(),
activity_name: metrics.name.clone(),
activity_date: metrics.date,
lap_count: laps.len() as u32,
best_time,
best_pace,
avg_time,
avg_pace,
direction: laps[0].direction.clone(),
section_distance: laps[0].distance,
laps,
})
})
.collect();
records.sort_by_key(|r| r.activity_date);
let best_record = records
.iter()
.min_by(|a, b| {
a.best_time
.partial_cmp(&b.best_time)
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned();
SectionPerformanceResult {
records,
best_record,
}
}
pub fn get_section_performances_json(&mut self, section_id: &str) -> String {
let result = self.get_section_performances(section_id);
serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
}
}
impl Default for RouteEngine {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct EngineStats {
pub activity_count: u32,
pub signature_count: u32,
pub group_count: u32,
pub section_count: u32,
pub cached_consensus_count: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ActivityBoundsInfo {
pub id: String,
pub bounds: [[f64; 2]; 2], pub activity_type: String,
pub distance: f64, }
#[derive(Debug, Clone, serde::Serialize)]
pub struct SignatureInfo {
pub points: Vec<GpsPoint>,
pub center: GpsPoint,
}
pub static ENGINE: Lazy<Mutex<RouteEngine>> = Lazy::new(|| Mutex::new(RouteEngine::new()));
pub fn with_engine<F, R>(f: F) -> R
where
F: FnOnce(&mut RouteEngine) -> R,
{
let mut engine = ENGINE.lock().unwrap();
f(&mut engine)
}
#[cfg(feature = "ffi")]
pub mod engine_ffi {
use super::*;
use log::info;
#[uniffi::export]
pub fn engine_init() {
crate::init_logging();
info!("[RouteEngine] Initialized");
}
#[uniffi::export]
pub fn engine_clear() {
with_engine(|e| e.clear());
info!("[RouteEngine] Cleared");
}
#[uniffi::export]
pub fn engine_add_activities(
activity_ids: Vec<String>,
all_coords: Vec<f64>,
offsets: Vec<u32>,
sport_types: Vec<String>,
) {
info!(
"[RouteEngine] Adding {} activities ({} coords)",
activity_ids.len(),
all_coords.len() / 2
);
with_engine(|e| {
e.add_activities_flat(&activity_ids, &all_coords, &offsets, &sport_types);
});
}
#[uniffi::export]
pub fn engine_remove_activities(activity_ids: Vec<String>) {
info!("[RouteEngine] Removing {} activities", activity_ids.len());
with_engine(|e| e.remove_activities(&activity_ids));
}
#[uniffi::export]
pub fn engine_get_activity_ids() -> Vec<String> {
with_engine(|e| e.get_activity_ids())
}
#[uniffi::export]
pub fn engine_get_activity_count() -> u32 {
with_engine(|e| e.activity_count() as u32)
}
#[uniffi::export]
pub fn engine_get_groups_json() -> String {
with_engine(|e| e.get_groups_json())
}
#[uniffi::export]
pub fn engine_get_sections_json() -> String {
with_engine(|e| e.get_sections_json())
}
#[uniffi::export]
pub fn engine_get_signatures_for_group_json(group_id: String) -> String {
with_engine(|e| e.get_signatures_for_group_json(&group_id))
}
#[uniffi::export]
pub fn engine_set_route_name(route_id: String, name: String) {
with_engine(|e| e.set_route_name(&route_id, &name))
}
#[uniffi::export]
pub fn engine_get_route_name(route_id: String) -> String {
with_engine(|e| e.get_route_name(&route_id).cloned().unwrap_or_default())
}
#[uniffi::export]
pub fn engine_query_viewport(
min_lat: f64,
max_lat: f64,
min_lng: f64,
max_lng: f64,
) -> Vec<String> {
with_engine(|e| e.query_viewport_raw(min_lat, max_lat, min_lng, max_lng))
}
#[uniffi::export]
pub fn engine_find_nearby(lat: f64, lng: f64, radius_degrees: f64) -> Vec<String> {
with_engine(|e| e.find_nearby(lat, lng, radius_degrees))
}
#[uniffi::export]
pub fn engine_get_consensus_route(group_id: String) -> Vec<f64> {
with_engine(|e| {
e.get_consensus_route(&group_id)
.map(|points| {
points
.iter()
.flat_map(|p| vec![p.latitude, p.longitude])
.collect()
})
.unwrap_or_default()
})
}
#[uniffi::export]
pub fn engine_get_stats() -> EngineStats {
with_engine(|e| e.stats())
}
#[uniffi::export]
pub fn engine_set_match_config(config: crate::MatchConfig) {
with_engine(|e| e.set_match_config(config));
}
#[uniffi::export]
pub fn engine_set_section_config(config: crate::SectionConfig) {
with_engine(|e| e.set_section_config(config));
}
#[uniffi::export]
pub fn engine_get_all_activity_bounds_json() -> String {
with_engine(|e| e.get_all_activity_bounds_json())
}
#[uniffi::export]
pub fn engine_get_all_signatures_json() -> String {
with_engine(|e| e.get_all_signatures_json())
}
#[uniffi::export]
pub fn engine_set_activity_metrics(metrics: Vec<crate::ActivityMetrics>) {
info!(
"[RouteEngine] Setting metrics for {} activities",
metrics.len()
);
with_engine(|e| e.set_activity_metrics(metrics));
}
#[uniffi::export]
pub fn engine_set_activity_metric(metric: crate::ActivityMetrics) {
with_engine(|e| e.set_activity_metric(metric));
}
#[uniffi::export]
pub fn engine_get_route_performances_json(
route_group_id: String,
current_activity_id: Option<String>,
) -> String {
with_engine(|e| {
e.get_route_performances_json(&route_group_id, current_activity_id.as_deref())
})
}
#[uniffi::export]
pub fn engine_set_time_streams(
activity_ids: Vec<String>,
all_times: Vec<u32>,
offsets: Vec<u32>,
) {
info!(
"[RouteEngine] Setting time streams for {} activities",
activity_ids.len()
);
with_engine(|e| e.set_time_streams_flat(&activity_ids, &all_times, &offsets));
}
#[uniffi::export]
pub fn engine_get_section_performances_json(section_id: String) -> String {
with_engine(|e| e.get_section_performances_json(§ion_id))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_coords() -> Vec<GpsPoint> {
(0..10)
.map(|i| GpsPoint::new(51.5074 + i as f64 * 0.001, -0.1278))
.collect()
}
#[test]
fn test_engine_add_activity() {
let mut engine = RouteEngine::new();
engine.add_activity("test-1".to_string(), sample_coords(), "cycling".to_string());
assert_eq!(engine.activity_count(), 1);
assert!(engine.has_activity("test-1"));
}
#[test]
fn test_engine_add_flat() {
let mut engine = RouteEngine::new();
let flat_coords: Vec<f64> = sample_coords()
.iter()
.flat_map(|p| vec![p.latitude, p.longitude])
.collect();
engine.add_activity_flat("test-1".to_string(), &flat_coords, "cycling".to_string());
assert_eq!(engine.activity_count(), 1);
}
#[test]
fn test_engine_get_signature() {
let mut engine = RouteEngine::new();
engine.add_activity("test-1".to_string(), sample_coords(), "cycling".to_string());
let sig = engine.get_signature("test-1");
assert!(sig.is_some());
assert_eq!(sig.unwrap().activity_id, "test-1");
}
#[test]
fn test_engine_grouping() {
let mut engine = RouteEngine::new();
let coords = sample_coords();
engine.add_activity("test-1".to_string(), coords.clone(), "cycling".to_string());
engine.add_activity("test-2".to_string(), coords.clone(), "cycling".to_string());
let groups = engine.get_groups();
assert_eq!(groups.len(), 1); assert_eq!(groups[0].activity_ids.len(), 2);
}
#[test]
fn test_engine_viewport_query() {
let mut engine = RouteEngine::new();
engine.add_activity("test-1".to_string(), sample_coords(), "cycling".to_string());
let results = engine.query_viewport_raw(51.5, 51.52, -0.15, -0.10);
assert_eq!(results.len(), 1);
let results = engine.query_viewport_raw(40.0, 41.0, -75.0, -74.0);
assert!(results.is_empty());
}
#[test]
fn test_engine_remove() {
let mut engine = RouteEngine::new();
engine.add_activity("test-1".to_string(), sample_coords(), "cycling".to_string());
engine.add_activity("test-2".to_string(), sample_coords(), "cycling".to_string());
engine.remove_activity("test-1");
assert_eq!(engine.activity_count(), 1);
assert!(!engine.has_activity("test-1"));
assert!(engine.has_activity("test-2"));
}
#[test]
fn test_engine_clear() {
let mut engine = RouteEngine::new();
engine.add_activity("test-1".to_string(), sample_coords(), "cycling".to_string());
engine.clear();
assert_eq!(engine.activity_count(), 0);
}
#[test]
fn test_engine_incremental_grouping() {
let mut engine = RouteEngine::new();
let coords = sample_coords();
engine.add_activity("test-1".to_string(), coords.clone(), "cycling".to_string());
engine.add_activity("test-2".to_string(), coords.clone(), "cycling".to_string());
let groups = engine.get_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].activity_ids.len(), 2);
engine.add_activity("test-3".to_string(), coords.clone(), "cycling".to_string());
let different_coords: Vec<GpsPoint> = (0..10)
.map(|i| GpsPoint::new(40.7128 + i as f64 * 0.001, -74.0060)) .collect();
engine.add_activity(
"test-4".to_string(),
different_coords,
"cycling".to_string(),
);
let groups = engine.get_groups();
assert_eq!(groups.len(), 2);
let large_group = groups.iter().find(|g| g.activity_ids.len() == 3);
assert!(
large_group.is_some(),
"Should have a group with 3 activities"
);
let small_group = groups.iter().find(|g| g.activity_ids.len() == 1);
assert!(small_group.is_some(), "Should have a group with 1 activity");
assert!(small_group
.unwrap()
.activity_ids
.contains(&"test-4".to_string()));
}
#[test]
fn test_engine_new_signatures_tracking() {
let mut engine = RouteEngine::new();
let coords = sample_coords();
engine.add_activity("test-1".to_string(), coords.clone(), "cycling".to_string());
assert!(engine.dirty_signatures.contains("test-1"));
let _sig = engine.get_signature("test-1");
assert!(engine.dirty_signatures.is_empty());
assert!(engine.new_signatures.contains("test-1"));
let _groups = engine.get_groups();
assert!(engine.new_signatures.is_empty());
}
}