mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-06 22:04:06 +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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user