mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-07 06:14:07 +00:00
progress
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<f64>) {
|
||||
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<f64> {
|
||||
(self.count > 0).then(|| self.sum / self.count as f64)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct GlobalStatistics {
|
||||
pub total_distance: f64,
|
||||
pub moving_distance: Option<f64>,
|
||||
pub total_time: Option<i32>,
|
||||
pub moving_time: Option<i32>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
pub start_time: Option<i64>,
|
||||
pub end_time: Option<i64>,
|
||||
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<f64>,
|
||||
pub total_time: Option<i32>,
|
||||
pub moving_time: Option<i32>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub speed: Option<f64>,
|
||||
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<f64> = 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::<f64>() / values.len() as f64;
|
||||
assert!((average.avg().unwrap() - expected).abs() < 1e-9, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
|
||||
EARTH_RADIUS * c
|
||||
}
|
||||
|
||||
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i32> {
|
||||
Some((a? - b?) as i32)
|
||||
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||
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);
|
||||
|
||||
@@ -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<f64>,
|
||||
pub moving_distance: Vec<f64>,
|
||||
pub total_time: Vec<i32>,
|
||||
pub moving_time: Vec<i32>,
|
||||
pub total_time: Vec<i64>,
|
||||
pub moving_time: Vec<i64>,
|
||||
pub speed: Vec<f64>,
|
||||
pub elevation_gain: Vec<f64>,
|
||||
pub elevation_loss: Vec<f64>,
|
||||
pub slope: Vec<f64>,
|
||||
pub slope_segment_slope: Vec<f64>,
|
||||
pub slope_segment_distance: Vec<f64>,
|
||||
pub lng: Vec<f64>,
|
||||
pub lat: Vec<f64>,
|
||||
pub ele: Vec<f64>,
|
||||
/// Timestamps, in milliseconds since the epoch.
|
||||
pub time: Vec<i64>,
|
||||
pub hr: Vec<f64>,
|
||||
pub cad: Vec<f64>,
|
||||
pub atemp: Vec<f64>,
|
||||
pub power: Vec<f64>,
|
||||
}
|
||||
|
||||
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<f64>| 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<GlobalStatistics> {
|
||||
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<usize> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TrackSegmentRevisionId, Statistics>,
|
||||
@@ -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<SelectedSegment<'a>> {
|
||||
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<SelectedSegment<'a>>) {
|
||||
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<SelectedSegment<'a>>) {
|
||||
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<SelectedSegment<'a>>,
|
||||
) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+103
-39
@@ -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<f64>| {
|
||||
if let Some(value) = value {
|
||||
set(&object, key, value);
|
||||
}
|
||||
};
|
||||
let seconds = |ms: Option<i64>| 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<f64>| {
|
||||
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<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid) -> Array {
|
||||
|
||||
Reference in New Issue
Block a user