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,
|
pub lat: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct LngLatBounds {
|
pub struct LngLatBounds {
|
||||||
pub sw: LngLat,
|
pub sw: LngLat,
|
||||||
pub ne: LngLat,
|
pub ne: LngLat,
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ impl Statistics {
|
|||||||
self.accumulate_distance_and_time(prev, cur);
|
self.accumulate_distance_and_time(prev, cur);
|
||||||
self.update_time_bounds(cur.time);
|
self.update_time_bounds(cur.time);
|
||||||
self.update_bounds(cur.coordinates);
|
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
|
self.local
|
||||||
.push(TrackpointStatistics::from_partial_stats(self));
|
.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)]
|
#[derive(Default, Debug)]
|
||||||
pub struct GlobalStatistics {
|
pub struct GlobalStatistics {
|
||||||
pub total_distance: f64,
|
pub total_distance: f64,
|
||||||
pub moving_distance: Option<f64>,
|
pub moving_distance: Option<f64>,
|
||||||
pub total_time: Option<i32>,
|
pub total_time: Option<i64>,
|
||||||
pub moving_time: Option<i32>,
|
pub moving_time: Option<i64>,
|
||||||
pub elevation_gain: f64,
|
pub elevation_gain: f64,
|
||||||
pub elevation_loss: f64,
|
pub elevation_loss: f64,
|
||||||
pub start_time: Option<i64>,
|
pub start_time: Option<i64>,
|
||||||
pub end_time: Option<i64>,
|
pub end_time: Option<i64>,
|
||||||
pub bounds: LngLatBounds,
|
pub bounds: LngLatBounds,
|
||||||
|
pub hr: Average,
|
||||||
|
pub cad: Average,
|
||||||
|
pub atemp: Average,
|
||||||
|
pub power: Average,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GlobalStatistics {
|
impl GlobalStatistics {
|
||||||
@@ -241,6 +275,10 @@ impl GlobalStatistics {
|
|||||||
self.start_time = min_options(self.start_time, other.start_time);
|
self.start_time = min_options(self.start_time, other.start_time);
|
||||||
self.end_time = max_options(self.end_time, other.end_time);
|
self.end_time = max_options(self.end_time, other.end_time);
|
||||||
self.bounds.merge(&other.bounds);
|
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 struct TrackpointStatistics {
|
||||||
pub total_distance: f64,
|
pub total_distance: f64,
|
||||||
pub moving_distance: Option<f64>,
|
pub moving_distance: Option<f64>,
|
||||||
pub total_time: Option<i32>,
|
pub total_time: Option<i64>,
|
||||||
pub moving_time: Option<i32>,
|
pub moving_time: Option<i64>,
|
||||||
pub speed: Option<f64>,
|
pub speed: Option<f64>,
|
||||||
pub elevation_gain: f64,
|
pub elevation_gain: f64,
|
||||||
pub elevation_loss: f64,
|
pub elevation_loss: f64,
|
||||||
@@ -417,4 +455,50 @@ mod tests {
|
|||||||
assert_eq!(cumul.total_time, Some(7_000));
|
assert_eq!(cumul.total_time, Some(7_000));
|
||||||
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
|
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
|
EARTH_RADIUS * c
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i32> {
|
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||||
Some((a? - b?) as i32)
|
Some(a? - b?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the speed for a given distance in kilometers and a time in milliseconds
|
/// 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)
|
distance / (time as f64 / 3_600_000.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +115,19 @@ mod tests {
|
|||||||
LngLat { lng, lat }
|
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]
|
#[test]
|
||||||
fn test_distance() {
|
fn test_distance() {
|
||||||
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
|
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)]
|
#[derive(Debug, Default)]
|
||||||
pub struct StatisticsBuffer {
|
pub struct StatisticsBuffer {
|
||||||
|
/// Statistics of the whole selection.
|
||||||
|
pub global: GlobalStatistics,
|
||||||
pub total_distance: Vec<f64>,
|
pub total_distance: Vec<f64>,
|
||||||
pub moving_distance: Vec<f64>,
|
pub moving_distance: Vec<f64>,
|
||||||
pub total_time: Vec<i32>,
|
pub total_time: Vec<i64>,
|
||||||
pub moving_time: Vec<i32>,
|
pub moving_time: Vec<i64>,
|
||||||
pub speed: Vec<f64>,
|
pub speed: Vec<f64>,
|
||||||
pub elevation_gain: Vec<f64>,
|
pub elevation_gain: Vec<f64>,
|
||||||
pub elevation_loss: Vec<f64>,
|
pub elevation_loss: Vec<f64>,
|
||||||
pub slope: Vec<f64>,
|
pub slope: Vec<f64>,
|
||||||
pub slope_segment_slope: Vec<f64>,
|
pub slope_segment_slope: Vec<f64>,
|
||||||
pub slope_segment_distance: 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 {
|
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.total_distance.clear();
|
||||||
self.moving_distance.clear();
|
self.moving_distance.clear();
|
||||||
self.total_time.clear();
|
self.total_time.clear();
|
||||||
@@ -26,10 +57,20 @@ impl StatisticsBuffer {
|
|||||||
self.slope.clear();
|
self.slope.clear();
|
||||||
self.slope_segment_slope.clear();
|
self.slope_segment_slope.clear();
|
||||||
self.slope_segment_distance.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();
|
let optional = |value: Option<f64>| value.unwrap_or(f64::NAN);
|
||||||
for stats in stats {
|
|
||||||
for trkpt_stats in stats.local.iter() {
|
for (segment, stats) in selected {
|
||||||
|
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
|
||||||
|
let cumul_stats = &self.global;
|
||||||
self.total_distance
|
self.total_distance
|
||||||
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
|
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
|
||||||
self.moving_distance.push(
|
self.moving_distance.push(
|
||||||
@@ -53,10 +94,55 @@ impl StatisticsBuffer {
|
|||||||
.push(trkpt_stats.slope_segment.slope);
|
.push(trkpt_stats.slope_segment.slope);
|
||||||
self.slope_segment_distance
|
self.slope_segment_distance
|
||||||
.push(trkpt_stats.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)]
|
#[cfg(test)]
|
||||||
@@ -65,28 +151,16 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn stats(path: &str) -> Statistics {
|
fn computed(path: &str) -> (TrackSegment, Statistics) {
|
||||||
let data = std::fs::read(path).unwrap();
|
let data = std::fs::read(path).unwrap();
|
||||||
let file = parse(&data).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 all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
|
||||||
fn test_empty() {
|
vec![
|
||||||
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 [
|
|
||||||
buffer.total_distance.len(),
|
buffer.total_distance.len(),
|
||||||
buffer.moving_distance.len(),
|
buffer.moving_distance.len(),
|
||||||
buffer.total_time.len(),
|
buffer.total_time.len(),
|
||||||
@@ -97,21 +171,76 @@ mod tests {
|
|||||||
buffer.slope.len(),
|
buffer.slope.len(),
|
||||||
buffer.slope_segment_slope.len(),
|
buffer.slope_segment_slope.len(),
|
||||||
buffer.slope_segment_distance.len(),
|
buffer.slope_segment_distance.len(),
|
||||||
] {
|
buffer.lng.len(),
|
||||||
assert_eq!(len, n);
|
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() {
|
for (i, local) in s.local.iter().enumerate() {
|
||||||
assert_eq!(buffer.total_distance[i], local.total_distance);
|
assert_eq!(buffer.total_distance[i], local.total_distance);
|
||||||
assert_eq!(buffer.slope[i], local.slope);
|
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]
|
#[test]
|
||||||
fn test_distances_accumulate_over_segments() {
|
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 n = s.local.len();
|
||||||
let mut buffer = StatisticsBuffer::default();
|
let mut buffer = StatisticsBuffer::default();
|
||||||
buffer.update(&[&s, &s]);
|
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||||
|
|
||||||
assert_eq!(buffer.total_distance.len(), 2 * n);
|
assert_eq!(buffer.total_distance.len(), 2 * n);
|
||||||
// the second segment starts where the first one ended
|
// the second segment starts where the first one ended
|
||||||
@@ -123,27 +252,156 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_total_time_accumulates_over_segments() {
|
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 n = s.local.len();
|
||||||
let duration = s.global.total_time.unwrap();
|
let duration = s.global.total_time.unwrap();
|
||||||
assert!(duration > 0);
|
assert!(duration > 0);
|
||||||
let mut buffer = StatisticsBuffer::default();
|
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 - 1], duration);
|
||||||
assert_eq!(buffer.total_time[n], duration);
|
assert_eq!(buffer.total_time[n], duration);
|
||||||
assert_eq!(buffer.total_time[2 * n - 1], 2 * 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.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]
|
#[test]
|
||||||
fn test_update_replaces_previous_content() {
|
fn test_update_replaces_previous_content() {
|
||||||
let s = stats("data/simple.gpx");
|
let (segment, s) = computed("data/simple.gpx");
|
||||||
let mut buffer = StatisticsBuffer::default();
|
let mut buffer = StatisticsBuffer::default();
|
||||||
buffer.update(&[&s]);
|
buffer.update(&[(&segment, &s)]);
|
||||||
buffer.update(&[&s]);
|
buffer.update(&[(&segment, &s)]);
|
||||||
assert_eq!(buffer.total_distance.len(), s.local.len());
|
assert_eq!(buffer.total_distance.len(), s.local.len());
|
||||||
|
assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len()));
|
||||||
buffer.update(&[]);
|
buffer.update(&[]);
|
||||||
assert!(buffer.total_distance.is_empty());
|
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 std::collections::{HashMap, HashSet, hash_map::Entry};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
File, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
|
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
|
||||||
TrackSegmentRevisionId,
|
TrackSegmentRevisionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A segment and its statistics.
|
||||||
|
pub type SelectedSegment<'a> = (&'a TrackSegment, &'a Statistics);
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct StatisticsCache {
|
pub struct StatisticsCache {
|
||||||
map: HashMap<TrackSegmentRevisionId, Statistics>,
|
map: HashMap<TrackSegmentRevisionId, Statistics>,
|
||||||
@@ -34,19 +37,38 @@ impl StatisticsCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Statistics of the segments of the selection.
|
||||||
pub fn get<'a>(
|
pub fn get<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
state: Option<&StackEntry>,
|
state: Option<&'a StackEntry>,
|
||||||
selection: &Selection,
|
selection: &Selection,
|
||||||
) -> Vec<&'a Statistics> {
|
) -> 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 {
|
if let Some(state) = state {
|
||||||
match selection {
|
match selection {
|
||||||
Selection::Empty => (),
|
Selection::Empty => (),
|
||||||
Selection::File { file_ids } => {
|
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) {
|
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) {
|
if let Some(file) = state.get(file_id) {
|
||||||
for trk in file.trk.iter() {
|
for trk in file.trk.iter() {
|
||||||
if trk_ids.contains(&trk.id) {
|
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 {
|
if *trk_id == trk.id {
|
||||||
for trkseg in trk.trkseg.iter() {
|
for trkseg in trk.trkseg.iter() {
|
||||||
if trkseg_ids.contains(&trkseg.id) {
|
if trkseg_ids.contains(&trkseg.id) {
|
||||||
self.add_segment_stats(trkseg, &mut stats);
|
self.add_segment_stats(trkseg, &mut selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -79,7 +101,7 @@ impl StatisticsCache {
|
|||||||
}
|
}
|
||||||
Selection::Waypoints { file_id } => {
|
Selection::Waypoints { file_id } => {
|
||||||
if let Some(file) = state.get(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 {
|
Selection::Waypoint {
|
||||||
@@ -87,40 +109,44 @@ impl StatisticsCache {
|
|||||||
wpt_ids: _,
|
wpt_ids: _,
|
||||||
} => {
|
} => {
|
||||||
if let Some(file) = state.get(file_id) {
|
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.
|
/// Global statistics of a file: its segments merged, in order.
|
||||||
pub fn file_global(&self, file: &File) -> GlobalStatistics {
|
pub fn file_global(&self, file: &File) -> GlobalStatistics {
|
||||||
let mut stats = vec![];
|
let mut selected = vec![];
|
||||||
self.add_file_stats(file, &mut stats);
|
self.add_file_stats(file, &mut selected);
|
||||||
let mut global = GlobalStatistics::default();
|
let mut global = GlobalStatistics::default();
|
||||||
for s in stats {
|
for (_, stats) in selected {
|
||||||
global.merge(&s.global);
|
global.merge(&stats.global);
|
||||||
}
|
}
|
||||||
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() {
|
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() {
|
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>) {
|
fn add_segment_stats<'a>(
|
||||||
if let Some(s) = self.map.get(&trkseg.rev_id) {
|
&'a self,
|
||||||
stats.push(s);
|
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);
|
cache.update(None);
|
||||||
assert!(cache.map.is_empty());
|
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.coordinates_cache.update(current);
|
||||||
self.structure_cache.update(current, self.diff.as_ref());
|
self.structure_cache.update(current, self.diff.as_ref());
|
||||||
self.statistics_buffer
|
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 wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds};
|
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]
|
#[wasm_bindgen]
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -133,14 +133,36 @@ macro_rules! stats_getter {
|
|||||||
|
|
||||||
stats_getter!(total_distance, Float64Array);
|
stats_getter!(total_distance, Float64Array);
|
||||||
stats_getter!(moving_distance, Float64Array);
|
stats_getter!(moving_distance, Float64Array);
|
||||||
stats_getter!(total_time, Int32Array);
|
// Durations since the start of the selection, in milliseconds.
|
||||||
stats_getter!(moving_time, Int32Array);
|
stats_getter!(total_time, BigInt64Array);
|
||||||
|
stats_getter!(moving_time, BigInt64Array);
|
||||||
stats_getter!(speed, Float64Array);
|
stats_getter!(speed, Float64Array);
|
||||||
stats_getter!(elevation_gain, Float64Array);
|
stats_getter!(elevation_gain, Float64Array);
|
||||||
stats_getter!(elevation_loss, Float64Array);
|
stats_getter!(elevation_loss, Float64Array);
|
||||||
stats_getter!(slope, Float64Array);
|
stats_getter!(slope, Float64Array);
|
||||||
stats_getter!(slope_segment_slope, Float64Array);
|
stats_getter!(slope_segment_slope, Float64Array);
|
||||||
stats_getter!(slope_segment_distance, 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
|
// File commands
|
||||||
|
|
||||||
@@ -443,8 +465,11 @@ export interface FileStructure {
|
|||||||
/** Changes when the waypoints of the file change: refetch their coordinates. */
|
/** Changes when the waypoints of the file change: refetch their coordinates. */
|
||||||
waypointsRev: string;
|
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 */
|
/** km */
|
||||||
totalDistance: number;
|
totalDistance: number;
|
||||||
movingDistance?: number;
|
movingDistance?: number;
|
||||||
@@ -459,7 +484,12 @@ export interface FileStatistics {
|
|||||||
/** km/h */
|
/** km/h */
|
||||||
totalSpeed?: number;
|
totalSpeed?: number;
|
||||||
movingSpeed?: 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 };
|
bounds?: { west: number; south: number; east: number; north: number };
|
||||||
}
|
}
|
||||||
export interface TrackNode {
|
export interface TrackNode {
|
||||||
@@ -494,8 +524,8 @@ extern "C" {
|
|||||||
pub type FilesUpdate;
|
pub type FilesUpdate;
|
||||||
#[wasm_bindgen(typescript_type = "FileStructure | undefined")]
|
#[wasm_bindgen(typescript_type = "FileStructure | undefined")]
|
||||||
pub type FileStructure;
|
pub type FileStructure;
|
||||||
#[wasm_bindgen(typescript_type = "FileStatistics | undefined")]
|
#[wasm_bindgen(typescript_type = "GlobalStatistics | undefined")]
|
||||||
pub type FileStatistics;
|
pub type GlobalStatistics;
|
||||||
#[wasm_bindgen(typescript_type = "string[]")]
|
#[wasm_bindgen(typescript_type = "string[]")]
|
||||||
pub type FileOrder;
|
pub type FileOrder;
|
||||||
}
|
}
|
||||||
@@ -594,41 +624,75 @@ pub fn file_structure(file_id: &str) -> FileStructure {
|
|||||||
structure.unchecked_into()
|
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.
|
/// Global statistics of a file, `undefined` if the id is unknown.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn file_statistics(file_id: &str) -> FileStatistics {
|
pub fn file_statistics(file_id: &str) -> GlobalStatistics {
|
||||||
let stats = uuid::Uuid::parse_str(file_id)
|
uuid::Uuid::parse_str(file_id)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten())
|
.and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten())
|
||||||
.map_or(JsValue::UNDEFINED, |stats| {
|
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
|
||||||
let object = Object::new();
|
statistics_object(&stats)
|
||||||
let optional = |key: &str, value: Option<f64>| {
|
})
|
||||||
if let Some(value) = value {
|
}
|
||||||
set(&object, key, value);
|
|
||||||
}
|
/// Global statistics of the whole selection (the arrays of the statistics buffers cover it).
|
||||||
};
|
#[wasm_bindgen]
|
||||||
set(&object, "totalDistance", stats.total_distance);
|
pub fn selection_statistics() -> GlobalStatistics {
|
||||||
optional("movingDistance", stats.moving_distance);
|
with_stats(|buffer| statistics_object(&buffer.global))
|
||||||
optional("totalTime", stats.total_time.map(f64::from));
|
.unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into())
|
||||||
optional("movingTime", stats.moving_time.map(f64::from));
|
}
|
||||||
set(&object, "elevationGain", stats.elevation_gain);
|
|
||||||
set(&object, "elevationLoss", stats.elevation_loss);
|
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
|
||||||
optional("startTime", stats.start_time.map(|t| t as f64));
|
/// as indexed by the statistics buffers. `undefined` when the range is not in the selection.
|
||||||
optional("endTime", stats.end_time.map(|t| t as f64));
|
#[wasm_bindgen]
|
||||||
optional("totalSpeed", stats.total_speed());
|
pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics {
|
||||||
optional("movingSpeed", stats.moving_speed());
|
with_stats(|buffer| buffer.slice(start, end))
|
||||||
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
|
.flatten()
|
||||||
if sw.lng <= ne.lng && sw.lat <= ne.lat {
|
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
|
||||||
let bounds = Object::new();
|
statistics_object(&stats)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uuids<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid) -> Array {
|
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 { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||||
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
|
||||||
import type { Readable } from 'svelte/store';
|
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';
|
import { settings } from '$lib/logic/settings';
|
||||||
|
|
||||||
const { velocityUnits } = settings;
|
const { velocityUnits } = settings;
|
||||||
@@ -16,18 +17,17 @@
|
|||||||
let panelWidth: number = $state(0);
|
let panelWidth: number = $state(0);
|
||||||
|
|
||||||
let {
|
let {
|
||||||
gpxStatistics,
|
statistics: selectionStatistics,
|
||||||
slicedGPXStatistics,
|
slicedStatistics,
|
||||||
orientation,
|
orientation,
|
||||||
}: {
|
}: {
|
||||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
statistics: Readable<SelectionStatistics>;
|
||||||
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
slicedStatistics: Readable<SlicedStatistics | undefined>;
|
||||||
orientation: 'horizontal' | 'vertical';
|
orientation: 'horizontal' | 'vertical';
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let statistics = $derived(
|
// the highlighted part of the selection, or all of it
|
||||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
|
let statistics = $derived($slicedStatistics?.global ?? $selectionStatistics.global);
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root
|
<Card.Root
|
||||||
@@ -46,15 +46,15 @@
|
|||||||
<Tooltip label={i18n._('quantities.distance')}>
|
<Tooltip label={i18n._('quantities.distance')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Ruler size="16" class="mr-1" />
|
<Ruler size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.distance.total} type="distance" />
|
<WithUnits value={statistics.totalDistance} type="distance" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<MoveUpRight size="16" class="mr-1" />
|
<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" />
|
<MoveDownRight size="16" class="mx-1" />
|
||||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
<WithUnits value={statistics.elevationLoss} type="elevation" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
|
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
|
||||||
@@ -67,9 +67,13 @@
|
|||||||
>
|
>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Zap size="16" class="mr-1" />
|
<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>
|
<span class="mx-1">/</span>
|
||||||
<WithUnits value={statistics.speed.total} type="speed" />
|
<WithUnits value={statistics.totalSpeed ?? 0} type="speed" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -81,9 +85,9 @@
|
|||||||
>
|
>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Timer size="16" class="mr-1" />
|
<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>
|
<span class="mx-1">/</span>
|
||||||
<WithUnits value={statistics.time.total} type="time" />
|
<WithUnits value={statistics.totalTime ?? 0} type="time" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
Construction,
|
Construction,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import type { Readable, Writable } from 'svelte/store';
|
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 { settings } from '$lib/logic/settings';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||||
@@ -26,15 +28,15 @@
|
|||||||
const { velocityUnits } = settings;
|
const { velocityUnits } = settings;
|
||||||
|
|
||||||
let {
|
let {
|
||||||
gpxStatistics,
|
statistics,
|
||||||
slicedGPXStatistics,
|
slicedStatistics,
|
||||||
hoveredPoint,
|
hoveredPoint,
|
||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
showControls = true,
|
showControls = true,
|
||||||
}: {
|
}: {
|
||||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
statistics: Readable<SelectionStatistics>;
|
||||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||||
hoveredPoint: Writable<Coordinates | null>;
|
hoveredPoint: Writable<Coordinates | null>;
|
||||||
additionalDatasets: Writable<string[]>;
|
additionalDatasets: Writable<string[]>;
|
||||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
@@ -47,8 +49,8 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
elevationProfile = new ElevationProfile(
|
elevationProfile = new ElevationProfile(
|
||||||
gpxStatistics,
|
statistics,
|
||||||
slicedGPXStatistics,
|
slicedStatistics,
|
||||||
hoveredPoint,
|
hoveredPoint,
|
||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import Chart, {
|
|||||||
type TooltipItem,
|
type TooltipItem,
|
||||||
} from 'chart.js/auto';
|
} from 'chart.js/auto';
|
||||||
import { get, type Readable, type Writable } from 'svelte/store';
|
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 { mode } from 'mode-watcher';
|
||||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||||
|
|
||||||
@@ -51,23 +53,23 @@ export class ElevationProfile {
|
|||||||
private _dragging = false;
|
private _dragging = false;
|
||||||
private _panning = false;
|
private _panning = false;
|
||||||
|
|
||||||
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
private _statistics: Readable<SelectionStatistics>;
|
||||||
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||||
private _hoveredPoint: Writable<Coordinates | null>;
|
private _hoveredPoint: Writable<Coordinates | null>;
|
||||||
private _additionalDatasets: Readable<string[]>;
|
private _additionalDatasets: Readable<string[]>;
|
||||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
gpxStatistics: Readable<GPXStatisticsGroup>,
|
statistics: Readable<SelectionStatistics>,
|
||||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
slicedStatistics: Writable<SlicedStatistics | undefined>,
|
||||||
hoveredPoint: Writable<Coordinates | null>,
|
hoveredPoint: Writable<Coordinates | null>,
|
||||||
additionalDatasets: Readable<string[]>,
|
additionalDatasets: Readable<string[]>,
|
||||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
overlay: HTMLCanvasElement
|
overlay: HTMLCanvasElement
|
||||||
) {
|
) {
|
||||||
this._gpxStatistics = gpxStatistics;
|
this._statistics = statistics;
|
||||||
this._slicedGPXStatistics = slicedGPXStatistics;
|
this._slicedStatistics = slicedStatistics;
|
||||||
this._hoveredPoint = hoveredPoint;
|
this._hoveredPoint = hoveredPoint;
|
||||||
this._additionalDatasets = additionalDatasets;
|
this._additionalDatasets = additionalDatasets;
|
||||||
this._elevationFill = elevationFill;
|
this._elevationFill = elevationFill;
|
||||||
@@ -78,10 +80,10 @@ export class ElevationProfile {
|
|||||||
Chart.register(module.default);
|
Chart.register(module.default);
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
this._gpxStatistics.subscribe(() => {
|
this._statistics.subscribe(() => {
|
||||||
this.updateData();
|
this.updateData();
|
||||||
});
|
});
|
||||||
this._slicedGPXStatistics.subscribe(() => {
|
this._slicedStatistics.subscribe(() => {
|
||||||
this.updateOverlay();
|
this.updateOverlay();
|
||||||
});
|
});
|
||||||
distanceUnits.subscribe(() => {
|
distanceUnits.subscribe(() => {
|
||||||
@@ -235,7 +237,7 @@ export class ElevationProfile {
|
|||||||
modifierKey: 'shift',
|
modifierKey: 'shift',
|
||||||
onPanStart: () => {
|
onPanStart: () => {
|
||||||
this._panning = true;
|
this._panning = true;
|
||||||
this._slicedGPXStatistics.set(undefined);
|
this._slicedStatistics.set(undefined);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onPanComplete: () => {
|
onPanComplete: () => {
|
||||||
@@ -260,7 +262,7 @@ export class ElevationProfile {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._slicedGPXStatistics.set(undefined);
|
this._slicedStatistics.set(undefined);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limits: {
|
limits: {
|
||||||
@@ -361,14 +363,12 @@ export class ElevationProfile {
|
|||||||
if (startIndex === undefined) {
|
if (startIndex === undefined) {
|
||||||
startIndex = endIndex;
|
startIndex = endIndex;
|
||||||
} else if (startIndex !== endIndex) {
|
} else if (startIndex !== endIndex) {
|
||||||
this._slicedGPXStatistics.set([
|
const start = Math.min(startIndex, endIndex);
|
||||||
get(this._gpxStatistics).sliced(
|
const end = Math.max(startIndex, endIndex);
|
||||||
Math.min(startIndex, endIndex),
|
const global = get(this._statistics).slice(start, end);
|
||||||
Math.max(startIndex, endIndex)
|
if (global) {
|
||||||
),
|
this._slicedStatistics.set({ global, start, end });
|
||||||
Math.min(startIndex, endIndex),
|
}
|
||||||
Math.max(startIndex, endIndex),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,7 +379,7 @@ export class ElevationProfile {
|
|||||||
this._canvas.style.cursor = '';
|
this._canvas.style.cursor = '';
|
||||||
endIndex = getIndex(evt);
|
endIndex = getIndex(evt);
|
||||||
if (startIndex === endIndex) {
|
if (startIndex === endIndex) {
|
||||||
this._slicedGPXStatistics.set(undefined);
|
this._slicedStatistics.set(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this._canvas.addEventListener('pointerdown', onMouseDown);
|
this._canvas.addEventListener('pointerdown', onMouseDown);
|
||||||
@@ -391,7 +391,7 @@ export class ElevationProfile {
|
|||||||
if (!this._chart) {
|
if (!this._chart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = get(this._gpxStatistics);
|
const data = get(this._statistics);
|
||||||
const units = {
|
const units = {
|
||||||
distance: get(distanceUnits),
|
distance: get(distanceUnits),
|
||||||
velocity: get(velocityUnits),
|
velocity: get(velocityUnits),
|
||||||
@@ -399,52 +399,49 @@ export class ElevationProfile {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
|
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({
|
datasets[0].push({
|
||||||
x: getConvertedDistance(distance, units.distance),
|
x,
|
||||||
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0,
|
y: ele ? getConvertedElevation(ele, units.distance) : 0,
|
||||||
time: trkpt.time,
|
time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
|
||||||
slope: slope,
|
slope: {
|
||||||
extensions: trkpt.getExtensions(),
|
at: data.slope[index],
|
||||||
coordinates: trkpt.getCoordinates(),
|
segment: data.slopeSegmentSlope[index],
|
||||||
index: 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({
|
datasets[1].push({
|
||||||
x: getConvertedDistance(distance, units.distance),
|
x,
|
||||||
y: getConvertedVelocity(speed, units.velocity, units.distance),
|
y: getConvertedVelocity(data.speed[index], units.velocity, units.distance),
|
||||||
index: index,
|
index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.global.hr.count > 0) {
|
if (global.hr) {
|
||||||
datasets[2].push({
|
datasets[2].push({ x, y: data.hr[index], index });
|
||||||
x: getConvertedDistance(distance, units.distance),
|
|
||||||
y: trkpt.getHeartRate(),
|
|
||||||
index: index,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (data.global.cad.count > 0) {
|
if (global.cad) {
|
||||||
datasets[3].push({
|
datasets[3].push({ x, y: data.cad[index], index });
|
||||||
x: getConvertedDistance(distance, units.distance),
|
|
||||||
y: trkpt.getCadence(),
|
|
||||||
index: index,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (data.global.atemp.count > 0) {
|
if (global.atemp) {
|
||||||
datasets[4].push({
|
datasets[4].push({
|
||||||
x: getConvertedDistance(distance, units.distance),
|
x,
|
||||||
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
|
y: getConvertedTemperature(data.atemp[index], units.temperature),
|
||||||
index: index,
|
index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.global.power.count > 0) {
|
if (global.power) {
|
||||||
datasets[5].push({
|
datasets[5].push({ x, y: data.power[index], index });
|
||||||
x: getConvertedDistance(distance, units.distance),
|
|
||||||
y: trkpt.getPower(),
|
|
||||||
index: index,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
this._chart.data.datasets[0] = {
|
this._chart.data.datasets[0] = {
|
||||||
label: i18n._('quantities.elevation'),
|
label: i18n._('quantities.elevation'),
|
||||||
@@ -482,7 +479,7 @@ export class ElevationProfile {
|
|||||||
|
|
||||||
this._chart.options.scales!.x!['min'] = 0;
|
this._chart.options.scales!.x!['min'] = 0;
|
||||||
this._chart.options.scales!.x!['max'] = getConvertedDistance(
|
this._chart.options.scales!.x!['max'] = getConvertedDistance(
|
||||||
data.global.distance.total,
|
global.totalDistance,
|
||||||
units.distance
|
units.distance
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -563,10 +560,10 @@ export class ElevationProfile {
|
|||||||
this._overlay.style.width = `${this._overlay.width}px`;
|
this._overlay.style.width = `${this._overlay.width}px`;
|
||||||
this._overlay.style.height = `${this._overlay.height}px`;
|
this._overlay.style.height = `${this._overlay.height}px`;
|
||||||
|
|
||||||
const slicedGPXStatistics = get(this._slicedGPXStatistics);
|
const slicedStatistics = get(this._slicedStatistics);
|
||||||
if (slicedGPXStatistics) {
|
if (slicedStatistics) {
|
||||||
let startIndex = slicedGPXStatistics[1];
|
let startIndex = slicedStatistics.start;
|
||||||
let endIndex = slicedGPXStatistics[2];
|
let endIndex = slicedStatistics.end;
|
||||||
|
|
||||||
// Draw selection rectangle
|
// Draw selection rectangle
|
||||||
let selectionContext = this._overlay.getContext('2d');
|
let selectionContext = this._overlay.getContext('2d');
|
||||||
@@ -575,14 +572,12 @@ export class ElevationProfile {
|
|||||||
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
|
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
|
||||||
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
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(
|
let startPixel = this._chart.scales.x.getPixelForValue(
|
||||||
getConvertedDistance(
|
getConvertedDistance(statistics.totalDistance[startIndex] ?? 0)
|
||||||
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
let endPixel = this._chart.scales.x.getPixelForValue(
|
let endPixel = this._chart.scales.x.getPixelForValue(
|
||||||
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
|
getConvertedDistance(statistics.totalDistance[endIndex] ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
selectionContext.fillRect(
|
selectionContext.fillRect(
|
||||||
|
|||||||
@@ -16,11 +16,13 @@
|
|||||||
import { setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
|
||||||
import { loadFile } from '$lib/logic/file-actions';
|
import { loadFile } from '$lib/logic/file-actions';
|
||||||
import { selection } from '$lib/logic/selection';
|
import { selection } from '$lib/logic/selection';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
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 {
|
let {
|
||||||
useHash = true,
|
useHash = true,
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
let additionalDatasets = writable<string[]>([]);
|
let additionalDatasets = writable<string[]>([]);
|
||||||
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
||||||
|
|
||||||
|
const { statistics } = engine;
|
||||||
const {
|
const {
|
||||||
currentBasemap,
|
currentBasemap,
|
||||||
selectedBasemapTree,
|
selectedBasemapTree,
|
||||||
@@ -122,14 +125,14 @@
|
|||||||
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||||
>
|
>
|
||||||
<GPXStatistics
|
<GPXStatistics
|
||||||
{gpxStatistics}
|
{statistics}
|
||||||
{slicedGPXStatistics}
|
{slicedStatistics}
|
||||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||||
/>
|
/>
|
||||||
{#if options.elevation.show}
|
{#if options.elevation.show}
|
||||||
<ElevationProfile
|
<ElevationProfile
|
||||||
{gpxStatistics}
|
{statistics}
|
||||||
{slicedGPXStatistics}
|
{slicedStatistics}
|
||||||
{hoveredPoint}
|
{hoveredPoint}
|
||||||
{additionalDatasets}
|
{additionalDatasets}
|
||||||
{elevationFill}
|
{elevationFill}
|
||||||
|
|||||||
+127
-4
@@ -4,10 +4,10 @@ import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
|
|||||||
import { setHidden, type Visibility } from '$lib/file-visibility';
|
import { setHidden, type Visibility } from '$lib/file-visibility';
|
||||||
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
|
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
|
||||||
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
|
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 {
|
export type {
|
||||||
FileStatistics,
|
GlobalStatistics,
|
||||||
FileStructure,
|
FileStructure,
|
||||||
FilesUpdate,
|
FilesUpdate,
|
||||||
Selection,
|
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 of the file: the one defined by its tracks if any, otherwise one from the palette. */
|
||||||
color: string;
|
color: string;
|
||||||
/** Global statistics of the file. */
|
/** Global statistics of the file. */
|
||||||
statistics: FileStatistics;
|
statistics: GlobalStatistics;
|
||||||
/** One LineString feature per track segment, in file order. */
|
/** One LineString feature per track segment, in file order. */
|
||||||
segments: FeatureCollection<LineString, SegmentProperties>;
|
segments: FeatureCollection<LineString, SegmentProperties>;
|
||||||
/** One Point feature per waypoint, in file order. */
|
/** One Point feature per waypoint, in file order. */
|
||||||
@@ -75,7 +75,85 @@ export type FileState = {
|
|||||||
visibility: Visibility;
|
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][] {
|
function toPositions(flat: Float64Array): [number, number][] {
|
||||||
const positions: [number, number][] = new Array(flat.length / 2);
|
const positions: [number, number][] = new Array(flat.length / 2);
|
||||||
@@ -119,9 +197,14 @@ class Engine {
|
|||||||
private _visibility = new Map<string, Visibility>();
|
private _visibility = new Map<string, Visibility>();
|
||||||
private _files = writable<Map<string, Writable<FileState>>>(new Map());
|
private _files = writable<Map<string, Writable<FileState>>>(new Map());
|
||||||
private _selection = writable<Selection>({ type: 'empty' });
|
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. */
|
/** Ids of the files in display order. */
|
||||||
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
|
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. */
|
/** What is currently selected. */
|
||||||
readonly selection: Readable<Selection> = { subscribe: this._selection.subscribe };
|
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. */
|
/** 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';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { exampleGPXFile } from '$lib/assets/example';
|
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
// TODO the example comes from the previous implementation
|
||||||
let slicedGPXStatistics = writable(undefined);
|
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 hoveredPoint = writable(null);
|
||||||
let additionalDatasets = writable(['speed', 'atemp']);
|
let additionalDatasets = writable(['speed', 'atemp']);
|
||||||
let elevationFill = writable(undefined);
|
let elevationFill = writable(undefined);
|
||||||
@@ -138,18 +161,14 @@
|
|||||||
>
|
>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<ElevationProfile
|
<ElevationProfile
|
||||||
{gpxStatistics}
|
{statistics}
|
||||||
{slicedGPXStatistics}
|
{slicedStatistics}
|
||||||
{hoveredPoint}
|
{hoveredPoint}
|
||||||
{additionalDatasets}
|
{additionalDatasets}
|
||||||
{elevationFill}
|
{elevationFill}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<GPXStatistics
|
<GPXStatistics {statistics} {slicedStatistics} orientation={'horizontal'} />
|
||||||
{gpxStatistics}
|
|
||||||
{slicedGPXStatistics}
|
|
||||||
orientation={'horizontal'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid md:grid-rows-subgrid md:row-start-3 md:row-end-5 gap-4">
|
<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 { loadFiles } from '$lib/logic/file-actions';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
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 { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
|
||||||
import { db } from '$lib/db';
|
import { db } from '$lib/db';
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
@@ -140,14 +142,14 @@
|
|||||||
style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''}
|
style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''}
|
||||||
>
|
>
|
||||||
<GPXStatistics
|
<GPXStatistics
|
||||||
{gpxStatistics}
|
statistics={engine.statistics}
|
||||||
{slicedGPXStatistics}
|
{slicedStatistics}
|
||||||
orientation={bottomPanelOrientation == 'horizontal' ? 'vertical' : 'horizontal'}
|
orientation={bottomPanelOrientation == 'horizontal' ? 'vertical' : 'horizontal'}
|
||||||
/>
|
/>
|
||||||
{#if $elevationProfile}
|
{#if $elevationProfile}
|
||||||
<ElevationProfile
|
<ElevationProfile
|
||||||
{gpxStatistics}
|
statistics={engine.statistics}
|
||||||
{slicedGPXStatistics}
|
{slicedStatistics}
|
||||||
{hoveredPoint}
|
{hoveredPoint}
|
||||||
{additionalDatasets}
|
{additionalDatasets}
|
||||||
{elevationFill}
|
{elevationFill}
|
||||||
|
|||||||
Reference in New Issue
Block a user