diff --git a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs index 41ac77eda..c9a04ab8b 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs @@ -10,6 +10,9 @@ pub const NO_TIME: i64 = i64::MIN; /// 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. +/// +/// The optional values (`time`, `hr`, `cad`, `atemp`, `power`) are `None` when no trackpoint of the +/// selection has one, and the OSM attributes are stored as [`Intervals`]. #[derive(Debug, Default)] pub struct StatisticsBuffer { /// Statistics of the whole selection. @@ -28,20 +31,47 @@ pub struct StatisticsBuffer { pub lat: Vec, pub ele: Vec, /// Timestamps, in milliseconds since the epoch. - pub time: Vec, - pub hr: Vec, - pub cad: Vec, - pub atemp: Vec, - pub power: Vec, - /// Surface of the trackpoints: 0 when unknown, else 1 + its code in the categories of the - /// engine (so that it is never 0 for a known surface). - pub surface: Vec, + pub time: Option>, + pub hr: Option>, + pub cad: Option>, + pub atemp: Option>, + pub power: Option>, + /// Surface of the trackpoints, see [`Intervals`]. + pub surface: Intervals, /// Highway of the trackpoints, as the surface. - pub highway: Vec, + pub highway: Intervals, /// SAC hiking scale of the trackpoints, as the surface. - pub sac_scale: Vec, + pub sac_scale: Intervals, /// Mountain biking scale of the trackpoints, as the surface. - pub mtb_scale: Vec, + pub mtb_scale: Intervals, +} + +/// A value per trackpoint that rarely changes from one trackpoint to the next, stored as the +/// intervals of trackpoints that share it. +/// +/// The values are 0 when unknown, else 1 + the code in the categories of the engine (so that it +/// is never 0 for a known value). `starts[i]` is the index of the first trackpoint of the +/// interval `i`, which ends where the next one starts, or at the last trackpoint for the last one. +/// Both are empty when there are no trackpoints. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Intervals { + pub starts: Vec, + pub values: Vec, +} + +impl Intervals { + fn clear(&mut self) { + self.starts.clear(); + self.values.clear(); + } + + /// Adds the value of the trackpoint at `index`, the indices being pushed in order. + fn push(&mut self, index: usize, value: u8) { + if self.values.last() != Some(&value) { + self.starts.push(index as u32); + self.values.push(value); + } + } } /// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it @@ -50,6 +80,21 @@ fn unknown_or_next(code: Option) -> u8 { code.and_then(|code| code.checked_add(1)).unwrap_or(0) } +/// Adds the value of the trackpoint at `index` to an optional column, the indices being pushed in +/// order. The column is created at the first present value, the previous trackpoints getting +/// `missing`. +fn push_optional(column: &mut Option>, index: usize, value: Option, missing: T) { + match (column.as_mut(), value) { + (Some(column), value) => column.push(value.unwrap_or(missing)), + (None, Some(value)) => { + let mut new_column = vec![missing; index]; + new_column.push(value); + *column = Some(new_column); + } + (None, None) => {} + } +} + impl StatisticsBuffer { pub fn len(&self) -> usize { self.total_distance.len() @@ -75,20 +120,19 @@ impl StatisticsBuffer { self.lng.clear(); self.lat.clear(); self.ele.clear(); - self.time.clear(); - self.hr.clear(); - self.cad.clear(); - self.atemp.clear(); - self.power.clear(); + self.time = None; + self.hr = None; + self.cad = None; + self.atemp = None; + self.power = None; self.surface.clear(); self.highway.clear(); self.sac_scale.clear(); self.mtb_scale.clear(); - let optional = |value: Option| value.unwrap_or(f64::NAN); - for (segment, stats) in selected { for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) { + let index = self.total_distance.len(); let cumul_stats = &self.global; self.total_distance .push(cumul_stats.total_distance + trkpt_stats.total_distance); @@ -116,15 +160,15 @@ impl StatisticsBuffer { 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))); - self.surface.push(unknown_or_next(trkpt.surface)); - self.highway.push(unknown_or_next(trkpt.highway)); - self.sac_scale.push(unknown_or_next(trkpt.sac_scale)); - self.mtb_scale.push(unknown_or_next(trkpt.mtb_scale)); + push_optional(&mut self.time, index, trkpt.time, NO_TIME); + push_optional(&mut self.hr, index, trkpt.hr.map(f64::from), f64::NAN); + push_optional(&mut self.cad, index, trkpt.cad.map(f64::from), f64::NAN); + push_optional(&mut self.atemp, index, trkpt.atemp.map(f64::from), f64::NAN); + push_optional(&mut self.power, index, trkpt.power.map(f64::from), f64::NAN); + self.surface.push(index, unknown_or_next(trkpt.surface)); + self.highway.push(index, unknown_or_next(trkpt.highway)); + self.sac_scale.push(index, unknown_or_next(trkpt.sac_scale)); + self.mtb_scale.push(index, unknown_or_next(trkpt.mtb_scale)); } self.global.merge(&stats.global); } @@ -143,7 +187,10 @@ impl StatisticsBuffer { 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]); + let time = |i: usize| { + let time = self.time.as_ref()?[i]; + (time != NO_TIME).then_some(time) + }; Some(GlobalStatistics { total_distance: delta(&self.total_distance), moving_distance: self @@ -182,6 +229,17 @@ mod tests { (segment, stats) } + /// The value of the trackpoint at `index`, 0 before the first interval. + fn value_at(intervals: &Intervals, index: usize) -> u8 { + match intervals + .starts + .partition_point(|start| *start as usize <= index) + { + 0 => 0, + next => intervals.values[next - 1], + } + } + fn all_lengths(buffer: &StatisticsBuffer) -> Vec { vec![ buffer.total_distance.len(), @@ -197,15 +255,6 @@ mod tests { buffer.lng.len(), buffer.lat.len(), buffer.ele.len(), - buffer.time.len(), - buffer.hr.len(), - buffer.cad.len(), - buffer.atemp.len(), - buffer.power.len(), - buffer.surface.len(), - buffer.highway.len(), - buffer.sac_scale.len(), - buffer.mtb_scale.len(), ] } @@ -290,33 +339,121 @@ mod tests { 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)); + assert!(buffer.time.unwrap().iter().all(|t| *t != NO_TIME)); } #[test] - fn test_missing_times_and_measures_are_nan() { + fn test_absent_optional_values_have_no_buffer() { 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!(buffer.time.is_none()); + assert!(buffer.hr.is_none()); + assert!(buffer.cad.is_none()); + assert!(buffer.atemp.is_none()); + assert!(buffer.power.is_none()); assert_eq!(buffer.global.hr.count, 0); assert_eq!(buffer.global.total_time, None); + assert_eq!(buffer.len(), s.local.len()); + // the slices do not need them + let slice = buffer.slice(0, 2).unwrap(); + assert_eq!((slice.start_time, slice.end_time), (None, None)); } #[test] - fn test_surface_and_highway_codes() { + fn test_present_optional_values_have_one_entry_per_trackpoint() { + let (segment, s) = computed("data/with_time.gpx"); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s)]); + assert_eq!(buffer.time.as_ref().unwrap().len(), buffer.len()); + assert!(buffer.hr.is_none()); + + let data = std::fs::read("data/with_hr.gpx").unwrap(); + let file = parse(&data, &mut Default::default()).unwrap(); + let segment = file.trk[0].trkseg[0].clone(); + let s = Statistics::compute(&segment); + buffer.update(&[(&segment, &s)]); + let hr = buffer.hr.as_ref().unwrap(); + assert_eq!(hr.len(), buffer.len()); + assert!(hr.iter().any(|v| !v.is_nan())); + } + + #[test] + fn test_a_value_missing_for_some_trackpoints_is_a_sentinel() { + let (mut segment, _) = computed("data/with_time.gpx"); + let n = segment.len(); + let mut point = segment[1].clone(); + point.time = None; + point.hr = Some(120); + segment.splice(1, 2, vec![point]); + let s = Statistics::compute(&segment); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s)]); + let time = buffer.time.as_ref().unwrap(); + assert_eq!(time.len(), n); + assert_eq!(time[1], NO_TIME); + assert_ne!(time[0], NO_TIME); + // the first trackpoint has no heart rate: it is filled once the first one is found + let hr = buffer.hr.as_ref().unwrap(); + assert_eq!(hr.len(), n); + assert_eq!(hr[1], 120.0); + assert!(hr[0].is_nan() && hr[2].is_nan()); + assert!(buffer.cad.is_none()); + } + + #[test] + fn test_surface_and_highway_intervals() { let (segment, s) = computed("data/with_highway.gpx"); let mut buffer = StatisticsBuffer::default(); buffer.update(&[(&segment, &s)]); // 0 for the trackpoints that have none, else the code of the engine plus one - assert_eq!(buffer.surface, [1, 1, 0, 2, 2]); - assert_eq!(buffer.highway, [1, 1, 0, 2, 0]); - assert_eq!(buffer.sac_scale, [0, 0, 0, 1, 2]); - assert_eq!(buffer.mtb_scale, [0, 0, 0, 1, 1]); + let surface = &buffer.surface; + assert_eq!(surface.starts, [0, 2, 3]); + assert_eq!(surface.values, [1, 0, 2]); + let highway = &buffer.highway; + assert_eq!( + (&highway.starts[..], &highway.values[..]), + (&[0, 2, 3, 4][..], &[1, 0, 2, 0][..]) + ); + let sac_scale = &buffer.sac_scale; + assert_eq!( + (&sac_scale.starts[..], &sac_scale.values[..]), + (&[0, 3, 4][..], &[0, 1, 2][..]) + ); + let mtb_scale = &buffer.mtb_scale; + assert_eq!( + (&mtb_scale.starts[..], &mtb_scale.values[..]), + (&[0, 3][..], &[0, 1][..]) + ); assert!(all_lengths(&buffer).iter().all(|len| *len == 5)); + + // and they can be looked up + let values: Vec = (0..6).map(|i| value_at(&buffer.surface, i)).collect(); + assert_eq!(values, [1, 1, 0, 2, 2, 2]); + } + + #[test] + fn test_intervals_merge_equal_neighbours_across_segments() { + let (mut segment, _) = computed("data/simple.gpx"); + let points: Vec<_> = segment + .iter() + .map(|trkpt| { + let mut trkpt = trkpt.clone(); + trkpt.surface = Some(4); + trkpt + }) + .collect(); + segment.splice(0, points.len(), points); + let s = Statistics::compute(&segment); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[(&segment, &s), (&segment, &s)]); + // one interval for the two segments + assert_eq!(buffer.surface.starts, [0]); + assert_eq!(buffer.surface.values, [5]); + // and a single one of unknown values when nothing is known + assert_eq!(buffer.highway.values, [0]); } #[test] @@ -331,26 +468,37 @@ mod tests { let s = Statistics::compute(&segment); let mut buffer = StatisticsBuffer::default(); buffer.update(&[(&segment, &s)]); - assert_eq!((buffer.surface[0], buffer.highway[0]), (8, 255)); - assert_eq!((buffer.sac_scale[0], buffer.mtb_scale[0]), (1, 4)); - assert_eq!((buffer.surface[1], buffer.highway[1]), (0, 0)); - assert_eq!((buffer.sac_scale[1], buffer.mtb_scale[1]), (0, 0)); + let at = |i: usize| { + ( + value_at(&buffer.surface, i), + value_at(&buffer.highway, i), + value_at(&buffer.sac_scale, i), + value_at(&buffer.mtb_scale, i), + ) + }; + assert_eq!(at(0), (8, 255, 1, 4)); + assert_eq!(at(1), (0, 0, 0, 0)); } #[test] - fn test_no_codes_without_surface_and_highway() { + fn test_unknown_intervals_without_surface_and_highway() { let (segment, s) = computed("data/simple.gpx"); let mut buffer = StatisticsBuffer::default(); buffer.update(&[(&segment, &s)]); - assert_eq!(buffer.surface.len(), s.local.len()); - assert!(buffer.surface.iter().all(|c| *c == 0)); - assert!(buffer.highway.iter().all(|c| *c == 0)); - assert!(buffer.sac_scale.iter().all(|c| *c == 0)); - assert!(buffer.mtb_scale.iter().all(|c| *c == 0)); + // a single interval of unknown values + assert_eq!(buffer.surface.starts, [0]); + assert_eq!(buffer.surface.values, [0]); + assert_eq!(buffer.highway.starts, [0]); + assert_eq!(buffer.highway.values, [0]); + assert_eq!(buffer.sac_scale.values, [0]); + assert_eq!(buffer.mtb_scale.values, [0]); // and they are reset by the next update buffer.update(&[]); - assert!(buffer.surface.is_empty() && buffer.highway.is_empty()); - assert!(buffer.sac_scale.is_empty() && buffer.mtb_scale.is_empty()); + assert_eq!(buffer.surface, Intervals::default()); + assert_eq!(buffer.highway, Intervals::default()); + assert_eq!(buffer.sac_scale, Intervals::default()); + assert_eq!(buffer.mtb_scale, Intervals::default()); + assert_eq!(value_at(&buffer.surface, 0), 0); } #[test] @@ -405,8 +553,8 @@ mod tests { 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_eq!(slice.start_time, Some(buffer.time.as_ref().unwrap()[start])); + assert_eq!(slice.end_time, Some(buffer.time.as_ref().unwrap()[end])); assert!(slice.total_distance < buffer.global.total_distance); assert!(slice.total_speed().is_some()); diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 8a8a6531b..e59983258 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -269,6 +269,15 @@ mod tests { use super::*; + fn expand(intervals: &crate::Intervals, len: usize) -> Vec { + let mut values = Vec::new(); + for (i, value) in intervals.values.iter().enumerate() { + let end = intervals.starts.get(i + 1).map_or(len, |end| *end as usize); + values.resize(end, *value); + } + values + } + fn edit(engine: &mut Engine, command: Command) -> bool { engine.execute(Action::Edit(command)) } @@ -1250,14 +1259,14 @@ mod tests { ["residential", "track"] ); // the trackpoints of the selection refer to them - assert_eq!(engine.statistics().surface, [1, 1, 0, 2, 2]); - assert_eq!(engine.statistics().highway, [1, 1, 0, 2, 0]); + assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]); + assert_eq!(expand(&engine.statistics().highway, 5), [1, 1, 0, 2, 0]); assert_eq!( engine.categories().sac_scale.names(), ["mountain_hiking", "hiking"] ); - assert_eq!(engine.statistics().sac_scale, [0, 0, 0, 1, 2]); - assert_eq!(engine.statistics().mtb_scale, [0, 0, 0, 1, 1]); + assert_eq!(expand(&engine.statistics().sac_scale, 5), [0, 0, 0, 1, 2]); + assert_eq!(expand(&engine.statistics().mtb_scale, 5), [0, 0, 0, 1, 1]); // a second file goes on with the same table load(&mut engine, "data/with_surface.gpx"); @@ -1265,17 +1274,10 @@ mod tests { engine.categories().surface.names(), ["asphalt", "gravel", "cobblestone"] ); - assert_eq!(engine.statistics().surface.len(), 80); - assert_eq!(engine.statistics().surface[0], 1); - assert_eq!( - engine - .statistics() - .surface - .iter() - .filter(|c| **c == 3) - .count(), - 1 - ); + assert_eq!(engine.statistics().len(), 80); + let surface = expand(&engine.statistics().surface, 80); + assert_eq!(surface[0], 1); + assert_eq!(surface.iter().filter(|c| **c == 3).count(), 1); // undoing does not forget what was learned: the codes stay valid in every state assert!(engine.execute(Action::Undo)); @@ -1287,7 +1289,7 @@ mod tests { assert!(engine.execute(Action::Redo)); let first = engine.order()[0]; select_files(&mut engine, &[first]); - assert_eq!(engine.statistics().surface, [1, 1, 0, 2, 2]); + assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]); } #[test] diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index d8d725d5f..84fbeb49a 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; use wasm_bindgen::prelude::*; use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds}; -use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array}; +use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array, Uint32Array}; #[wasm_bindgen] #[derive(Clone, Copy)] @@ -146,32 +146,63 @@ 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)) +// The optional values are `undefined` when no trackpoint of the selection has one, else they have +// an entry per trackpoint, the missing ones being NaN (`NO_TIME` for the timestamps). +macro_rules! optional_stats_getter { + ($name:ident, $field:ident, $array:ident) => { + #[wasm_bindgen] + pub fn $name() -> Option<$array> { + with_stats(|s| { + s.$field + .as_ref() + .map(|values| unsafe { $array::view(values) }) + }) + .flatten() + } + }; } +// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing). +optional_stats_getter!(timestamps, time, BigInt64Array); + /// 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); +optional_stats_getter!(hr, hr, Float64Array); +optional_stats_getter!(cad, cad, Float64Array); +optional_stats_getter!(atemp, atemp, Float64Array); +optional_stats_getter!(power, power, Float64Array); -// Surface, highway, SAC scale and MTB scale of the trackpoints: 0 when unknown, else 1 + the index -// in `surfaces()`, `highways()`, `sac_scales()` or `mtb_scales()`. -stats_getter!(surface, Uint8Array); -stats_getter!(highway, Uint8Array); -stats_getter!(sac_scale, Uint8Array); -stats_getter!(mtb_scale, Uint8Array); +// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that +// have the same value: `_starts` holds the index of the first trackpoint of each interval, +// and `_values` its value (0 when unknown, else 1 + the index in `surfaces()`, +// `highways()`, `sac_scales()` or `mtb_scales()`). A value that is unknown for every trackpoint is +// a single interval. +macro_rules! intervals_getters { + ($starts:ident, $values:ident, $field:ident) => { + #[wasm_bindgen] + pub fn $starts() -> Uint32Array { + with_stats(|s| unsafe { Uint32Array::view(&s.$field.starts) }) + .unwrap_or_else(|| Uint32Array::new_with_length(0)) + } -/// The names of the surfaces, in the order of their codes (see `surface`). The list only grows: + #[wasm_bindgen] + pub fn $values() -> Uint8Array { + with_stats(|s| unsafe { Uint8Array::view(&s.$field.values) }) + .unwrap_or_else(|| Uint8Array::new_with_length(0)) + } + }; +} + +intervals_getters!(surface_starts, surface_values, surface); +intervals_getters!(highway_starts, highway_values, highway); +intervals_getters!(sac_scale_starts, sac_scale_values, sac_scale); +intervals_getters!(mtb_scale_starts, mtb_scale_values, mtb_scale); + +/// The names of the surfaces, in the order of their codes (see `surface_values`). The list only grows: /// a name keeps its index. #[wasm_bindgen] pub fn surfaces() -> StringList { @@ -180,7 +211,7 @@ pub fn surfaces() -> StringList { .unchecked_into() } -/// The names of the highways, in the order of their codes (see `highway`). +/// The names of the highways, in the order of their codes (see `highway_values`). #[wasm_bindgen] pub fn highways() -> StringList { with_engine(|e| array(e.categories().highway.names(), |name| name.as_str().into())) @@ -188,7 +219,7 @@ pub fn highways() -> StringList { .unchecked_into() } -/// The names of the SAC scales, in the order of their codes (see `sac_scale`). +/// The names of the SAC scales, in the order of their codes (see `sac_scale_values`). #[wasm_bindgen] pub fn sac_scales() -> StringList { with_engine(|e| { @@ -200,7 +231,7 @@ pub fn sac_scales() -> StringList { .unchecked_into() } -/// The names of the MTB scales, in the order of their codes (see `mtb_scale`). +/// The names of the MTB scales, in the order of their codes (see `mtb_scale_values`). #[wasm_bindgen] pub fn mtb_scales() -> StringList { with_engine(|e| { diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index 5f56d7aec..66eede223 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -22,10 +22,16 @@ import Chart, { } from 'chart.js/auto'; import { get, type Readable, type Writable } from 'svelte/store'; import type { Coordinates } from 'gpx'; -import { NO_TIME, type SelectionStatistics } from '$lib/engine'; +import { + engine, + NO_TIME, + type SelectionStatistics, + type StatisticsMetric, + type StatisticsRequest, +} from '$lib/engine'; import type { SlicedStatistics } from '$lib/logic/selection-statistics'; import { mode } from 'mode-watcher'; -import { categoryAt } from '$lib/trackpoint-categories'; +import { categoryValues } from '$lib/trackpoint-categories'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; const { distanceUnits, velocityUnits, temperatureUnits } = settings; @@ -62,6 +68,8 @@ export class ElevationProfile { private _hoveredPoint: Writable; private _additionalDatasets: Readable; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; + /** The statistics that are only read from the engine while the profile shows them. */ + private _request: StatisticsRequest; constructor( statistics: Readable, @@ -79,6 +87,8 @@ export class ElevationProfile { this._elevationFill = elevationFill; this._canvas = canvas; this._overlay = overlay; + this._request = engine.requestStatistics(); + this.updateRequest(); import('chartjs-plugin-zoom').then((module) => { Chart.register(module.default); @@ -100,14 +110,34 @@ export class ElevationProfile { this.updateData(); }); this._additionalDatasets.subscribe(() => { + // requesting a missing metric updates the data, and then the visibility + this.updateRequest(); this.updateDataVisibility(); }); this._elevationFill.subscribe(() => { + this.updateRequest(); this.updateFill(); }); }); } + /** Asks the engine for what the profile currently shows, and nothing more. */ + updateRequest() { + const metrics = new Set(); + for (const dataset of get(this._additionalDatasets)) { + if (['speed', 'hr', 'cad', 'atemp', 'power'].includes(dataset)) { + metrics.add(dataset as StatisticsMetric); + } + } + const elevationFill = get(this._elevationFill); + if (elevationFill === 'slope') { + metrics.add('slopeSegment'); + } else if (elevationFill) { + metrics.add(elevationFill); + } + this._request.set(metrics); + } + initialize() { let options: ChartOptions<'line'> = { animation: false, @@ -400,47 +430,51 @@ export class ElevationProfile { const datasets: Array> = [[], [], [], [], [], []]; const { global } = data; + const surfaces = categoryValues(data.surface, data.length); + const highways = categoryValues(data.highway, data.length); + const sacScales = categoryValues(data.sacScale, data.length); + const mtbScales = categoryValues(data.mtbScale, data.length); 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]; + const timestamp = data.timestamps?.[index] ?? NO_TIME; datasets[0].push({ 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], + segment: data.slopeSegmentSlope?.[index] ?? NaN, + length: data.slopeSegmentDistance?.[index] ?? NaN, }, - surface: categoryAt(data.surface, data.surfaces, index), - highway: categoryAt(data.highway, data.highways, index), - sacScale: categoryAt(data.sacScale, data.sacScales, index), - mtbScale: categoryAt(data.mtbScale, data.mtbScales, index), + surface: surfaces[index], + highway: highways[index], + sacScale: sacScales[index], + mtbScale: mtbScales[index], coordinates: { lat: data.lat[index], lon: data.lng[index] }, index, }); - if ((global.totalTime ?? 0) > 0) { + if (data.speed && (global.totalTime ?? 0) > 0) { datasets[1].push({ x, y: getConvertedVelocity(data.speed[index], units.velocity, units.distance), index, }); } - if (global.hr) { + if (data.hr) { datasets[2].push({ x, y: data.hr[index], index }); } - if (global.cad) { + if (data.cad) { datasets[3].push({ x, y: data.cad[index], index }); } - if (global.atemp) { + if (data.atemp) { datasets[4].push({ x, y: getConvertedTemperature(data.atemp[index], units.temperature), index, }); } - if (global.power) { + if (data.power) { datasets[5].push({ x, y: data.power[index], index }); } } @@ -613,6 +647,7 @@ export class ElevationProfile { } destroy() { + this._request.release(); if (this._chart) { this._chart.destroy(); this._chart = null; diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 1ff3bc5fb..f7b0527ef 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -2,6 +2,7 @@ import { browser } from '$app/environment'; import { get, writable, type Readable, type Writable } from 'svelte/store'; import { FileColorAllocator, normalizeColor } from '$lib/file-colors'; import { setHidden, type Visibility } from '$lib/file-visibility'; +import type { CategoryIntervals } from '$lib/trackpoint-categories'; import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers'; import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; import type { @@ -106,56 +107,62 @@ const EMPTY_STATISTICS: GlobalStatistics = { totalDistance: 0, elevationGain: 0, */ export const NO_TIME = -(2n ** 63n); +/** + * Statistics of the selection that are only read from the engine when something asks for them + * (see `Engine.requestStatistics`). A metric can bring several arrays. + */ +export type StatisticsMetric = + | 'speed' + | 'hr' + | 'cad' + | 'atemp' + | 'power' + /** `slopeSegmentSlope` and `slopeSegmentDistance`. */ + | 'slopeSegment' + | 'surface' + /** `highway`, `sacScale` and `mtbScale`. */ + | 'highway'; + /** * 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. + * Distances 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 %. + * + * The values of the trackpoints that the selection does not have at all (for example the heart + * rates, when no trackpoint has any) are `undefined`, and so are the ones that were not requested + * (see `StatisticsMetric`). Otherwise they have one entry per trackpoint: 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; - /** Surface of the trackpoints: 0 when unknown, else 1 + the index in `surfaces`. */ - surface: Uint8Array; - /** Names of the surfaces, by code: a name keeps its index. */ - surfaces: string[]; - /** Highway of the trackpoints: 0 when unknown, else 1 + the index in `highways`. */ - highway: Uint8Array; - /** Names of the highways, by code. */ - highways: string[]; - /** SAC hiking scale of the trackpoints: 0 when unknown, else 1 + the index in `sacScales`. */ - sacScale: Uint8Array; - /** Names of the SAC scales, by code. */ - sacScales: string[]; - /** Mountain biking scale of the trackpoints: 0 when unknown, else 1 + the index in `mtbScales`. */ - mtbScale: Uint8Array; - /** Names of the mountain biking scales, by code. */ - mtbScales: string[]; + timestamps?: BigInt64Array; + + // the following need to be requested + speed?: Float64Array; + hr?: Float64Array; + cad?: Float64Array; + atemp?: Float64Array; + power?: Float64Array; + /** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */ + slopeSegmentSlope?: Float64Array; + slopeSegmentDistance?: Float64Array; + surface?: CategoryIntervals; + highway?: CategoryIntervals; + /** SAC hiking scale. */ + sacScale?: CategoryIntervals; + /** Mountain biking scale. */ + mtbScale?: CategoryIntervals; + /** * 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 @@ -168,34 +175,21 @@ 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(), - surface: new Uint8Array(), - surfaces: [], - highway: new Uint8Array(), - highways: [], - sacScale: new Uint8Array(), - sacScales: [], - mtbScale: new Uint8Array(), - mtbScales: [], slice: () => undefined, }; +/** What a consumer of the statistics needs, see `Engine.requestStatistics`. */ +export type StatisticsRequest = { + /** Replaces the metrics needed by this consumer. */ + set(metrics: Iterable): void; + /** The consumer does not need any metric anymore. */ + release(): void; +}; + function toPositions(flat: Float64Array): [number, number][] { const positions: [number, number][] = new Array(flat.length / 2); for (let i = 0; i < positions.length; i++) { @@ -245,6 +239,10 @@ class Engine { private _statistics = writable(EMPTY_SELECTION_STATISTICS); /** Identifies the statistics currently in the engine's buffers. */ private _statisticsVersion = 0; + /** What each consumer of the statistics asked for. */ + private _statisticsRequests = new Set>(); + /** The metrics that were read for the current statistics. */ + private _loadedMetrics = new Set(); /** Ids of the files in display order. */ readonly order: Readable = { subscribe: this._order.subscribe }; @@ -641,42 +639,141 @@ class Engine { } } - /** Copies the statistics buffers of the engine (they are overwritten by the next action). */ + /** + * Copies the statistics buffers of the engine (they are overwritten by the next action), but + * only the metrics that were requested. + */ private readStatistics(wasm: Wasm): SelectionStatistics { const version = ++this._statisticsVersion; + this._loadedMetrics.clear(); const totalDistance = wasm.total_distance().slice(); - return { + const statistics: SelectionStatistics = { 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(), - surface: wasm.surface().slice(), - surfaces: wasm.surfaces(), - highway: wasm.highway().slice(), - highways: wasm.highways(), - sacScale: wasm.sac_scale().slice(), - sacScales: wasm.sac_scales(), - mtbScale: wasm.mtb_scale().slice(), - mtbScales: wasm.mtb_scales(), + timestamps: wasm.timestamps()?.slice(), slice: (start, end) => version === this._statisticsVersion ? wasm.slice_statistics(start, end) : undefined, }; + this.readMetrics(wasm, statistics, this.requestedMetrics()); + return statistics; + } + + private requestedMetrics(): Set { + const metrics = new Set(); + this._statisticsRequests.forEach((request) => request.forEach((m) => metrics.add(m))); + return metrics; + } + + /** Reads the metrics that were not read yet into `statistics`, which is the current one. */ + private readMetrics( + wasm: Wasm, + statistics: SelectionStatistics, + metrics: Set + ) { + const categories = ( + starts: Uint32Array, + values: Uint8Array, + names: () => string[] + ): CategoryIntervals => ({ + starts: starts.slice(), + values: values.slice(), + names: names(), + }); + + for (const metric of metrics) { + if (this._loadedMetrics.has(metric)) { + continue; + } + this._loadedMetrics.add(metric); + switch (metric) { + case 'speed': + statistics.speed = wasm.speed().slice(); + break; + case 'hr': + statistics.hr = wasm.hr()?.slice(); + break; + case 'cad': + statistics.cad = wasm.cad()?.slice(); + break; + case 'atemp': + statistics.atemp = wasm.atemp()?.slice(); + break; + case 'power': + statistics.power = wasm.power()?.slice(); + break; + case 'slopeSegment': + statistics.slopeSegmentSlope = wasm.slope_segment_slope().slice(); + statistics.slopeSegmentDistance = wasm.slope_segment_distance().slice(); + break; + case 'surface': + statistics.surface = categories( + wasm.surface_starts(), + wasm.surface_values(), + wasm.surfaces + ); + break; + case 'highway': + statistics.highway = categories( + wasm.highway_starts(), + wasm.highway_values(), + wasm.highways + ); + statistics.sacScale = categories( + wasm.sac_scale_starts(), + wasm.sac_scale_values(), + wasm.sac_scales + ); + statistics.mtbScale = categories( + wasm.mtb_scale_starts(), + wasm.mtb_scale_values(), + wasm.mtb_scales + ); + break; + } + } + } + + /** + * Registers a consumer of the statistics that needs some of the metrics that are not part of + * them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are + * added to `statistics`: the ones that are missing are read right away, and then after each + * change of the selection or of the files, and the store is updated. Call `release` when the + * consumer goes away. + */ + requestStatistics(): StatisticsRequest { + const wanted = new Set(); + this._statisticsRequests.add(wanted); + return { + set: (metrics) => { + wanted.clear(); + for (const metric of metrics) { + wanted.add(metric); + } + this.loadMissingMetrics(); + }, + release: () => { + this._statisticsRequests.delete(wanted); + }, + }; + } + + /** Reads the requested metrics that are not in the current statistics yet. */ + private loadMissingMetrics() { + const wasm = this.wasm; + const metrics = this.requestedMetrics(); + if (!wasm || [...metrics].every((metric) => this._loadedMetrics.has(metric))) { + return; + } + // the buffers of the engine still are the ones of the current statistics, as they are + // read after every action + const statistics = { ...get(this._statistics) }; + this.readMetrics(wasm, statistics, metrics); + this._statistics.set(statistics); } /** Reads the state of a file, reusing from `previous` what did not change. */ diff --git a/website/src/lib/trackpoint-categories.test.ts b/website/src/lib/trackpoint-categories.test.ts index 1114ee79d..e7137841b 100644 --- a/website/src/lib/trackpoint-categories.test.ts +++ b/website/src/lib/trackpoint-categories.test.ts @@ -1,24 +1,45 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { categoryAt } from './trackpoint-categories'; +import { categoryValues, type CategoryIntervals } from './trackpoint-categories'; -describe('categoryAt', () => { - const codes = new Uint8Array([1, 1, 0, 2, 0]); - const names = ['asphalt', 'gravel']; +describe('categoryValues', () => { + // asphalt, asphalt, unknown, gravel, gravel, gravel, unknown + const intervals: CategoryIntervals = { + starts: new Uint32Array([0, 2, 3, 6]), + values: new Uint8Array([1, 0, 2, 0]), + names: ['asphalt', 'gravel'], + }; - it('is the name of the code', () => { - assert.equal(categoryAt(codes, names, 0), 'asphalt'); - assert.equal(categoryAt(codes, names, 3), 'gravel'); + it('is the name of the value of the interval of each trackpoint', () => { + assert.deepEqual(categoryValues(intervals, 7), [ + 'asphalt', + 'asphalt', + undefined, + 'gravel', + 'gravel', + 'gravel', + undefined, + ]); }); - it('is undefined when the value is unknown', () => { - assert.equal(categoryAt(codes, names, 2), undefined); - assert.equal(categoryAt(codes, names, 4), undefined); + it('goes on with the last interval until the last trackpoint', () => { + const last = { ...intervals, values: new Uint8Array([1, 0, 2, 2]) }; + assert.deepEqual(categoryValues(last, 9).slice(5), [ + 'gravel', + 'gravel', + 'gravel', + 'gravel', + ]); }); - it('is undefined out of the trackpoints, or for a code without name', () => { - assert.equal(categoryAt(codes, names, 5), undefined); - assert.equal(categoryAt(new Uint8Array([3]), names, 0), undefined); - assert.equal(categoryAt(new Uint8Array(), [], 0), undefined); + it('does not go past the trackpoints', () => { + assert.deepEqual(categoryValues(intervals, 3), ['asphalt', 'asphalt', undefined]); + assert.deepEqual(categoryValues(intervals, 0), []); + }); + + it('is undefined without intervals, or for a code without name', () => { + assert.deepEqual(categoryValues(undefined, 2), [undefined, undefined]); + const unnamed = { starts: new Uint32Array([0]), values: new Uint8Array([3]), names: [] }; + assert.deepEqual(categoryValues(unnamed, 2), [undefined, undefined]); }); }); diff --git a/website/src/lib/trackpoint-categories.ts b/website/src/lib/trackpoint-categories.ts index 058d31708..2942c069a 100644 --- a/website/src/lib/trackpoint-categories.ts +++ b/website/src/lib/trackpoint-categories.ts @@ -1,12 +1,37 @@ /** - * The value of a trackpoint for a category of the engine (surface, highway): `codes` has one entry - * per trackpoint, 0 when the value is unknown, else 1 + the index of the value in `names`. + * A category of the engine (surface, highway...) for each trackpoint, as the intervals of + * consecutive trackpoints that share the same value: `starts[i]` is the index of the first + * trackpoint of the interval `i`, which lasts until the next start (or the last trackpoint). Its + * `values[i]` is 0 when the value is unknown, else 1 + the index of the value in `names`. */ -export function categoryAt( - codes: ArrayLike, - names: readonly string[], - index: number -): string | undefined { - const code = codes[index]; - return code ? names[code - 1] : undefined; +export type CategoryIntervals = { + starts: Uint32Array; + values: Uint8Array; + /** Names of the values, by code: a name keeps its index. */ + names: string[]; +}; + +/** + * The value of each of the `length` trackpoints for a category of the engine, `undefined` when it + * is unknown, or when the category is missing (it is unknown for every trackpoint). The intervals + * are scanned once, so it takes a time linear in `length`. + */ +export function categoryValues( + intervals: CategoryIntervals | undefined, + length: number +): (string | undefined)[] { + const result: (string | undefined)[] = new Array(length).fill(undefined); + if (!intervals) { + return result; + } + const { starts, values, names } = intervals; + for (let i = 0; i < starts.length; i++) { + if (!values[i]) { + continue; // unknown, already `undefined` + } + const name = names[values[i] - 1]; + const end = i + 1 < starts.length ? Math.min(starts[i + 1], length) : length; + result.fill(name, starts[i], end); + } + return result; } diff --git a/website/src/routes/[[language]]/+page.svelte b/website/src/routes/[[language]]/+page.svelte index c54b9089e..dae664141 100644 --- a/website/src/routes/[[language]]/+page.svelte +++ b/website/src/routes/[[language]]/+page.svelte @@ -17,6 +17,7 @@ ExternalLink, } from '@lucide/svelte'; import { i18n } from '$lib/i18n.svelte'; + import type { SelectionStatistics } from '$lib/engine'; import { getURLForLanguage } from '$lib/utils'; import { writable } from 'svelte/store'; import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte'; @@ -24,35 +25,14 @@ import { onDestroy, onMount } from 'svelte'; // TODO the example comes from the previous implementation - let statistics = writable({ + 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(), - surface: new Uint8Array(), - surfaces: [], - highway: new Uint8Array(), - highways: [], - sacScale: new Uint8Array(), - sacScales: [], - mtbScale: new Uint8Array(), - mtbScales: [], slice: () => undefined, }); let slicedStatistics = writable(undefined);