diff --git a/gpx-rs/src/api/dto.rs b/gpx-rs/src/api/dto.rs index 6e6ca259f..c4a98eb67 100644 --- a/gpx-rs/src/api/dto.rs +++ b/gpx-rs/src/api/dto.rs @@ -522,6 +522,29 @@ impl TrackpointDetails { } } +/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint. +/// Absent fields are unknown. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectionPoint { + /// ms since the epoch. + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + /// Slope (%) and length (km) of the smoothed elevation segment of the trackpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub slope_segment_slope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slope_segment_distance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub surface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub highway: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sac_scale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtb_scale: Option, +} + // Routing /// A category of the trackpoints of a route, as in `RouteCategory` of the engine. diff --git a/gpx-rs/src/api/queries.rs b/gpx-rs/src/api/queries.rs index a65c65358..63de19669 100644 --- a/gpx-rs/src/api/queries.rs +++ b/gpx-rs/src/api/queries.rs @@ -154,8 +154,6 @@ buffers!( elevation_gain, elevation_loss, slope, - slope_segment_slope, - slope_segment_distance, lng, lat, ele @@ -175,9 +173,48 @@ macro_rules! optional_buffers { optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64); optional_buffers!(total_time: i64, moving_time: i64); -/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), see `optional_buffers`. -pub fn timestamps(engine: &Engine) -> Option<&[i64]> { - engine.statistics().time.as_deref() +// The smoothed elevation segments of the selection, over which the slope is averaged, as +// intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each +// segment, and `slope_segment_slopes` its slope (%). Their length is in `selection_point`. + +pub fn slope_segment_starts(engine: &Engine) -> &[u32] { + &engine.statistics().slope_segments.starts +} + +pub fn slope_segment_slopes(engine: &Engine) -> &[f64] { + &engine.statistics().slope_segments.slopes +} + +/// What the buffers do not hold for each trackpoint of the selection, for the one at `index` +/// (numbered as in the buffers), `None` if there is none: for example the one that is hovered on +/// the elevation profile. +pub fn selection_point(engine: &Engine, index: usize) -> Option { + let stats = engine.statistics(); + if index >= stats.len() { + return None; + } + let categories = engine.categories(); + let name = |intervals: &engine::Intervals, categories: &engine::Categories| { + let value = intervals.value_at(index); + let code = value.checked_sub(1)?; + categories.name(code).map(str::to_owned) + }; + let segment = stats.slope_segments.at(index); + let finite = |value: f64| value.is_finite().then_some(value); + Some(SelectionPoint { + time: stats + .time + .as_ref() + .map(|time| time[index]) + .filter(|time| *time != engine::NO_TIME) + .map(|time| time as f64), + slope_segment_slope: segment.and_then(|i| finite(stats.slope_segments.slopes[i])), + slope_segment_distance: segment.and_then(|i| finite(stats.slope_segments.distances[i])), + surface: name(&stats.surface, &categories.surface), + highway: name(&stats.highway, &categories.highway), + sac_scale: name(&stats.sac_scale, &categories.sac_scale), + mtb_scale: name(&stats.mtb_scale, &categories.mtb_scale), + }) } // Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that diff --git a/gpx-rs/src/engine/derived/statistics_buffer.rs b/gpx-rs/src/engine/derived/statistics_buffer.rs index f436dacca..f3d11548f 100644 --- a/gpx-rs/src/engine/derived/statistics_buffer.rs +++ b/gpx-rs/src/engine/derived/statistics_buffer.rs @@ -1,4 +1,4 @@ -use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options}; +use crate::{GlobalStatistics, SlopeSegment, Statistics, TrackSegment, sum_options}; /// Value of the timestamps of the trackpoints that have none. pub const NO_TIME: i64 = i64::MIN; @@ -27,8 +27,8 @@ pub struct StatisticsBuffer { pub elevation_gain: Vec, pub elevation_loss: Vec, pub slope: Vec, - pub slope_segment_slope: Vec, - pub slope_segment_distance: Vec, + /// The smoothed elevation segments that the trackpoints belong to, see [`SlopeSegments`]. + pub slope_segments: SlopeSegments, pub lng: Vec, pub lat: Vec, pub ele: Vec, @@ -62,6 +62,18 @@ pub struct Intervals { } impl Intervals { + /// The value of the trackpoint at `index` (the one of the last interval past the trackpoints), + /// 0 (unknown) when there are no trackpoints. + pub fn value_at(&self, index: usize) -> u8 { + match self + .starts + .partition_point(|start| *start as usize <= index) + { + 0 => 0, + next => self.values[next - 1], + } + } + fn clear(&mut self) { self.starts.clear(); self.values.clear(); @@ -76,6 +88,50 @@ impl Intervals { } } +/// The smoothed elevation segments of the selection, over which the slope is averaged, as the +/// intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint +/// `starts[i]` and ends where the next one starts, or at the last trackpoint. It has a `slope` (%) +/// and a length (`distance`, km). A segment never spans two segments of the selection. All are +/// empty when there are no trackpoints. +#[derive(Debug, Default, PartialEq)] +pub struct SlopeSegments { + pub starts: Vec, + pub slopes: Vec, + pub distances: Vec, +} + +impl SlopeSegments { + /// The index of the segment of the trackpoint at `index` (the last one past the trackpoints), + /// `None` when there are no trackpoints. + pub fn at(&self, index: usize) -> Option { + let next = self + .starts + .partition_point(|start| *start as usize <= index); + next.checked_sub(1) + } + + fn clear(&mut self) { + self.starts.clear(); + self.slopes.clear(); + self.distances.clear(); + } + + /// Adds the trackpoint at `index`, the indices being pushed in order: it starts a segment if + /// it is the first of a segment of the selection (`first`), or if its segment is not the one + /// of the previous trackpoint. + fn push(&mut self, index: usize, segment: &SlopeSegment, first: bool) { + // compared as bits, so that a NaN slope (no distance) is the same as the previous one + let same = |values: &[f64], value: f64| { + values.last().map(|last| last.to_bits()) == Some(value.to_bits()) + }; + if first || !same(&self.slopes, segment.slope) || !same(&self.distances, segment.distance) { + self.starts.push(index as u32); + self.slopes.push(segment.slope); + self.distances.push(segment.distance); + } + } +} + /// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it /// fits. fn unknown_or_next(code: Option) -> u8 { @@ -117,8 +173,7 @@ impl StatisticsBuffer { self.elevation_gain.clear(); self.elevation_loss.clear(); self.slope.clear(); - self.slope_segment_slope.clear(); - self.slope_segment_distance.clear(); + self.slope_segments.clear(); self.lng.clear(); self.lat.clear(); self.ele.clear(); @@ -133,6 +188,7 @@ impl StatisticsBuffer { self.mtb_scale.clear(); for (segment, stats) in selected { + let segment_start = self.total_distance.len(); for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) { let index = self.total_distance.len(); let cumul_stats = &self.global; @@ -162,10 +218,8 @@ impl StatisticsBuffer { self.elevation_loss .push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss); self.slope.push(trkpt_stats.slope); - self.slope_segment_slope - .push(trkpt_stats.slope_segment.slope); - self.slope_segment_distance - .push(trkpt_stats.slope_segment.distance); + self.slope_segments + .push(index, &trkpt_stats.slope_segment, index == segment_start); self.lng.push(trkpt.coordinates.lng); self.lat.push(trkpt.coordinates.lat); self.ele.push(trkpt.ele); @@ -229,25 +283,12 @@ 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(), buffer.elevation_gain.len(), buffer.elevation_loss.len(), buffer.slope.len(), - buffer.slope_segment_slope.len(), - buffer.slope_segment_distance.len(), buffer.lng.len(), buffer.lat.len(), buffer.ele.len(), @@ -276,7 +317,19 @@ mod tests { for (i, local) in s.local.iter().enumerate() { assert_eq!(buffer.total_distance[i], local.total_distance); assert_eq!(buffer.slope[i], local.slope); + let segment = buffer.slope_segments.at(i).unwrap(); + assert_eq!( + buffer.slope_segments.slopes[segment].to_bits(), + local.slope_segment.slope.to_bits() + ); + assert_eq!( + buffer.slope_segments.distances[segment], + local.slope_segment.distance + ); } + // a segment per smoothed elevation segment, not per trackpoint + assert!(buffer.slope_segments.starts.len() < n); + assert_eq!(buffer.slope_segments.starts[0], 0); // the trackpoints for (i, trkpt) in segment.iter().enumerate() { assert_eq!(buffer.lng[i], trkpt.coordinates.lng); @@ -454,7 +507,7 @@ mod tests { 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(); + let values: Vec = (0..6).map(|i| buffer.surface.value_at(i)).collect(); assert_eq!(values, [1, 1, 0, 2, 2, 2]); } @@ -494,10 +547,10 @@ mod tests { buffer.update(&[(&segment, &s)]); 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), + buffer.surface.value_at(i), + buffer.highway.value_at(i), + buffer.sac_scale.value_at(i), + buffer.mtb_scale.value_at(i), ) }; assert_eq!(at(0), (8, 255, 1, 4)); @@ -522,7 +575,7 @@ mod tests { 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); + assert_eq!(buffer.surface.value_at(0), 0); } #[test] diff --git a/tauri/src/bytes.rs b/tauri/src/bytes.rs index 0bd9d45e6..b04edfefd 100644 --- a/tauri/src/bytes.rs +++ b/tauri/src/bytes.rs @@ -34,7 +34,3 @@ pub fn optional(array: Option>) -> Bytes { pub fn f64_bytes(values: &[f64]) -> Vec { values.iter().flat_map(|v| v.to_le_bytes()).collect() } - -pub fn i64_bytes(values: &[i64]) -> Vec { - values.iter().flat_map(|v| v.to_le_bytes()).collect() -} diff --git a/tauri/src/commands/statistics.rs b/tauri/src/commands/statistics.rs index a20f5991e..a399e4148 100644 --- a/tauri/src/commands/statistics.rs +++ b/tauri/src/commands/statistics.rs @@ -17,15 +17,7 @@ macro_rules! f64_getters { )*}; } -f64_getters!( - total_distance, - slope, - slope_segment_slope, - slope_segment_distance, - lng, - lat, - ele -); +f64_getters!(total_distance, slope, slope_segment_slopes, lng, lat, ele); macro_rules! optional_f64_getters { ($($name:ident),*) => {$( @@ -38,10 +30,16 @@ macro_rules! optional_f64_getters { optional_f64_getters!(speed, hr, cad, atemp, power); -/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), as `i64`. +/// The index of the first trackpoint of each smoothed elevation segment of the selection. #[tauri::command(async)] -pub fn timestamps(state: State) -> Bytes { - optional(state.read(|e| api::timestamps(e).map(i64_bytes))) +pub fn slope_segment_starts(state: State) -> Bytes { + u32s(&state.read(|e| api::slope_segment_starts(e).to_vec())) +} + +/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`. +#[tauri::command(async)] +pub fn selection_point(state: State, index: usize) -> Option { + state.read(move |e| api::selection_point(e, index)) } #[tauri::command(async)] diff --git a/tauri/src/lib.rs b/tauri/src/lib.rs index b40606759..3bba54fa1 100644 --- a/tauri/src/lib.rs +++ b/tauri/src/lib.rs @@ -146,8 +146,8 @@ fn register_commands(builder: tauri::Builder) -> tauri::Bu // statistics statistics::total_distance, statistics::slope, - statistics::slope_segment_slope, - statistics::slope_segment_distance, + statistics::slope_segment_starts, + statistics::slope_segment_slopes, statistics::lng, statistics::lat, statistics::ele, @@ -156,7 +156,7 @@ fn register_commands(builder: tauri::Builder) -> tauri::Bu statistics::cad, statistics::atemp, statistics::power, - statistics::timestamps, + statistics::selection_point, statistics::selection_statistics, statistics::slice_statistics, statistics::surface_starts, diff --git a/tauri/src/tests.rs b/tauri/src/tests.rs index 9f26488f8..7fdfed47b 100644 --- a/tauri/src/tests.rs +++ b/tauri/src/tests.rs @@ -142,6 +142,18 @@ fn reads_the_statistics_of_the_selection() { // optional arrays start with a byte telling whether they are there let hr = app.bytes("hr", json!({})); assert_eq!(hr[0], 0); + // the slope segments are intervals of trackpoints + let starts = app.bytes("slope_segment_starts", json!({})); + let slopes = f64s(&app.bytes("slope_segment_slopes", json!({}))); + assert_eq!(starts.len() / 4, slopes.len()); + assert!(!slopes.is_empty() && slopes.len() < distance.len()); + let point = app.call("selection_point", json!({ "index": 1 })); + assert!(point["slopeSegmentDistance"].is_number()); + assert!(point.get("time").is_none()); + assert_eq!( + app.call("selection_point", json!({ "index": distance.len() })), + Value::Null + ); assert!( app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number() ); diff --git a/wasm/src/queries/statistics.rs b/wasm/src/queries/statistics.rs index 7595b1aa3..d8471c3cf 100644 --- a/wasm/src/queries/statistics.rs +++ b/wasm/src/queries/statistics.rs @@ -26,8 +26,6 @@ stats_getter!(total_distance, Float64Array); stats_getter!(elevation_gain, Float64Array); stats_getter!(elevation_loss, Float64Array); stats_getter!(slope, Float64Array); -stats_getter!(slope_segment_slope, Float64Array); -stats_getter!(slope_segment_distance, Float64Array); stats_getter!(lng, Float64Array); stats_getter!(lat, Float64Array); stats_getter!(ele, Float64Array); @@ -49,14 +47,6 @@ optional_stats_getter!(moving_distance, Float64Array); optional_stats_getter!(total_time, BigInt64Array); optional_stats_getter!(moving_time, BigInt64Array); optional_stats_getter!(speed, Float64Array); -// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing). -optional_stats_getter!(timestamps, BigInt64Array); - -/// Value of the timestamps of the trackpoints that have none. -#[wasm_bindgen] -pub fn no_time() -> i64 { - gpx_engine::NO_TIME -} optional_stats_getter!(hr, Float64Array); optional_stats_getter!(cad, Float64Array); @@ -96,6 +86,19 @@ categories_getters!(highway_starts, highway_values, highways); categories_getters!(sac_scale_starts, sac_scale_values, sac_scales); categories_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales); +// The smoothed elevation segments of the selection, over which the slope is averaged, as +// intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each +// segment, and `slope_segment_slopes` its slope (%). Their length is in `selection_point`. +stats_getter!(slope_segment_starts, Uint32Array); +stats_getter!(slope_segment_slopes, Float64Array); + +/// What the buffers do not hold for each trackpoint of the selection, for the one at `index` +/// (numbered as in the buffers), `undefined` if there is none. +#[wasm_bindgen] +pub fn selection_point(index: usize) -> SelectionPoint { + optional_js(with_engine(|e| api::selection_point(e, index)).flatten()).unchecked_into() +} + /// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance /// (m) of `reduce` that keeps it: infinite for the ends of the segments. #[wasm_bindgen] diff --git a/wasm/src/tests.rs b/wasm/src/tests.rs index ab084d18f..a4f78a076 100644 --- a/wasm/src/tests.rs +++ b/wasm/src/tests.rs @@ -4,7 +4,7 @@ #![allow(clippy::needless_borrows_for_generic_args)] -use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect}; +use js_sys::{Array, Float64Array, Object, Reflect}; use wasm_bindgen::prelude::*; use wasm_bindgen_test::*; @@ -144,7 +144,10 @@ fn test_loaded_file_has_statistics_and_coordinates() { assert_eq!(crate::queries::statistics::lng().length(), n); assert_eq!(crate::queries::statistics::ele().length(), n); // no timestamps in this file - assert!(crate::queries::statistics::timestamps().is_none()); + let point = crate::queries::statistics::selection_point(0); + assert!(get(&point, "slopeSegmentDistance").as_f64().is_some()); + assert!(get(&point, "time").is_undefined()); + assert!(crate::queries::statistics::selection_point(n as usize).is_undefined()); let structure = crate::queries::files::file_structure(&id); let segment = get( @@ -165,8 +168,8 @@ fn test_loaded_file_has_statistics_and_coordinates() { #[wasm_bindgen_test] fn test_timestamps_and_categories() { started_with(&[(WITH_TIME, "time")]); - let times: BigInt64Array = crate::queries::statistics::timestamps().unwrap(); - assert!(times.length() > 0); + let point = crate::queries::statistics::selection_point(0); + assert!(get(&point, "time").as_f64().unwrap() > 0.0); assert!(crate::queries::statistics::total_time().is_some()); started_with(&[(WITH_SURFACE, "surface")]); @@ -179,6 +182,14 @@ fn test_timestamps_and_categories() { crate::queries::statistics::surface_starts().length(), values.len() as u32 ); + // the name of the surface of a trackpoint that has one + let starts = crate::queries::statistics::surface_starts().to_vec(); + let known = values.iter().position(|v| *v > 0).unwrap(); + let point = crate::queries::statistics::selection_point(starts[known] as usize); + assert_eq!( + get(&point, "surface").as_string(), + Some(surfaces[values[known] as usize - 1].clone()) + ); } #[wasm_bindgen_test] diff --git a/wasm/src/ts.rs b/wasm/src/ts.rs index bc70eb5b4..841c884cf 100644 --- a/wasm/src/ts.rs +++ b/wasm/src/ts.rs @@ -64,6 +64,21 @@ export interface TrackpointDetails { /** ms since epoch */ time?: number; } +/** + * A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint. + * Absent fields are unknown. + */ +export interface SelectionPoint { + /** ms since epoch */ + time?: number; + /** Slope (%) and length (km) of the smoothed elevation segment of the trackpoint. */ + slopeSegmentSlope?: number; + slopeSegmentDistance?: number; + surface?: string; + highway?: string; + sacScale?: string; + mtbScale?: string; +} /** What was copied or cut, to be pasted: the kind of the elements and their ids. */ export interface Clipboard { type: 'files' | 'tracks' | 'segments' | 'waypoints'; @@ -161,6 +176,8 @@ extern "C" { pub type WaypointDetails; #[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")] pub type TrackpointDetails; + #[wasm_bindgen(typescript_type = "SelectionPoint | undefined")] + pub type SelectionPoint; #[wasm_bindgen(typescript_type = "MoveTarget")] pub type MoveTarget; diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index 6979e4233..4cea6859c 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -24,14 +24,15 @@ import { get, type Readable, type Writable } from 'svelte/store'; import type { Coordinates } from '$lib/geo/coordinates'; import { engine, - NO_TIME, + EMPTY_SELECTION_STATISTICS, + type SelectionPoint, type SelectionStatistics, type StatisticsMetric, type StatisticsRequest, } from '$lib/engine'; import type { SlicedStatistics } from '$lib/state/selection-statistics'; import { mode } from 'mode-watcher'; -import { categoryValues } from '$lib/engine/trackpoint-categories'; +import { categoryValue, intervalAt } from '$lib/engine/trackpoint-categories'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors'; const { distanceUnits, velocityUnits, temperatureUnits } = settings; @@ -44,20 +45,13 @@ function optional(value: number, convert: (value: number) => number = (v) => v) return Number.isNaN(value) ? undefined : convert(value); } +/** + * A point of the chart. The rest of the data of the trackpoint is only read when it is hovered + * (see `SelectionStatistics.point`), and the categories of the fill from their intervals. + */ interface ElevationProfilePoint { x: number; y: number; - time?: Date; - slope: { - at: number; - segment: number; - length: number; - }; - surface?: string; - highway?: string; - sacScale?: string; - mtbScale?: string; - coordinates: Coordinates; index: number; } @@ -69,6 +63,14 @@ export class ElevationProfile { private _panning = false; private _statistics: Readable; + /** The statistics that the chart shows, which the fills read for each of its segments. */ + private _data: SelectionStatistics = EMPTY_SELECTION_STATISTICS; + /** + * The hovered trackpoint, and its data once it is read from the engine (`undefined` if the + * statistics changed in the meantime). + */ + private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null; + private _loadingHovered = false; private _slicedStatistics: Writable; private _hoveredPoint: Writable; private _additionalDatasets: Readable; @@ -200,7 +202,7 @@ export class ElevationProfile { if (this._dragging) { this._hoveredPoint.set(null); } else { - this._hoveredPoint.set(point.coordinates); + this.hover(point.index); } return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`; } else if (context.datasetIndex === 1) { @@ -219,28 +221,40 @@ export class ElevationProfile { let context = contexts.filter((context) => context.datasetIndex === 0); if (context.length === 0) return; let point = context[0].raw as ElevationProfilePoint; - let slope = { - at: point.slope.at.toFixed(1), - segment: point.slope.segment.toFixed(1), - length: getDistanceWithUnits(point.slope.length), - }; - let surface = point.surface ?? 'unknown'; - let highway = point.highway ?? 'unknown'; - let sacScale = point.sacScale; - let mtbScale = point.mtbScale; + // the rest of the data is there once it is read (see `hover`) + const hovered = this._hovered; + const details = + hovered?.index === point.index ? hovered.point : undefined; + const elevationFill = get(this._elevationFill); + let slope = `${this._data.slope[point.index]?.toFixed(1)} %`; + if ( + elevationFill === 'slope' && + details?.slopeSegmentSlope !== undefined && + details.slopeSegmentDistance !== undefined + ) { + slope += ` (${getDistanceWithUnits(details.slopeSegmentDistance)} @${details.slopeSegmentSlope.toFixed(1)} %)`; + } let labels = [ ` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`, - ` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`, + ` ${i18n._('quantities.slope')}: ${slope}`, ]; - if (get(this._elevationFill) === 'surface') { + if (!details) { + return labels; + } + let surface = details.surface ?? 'unknown'; + let highway = details.highway ?? 'unknown'; + let sacScale = details.sacScale; + let mtbScale = details.mtbScale; + + if (elevationFill === 'surface') { labels.push( ` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}` ); } - if (get(this._elevationFill) === 'highway') { + if (elevationFill === 'highway') { labels.push( ` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${ sacScale @@ -255,9 +269,9 @@ export class ElevationProfile { } } - if (point.time) { + if (details.time !== undefined) { labels.push( - ` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}` + ` ${i18n._('quantities.time')}: ${i18n.df.format(new Date(details.time))}` ); } @@ -339,6 +353,7 @@ export class ElevationProfile { events: ['mouseout'], afterEvent: (chart: Chart, args: { event: ChartEvent }) => { if (args.event.type === 'mouseout') { + this._hovered = null; this._hoveredPoint.set(null); } }, @@ -430,6 +445,9 @@ export class ElevationProfile { return; } const data = get(this._statistics); + this._data = data; + // the indices of the trackpoints are the ones of other statistics + this._hovered = null; const units = { distance: get(distanceUnits), velocity: get(velocityUnits), @@ -438,28 +456,11 @@ 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] ?? NO_TIME; datasets[0].push({ x, - y: getConvertedElevation(ele, units.distance), - time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)), - slope: { - at: data.slope[index], - segment: data.slopeSegmentSlope?.[index] ?? NaN, - length: data.slopeSegmentDistance?.[index] ?? NaN, - }, - surface: surfaces[index], - highway: highways[index], - sacScale: sacScales[index], - mtbScale: mtbScales[index], - coordinates: { lat: data.lat[index], lng: data.lng[index] }, + y: getConvertedElevation(data.ele[index], units.distance), index, }); if (data.speed && (global.totalTime ?? 0) > 0) { @@ -643,21 +644,71 @@ export class ElevationProfile { } } - slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { - const point = context.p0.raw as ElevationProfilePoint; - return getSlopeColor(point.slope.segment); + /** + * Shows the hovered trackpoint on the map, and reads the rest of its data from the engine for + * the tooltip. One trackpoint is read at a time, the last one hovered. + */ + hover(index: number) { + this._hoveredPoint.set({ lat: this._data.lat[index], lng: this._data.lng[index] }); + if (this._hovered?.index === index) { + return; + } + this._hovered = { index, loaded: false }; + this.loadHovered(); } - surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { - const point = context.p0.raw as ElevationProfilePoint; - return getSurfaceColor(point.surface ?? ''); + private async loadHovered() { + if (this._loadingHovered) { + return; // the one being read is followed by the last one hovered + } + this._loadingHovered = true; + try { + while (this._hovered && !this._hovered.loaded) { + const hovered = this._hovered; + const point = await this._data.point(hovered.index); + if (this._hovered !== hovered) { + continue; // another trackpoint is hovered, or none + } + hovered.loaded = true; + hovered.point = point; + const chart = this._chart; + if (chart?.tooltip && chart.tooltip.getActiveElements().length > 0) { + // the tooltip asks its callbacks again (the method is not in the types) + (chart.tooltip as unknown as { update(changed: boolean): void }).update(true); + chart.draw(); + } + } + } finally { + this._loadingHovered = false; + } } - highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { - const point = context.p0.raw as ElevationProfilePoint; - return getHighwayColor(point.highway ?? '', point.sacScale, point.mtbScale); + /** The trackpoint that a segment of the chart starts at. */ + private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { + return (context.p0.raw as ElevationProfilePoint).index; } + slopeFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => { + const segments = this._data.slopeSegments; + const index = ElevationProfile.segmentStart(context); + const segment = segments ? intervalAt(segments.starts, index) : -1; + return getSlopeColor(segment >= 0 ? segments!.slopes[segment] : NaN); + }; + + surfaceFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => { + const index = ElevationProfile.segmentStart(context); + return getSurfaceColor(categoryValue(this._data.surface, index) ?? ''); + }; + + highwayFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => { + const index = ElevationProfile.segmentStart(context); + return getHighwayColor( + categoryValue(this._data.highway, index) ?? '', + categoryValue(this._data.sacScale, index), + categoryValue(this._data.mtbScale, index) + ); + }; + destroy() { this._request.release(); if (this._chart) { diff --git a/website/src/lib/components/ui/slider/slider.svelte b/website/src/lib/components/ui/slider/slider.svelte index 3aa502d98..f52e2efb1 100644 --- a/website/src/lib/components/ui/slider/slider.svelte +++ b/website/src/lib/components/ui/slider/slider.svelte @@ -26,7 +26,11 @@ get along, so we shut typescript up by casting `value` to `never`. )} {...restProps} > - {#snippet children({ thumbItems })} + + {#snippet children()} - {#each thumbItems as thumb (thumb)} + {#each Array.isArray(value) ? value : [value] as _, index (index)} {/each} diff --git a/website/src/lib/data/example/example-data.ts b/website/src/lib/data/example/example-data.ts index c3c3f635c..af5b6a90a 100644 --- a/website/src/lib/data/example/example-data.ts +++ b/website/src/lib/data/example/example-data.ts @@ -174,61 +174,22 @@ export const exampleData = { 1.5840695473562374, 0.1214109154598013, -4.214883885805801, -4.182735032776085, -5.523076373263165, -10.644434966065772, ], - slopeSegmentSlope: [ - 7.077037465314024, 7.077037465314024, 7.077037465314024, 7.077037465314024, - 0.5753520337401837, 0.5753520337401837, 0.5753520337401837, 0.5753520337401837, - 0.5753520337401837, 5.143330470981779, -5.289467529413177, 3.1409086334150653, - 3.1409086334150653, -0.553863318953831, -0.553863318953831, -0.553863318953831, - -0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831, - -0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831, - -0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831, - -0.553863318953831, -0.553863318953831, 6.771412441716311, 6.771412441716311, - 6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311, - 6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311, - 6.771412441716311, 6.771412441716311, 1.246024238407464, 1.246024238407464, - 5.604351892140715, 5.604351892140715, 5.604351892140715, 2.2941711127147597, - 2.2941711127147597, 2.2941711127147597, 2.2941711127147597, 2.2941711127147597, - 2.2941711127147597, 10.861290836693483, 10.861290836693483, 10.861290836693483, - 10.861290836693483, -4.414487617055606, -4.414487617055606, 6.94339810974512, - -3.539036752039, -3.539036752039, -3.539036752039, -13.990509017835178, -13.990509017835178, - -13.990509017835178, -13.990509017835178, -2.033792785924166, -2.033792785924166, - -2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166, - -2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166, - -2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166, - -2.033792785924166, -2.033792785924166, 0.45298474033593794, 0.45298474033593794, - 0.45298474033593794, 0.45298474033593794, 0.45298474033593794, 0.45298474033593794, - 0.45298474033593794, 0.45298474033593794, -7.692745586787598, -7.692745586787598, - -0.4142590757867528, -0.4142590757867528, -0.4142590757867528, -0.4142590757867528, - -0.4142590757867528, -5.154482373206842, -5.154482373206842, -5.154482373206842, - -5.154482373206842, + slopeSegmentStarts: [ + 0, 4, 9, 10, 11, 13, 30, 42, 44, 47, 53, 57, 59, 60, 63, 67, 83, 91, 93, 98, ], - slopeSegmentDistance: [ - 1.0682436029275066, 1.0682436029275066, 1.0682436029275066, 1.0682436029275066, - 1.8771116406407027, 1.8771116406407027, 1.8771116406407027, 1.8771116406407027, - 1.8771116406407027, 0.6571617396684242, 0.38189099162956897, 1.2607816597626882, - 1.2607816597626882, 4.622078972183025, 4.622078972183025, 4.622078972183025, - 4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025, - 4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025, - 4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025, - 4.622078972183025, 4.622078972183025, 4.040515953724009, 4.040515953724009, - 4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009, - 4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009, - 4.040515953724009, 4.040515953724009, 0.802552606262374, 0.802552606262374, - 3.3545359680865605, 3.3545359680865605, 3.3545359680865605, 2.9030092668715675, - 2.9030092668715675, 2.9030092668715675, 2.9030092668715675, 2.9030092668715675, - 2.9030092668715675, 0.8857142437895185, 0.8857142437895185, 0.8857142437895185, - 0.8857142437895185, 0.8200272181110968, 0.8200272181110968, 0.5818476682663238, - 1.3280449820963618, 1.3280449820963618, 1.3280449820963618, 1.255136605652762, - 1.255136605652762, 1.255136605652762, 1.255136605652762, 4.7694141050816405, - 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, - 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, - 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, - 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 1.7660639062735584, - 1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 1.7660639062735584, - 1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 0.610965220021356, - 0.610965220021356, 1.738042790330141, 1.738042790330141, 1.738042790330141, - 1.738042790330141, 1.738042790330141, 1.2493980061849328, 1.2493980061849328, - 1.2493980061849328, 1.2493980061849328, + slopeSegmentSlopes: [ + 7.077037465314024, 0.5753520337401837, 5.143330470981779, -5.289467529413177, + 3.1409086334150653, -0.553863318953831, 6.771412441716311, 1.246024238407464, + 5.604351892140715, 2.2941711127147597, 10.861290836693483, -4.414487617055606, + 6.94339810974512, -3.539036752039, -13.990509017835178, -2.033792785924166, + 0.45298474033593794, -7.692745586787598, -0.4142590757867528, -5.154482373206842, + ], + slopeSegmentDistances: [ + 1.0682436029275066, 1.8771116406407027, 0.6571617396684242, 0.38189099162956897, + 1.2607816597626882, 4.622078972183025, 4.040515953724009, 0.802552606262374, + 3.3545359680865605, 2.9030092668715675, 0.8857142437895185, 0.8200272181110968, + 0.5818476682663238, 1.3280449820963618, 1.255136605652762, 4.7694141050816405, + 1.7660639062735584, 0.610965220021356, 1.738042790330141, 1.2493980061849328, ], lng: [ -66.658624, -66.658127, -66.658472, -66.660451, -66.650629, -66.646183, -66.646388, diff --git a/website/src/lib/data/example/index.ts b/website/src/lib/data/example/index.ts index 9e68dd5f9..abea7f2d6 100644 --- a/website/src/lib/data/example/index.ts +++ b/website/src/lib/data/example/index.ts @@ -4,7 +4,8 @@ * and the documentation. They are the ones that the engine computes for `example.gpx`, kept as * they are (see `example-data.ts`) so that the example does not need the engine. */ -import type { GlobalStatistics, SelectionStatistics } from '$lib/engine'; +import type { GlobalStatistics, SelectionPoint, SelectionStatistics } from '$lib/engine'; +import { intervalAt } from '$lib/engine/trackpoint-categories'; import { exampleData as data } from '$lib/data/example/example-data'; const length = data.lng.length; @@ -46,6 +47,21 @@ async function slice(start: number, end: number): Promise { + if (!(index >= 0 && index < length)) { + return undefined; + } + const segment = intervalAt(slopeSegmentStarts, index); + return { + time: data.timestamps[index], + slopeSegmentSlope: data.slopeSegmentSlopes[segment], + slopeSegmentDistance: data.slopeSegmentDistances[segment], + }; +} + export const exampleStatistics: SelectionStatistics = { global: data.global, length, @@ -54,10 +70,12 @@ export const exampleStatistics: SelectionStatistics = { lng: Float64Array.from(data.lng), lat: Float64Array.from(data.lat), ele: Float64Array.from(data.ele), - timestamps: BigInt64Array.from(data.timestamps, (time) => BigInt(time)), speed: Float64Array.from(data.speed), atemp: Float64Array.from(data.atemp), - slopeSegmentSlope: Float64Array.from(data.slopeSegmentSlope), - slopeSegmentDistance: Float64Array.from(data.slopeSegmentDistance), + slopeSegments: { + starts: slopeSegmentStarts, + slopes: Float64Array.from(data.slopeSegmentSlopes), + }, slice, + point, }; diff --git a/website/src/lib/engine/backend/tauri.ts b/website/src/lib/engine/backend/tauri.ts index 4655dbefd..39b983b6e 100644 --- a/website/src/lib/engine/backend/tauri.ts +++ b/website/src/lib/engine/backend/tauri.ts @@ -182,14 +182,14 @@ export async function createTauriBackend(): Promise { lng: () => f64('lng'), lat: () => f64('lat'), ele: () => f64('ele'), - timestamps: () => optionalArray('timestamps', BigInt64Array), speed: () => f64Optional('speed'), hr: () => f64Optional('hr'), cad: () => f64Optional('cad'), atemp: () => f64Optional('atemp'), power: () => f64Optional('power'), - slope_segment_slope: () => f64('slope_segment_slope'), - slope_segment_distance: () => f64('slope_segment_distance'), + slope_segment_starts: () => u32('slope_segment_starts'), + slope_segment_slopes: () => f64('slope_segment_slopes'), + selection_point: async (index) => (await invoke('selection_point', { index })) ?? undefined, surface_starts: () => u32('surface_starts'), surface_values: () => u8('surface_values'), surfaces: () => invoke('surfaces'), diff --git a/website/src/lib/engine/backend/types.ts b/website/src/lib/engine/backend/types.ts index 91822e1c1..7f493f59c 100644 --- a/website/src/lib/engine/backend/types.ts +++ b/website/src/lib/engine/backend/types.ts @@ -6,6 +6,7 @@ import type { Outcome, RouteAttributes, Selection, + SelectionPoint, StorageOpened, TrackpointDetails, WaypointDetails, @@ -277,14 +278,14 @@ export interface Backend { lng(): Promise; lat(): Promise; ele(): Promise; - timestamps(): Promise; speed(): Promise; hr(): Promise; cad(): Promise; atemp(): Promise; power(): Promise; - slope_segment_slope(): Promise; - slope_segment_distance(): Promise; + slope_segment_starts(): Promise; + slope_segment_slopes(): Promise; + selection_point(index: number): Promise; surface_starts(): Promise; surface_values(): Promise; surfaces(): Promise; diff --git a/website/src/lib/engine/backend/wasm.ts b/website/src/lib/engine/backend/wasm.ts index 9da58b5c7..efaed10dc 100644 --- a/website/src/lib/engine/backend/wasm.ts +++ b/website/src/lib/engine/backend/wasm.ts @@ -135,14 +135,14 @@ export async function createWasmBackend(): Promise { lng: lift(w.lng), lat: lift(w.lat), ele: lift(w.ele), - timestamps: lift(w.timestamps), speed: lift(w.speed), hr: lift(w.hr), cad: lift(w.cad), atemp: lift(w.atemp), power: lift(w.power), - slope_segment_slope: lift(w.slope_segment_slope), - slope_segment_distance: lift(w.slope_segment_distance), + slope_segment_starts: lift(w.slope_segment_starts), + slope_segment_slopes: lift(w.slope_segment_slopes), + selection_point: lift(w.selection_point), surface_starts: lift(w.surface_starts), surface_values: lift(w.surface_values), surfaces: lift(w.surfaces), diff --git a/website/src/lib/engine/engine.test.ts b/website/src/lib/engine/engine.test.ts index ddd1a42c6..0c2ba3997 100644 --- a/website/src/lib/engine/engine.test.ts +++ b/website/src/lib/engine/engine.test.ts @@ -9,7 +9,6 @@ import { ALL_EXPORT_OPTIONS, engine, FileStateCollectionObserver, - NO_TIME, type ExportOptions, type FileState, } from '$lib/engine'; @@ -195,7 +194,7 @@ describe('statistics', () => { assert.equal(statistics.totalDistance[0], 0); assert.ok(statistics.global.totalDistance > 0); assert.equal(statistics.global.totalDistance, file.statistics.totalDistance); - assert.equal(statistics.timestamps, undefined); + assert.equal((await statistics.point(0))!.time, undefined); assert.ok(statistics.global.bounds!.west <= statistics.global.bounds!.east); await engine.select([]); @@ -206,9 +205,7 @@ describe('statistics', () => { it('has timestamps when the file has some', async () => { await load('with_time'); const statistics = get(engine.statistics); - assert.ok(statistics.timestamps); - assert.equal(statistics.timestamps!.length, statistics.length); - assert.ok(statistics.timestamps!.some((t) => t !== NO_TIME)); + assert.ok((await statistics.point(0))!.time! > 0); assert.ok(statistics.global.totalTime! > 0); assert.ok(statistics.global.movingSpeed === undefined || statistics.global.movingSpeed > 0); }); @@ -218,8 +215,13 @@ describe('statistics', () => { assert.equal(get(engine.statistics).hr, undefined); const request = engine.requestStatistics(); - await request.set(['hr', 'anchors']); + await request.set(['hr', 'anchors', 'slopeSegment']); const statistics = get(engine.statistics); + assert.equal(statistics.slopeSegments!.starts[0], 0); + assert.equal( + statistics.slopeSegments!.starts.length, + statistics.slopeSegments!.slopes.length + ); assert.equal(statistics.hr!.length, statistics.length); assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr))); assert.equal(statistics.cad, undefined); @@ -248,6 +250,18 @@ describe('statistics', () => { await engine.select([]); assert.equal(await statistics.slice(2, 5), undefined); }); + + it('gives the data of a trackpoint of the selection', async () => { + await load('simple'); + const statistics = get(engine.statistics); + const point = (await statistics.point(3))!; + assert.ok(point.slopeSegmentDistance! > 0); + assert.equal(await statistics.point(statistics.length), undefined); + + // only for the selection it was read for + await engine.select([]); + assert.equal(await statistics.point(3), undefined); + }); }); describe('file state', () => { diff --git a/website/src/lib/engine/statistics.ts b/website/src/lib/engine/statistics.ts index 222936afb..46358d622 100644 --- a/website/src/lib/engine/statistics.ts +++ b/website/src/lib/engine/statistics.ts @@ -46,15 +46,16 @@ export class StatisticsReader { private async read(backend: Backend): Promise { const version = ++this._version; this._loaded.clear(); - const [global, totalDistance, slope, lng, lat, ele, timestamps] = await Promise.all([ + const [global, totalDistance, slope, lng, lat, ele] = await Promise.all([ backend.selection_statistics(), backend.total_distance(), backend.slope(), backend.lng(), backend.lat(), backend.ele(), - backend.timestamps(), ]); + const current = (read: () => Promise) => + this.enqueue(async () => (version === this._version ? read() : undefined)); const statistics: SelectionStatistics = { global: global ?? EMPTY_STATISTICS, length: totalDistance.length, @@ -63,11 +64,8 @@ export class StatisticsReader { lng, lat, ele, - timestamps, - slice: (start, end) => - this.enqueue(async () => - version === this._version ? backend.slice_statistics(start, end) : undefined - ), + slice: (start, end) => current(() => backend.slice_statistics(start, end)), + point: (index) => current(() => backend.selection_point(index)), }; await this.readMetrics(backend, statistics, this.requestedMetrics()); return statistics; @@ -114,13 +112,14 @@ export class StatisticsReader { case 'power': statistics.power = await backend.power(); break; - case 'slopeSegment': - [statistics.slopeSegmentSlope, statistics.slopeSegmentDistance] = - await Promise.all([ - backend.slope_segment_slope(), - backend.slope_segment_distance(), - ]); + case 'slopeSegment': { + const [starts, slopes] = await Promise.all([ + backend.slope_segment_starts(), + backend.slope_segment_slopes(), + ]); + statistics.slopeSegments = { starts, slopes }; break; + } case 'surface': statistics.surface = await categories( backend.surface_starts(), diff --git a/website/src/lib/engine/trackpoint-categories.test.ts b/website/src/lib/engine/trackpoint-categories.test.ts index abcef5251..c29e2a283 100644 --- a/website/src/lib/engine/trackpoint-categories.test.ts +++ b/website/src/lib/engine/trackpoint-categories.test.ts @@ -2,7 +2,9 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { categoryIntervals, + categoryValue, categoryValues, + intervalAt, type CategoryIntervals, } from '$lib/engine/trackpoint-categories'; @@ -68,3 +70,37 @@ describe('categoryIntervals', () => { } }); }); + +describe('intervalAt', () => { + const starts = new Uint32Array([0, 2, 3, 6]); + + it('is the interval that the trackpoint is in', () => { + const intervals = [0, 1, 2, 3, 4, 5, 6, 9].map((index) => intervalAt(starts, index)); + assert.deepEqual(intervals, [0, 0, 1, 2, 2, 2, 3, 3]); + }); + + it('is -1 when there is no interval', () => { + assert.equal(intervalAt(new Uint32Array(), 0), -1); + }); +}); + +describe('categoryValue', () => { + const intervals: CategoryIntervals = { + starts: new Uint32Array([0, 2, 3, 6]), + values: new Uint8Array([1, 0, 2, 0]), + names: ['asphalt', 'gravel'], + }; + + it('is the value of each trackpoint, as categoryValues', () => { + const values = categoryValues(intervals, 7); + values.forEach((value, index) => assert.equal(categoryValue(intervals, index), value)); + }); + + it('is unknown without intervals', () => { + assert.equal(categoryValue(undefined, 3), undefined); + assert.equal( + categoryValue({ starts: new Uint32Array(), values: new Uint8Array(), names: [] }, 0), + undefined + ); + }); +}); diff --git a/website/src/lib/engine/trackpoint-categories.ts b/website/src/lib/engine/trackpoint-categories.ts index 06c9c1d12..894328204 100644 --- a/website/src/lib/engine/trackpoint-categories.ts +++ b/website/src/lib/engine/trackpoint-categories.ts @@ -36,6 +36,34 @@ export function categoryValues( return result; } +/** The index of the interval of `starts` (sorted) that the trackpoint `index` is in, -1 if none. */ +export function intervalAt(starts: Uint32Array, index: number): number { + let low = 0; + let high = starts.length; + // the first interval that starts after the trackpoint + while (low < high) { + const middle = (low + high) >>> 1; + if (starts[middle] <= index) { + low = middle + 1; + } else { + high = middle; + } + } + return low - 1; +} + +/** The value of the trackpoint `index` for a category, `undefined` when it is unknown. */ +export function categoryValue( + intervals: CategoryIntervals | undefined, + index: number +): string | undefined { + if (!intervals) { + return undefined; + } + const value = intervals.values[intervalAt(intervals.starts, index)]; + return value ? intervals.names[value - 1] : undefined; +} + /** * The intervals of the trackpoints that share a value, for a value per trackpoint (`undefined` * when unknown): the inverse of `categoryValues`. The names are in the order of their first diff --git a/website/src/lib/engine/types.ts b/website/src/lib/engine/types.ts index 959d60c9c..c20ac451e 100644 --- a/website/src/lib/engine/types.ts +++ b/website/src/lib/engine/types.ts @@ -2,7 +2,7 @@ import type { Readable } from 'svelte/store'; import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; import type { Visibility } from '$lib/engine/file-visibility'; import type { CategoryIntervals } from '$lib/engine/trackpoint-categories'; -import type { FileStructure, GlobalStatistics } from 'gpx-wasm'; +import type { FileStructure, GlobalStatistics, SelectionPoint } from 'gpx-wasm'; export type { FileStatus, FolderEntry, UnsavedChoice, WorkspaceState } from './backend/types'; @@ -12,6 +12,7 @@ export type { TrackpointDetails, MoveTarget, GlobalStatistics, + SelectionPoint, FileStructure, FilesUpdate, Outcome, @@ -136,12 +137,6 @@ export const ALL_EXPORT_OPTIONS: ExportOptions = { asRoute: false, }; -/** - * 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); - /** * 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. @@ -152,7 +147,7 @@ export type StatisticsMetric = | 'cad' | 'atemp' | 'power' - /** `slopeSegmentSlope` and `slopeSegmentDistance`. */ + /** `slopeSegments`. */ | 'slopeSegment' | 'surface' /** `highway`, `sacScale` and `mtbScale`. */ @@ -185,17 +180,29 @@ export type SelectionAnchors = { revision: number; }; +/** + * The smoothed elevation segments of the selection, over which the slope is averaged, as the + * intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint + * `starts[i]` and lasts until the next start (or the last trackpoint), with a slope of + * `slopes[i]` (%). Their length is in `SelectionStatistics.point`. + */ +export type SlopeSegments = { + starts: Uint32Array; + slopes: Float64Array; +}; + /** * 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. + * trackpoints (all the selected segments, one after the other). The arrays are copies. What is + * only needed for one trackpoint at a time (its time, its OSM data...) is read with `point`. * * 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. + * (see `StatisticsMetric`). Otherwise they have one entry per trackpoint, missing measures are + * NaN. */ export type SelectionStatistics = { global: GlobalStatistics; @@ -207,7 +214,6 @@ export type SelectionStatistics = { lng: Float64Array; lat: Float64Array; ele: Float64Array; - timestamps?: BigInt64Array; // the following need to be requested speed?: Float64Array; @@ -215,9 +221,7 @@ export type SelectionStatistics = { cad?: Float64Array; atemp?: Float64Array; power?: Float64Array; - /** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */ - slopeSegmentSlope?: Float64Array; - slopeSegmentDistance?: Float64Array; + slopeSegments?: SlopeSegments; surface?: CategoryIntervals; highway?: CategoryIntervals; /** SAC hiking scale. */ @@ -232,6 +236,12 @@ export type SelectionStatistics = { * in the selection, or if the selection is not the current one anymore. */ slice(start: number, end: number): Promise; + /** + * All the data of the trackpoint at `index`, for example the one hovered on the elevation + * profile. `undefined` if it is not in the selection, or if the selection is not the current + * one anymore. + */ + point(index: number): Promise; }; export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = { @@ -243,6 +253,7 @@ export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = { lat: new Float64Array(), ele: new Float64Array(), slice: async () => undefined, + point: async () => undefined, }; export type StatisticsRequest = { diff --git a/website/src/lib/scripts/example-statistics.ts b/website/src/lib/scripts/example-statistics.ts index 3a8fe7a2f..9e3d811bb 100644 --- a/website/src/lib/scripts/example-statistics.ts +++ b/website/src/lib/scripts/example-statistics.ts @@ -48,8 +48,11 @@ export async function computeExampleData() { engine.load_files(new Uint8Array(data), Uint32Array.from([data.length]), ['example']); // numbers are enough for the times, which are milliseconds - const numbers = (values: Values) => + const numbers = (values: Values | Uint32Array) => values && Array.from(values as ArrayLike, (value) => Number(value)); + const length = engine.total_distance().length; + const points = Array.from({ length }, (_, index) => engine.selection_point(index)!); + const slopeSegmentStarts = Array.from(engine.slope_segment_starts()); return { global: engine.selection_statistics(), totalDistance: numbers(engine.total_distance()), @@ -59,12 +62,16 @@ export async function computeExampleData() { elevationGain: numbers(engine.elevation_gain()), elevationLoss: numbers(engine.elevation_loss()), slope: numbers(engine.slope()), - slopeSegmentSlope: numbers(engine.slope_segment_slope()), - slopeSegmentDistance: numbers(engine.slope_segment_distance()), + slopeSegmentStarts, + slopeSegmentSlopes: numbers(engine.slope_segment_slopes()), + // the length of each segment, which is the one of its trackpoints + slopeSegmentDistances: slopeSegmentStarts.map( + (start) => points[start].slopeSegmentDistance! + ), lng: numbers(engine.lng()), lat: numbers(engine.lat()), ele: numbers(engine.ele()), - timestamps: numbers(engine.timestamps()), + timestamps: points.map((point) => point.time!), speed: numbers(engine.speed()), atemp: numbers(engine.atemp()), };