This commit is contained in:
vcoppe
2026-10-04 11:52:46 +02:00
parent 9015da4876
commit b7ca462f58
15 changed files with 859 additions and 217 deletions
+1 -1
View File
@@ -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}");
}
}
}
+16 -3
View File
@@ -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]);
}
}
+5 -1
View File
@@ -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
View File
@@ -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 {
+19 -15
View File
@@ -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<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
statistics: Readable<SelectionStatistics>;
slicedStatistics: Readable<SlicedStatistics | undefined>;
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);
</script>
<Card.Root
@@ -46,15 +46,15 @@
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" />
<WithUnits value={statistics.totalDistance} type="distance" />
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<WithUnits value={statistics.elevationGain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
<WithUnits value={statistics.elevationLoss} type="elevation" />
</span>
</Tooltip>
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
@@ -67,9 +67,13 @@
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<WithUnits
value={statistics.movingSpeed ?? 0}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
<WithUnits value={statistics.totalSpeed ?? 0} type="speed" />
</span>
</Tooltip>
{/if}
@@ -81,9 +85,9 @@
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<WithUnits value={statistics.movingTime ?? 0} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
<WithUnits value={statistics.totalTime ?? 0} type="time" />
</span>
</Tooltip>
{/if}
@@ -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<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
statistics: Readable<SelectionStatistics>;
slicedStatistics: Writable<SlicedStatistics | undefined>;
hoveredPoint: Writable<Coordinates | null>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
@@ -47,8 +49,8 @@
onMount(() => {
elevationProfile = new ElevationProfile(
gpxStatistics,
slicedGPXStatistics,
statistics,
slicedStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
@@ -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<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
private _statistics: Readable<SelectionStatistics>;
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor(
gpxStatistics: Readable<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
statistics: Readable<SelectionStatistics>,
slicedStatistics: Writable<SlicedStatistics | undefined>,
hoveredPoint: Writable<Coordinates | null>,
additionalDatasets: Readable<string[]>,
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<Array<any>> = [[], [], [], [], [], []];
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(
@@ -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<string[]>([]);
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` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
+127 -4
View File
@@ -4,10 +4,10 @@ import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { FileStatistics, FileStructure, Selection } from 'gpx-rs';
import type { GlobalStatistics, FileStructure, Selection } from 'gpx-rs';
export type {
FileStatistics,
GlobalStatistics,
FileStructure,
FilesUpdate,
Selection,
@@ -63,7 +63,7 @@ export type FileState = {
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
color: string;
/** Global statistics of the file. */
statistics: FileStatistics;
statistics: GlobalStatistics;
/** One LineString feature per track segment, in file order. */
segments: FeatureCollection<LineString, SegmentProperties>;
/** 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<string, string>[];
/**
* 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<string, Visibility>();
private _files = writable<Map<string, Writable<FileState>>>(new Map());
private _selection = writable<Selection>({ type: 'empty' });
private _statistics = writable<SelectionStatistics>(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<string[]> = { subscribe: this._order.subscribe };
/** Statistics of what is currently selected. */
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
/** What is currently selected. */
readonly selection: Readable<Selection> = { 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. */
@@ -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<SlicedStatistics | undefined>(undefined);
// the indices only make sense for the selection they were made on
engine.statistics.subscribe(() => slicedStatistics.set(undefined));
+29 -10
View File
@@ -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 @@
>
<div class="grow">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
/>
</div>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
orientation={'horizontal'}
/>
<GPXStatistics {statistics} {slicedStatistics} orientation={'horizontal'} />
</div>
</div>
<div class="grid md:grid-rows-subgrid md:row-start-3 md:row-end-5 gap-4">
@@ -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` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
statistics={engine.statistics}
{slicedStatistics}
orientation={bottomPanelOrientation == 'horizontal' ? 'vertical' : 'horizontal'}
/>
{#if $elevationProfile}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
statistics={engine.statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}