From b7ca462f587fec9f6a14b77af8e4afa2599bfb8a Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 4 Oct 2026 11:52:46 +0200 Subject: [PATCH] progress --- gpx-rs/engine/src/core/gpx/common.rs | 2 +- .../engine/src/core/statistics/statistics.rs | 92 ++++- gpx-rs/engine/src/core/utils.rs | 19 +- .../src/engine/derived/statistics_buffer.rs | 330 ++++++++++++++++-- .../src/engine/derived/statistics_cache.rs | 97 +++-- gpx-rs/engine/src/engine/engine.rs | 6 +- gpx-rs/wasm/src/lib.rs | 142 +++++--- .../src/lib/components/GPXStatistics.svelte | 34 +- .../elevation-profile/ElevationProfile.svelte | 16 +- .../elevation-profile/elevation-profile.ts | 127 ++++--- .../lib/components/embedding/Embedding.svelte | 13 +- website/src/lib/engine.ts | 131 ++++++- website/src/lib/logic/selection-statistics.ts | 16 + website/src/routes/[[language]]/+page.svelte | 39 ++- .../src/routes/[[language]]/app/+page.svelte | 12 +- 15 files changed, 859 insertions(+), 217 deletions(-) create mode 100644 website/src/lib/logic/selection-statistics.ts diff --git a/gpx-rs/engine/src/core/gpx/common.rs b/gpx-rs/engine/src/core/gpx/common.rs index fe1965bf3..62b00f049 100644 --- a/gpx-rs/engine/src/core/gpx/common.rs +++ b/gpx-rs/engine/src/core/gpx/common.rs @@ -10,7 +10,7 @@ pub struct LngLat { pub lat: f64, } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub struct LngLatBounds { pub sw: LngLat, pub ne: LngLat, diff --git a/gpx-rs/engine/src/core/statistics/statistics.rs b/gpx-rs/engine/src/core/statistics/statistics.rs index efd96582e..fd24bf828 100644 --- a/gpx-rs/engine/src/core/statistics/statistics.rs +++ b/gpx-rs/engine/src/core/statistics/statistics.rs @@ -41,6 +41,10 @@ impl Statistics { self.accumulate_distance_and_time(prev, cur); self.update_time_bounds(cur.time); self.update_bounds(cur.coordinates); + self.global.hr.add(cur.hr.map(f64::from)); + self.global.cad.add(cur.cad.map(f64::from)); + self.global.atemp.add(cur.atemp.map(f64::from)); + self.global.power.add(cur.power.map(f64::from)); self.local .push(TrackpointStatistics::from_partial_stats(self)); } @@ -207,17 +211,47 @@ impl Statistics { } } +/// Average of an optional measure (heart rate, cadence...) over the trackpoints that have it. +/// Keeps the sum so that averages can be merged. +#[derive(Default, Debug, Clone, Copy, PartialEq)] +pub struct Average { + pub sum: f64, + pub count: u32, +} + +impl Average { + pub fn add(&mut self, value: Option) { + if let Some(value) = value { + self.sum += value; + self.count += 1; + } + } + + pub fn merge(&mut self, other: &Average) { + self.sum += other.sum; + self.count += other.count; + } + + pub fn avg(&self) -> Option { + (self.count > 0).then(|| self.sum / self.count as f64) + } +} + #[derive(Default, Debug)] pub struct GlobalStatistics { pub total_distance: f64, pub moving_distance: Option, - pub total_time: Option, - pub moving_time: Option, + pub total_time: Option, + pub moving_time: Option, pub elevation_gain: f64, pub elevation_loss: f64, pub start_time: Option, pub end_time: Option, pub bounds: LngLatBounds, + pub hr: Average, + pub cad: Average, + pub atemp: Average, + pub power: Average, } impl GlobalStatistics { @@ -241,6 +275,10 @@ impl GlobalStatistics { self.start_time = min_options(self.start_time, other.start_time); self.end_time = max_options(self.end_time, other.end_time); self.bounds.merge(&other.bounds); + self.hr.merge(&other.hr); + self.cad.merge(&other.cad); + self.atemp.merge(&other.atemp); + self.power.merge(&other.power); } } @@ -248,8 +286,8 @@ impl GlobalStatistics { pub struct TrackpointStatistics { pub total_distance: f64, pub moving_distance: Option, - pub total_time: Option, - pub moving_time: Option, + pub total_time: Option, + pub moving_time: Option, pub speed: Option, pub elevation_gain: f64, pub elevation_loss: f64, @@ -417,4 +455,50 @@ mod tests { assert_eq!(cumul.total_time, Some(7_000)); assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0)); } + + #[test] + fn test_average() { + let mut average = Average::default(); + assert_eq!(average.avg(), None); + average.add(None); + assert_eq!(average.avg(), None); + average.add(Some(100.0)); + average.add(Some(110.0)); + assert_eq!(average.avg(), Some(105.0)); + + let mut other = Average::default(); + other.add(Some(130.0)); + average.merge(&other); + assert_eq!(average.count, 3); + assert_eq!(average.avg(), Some(113.33333333333333)); + } + + #[test] + fn test_measures_of_the_trackpoints_are_averaged() { + for (path, measure) in [ + ("data/with_hr.gpx", 0), + ("data/with_cad.gpx", 1), + ("data/with_temp.gpx", 2), + ("data/with_power_1.gpx", 3), + ] { + let data = std::fs::read(path).unwrap(); + let gpx = parse(&data).unwrap(); + let trkseg = &gpx.trk[0].trkseg[0]; + let global = Statistics::compute(trkseg).global; + let values: Vec = trkseg + .iter() + .filter_map(|trkpt| match measure { + 0 => trkpt.hr.map(f64::from), + 1 => trkpt.cad.map(f64::from), + 2 => trkpt.atemp.map(f64::from), + _ => trkpt.power.map(f64::from), + }) + .collect(); + let average = [global.hr, global.cad, global.atemp, global.power][measure]; + assert!(!values.is_empty(), "{path}"); + assert_eq!(average.count as usize, values.len(), "{path}"); + let expected = values.iter().sum::() / values.len() as f64; + assert!((average.avg().unwrap() - expected).abs() < 1e-9, "{path}"); + } + } } diff --git a/gpx-rs/engine/src/core/utils.rs b/gpx-rs/engine/src/core/utils.rs index 5c3a5307c..5ed9e3044 100644 --- a/gpx-rs/engine/src/core/utils.rs +++ b/gpx-rs/engine/src/core/utils.rs @@ -18,12 +18,12 @@ pub fn distance(p1: LngLat, p2: LngLat) -> f64 { EARTH_RADIUS * c } -pub fn time_diff(a: Option, b: Option) -> Option { - Some((a? - b?) as i32) +pub fn time_diff(a: Option, b: Option) -> Option { + Some(a? - b?) } /// Computes the speed for a given distance in kilometers and a time in milliseconds -pub fn speed(distance: f64, time: i32) -> f64 { +pub fn speed(distance: f64, time: i64) -> f64 { distance / (time as f64 / 3_600_000.0) } @@ -115,6 +115,19 @@ mod tests { LngLat { lng, lat } } + #[test] + fn test_time_diff_and_speed_with_long_durations() { + // 40 days in milliseconds, more than an i32 can hold + let forty_days = 40 * 24 * 3_600_000_i64; + assert!(forty_days > i64::from(i32::MAX)); + assert_eq!(time_diff(Some(forty_days), Some(0)), Some(forty_days)); + assert_eq!(time_diff(Some(0), Some(forty_days)), Some(-forty_days)); + assert_eq!(time_diff(None, Some(1)), None); + assert_eq!(time_diff(Some(1), None), None); + // 1 km in one hour + assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12); + } + #[test] fn test_distance() { assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0); diff --git a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs index c4ec0b9a8..6f3626638 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs @@ -1,21 +1,52 @@ -use crate::{GlobalStatistics, Statistics, sum_options}; +use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options}; +/// Value of the timestamps of the trackpoints that have none. +pub const NO_TIME: i64 = i64::MIN; + +/// Flattened statistics of the selected segments, one entry per trackpoint, in selection order. +/// +/// The distance, time, moving and elevation values are cumulative over the whole selection (the +/// second segment continues where the first one ended), so the statistics of any range of +/// trackpoints are the difference between its two ends: see [`StatisticsBuffer::slice`]. +/// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are +/// [`NO_TIME`], missing measures are NaN. #[derive(Debug, Default)] pub struct StatisticsBuffer { + /// Statistics of the whole selection. + pub global: GlobalStatistics, pub total_distance: Vec, pub moving_distance: Vec, - pub total_time: Vec, - pub moving_time: Vec, + pub total_time: Vec, + pub moving_time: Vec, pub speed: Vec, pub elevation_gain: Vec, pub elevation_loss: Vec, pub slope: Vec, pub slope_segment_slope: Vec, pub slope_segment_distance: Vec, + pub lng: Vec, + pub lat: Vec, + pub ele: Vec, + /// Timestamps, in milliseconds since the epoch. + pub time: Vec, + pub hr: Vec, + pub cad: Vec, + pub atemp: Vec, + pub power: Vec, } impl StatisticsBuffer { - pub fn update(&mut self, stats: &[&Statistics]) { + pub fn len(&self) -> usize { + self.total_distance.len() + } + + pub fn is_empty(&self) -> bool { + self.total_distance.is_empty() + } + + /// `selected`: the segments and their statistics, in order. + pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) { + self.global = GlobalStatistics::default(); self.total_distance.clear(); self.moving_distance.clear(); self.total_time.clear(); @@ -26,10 +57,20 @@ impl StatisticsBuffer { self.slope.clear(); self.slope_segment_slope.clear(); self.slope_segment_distance.clear(); + self.lng.clear(); + self.lat.clear(); + self.ele.clear(); + self.time.clear(); + self.hr.clear(); + self.cad.clear(); + self.atemp.clear(); + self.power.clear(); - let mut cumul_stats = GlobalStatistics::default(); - for stats in stats { - for trkpt_stats in stats.local.iter() { + let optional = |value: Option| value.unwrap_or(f64::NAN); + + for (segment, stats) in selected { + for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) { + let cumul_stats = &self.global; self.total_distance .push(cumul_stats.total_distance + trkpt_stats.total_distance); self.moving_distance.push( @@ -53,10 +94,55 @@ impl StatisticsBuffer { .push(trkpt_stats.slope_segment.slope); self.slope_segment_distance .push(trkpt_stats.slope_segment.distance); + self.lng.push(trkpt.coordinates.lng); + self.lat.push(trkpt.coordinates.lat); + self.ele.push(trkpt.ele); + self.time.push(trkpt.time.unwrap_or(NO_TIME)); + self.hr.push(optional(trkpt.hr.map(f64::from))); + self.cad.push(optional(trkpt.cad.map(f64::from))); + self.atemp.push(optional(trkpt.atemp.map(f64::from))); + self.power.push(optional(trkpt.power.map(f64::from))); } - cumul_stats.merge(&stats.global); + self.global.merge(&stats.global); } } + + /// Statistics of the trackpoints from `start` to `end` (both included) of the selection, for + /// example the part of an elevation profile the user dragged over. `None` if the range is + /// not inside the selection. + /// + /// It is done in constant time, from the cumulative values at both ends: the bounds and + /// the averages are the ones of the whole selection, they are not computed for the range. + pub fn slice(&self, start: usize, end: usize) -> Option { + if start > end || end >= self.len() { + return None; + } + + let delta = |values: &[f64]| values[end] - values[start]; + let delta_time = |values: &[i64]| values[end] - values[start]; + let time = |i: usize| (self.time[i] != NO_TIME).then_some(self.time[i]); + Some(GlobalStatistics { + total_distance: delta(&self.total_distance), + moving_distance: self + .global + .moving_distance + .map(|_| delta(&self.moving_distance)), + total_time: self.global.total_time.map(|_| delta_time(&self.total_time)), + moving_time: self + .global + .moving_time + .map(|_| delta_time(&self.moving_time)), + elevation_gain: delta(&self.elevation_gain), + elevation_loss: delta(&self.elevation_loss), + start_time: time(start), + end_time: time(end), + bounds: self.global.bounds, + hr: self.global.hr, + cad: self.global.cad, + atemp: self.global.atemp, + power: self.global.power, + }) + } } #[cfg(test)] @@ -65,28 +151,16 @@ mod tests { use super::*; - fn stats(path: &str) -> Statistics { + fn computed(path: &str) -> (TrackSegment, Statistics) { let data = std::fs::read(path).unwrap(); let file = parse(&data).unwrap(); - Statistics::compute(&file.trk[0].trkseg[0]) + let segment = file.trk[0].trkseg[0].clone(); + let stats = Statistics::compute(&segment); + (segment, stats) } - #[test] - fn test_empty() { - let mut buffer = StatisticsBuffer::default(); - buffer.update(&[]); - assert!(buffer.total_distance.is_empty()); - assert!(buffer.speed.is_empty()); - } - - #[test] - fn test_single_segment_matches_local_stats() { - let s = stats("data/simple.gpx"); - let mut buffer = StatisticsBuffer::default(); - buffer.update(&[&s]); - - let n = s.local.len(); - for len in [ + fn all_lengths(buffer: &StatisticsBuffer) -> Vec { + vec![ buffer.total_distance.len(), buffer.moving_distance.len(), buffer.total_time.len(), @@ -97,21 +171,76 @@ mod tests { buffer.slope.len(), buffer.slope_segment_slope.len(), buffer.slope_segment_distance.len(), - ] { - assert_eq!(len, n); - } + buffer.lng.len(), + buffer.lat.len(), + buffer.ele.len(), + buffer.time.len(), + buffer.hr.len(), + buffer.cad.len(), + buffer.atemp.len(), + buffer.power.len(), + ] + } + + #[test] + fn test_empty() { + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[]); + assert!(buffer.is_empty()); + assert!(buffer.speed.is_empty()); + assert_eq!(buffer.global.total_distance, 0.0); + assert!(buffer.slice(0, 0).is_none()); + } + + #[test] + fn test_single_segment_matches_local_stats() { + let (segment, s) = computed("data/simple.gpx"); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s)]); + + let n = s.local.len(); + assert!(all_lengths(&buffer).iter().all(|len| *len == n)); + assert_eq!(buffer.len(), n); for (i, local) in s.local.iter().enumerate() { assert_eq!(buffer.total_distance[i], local.total_distance); assert_eq!(buffer.slope[i], local.slope); } + // the trackpoints + for (i, trkpt) in segment.iter().enumerate() { + assert_eq!(buffer.lng[i], trkpt.coordinates.lng); + assert_eq!(buffer.lat[i], trkpt.coordinates.lat); + assert_eq!(buffer.ele[i], trkpt.ele); + } + } + + #[test] + fn test_global_is_the_merge_of_the_segments() { + let (segment, s) = computed("data/simple.gpx"); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s), (&segment, &s)]); + + let global = &buffer.global; + assert!((global.total_distance - 2.0 * s.global.total_distance).abs() < 1e-9); + assert!((global.elevation_gain - 2.0 * s.global.elevation_gain).abs() < 1e-9); + assert!((global.elevation_loss - 2.0 * s.global.elevation_loss).abs() < 1e-9); + // and it is the last cumulative value of the points + assert_eq!( + global.total_distance, + *buffer.total_distance.last().unwrap() + ); + + buffer.update(&[(&segment, &s)]); + assert_eq!(buffer.global.total_distance, s.global.total_distance); + buffer.update(&[]); + assert_eq!(buffer.global.total_distance, 0.0); } #[test] fn test_distances_accumulate_over_segments() { - let s = stats("data/simple.gpx"); + let (segment, s) = computed("data/simple.gpx"); let n = s.local.len(); let mut buffer = StatisticsBuffer::default(); - buffer.update(&[&s, &s]); + buffer.update(&[(&segment, &s), (&segment, &s)]); assert_eq!(buffer.total_distance.len(), 2 * n); // the second segment starts where the first one ended @@ -123,27 +252,156 @@ mod tests { #[test] fn test_total_time_accumulates_over_segments() { - let s = stats("data/with_time.gpx"); + let (segment, s) = computed("data/with_time.gpx"); let n = s.local.len(); let duration = s.global.total_time.unwrap(); assert!(duration > 0); let mut buffer = StatisticsBuffer::default(); - buffer.update(&[&s, &s]); + buffer.update(&[(&segment, &s), (&segment, &s)]); assert_eq!(buffer.total_time[n - 1], duration); assert_eq!(buffer.total_time[n], duration); assert_eq!(buffer.total_time[2 * n - 1], 2 * duration); assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1])); + assert!(buffer.time.iter().all(|t| *t != NO_TIME)); + } + + #[test] + fn test_missing_times_and_measures_are_nan() { + let (segment, s) = computed("data/simple.gpx"); + assert!(segment.iter().all(|trkpt| trkpt.time.is_none())); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s)]); + assert!(buffer.time.iter().all(|t| *t == NO_TIME)); + assert!(buffer.hr.iter().all(|v| v.is_nan())); + assert_eq!(buffer.global.hr.count, 0); + assert_eq!(buffer.global.total_time, None); } #[test] fn test_update_replaces_previous_content() { - let s = stats("data/simple.gpx"); + let (segment, s) = computed("data/simple.gpx"); let mut buffer = StatisticsBuffer::default(); - buffer.update(&[&s]); - buffer.update(&[&s]); + buffer.update(&[(&segment, &s)]); + buffer.update(&[(&segment, &s)]); assert_eq!(buffer.total_distance.len(), s.local.len()); + assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len())); buffer.update(&[]); assert!(buffer.total_distance.is_empty()); + assert!(all_lengths(&buffer).iter().all(|len| *len == 0)); + } + + #[test] + fn test_slice_of_the_whole_selection_is_the_global_statistics() { + let (segment, s) = computed("data/with_time.gpx"); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s), (&segment, &s)]); + + let slice = buffer.slice(0, buffer.len() - 1).unwrap(); + let global = &buffer.global; + assert!((slice.total_distance - global.total_distance).abs() < 1e-9); + assert!((slice.elevation_gain - global.elevation_gain).abs() < 1e-9); + assert!((slice.elevation_loss - global.elevation_loss).abs() < 1e-9); + assert_eq!(slice.total_time, global.total_time); + assert_eq!(slice.moving_time, global.moving_time); + assert_eq!(slice.start_time, global.start_time); + assert_eq!(slice.end_time, global.end_time); + } + + #[test] + fn test_slice_of_a_range() { + let (segment, s) = computed("data/with_time.gpx"); + let n = s.local.len(); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s)]); + + let (start, end) = (n / 4, n / 2); + let slice = buffer.slice(start, end).unwrap(); + assert!( + (slice.total_distance - (buffer.total_distance[end] - buffer.total_distance[start])) + .abs() + < 1e-12 + ); + assert_eq!( + slice.total_time, + Some(buffer.total_time[end] - buffer.total_time[start]) + ); + assert_eq!( + slice.moving_time, + Some(buffer.moving_time[end] - buffer.moving_time[start]) + ); + assert_eq!(slice.start_time, Some(buffer.time[start])); + assert_eq!(slice.end_time, Some(buffer.time[end])); + assert!(slice.total_distance < buffer.global.total_distance); + assert!(slice.total_speed().is_some()); + + // a single point has nothing between its ends + let point = buffer.slice(start, start).unwrap(); + assert_eq!(point.total_distance, 0.0); + assert_eq!(point.total_time, Some(0)); + } + + #[test] + fn test_slice_keeps_the_bounds_and_averages_of_the_selection() { + let data = std::fs::read("data/with_hr.gpx").unwrap(); + let file = parse(&data).unwrap(); + let segment = file.trk[0].trkseg[0].clone(); + let s = Statistics::compute(&segment); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s)]); + assert!(buffer.global.hr.count > 0); + + let slice = buffer.slice(1, 3).unwrap(); + let (global, bounds) = (&buffer.global, &slice.bounds); + assert_eq!(slice.hr, global.hr); + assert_eq!(slice.cad, global.cad); + assert_eq!(slice.atemp, global.atemp); + assert_eq!(slice.power, global.power); + assert_eq!( + (bounds.sw.lng, bounds.sw.lat), + (global.bounds.sw.lng, global.bounds.sw.lat) + ); + assert_eq!( + (bounds.ne.lng, bounds.ne.lat), + (global.bounds.ne.lng, global.bounds.ne.lat) + ); + } + + #[test] + fn test_durations_are_not_limited_to_24_days() { + // 30 days of recording, in milliseconds + let (segment, mut s) = computed("data/with_time.gpx"); + let month = 30 * 24 * 3_600_000_i64; + s.global.total_time = Some(month); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s), (&segment, &s)]); + assert_eq!(buffer.global.total_time, Some(2 * month)); + assert!(2 * month > i64::from(i32::MAX)); + } + + #[test] + fn test_slice_across_segments() { + let (segment, s) = computed("data/simple.gpx"); + let n = s.local.len(); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s), (&segment, &s)]); + + // from the middle of the first segment to the middle of the second one + let slice = buffer.slice(n / 2, n + n / 2).unwrap(); + let expected = buffer.total_distance[n + n / 2] - buffer.total_distance[n / 2]; + assert!((slice.total_distance - expected).abs() < 1e-9); + assert_eq!(slice.total_time, None); + } + + #[test] + fn test_slice_out_of_range() { + let (segment, s) = computed("data/simple.gpx"); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s)]); + let n = buffer.len(); + assert!(buffer.slice(0, n).is_none()); + assert!(buffer.slice(n, n).is_none()); + assert!(buffer.slice(5, 4).is_none()); + assert!(buffer.slice(0, n - 1).is_some()); } } diff --git a/gpx-rs/engine/src/engine/derived/statistics_cache.rs b/gpx-rs/engine/src/engine/derived/statistics_cache.rs index 2ab10cbeb..afa38df37 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_cache.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_cache.rs @@ -1,10 +1,13 @@ use std::collections::{HashMap, HashSet, hash_map::Entry}; use crate::{ - File, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment, + File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment, TrackSegmentRevisionId, }; +/// A segment and its statistics. +pub type SelectedSegment<'a> = (&'a TrackSegment, &'a Statistics); + #[derive(Debug, Default)] pub struct StatisticsCache { map: HashMap, @@ -34,19 +37,38 @@ impl StatisticsCache { } } + /// Statistics of the segments of the selection. pub fn get<'a>( &'a self, - state: Option<&StackEntry>, + state: Option<&'a StackEntry>, selection: &Selection, ) -> Vec<&'a Statistics> { - let mut stats = vec![]; + self.selected(state, selection, &[]) + .into_iter() + .map(|(_, stats)| stats) + .collect() + } + + /// The segments of the selection with their statistics. The segments of several selected + /// files follow `order` (the files missing from it come last). + pub fn selected<'a>( + &'a self, + state: Option<&'a StackEntry>, + selection: &Selection, + order: &[FileId], + ) -> Vec> { + let mut selected = vec![]; if let Some(state) = state { match selection { Selection::Empty => (), Selection::File { file_ids } => { - for id in file_ids.iter() { + let ordered = order + .iter() + .filter(|id| file_ids.contains(id)) + .chain(file_ids.iter().filter(|id| !order.contains(id))); + for id in ordered { if let Some(file) = state.get(id) { - self.add_file_stats(file, &mut stats); + self.add_file_stats(file, &mut selected); } } } @@ -54,7 +76,7 @@ impl StatisticsCache { if let Some(file) = state.get(file_id) { for trk in file.trk.iter() { if trk_ids.contains(&trk.id) { - self.add_track_stats(trk, &mut stats); + self.add_track_stats(trk, &mut selected); } } } @@ -69,7 +91,7 @@ impl StatisticsCache { if *trk_id == trk.id { for trkseg in trk.trkseg.iter() { if trkseg_ids.contains(&trkseg.id) { - self.add_segment_stats(trkseg, &mut stats); + self.add_segment_stats(trkseg, &mut selected); } } break; @@ -79,7 +101,7 @@ impl StatisticsCache { } Selection::Waypoints { file_id } => { if let Some(file) = state.get(file_id) { - self.add_file_stats(file, &mut stats); + self.add_file_stats(file, &mut selected); } } Selection::Waypoint { @@ -87,40 +109,44 @@ impl StatisticsCache { wpt_ids: _, } => { if let Some(file) = state.get(file_id) { - self.add_file_stats(file, &mut stats); + self.add_file_stats(file, &mut selected); } } } } - stats + selected } /// Global statistics of a file: its segments merged, in order. pub fn file_global(&self, file: &File) -> GlobalStatistics { - let mut stats = vec![]; - self.add_file_stats(file, &mut stats); + let mut selected = vec![]; + self.add_file_stats(file, &mut selected); let mut global = GlobalStatistics::default(); - for s in stats { - global.merge(&s.global); + for (_, stats) in selected { + global.merge(&stats.global); } global } - fn add_file_stats<'a>(&'a self, file: &File, stats: &mut Vec<&'a Statistics>) { + fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec>) { for trk in file.trk.iter() { - self.add_track_stats(trk, stats); + self.add_track_stats(trk, selected); } } - fn add_track_stats<'a>(&'a self, trk: &Track, stats: &mut Vec<&'a Statistics>) { + fn add_track_stats<'a>(&'a self, trk: &'a Track, selected: &mut Vec>) { for trkseg in trk.trkseg.iter() { - self.add_segment_stats(trkseg, stats); + self.add_segment_stats(trkseg, selected); } } - fn add_segment_stats<'a>(&'a self, trkseg: &TrackSegment, stats: &mut Vec<&'a Statistics>) { - if let Some(s) = self.map.get(&trkseg.rev_id) { - stats.push(s); + fn add_segment_stats<'a>( + &'a self, + trkseg: &'a TrackSegment, + selected: &mut Vec>, + ) { + if let Some(stats) = self.map.get(&trkseg.rev_id) { + selected.push((trkseg, stats)); } } } @@ -241,4 +267,33 @@ mod tests { cache.update(None); assert!(cache.map.is_empty()); } + + #[test] + fn test_segments_of_several_files_follow_the_given_order() { + let (mut state, first) = state("data/simple.gpx"); + let (other, second) = { + let data = std::fs::read("data/with_time.gpx").unwrap(); + let file = Rc::new(parse(&data).unwrap()); + (file.clone(), file) + }; + state.insert(other.id, other); + let mut cache = StatisticsCache::default(); + cache.update(Some(&state)); + let selection = Selection::File { + file_ids: HashSet::from([first.id, second.id]), + }; + + let ids = |order: &[FileId]| -> Vec<_> { + cache + .selected(Some(&state), &selection, order) + .iter() + .map(|(segment, _)| segment.id) + .collect() + }; + let (a, b) = (first.trk[0].trkseg[0].id, second.trk[0].trkseg[0].id); + assert_eq!(ids(&[first.id, second.id]), vec![a, b]); + assert_eq!(ids(&[second.id, first.id]), vec![b, a]); + // the files that are not in the order come last + assert_eq!(ids(&[second.id]), vec![b, a]); + } } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 018f955e0..5cadcd5cd 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -144,7 +144,11 @@ impl Engine { self.coordinates_cache.update(current); self.structure_cache.update(current, self.diff.as_ref()); self.statistics_buffer - .update(&self.statistics_cache.get(current, &self.selection)); + .update( + &self + .statistics_cache + .selected(current, &self.selection, &self.order.0), + ); } } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 7658d26da..8f55700e1 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; use wasm_bindgen::prelude::*; use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds}; -use js_sys::{Array, Float64Array, Int32Array, Object, Reflect}; +use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect}; #[wasm_bindgen] #[derive(Clone, Copy)] @@ -133,14 +133,36 @@ macro_rules! stats_getter { stats_getter!(total_distance, Float64Array); stats_getter!(moving_distance, Float64Array); -stats_getter!(total_time, Int32Array); -stats_getter!(moving_time, Int32Array); +// Durations since the start of the selection, in milliseconds. +stats_getter!(total_time, BigInt64Array); +stats_getter!(moving_time, BigInt64Array); stats_getter!(speed, Float64Array); stats_getter!(elevation_gain, Float64Array); stats_getter!(elevation_loss, Float64Array); stats_getter!(slope, Float64Array); stats_getter!(slope_segment_slope, Float64Array); stats_getter!(slope_segment_distance, Float64Array); +stats_getter!(lng, Float64Array); +stats_getter!(lat, Float64Array); +stats_getter!(ele, Float64Array); + +/// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing). +#[wasm_bindgen] +pub fn timestamps() -> BigInt64Array { + with_stats(|s| unsafe { BigInt64Array::view(&s.time) }) + .unwrap_or_else(|| BigInt64Array::new_with_length(0)) +} + +/// Value of the timestamps of the trackpoints that have none. +#[wasm_bindgen] +pub fn no_time() -> i64 { + engine::NO_TIME +} + +stats_getter!(hr, Float64Array); +stats_getter!(cad, Float64Array); +stats_getter!(atemp, Float64Array); +stats_getter!(power, Float64Array); // File commands @@ -443,8 +465,11 @@ export interface FileStructure { /** Changes when the waypoints of the file change: refetch their coordinates. */ waypointsRev: string; } -/** Global statistics of a file (optional fields are absent when the file has no timestamps). */ -export interface FileStatistics { +/** + * Global statistics of a file, of the selection, or of a part of it. The optional fields are + * absent when there is no data for them (no timestamps, no heart rate...). + */ +export interface GlobalStatistics { /** km */ totalDistance: number; movingDistance?: number; @@ -459,7 +484,12 @@ export interface FileStatistics { /** km/h */ totalSpeed?: number; movingSpeed?: number; - /** Absent when the file has no trackpoints. */ + /** Average and number of trackpoints having the measure. */ + hr?: { avg: number; count: number }; + cad?: { avg: number; count: number }; + atemp?: { avg: number; count: number }; + power?: { avg: number; count: number }; + /** Absent when there are no trackpoints. */ bounds?: { west: number; south: number; east: number; north: number }; } export interface TrackNode { @@ -494,8 +524,8 @@ extern "C" { pub type FilesUpdate; #[wasm_bindgen(typescript_type = "FileStructure | undefined")] pub type FileStructure; - #[wasm_bindgen(typescript_type = "FileStatistics | undefined")] - pub type FileStatistics; + #[wasm_bindgen(typescript_type = "GlobalStatistics | undefined")] + pub type GlobalStatistics; #[wasm_bindgen(typescript_type = "string[]")] pub type FileOrder; } @@ -594,41 +624,75 @@ pub fn file_structure(file_id: &str) -> FileStructure { structure.unchecked_into() } +fn statistics_object(stats: &engine::GlobalStatistics) -> GlobalStatistics { + let object = Object::new(); + let optional = |key: &str, value: Option| { + if let Some(value) = value { + set(&object, key, value); + } + }; + let seconds = |ms: Option| ms.map(|ms| ms as f64 / 1000.0); + let average = |key: &str, average: &engine::Average| { + if let Some(avg) = average.avg() { + let value = Object::new(); + set(&value, "avg", avg); + set(&value, "count", f64::from(average.count)); + set(&object, key, value); + } + }; + set(&object, "totalDistance", stats.total_distance); + optional("movingDistance", stats.moving_distance); + optional("totalTime", seconds(stats.total_time)); + optional("movingTime", seconds(stats.moving_time)); + set(&object, "elevationGain", stats.elevation_gain); + set(&object, "elevationLoss", stats.elevation_loss); + optional("startTime", stats.start_time.map(|t| t as f64)); + optional("endTime", stats.end_time.map(|t| t as f64)); + optional("totalSpeed", stats.total_speed()); + optional("movingSpeed", stats.moving_speed()); + average("hr", &stats.hr); + average("cad", &stats.cad); + average("atemp", &stats.atemp); + average("power", &stats.power); + let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne); + if sw.lng <= ne.lng && sw.lat <= ne.lat { + let bounds = Object::new(); + set(&bounds, "west", sw.lng); + set(&bounds, "south", sw.lat); + set(&bounds, "east", ne.lng); + set(&bounds, "north", ne.lat); + set(&object, "bounds", bounds); + } + object.unchecked_into() +} + /// Global statistics of a file, `undefined` if the id is unknown. #[wasm_bindgen] -pub fn file_statistics(file_id: &str) -> FileStatistics { - let stats = uuid::Uuid::parse_str(file_id) +pub fn file_statistics(file_id: &str) -> GlobalStatistics { + uuid::Uuid::parse_str(file_id) .ok() .and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten()) - .map_or(JsValue::UNDEFINED, |stats| { - let object = Object::new(); - let optional = |key: &str, value: Option| { - if let Some(value) = value { - set(&object, key, value); - } - }; - set(&object, "totalDistance", stats.total_distance); - optional("movingDistance", stats.moving_distance); - optional("totalTime", stats.total_time.map(f64::from)); - optional("movingTime", stats.moving_time.map(f64::from)); - set(&object, "elevationGain", stats.elevation_gain); - set(&object, "elevationLoss", stats.elevation_loss); - optional("startTime", stats.start_time.map(|t| t as f64)); - optional("endTime", stats.end_time.map(|t| t as f64)); - optional("totalSpeed", stats.total_speed()); - optional("movingSpeed", stats.moving_speed()); - let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne); - if sw.lng <= ne.lng && sw.lat <= ne.lat { - let bounds = Object::new(); - set(&bounds, "west", sw.lng); - set(&bounds, "south", sw.lat); - set(&bounds, "east", ne.lng); - set(&bounds, "north", ne.lat); - set(&object, "bounds", bounds); - } - object.into() - }); - stats.unchecked_into() + .map_or(JsValue::UNDEFINED.unchecked_into(), |stats| { + statistics_object(&stats) + }) +} + +/// Global statistics of the whole selection (the arrays of the statistics buffers cover it). +#[wasm_bindgen] +pub fn selection_statistics() -> GlobalStatistics { + with_stats(|buffer| statistics_object(&buffer.global)) + .unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into()) +} + +/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection, +/// as indexed by the statistics buffers. `undefined` when the range is not in the selection. +#[wasm_bindgen] +pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics { + with_stats(|buffer| buffer.slice(start, end)) + .flatten() + .map_or(JsValue::UNDEFINED.unchecked_into(), |stats| { + statistics_object(&stats) + }) } fn uuids(items: impl IntoIterator, uuid: impl Fn(T) -> uuid::Uuid) -> Array { diff --git a/website/src/lib/components/GPXStatistics.svelte b/website/src/lib/components/GPXStatistics.svelte index 484351eca..d86d39230 100644 --- a/website/src/lib/components/GPXStatistics.svelte +++ b/website/src/lib/components/GPXStatistics.svelte @@ -6,8 +6,9 @@ import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte'; import { i18n } from '$lib/i18n.svelte'; - import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import type { Readable } from 'svelte/store'; + import type { SelectionStatistics } from '$lib/engine'; + import type { SlicedStatistics } from '$lib/logic/selection-statistics'; import { settings } from '$lib/logic/settings'; const { velocityUnits } = settings; @@ -16,18 +17,17 @@ let panelWidth: number = $state(0); let { - gpxStatistics, - slicedGPXStatistics, + statistics: selectionStatistics, + slicedStatistics, orientation, }: { - gpxStatistics: Readable; - slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>; + statistics: Readable; + slicedStatistics: Readable; orientation: 'horizontal' | 'vertical'; } = $props(); - let statistics = $derived( - $slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global - ); + // the highlighted part of the selection, or all of it + let statistics = $derived($slicedStatistics?.global ?? $selectionStatistics.global); - + - + - + {#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)} @@ -67,9 +67,13 @@ > - + / - + {/if} @@ -81,9 +85,9 @@ > - + / - + {/if} diff --git a/website/src/lib/components/elevation-profile/ElevationProfile.svelte b/website/src/lib/components/elevation-profile/ElevationProfile.svelte index fea79a50c..650710db8 100644 --- a/website/src/lib/components/elevation-profile/ElevationProfile.svelte +++ b/website/src/lib/components/elevation-profile/ElevationProfile.svelte @@ -18,7 +18,9 @@ Construction, } from '@lucide/svelte'; import type { Readable, Writable } from 'svelte/store'; - import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; + import type { Coordinates } from 'gpx'; + import type { SelectionStatistics } from '$lib/engine'; + import type { SlicedStatistics } from '$lib/logic/selection-statistics'; import { settings } from '$lib/logic/settings'; import { i18n } from '$lib/i18n.svelte'; import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile'; @@ -26,15 +28,15 @@ const { velocityUnits } = settings; let { - gpxStatistics, - slicedGPXStatistics, + statistics, + slicedStatistics, hoveredPoint, additionalDatasets, elevationFill, showControls = true, }: { - gpxStatistics: Readable; - slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; + statistics: Readable; + slicedStatistics: Writable; hoveredPoint: Writable; additionalDatasets: Writable; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; @@ -47,8 +49,8 @@ onMount(() => { elevationProfile = new ElevationProfile( - gpxStatistics, - slicedGPXStatistics, + statistics, + slicedStatistics, hoveredPoint, additionalDatasets, elevationFill, diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index 4aa8b969f..a0cf7d270 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -21,7 +21,9 @@ import Chart, { type TooltipItem, } from 'chart.js/auto'; import { get, type Readable, type Writable } from 'svelte/store'; -import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; +import type { Coordinates } from 'gpx'; +import { NO_TIME, type SelectionStatistics } from '$lib/engine'; +import type { SlicedStatistics } from '$lib/logic/selection-statistics'; import { mode } from 'mode-watcher'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; @@ -51,23 +53,23 @@ export class ElevationProfile { private _dragging = false; private _panning = false; - private _gpxStatistics: Readable; - private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; + private _statistics: Readable; + private _slicedStatistics: Writable; private _hoveredPoint: Writable; private _additionalDatasets: Readable; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; constructor( - gpxStatistics: Readable, - slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>, + statistics: Readable, + slicedStatistics: Writable, hoveredPoint: Writable, additionalDatasets: Readable, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, canvas: HTMLCanvasElement, overlay: HTMLCanvasElement ) { - this._gpxStatistics = gpxStatistics; - this._slicedGPXStatistics = slicedGPXStatistics; + this._statistics = statistics; + this._slicedStatistics = slicedStatistics; this._hoveredPoint = hoveredPoint; this._additionalDatasets = additionalDatasets; this._elevationFill = elevationFill; @@ -78,10 +80,10 @@ export class ElevationProfile { Chart.register(module.default); this.initialize(); - this._gpxStatistics.subscribe(() => { + this._statistics.subscribe(() => { this.updateData(); }); - this._slicedGPXStatistics.subscribe(() => { + this._slicedStatistics.subscribe(() => { this.updateOverlay(); }); distanceUnits.subscribe(() => { @@ -235,7 +237,7 @@ export class ElevationProfile { modifierKey: 'shift', onPanStart: () => { this._panning = true; - this._slicedGPXStatistics.set(undefined); + this._slicedStatistics.set(undefined); return true; }, onPanComplete: () => { @@ -260,7 +262,7 @@ export class ElevationProfile { return false; } - this._slicedGPXStatistics.set(undefined); + this._slicedStatistics.set(undefined); }, }, limits: { @@ -361,14 +363,12 @@ export class ElevationProfile { if (startIndex === undefined) { startIndex = endIndex; } else if (startIndex !== endIndex) { - this._slicedGPXStatistics.set([ - get(this._gpxStatistics).sliced( - Math.min(startIndex, endIndex), - Math.max(startIndex, endIndex) - ), - Math.min(startIndex, endIndex), - Math.max(startIndex, endIndex), - ]); + const start = Math.min(startIndex, endIndex); + const end = Math.max(startIndex, endIndex); + const global = get(this._statistics).slice(start, end); + if (global) { + this._slicedStatistics.set({ global, start, end }); + } } } } @@ -379,7 +379,7 @@ export class ElevationProfile { this._canvas.style.cursor = ''; endIndex = getIndex(evt); if (startIndex === endIndex) { - this._slicedGPXStatistics.set(undefined); + this._slicedStatistics.set(undefined); } }; this._canvas.addEventListener('pointerdown', onMouseDown); @@ -391,7 +391,7 @@ export class ElevationProfile { if (!this._chart) { return; } - const data = get(this._gpxStatistics); + const data = get(this._statistics); const units = { distance: get(distanceUnits), velocity: get(velocityUnits), @@ -399,52 +399,49 @@ export class ElevationProfile { }; const datasets: Array> = [[], [], [], [], [], []]; - data.forEachTrackPoint((trkpt, distance, speed, slope, index) => { + const { global } = data; + for (let index = 0; index < data.length; index++) { + const x = getConvertedDistance(data.totalDistance[index], units.distance); + const ele = data.ele[index]; + const timestamp = data.timestamps[index]; datasets[0].push({ - x: getConvertedDistance(distance, units.distance), - y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0, - time: trkpt.time, - slope: slope, - extensions: trkpt.getExtensions(), - coordinates: trkpt.getCoordinates(), - index: index, + x, + y: ele ? getConvertedElevation(ele, units.distance) : 0, + time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)), + slope: { + at: data.slope[index], + segment: data.slopeSegmentSlope[index], + length: data.slopeSegmentDistance[index], + }, + // TODO the engine does not store the extensions (surface, highway...) yet + extensions: data.extensions[index] ?? {}, + coordinates: { lat: data.lat[index], lon: data.lng[index] }, + index, }); - if (data.global.time.total > 0) { + if ((global.totalTime ?? 0) > 0) { datasets[1].push({ - x: getConvertedDistance(distance, units.distance), - y: getConvertedVelocity(speed, units.velocity, units.distance), - index: index, + x, + y: getConvertedVelocity(data.speed[index], units.velocity, units.distance), + index, }); } - if (data.global.hr.count > 0) { - datasets[2].push({ - x: getConvertedDistance(distance, units.distance), - y: trkpt.getHeartRate(), - index: index, - }); + if (global.hr) { + datasets[2].push({ x, y: data.hr[index], index }); } - if (data.global.cad.count > 0) { - datasets[3].push({ - x: getConvertedDistance(distance, units.distance), - y: trkpt.getCadence(), - index: index, - }); + if (global.cad) { + datasets[3].push({ x, y: data.cad[index], index }); } - if (data.global.atemp.count > 0) { + if (global.atemp) { datasets[4].push({ - x: getConvertedDistance(distance, units.distance), - y: getConvertedTemperature(trkpt.getTemperature(), units.temperature), - index: index, + x, + y: getConvertedTemperature(data.atemp[index], units.temperature), + index, }); } - if (data.global.power.count > 0) { - datasets[5].push({ - x: getConvertedDistance(distance, units.distance), - y: trkpt.getPower(), - index: index, - }); + if (global.power) { + datasets[5].push({ x, y: data.power[index], index }); } - }); + } this._chart.data.datasets[0] = { label: i18n._('quantities.elevation'), @@ -482,7 +479,7 @@ export class ElevationProfile { this._chart.options.scales!.x!['min'] = 0; this._chart.options.scales!.x!['max'] = getConvertedDistance( - data.global.distance.total, + global.totalDistance, units.distance ); @@ -563,10 +560,10 @@ export class ElevationProfile { this._overlay.style.width = `${this._overlay.width}px`; this._overlay.style.height = `${this._overlay.height}px`; - const slicedGPXStatistics = get(this._slicedGPXStatistics); - if (slicedGPXStatistics) { - let startIndex = slicedGPXStatistics[1]; - let endIndex = slicedGPXStatistics[2]; + const slicedStatistics = get(this._slicedStatistics); + if (slicedStatistics) { + let startIndex = slicedStatistics.start; + let endIndex = slicedStatistics.end; // Draw selection rectangle let selectionContext = this._overlay.getContext('2d'); @@ -575,14 +572,12 @@ export class ElevationProfile { selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1; selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height); - const gpxStatistics = get(this._gpxStatistics); + const statistics = get(this._statistics); let startPixel = this._chart.scales.x.getPixelForValue( - getConvertedDistance( - gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0 - ) + getConvertedDistance(statistics.totalDistance[startIndex] ?? 0) ); let endPixel = this._chart.scales.x.getPixelForValue( - getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0) + getConvertedDistance(statistics.totalDistance[endIndex] ?? 0) ); selectionContext.fillRect( diff --git a/website/src/lib/components/embedding/Embedding.svelte b/website/src/lib/components/embedding/Embedding.svelte index 7a776271b..3387ff5e0 100644 --- a/website/src/lib/components/embedding/Embedding.svelte +++ b/website/src/lib/components/embedding/Embedding.svelte @@ -16,11 +16,13 @@ import { setMode } from 'mode-watcher'; import { settings } from '$lib/logic/settings'; import { fileStateCollection } from '$lib/logic/file-state'; - import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; import { loadFile } from '$lib/logic/file-actions'; import { selection } from '$lib/logic/selection'; import { untrack } from 'svelte'; import { isSelected, toggle } from '$lib/components/map/layer-control/utils'; + import { engine } from '$lib/engine'; + import { hoveredPoint } from '$lib/logic/statistics'; + import { slicedStatistics } from '$lib/logic/selection-statistics'; let { useHash = true, @@ -31,6 +33,7 @@ let additionalDatasets = writable([]); let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined); + const { statistics } = engine; const { currentBasemap, selectedBasemapTree, @@ -122,14 +125,14 @@ style={options.elevation.show ? `height: ${options.elevation.height}px` : ''} > {#if options.elevation.show} ; /** One Point feature per waypoint, in file order. */ @@ -75,7 +75,85 @@ export type FileState = { visibility: Visibility; }; -const EMPTY_STATISTICS: FileStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 }; +const EMPTY_STATISTICS: GlobalStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 }; + +/** + * Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest + * 64-bit integer, like `NO_TIME` in the engine). + */ +export const NO_TIME = -(2n ** 63n); + +/** + * The statistics of the selection: its global statistics, and the values at each of its + * trackpoints (all the selected segments, one after the other). The arrays are copies. + * + * Distances, times, moving values and elevation gain / loss are cumulative over the whole + * selection. Distances are in km, speeds in km/h, times in ms (timestamps since the epoch, as + * 64-bit integers), elevations in m and slopes in %. Missing timestamps are `NO_TIME`, missing + * measures are NaN. + */ +export type SelectionStatistics = { + global: GlobalStatistics; + /** Number of trackpoints. */ + length: number; + totalDistance: Float64Array; + movingDistance: Float64Array; + /** Duration since the start of the selection, in ms. */ + totalTime: BigInt64Array; + movingTime: BigInt64Array; + speed: Float64Array; + elevationGain: Float64Array; + elevationLoss: Float64Array; + /** Slope at the trackpoint. */ + slope: Float64Array; + /** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */ + slopeSegmentSlope: Float64Array; + slopeSegmentDistance: Float64Array; + lng: Float64Array; + lat: Float64Array; + ele: Float64Array; + timestamps: BigInt64Array; + hr: Float64Array; + cad: Float64Array; + atemp: Float64Array; + power: Float64Array; + /** + * Extensions of the trackpoints (surface, highway...), or an empty array when they are not + * known. TODO the engine does not store them yet. + */ + extensions: Record[]; + /** + * Global statistics of the trackpoints from `start` to `end` (both included), for example + * the part of the elevation profile that was dragged over. `undefined` if the range is not + * in the selection, or if the selection is not the current one anymore. + */ + slice(start: number, end: number): GlobalStatistics | undefined; +}; + +const EMPTY_SELECTION_STATISTICS: SelectionStatistics = { + global: EMPTY_STATISTICS, + length: 0, + totalDistance: new Float64Array(), + movingDistance: new Float64Array(), + totalTime: new BigInt64Array(), + movingTime: new BigInt64Array(), + speed: new Float64Array(), + elevationGain: new Float64Array(), + elevationLoss: new Float64Array(), + slope: new Float64Array(), + slopeSegmentSlope: new Float64Array(), + slopeSegmentDistance: new Float64Array(), + lng: new Float64Array(), + lat: new Float64Array(), + ele: new Float64Array(), + timestamps: new BigInt64Array(), + hr: new Float64Array(), + cad: new Float64Array(), + atemp: new Float64Array(), + power: new Float64Array(), + extensions: [], + slice: () => undefined, +}; function toPositions(flat: Float64Array): [number, number][] { const positions: [number, number][] = new Array(flat.length / 2); @@ -119,9 +197,14 @@ class Engine { private _visibility = new Map(); private _files = writable>>(new Map()); private _selection = writable({ type: 'empty' }); + private _statistics = writable(EMPTY_SELECTION_STATISTICS); + /** Identifies the statistics currently in the engine's buffers. */ + private _statisticsVersion = 0; /** Ids of the files in display order. */ readonly order: Readable = { subscribe: this._order.subscribe }; + /** Statistics of what is currently selected. */ + readonly statistics: Readable = { subscribe: this._statistics.subscribe }; /** What is currently selected. */ readonly selection: Readable = { subscribe: this._selection.subscribe }; /** @@ -365,6 +448,46 @@ class Engine { } } } + + // the statistics depend on the selection, on its order, and on the files + if ( + update.selectionChanged || + update.orderChanged || + update.added.length + update.modified.length + update.removed.length > 0 + ) { + this._statistics.set(this.readStatistics(wasm)); + } + } + + /** Copies the statistics buffers of the engine (they are overwritten by the next action). */ + private readStatistics(wasm: Wasm): SelectionStatistics { + const version = ++this._statisticsVersion; + const totalDistance = wasm.total_distance().slice(); + return { + global: wasm.selection_statistics() ?? EMPTY_STATISTICS, + length: totalDistance.length, + totalDistance, + movingDistance: wasm.moving_distance().slice(), + totalTime: wasm.total_time().slice(), + movingTime: wasm.moving_time().slice(), + speed: wasm.speed().slice(), + elevationGain: wasm.elevation_gain().slice(), + elevationLoss: wasm.elevation_loss().slice(), + slope: wasm.slope().slice(), + slopeSegmentSlope: wasm.slope_segment_slope().slice(), + slopeSegmentDistance: wasm.slope_segment_distance().slice(), + lng: wasm.lng().slice(), + lat: wasm.lat().slice(), + ele: wasm.ele().slice(), + timestamps: wasm.timestamps().slice(), + hr: wasm.hr().slice(), + cad: wasm.cad().slice(), + atemp: wasm.atemp().slice(), + power: wasm.power().slice(), + extensions: [], + slice: (start, end) => + version === this._statisticsVersion ? wasm.slice_statistics(start, end) : undefined, + }; } /** Reads the state of a file, reusing from `previous` what did not change. */ diff --git a/website/src/lib/logic/selection-statistics.ts b/website/src/lib/logic/selection-statistics.ts new file mode 100644 index 000000000..c24e2b685 --- /dev/null +++ b/website/src/lib/logic/selection-statistics.ts @@ -0,0 +1,16 @@ +import { writable } from 'svelte/store'; +import { engine, type GlobalStatistics } from '$lib/engine'; + +/** The part of the selection that is highlighted in the elevation profile. */ +export type SlicedStatistics = { + /** Statistics of the part. */ + global: GlobalStatistics; + /** Indices of its first and last trackpoints in the statistics of the selection. */ + start: number; + end: number; +}; + +export const slicedStatistics = writable(undefined); + +// the indices only make sense for the selection they were made on +engine.statistics.subscribe(() => slicedStatistics.set(undefined)); diff --git a/website/src/routes/[[language]]/+page.svelte b/website/src/routes/[[language]]/+page.svelte index c6b5bd855..e1d1d5a06 100644 --- a/website/src/routes/[[language]]/+page.svelte +++ b/website/src/routes/[[language]]/+page.svelte @@ -18,14 +18,37 @@ } from '@lucide/svelte'; import { i18n } from '$lib/i18n.svelte'; import { getURLForLanguage } from '$lib/utils'; - import { exampleGPXFile } from '$lib/assets/example'; import { writable } from 'svelte/store'; import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte'; import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { onDestroy, onMount } from 'svelte'; - let gpxStatistics = writable(exampleGPXFile.getStatistics()); - let slicedGPXStatistics = writable(undefined); + // TODO the example comes from the previous implementation + let statistics = writable({ + global: { totalDistance: 0, elevationGain: 0, elevationLoss: 0 }, + length: 0, + totalDistance: new Float64Array(), + movingDistance: new Float64Array(), + totalTime: new BigInt64Array(), + movingTime: new BigInt64Array(), + speed: new Float64Array(), + elevationGain: new Float64Array(), + elevationLoss: new Float64Array(), + slope: new Float64Array(), + slopeSegmentSlope: new Float64Array(), + slopeSegmentDistance: new Float64Array(), + lng: new Float64Array(), + lat: new Float64Array(), + ele: new Float64Array(), + timestamps: new BigInt64Array(), + hr: new Float64Array(), + cad: new Float64Array(), + atemp: new Float64Array(), + power: new Float64Array(), + extensions: [], + slice: () => undefined, + }); + let slicedStatistics = writable(undefined); let hoveredPoint = writable(null); let additionalDatasets = writable(['speed', 'atemp']); let elevationFill = writable(undefined); @@ -138,18 +161,14 @@ >
- +
diff --git a/website/src/routes/[[language]]/app/+page.svelte b/website/src/routes/[[language]]/app/+page.svelte index 6af1ad091..6b243fde7 100644 --- a/website/src/routes/[[language]]/app/+page.svelte +++ b/website/src/routes/[[language]]/app/+page.svelte @@ -16,7 +16,9 @@ import { loadFiles } from '$lib/logic/file-actions'; import { onDestroy, onMount } from 'svelte'; import { page } from '$app/state'; - import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; + import { hoveredPoint } from '$lib/logic/statistics'; + import { slicedStatistics } from '$lib/logic/selection-statistics'; + import { engine } from '$lib/engine'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; import { db } from '$lib/db'; import { fileStateCollection } from '$lib/logic/file-state'; @@ -140,14 +142,14 @@ style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''} > {#if $elevationProfile}