diff --git a/gpx-rs/engine/data/with_highway.gpx b/gpx-rs/engine/data/with_highway.gpx new file mode 100644 index 000000000..c79581a9e --- /dev/null +++ b/gpx-rs/engine/data/with_highway.gpx @@ -0,0 +1,58 @@ + + + + with_highway + + + with_highway + + + 109.0 + + + + asphalt + residential + + + + + + 110.0 + + + + asphalt + residential + + + + + + 111.0 + + + 112.0 + + + + gravel + track + + + + + + 113.0 + + + + gravel + + + + + + + diff --git a/gpx-rs/engine/src/core/gpx/categories.rs b/gpx-rs/engine/src/core/gpx/categories.rs new file mode 100644 index 000000000..338ea4620 --- /dev/null +++ b/gpx-rs/engine/src/core/gpx/categories.rs @@ -0,0 +1,90 @@ +/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes, +/// so that the trackpoints only hold a `u8` instead of a string. +/// +/// The codes are the positions of the names in the order of first appearance. The table only +/// grows: a code never changes meaning, so it stays valid in every state of the history. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Categories(Vec); + +impl Categories { + /// Maximum number of distinct values. + pub const CAPACITY: usize = u8::MAX as usize; + + /// The code of `name`, which is added to the table if it is new. `None` if the table is full. + pub fn code(&mut self, name: &str) -> Option { + let index = match self.0.iter().position(|known| known == name) { + Some(index) => index, + None if self.0.len() < Self::CAPACITY => { + self.0.push(name.to_owned()); + self.0.len() - 1 + } + None => return None, + }; + u8::try_from(index).ok() + } + + /// The name of a code, `None` if it is unknown. + pub fn name(&self, code: u8) -> Option<&str> { + self.0.get(usize::from(code)).map(String::as_str) + } + + /// All the names, in the order of their codes. + pub fn names(&self) -> &[String] { + &self.0 + } +} + +/// The categories of the data of the trackpoints. There is one table per engine, shared by all +/// its files. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct TrackpointCategories { + pub surface: Categories, + pub highway: Categories, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_codes_follow_the_order_of_appearance() { + let mut categories = Categories::default(); + assert_eq!(categories.code("asphalt"), Some(0)); + assert_eq!(categories.code("gravel"), Some(1)); + // a known name keeps its code + assert_eq!(categories.code("asphalt"), Some(0)); + assert_eq!(categories.names(), ["asphalt", "gravel"]); + } + + #[test] + fn test_names_of_codes() { + let mut categories = Categories::default(); + let code = categories.code("asphalt").unwrap(); + assert_eq!(categories.name(code), Some("asphalt")); + assert_eq!(categories.name(code + 1), None); + assert_eq!(Categories::default().name(0), None); + } + + #[test] + fn test_a_full_table_gives_no_code_to_new_names() { + let mut categories = Categories::default(); + for i in 0..Categories::CAPACITY { + assert_eq!(categories.code(&i.to_string()), Some(i as u8)); + } + assert_eq!(categories.code("one too many"), None); + assert_eq!(categories.names().len(), Categories::CAPACITY); + // the known ones still have their code, the last one included + assert_eq!(categories.code("0"), Some(0)); + assert_eq!(categories.code("254"), Some(254)); + assert_eq!(categories.name(254), Some("254")); + } + + #[test] + fn test_the_categories_are_independent() { + let mut categories = TrackpointCategories::default(); + categories.surface.code("asphalt"); + assert_eq!(categories.highway.code("path"), Some(0)); + assert_eq!(categories.surface.names(), ["asphalt"]); + assert_eq!(categories.highway.names(), ["path"]); + } +} diff --git a/gpx-rs/engine/src/core/gpx/mod.rs b/gpx-rs/engine/src/core/gpx/mod.rs index e8b58a9f1..a702aa650 100644 --- a/gpx-rs/engine/src/core/gpx/mod.rs +++ b/gpx-rs/engine/src/core/gpx/mod.rs @@ -1,3 +1,4 @@ +mod categories; mod chunk; mod common; mod file; @@ -6,6 +7,7 @@ mod track; mod trackpoint; mod waypoint; +pub use categories::*; pub use chunk::*; pub use common::*; pub use file::*; diff --git a/gpx-rs/engine/src/core/gpx/trackpoint.rs b/gpx-rs/engine/src/core/gpx/trackpoint.rs index f724df429..e2e628f62 100644 --- a/gpx-rs/engine/src/core/gpx/trackpoint.rs +++ b/gpx-rs/engine/src/core/gpx/trackpoint.rs @@ -9,5 +9,8 @@ pub struct Trackpoint { pub hr: Option, pub cad: Option, pub power: Option, - // TODO OSM data? or store intervals at a higher level? + /// Code of the surface, see [`crate::TrackpointCategories`]. + pub surface: Option, + /// Code of the highway, see [`crate::TrackpointCategories`]. + pub highway: Option, } diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index 671b1f4d6..16bf93f50 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use crate::{ - Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointChunk, Waypoint, - WaypointChunk, + Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories, + TrackpointChunk, Waypoint, WaypointChunk, }; use chrono::DateTime; use quick_xml::Error; @@ -29,6 +29,8 @@ enum GPXElement { Heartrate, Cadence, Power, + Surface, + Highway, Symbol, Type, Color, @@ -48,7 +50,9 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat { coordinates } -pub fn parse(data: &[u8]) -> Result { +/// Parses a GPX file. The surface and the highway of the trackpoints are stored as codes of +/// `categories`, which learns the values it does not know yet. +pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { let mut reader = Reader::from_reader(data); let mut buf = vec![]; let mut gpx = File::default(); @@ -95,8 +99,10 @@ pub fn parse(data: &[u8]) -> Result { e if e.ends_with("atemp") => stack.push(GPXElement::Temperature), e if e.ends_with("hr") => stack.push(GPXElement::Heartrate), e if e.ends_with("cad") => stack.push(GPXElement::Cadence), - "power" => stack.push(GPXElement::Power), + e if e.ends_with("power") => stack.push(GPXElement::Power), e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power), + "surface" => stack.push(GPXElement::Surface), + "highway" => stack.push(GPXElement::Highway), "sym" => stack.push(GPXElement::Symbol), "type" => stack.push(GPXElement::Type), e if e.ends_with("color") => stack.push(GPXElement::Color), @@ -270,6 +276,18 @@ pub fn parse(data: &[u8]) -> Result { trkpt.power = e.parse().ok(); } } + Some(GPXElement::Surface) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.surface = categories.surface.code(&e); + } + } + Some(GPXElement::Highway) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.highway = categories.highway.code(&e); + } + } Some(GPXElement::Symbol) => { stack.pop(); if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() { @@ -322,7 +340,11 @@ mod tests { use super::*; fn parse_data(name: &str) -> File { - parse(&std::fs::read(format!("data/{name}.gpx")).unwrap()).unwrap() + parse( + &std::fs::read(format!("data/{name}.gpx")).unwrap(), + &mut Default::default(), + ) + .unwrap() } #[test] @@ -513,6 +535,88 @@ mod tests { assert!(trkpt.hr.is_some_and(|h| h == 150)); } + fn parse_data_with_categories(name: &str) -> (File, TrackpointCategories) { + let mut categories = TrackpointCategories::default(); + let file = parse( + &std::fs::read(format!("data/{name}.gpx")).unwrap(), + &mut categories, + ) + .unwrap(); + (file, categories) + } + + #[test] + fn test_parse_trackpoint_surface() { + let (gpx, categories) = parse_data_with_categories("with_surface"); + + let trkseg = &gpx.trk[0].trkseg[0]; + assert_eq!(trkseg.len(), 80); + // the codes follow the order of appearance in the file + assert_eq!(categories.surface.names(), ["asphalt", "cobblestone"]); + let asphalt = Some(0); + let cobblestone = Some(1); + assert_eq!(trkseg.iter().filter(|p| p.surface == asphalt).count(), 79); + assert_eq!( + trkseg.iter().filter(|p| p.surface == cobblestone).count(), + 1 + ); + // no highway in this file + assert!(trkseg.iter().all(|trkpt| trkpt.highway.is_none())); + assert!(categories.highway.names().is_empty()); + } + + #[test] + fn test_parse_trackpoint_surface_and_highway() { + let (gpx, categories) = parse_data_with_categories("with_highway"); + + let trkseg = &gpx.trk[0].trkseg[0]; + let names = |code: Option, names: &crate::Categories| { + code.and_then(|code| names.name(code)).map(str::to_owned) + }; + let values: Vec<_> = trkseg + .iter() + .map(|trkpt| { + ( + names(trkpt.surface, &categories.surface), + names(trkpt.highway, &categories.highway), + ) + }) + .collect(); + let some = |s: &str| Some(s.to_owned()); + assert_eq!( + values, + [ + (some("asphalt"), some("residential")), + (some("asphalt"), some("residential")), + (None, None), + (some("gravel"), some("track")), + (some("gravel"), None), + ] + ); + } + + #[test] + fn test_parse_shares_the_categories_between_files() { + let mut categories = TrackpointCategories::default(); + let mut parsed = |name: &str| { + parse( + &std::fs::read(format!("data/{name}.gpx")).unwrap(), + &mut categories, + ) + .unwrap() + }; + let first = parsed("with_highway"); + let second = parsed("with_surface"); + let code = |file: &File, i: usize| file.trk[0].trkseg[0][i].surface; + // "asphalt" is the same in both, "cobblestone" comes after "gravel" + assert_eq!(code(&first, 0), Some(0)); + assert_eq!(code(&second, 0), Some(0)); + assert_eq!( + categories.surface.names(), + ["asphalt", "gravel", "cobblestone"] + ); + } + #[test] fn test_parse_trackpoint_cad() { let gpx = parse_data("with_cad"); diff --git a/gpx-rs/engine/src/core/statistics/statistics.rs b/gpx-rs/engine/src/core/statistics/statistics.rs index eeee26a7d..5a4ac1460 100644 --- a/gpx-rs/engine/src/core/statistics/statistics.rs +++ b/gpx-rs/engine/src/core/statistics/statistics.rs @@ -331,7 +331,7 @@ mod tests { let mut f = File::open("data/with_time.gpx").unwrap(); let mut data = String::new(); let _ = f.read_to_string(&mut data); - let gpx = parse(data.as_bytes()).unwrap(); + let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); let trkseg = &gpx.trk[0].trkseg[0]; let stats = Statistics::compute(trkseg); @@ -348,7 +348,7 @@ mod tests { let mut f = File::open(path).unwrap(); let mut data = String::new(); let _ = f.read_to_string(&mut data); - parse(data.as_bytes()).unwrap() + parse(data.as_bytes(), &mut Default::default()).unwrap() } #[test] @@ -482,7 +482,7 @@ mod tests { ("data/with_power_1.gpx", 3), ] { let data = std::fs::read(path).unwrap(); - let gpx = parse(&data).unwrap(); + let gpx = parse(&data, &mut Default::default()).unwrap(); let trkseg = &gpx.trk[0].trkseg[0]; let global = Statistics::compute(trkseg).global; let values: Vec = trkseg diff --git a/gpx-rs/engine/src/engine/command/file/load.rs b/gpx-rs/engine/src/engine/command/file/load.rs index b94ed43fe..40f98da96 100644 --- a/gpx-rs/engine/src/engine/command/file/load.rs +++ b/gpx-rs/engine/src/engine/command/file/load.rs @@ -1,4 +1,4 @@ -use crate::{Apply, CommandError, File, State, parse, produce}; +use crate::{Apply, CommandError, File, State, TrackpointCategories, parse, produce}; #[derive(Debug)] pub struct Load<'a> { @@ -9,9 +9,9 @@ pub struct Load<'a> { } impl Load<'_> { - fn parse(self) -> Result { - let mut file = - parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?; + fn parse(self, categories: &mut TrackpointCategories) -> Result { + let mut file = parse(self.data, categories) + .map_err(|err| CommandError::InvalidData(err.to_string()))?; if file.info.name.trim().is_empty() { file.info.name = self.name.to_owned(); } @@ -40,7 +40,7 @@ impl Apply for LoadFiles<'_> { let mut files = Vec::with_capacity(self.files.len()); let mut first_error = None; for load in self.files { - match load.parse() { + match load.parse(state.categories) { Ok(file) => files.push(file), Err(error) => { first_error.get_or_insert(error); diff --git a/gpx-rs/engine/src/engine/command/fixture.rs b/gpx-rs/engine/src/engine/command/fixture.rs index bf5747479..fe21e3c67 100644 --- a/gpx-rs/engine/src/engine/command/fixture.rs +++ b/gpx-rs/engine/src/engine/command/fixture.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; -use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State}; +use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories}; #[derive(Default)] pub struct Fixture { @@ -10,11 +10,13 @@ pub struct Fixture { pub selection: Selection, pub order: FileOrder, pub clipboard: Option, + pub categories: TrackpointCategories, } impl Fixture { pub fn state(&mut self) -> State<'_> { State { + categories: &mut self.categories, files: &mut self.files, selection: &mut self.selection, order: &mut self.order, diff --git a/gpx-rs/engine/src/engine/command/pattern/update_selected.rs b/gpx-rs/engine/src/engine/command/pattern/update_selected.rs index 439fbe527..3307e8254 100644 --- a/gpx-rs/engine/src/engine/command/pattern/update_selected.rs +++ b/gpx-rs/engine/src/engine/command/pattern/update_selected.rs @@ -179,7 +179,7 @@ mod tests { fn fixture_with_tracks() -> (Fixture, FileId) { let mut fx = Fixture::default(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); - let file = Rc::new(crate::parse(&data).unwrap()); + let file = Rc::new(crate::parse(&data, &mut Default::default()).unwrap()); let id = file.id; fx.files.insert(id, file); (fx, id) diff --git a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs index 6f3626638..69b3977b0 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs @@ -33,6 +33,17 @@ pub struct StatisticsBuffer { 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, + /// Highway of the trackpoints, as the surface. + pub highway: Vec, +} + +/// 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 { + code.and_then(|code| code.checked_add(1)).unwrap_or(0) } impl StatisticsBuffer { @@ -65,6 +76,8 @@ impl StatisticsBuffer { self.cad.clear(); self.atemp.clear(); self.power.clear(); + self.surface.clear(); + self.highway.clear(); let optional = |value: Option| value.unwrap_or(f64::NAN); @@ -102,6 +115,8 @@ impl StatisticsBuffer { 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.global.merge(&stats.global); } @@ -153,7 +168,7 @@ mod tests { fn computed(path: &str) -> (TrackSegment, Statistics) { let data = std::fs::read(path).unwrap(); - let file = parse(&data).unwrap(); + let file = parse(&data, &mut Default::default()).unwrap(); let segment = file.trk[0].trkseg[0].clone(); let stats = Statistics::compute(&segment); (segment, stats) @@ -179,6 +194,8 @@ mod tests { buffer.cad.len(), buffer.atemp.len(), buffer.power.len(), + buffer.surface.len(), + buffer.highway.len(), ] } @@ -278,6 +295,45 @@ mod tests { assert_eq!(buffer.global.total_time, None); } + #[test] + fn test_surface_and_highway_codes() { + 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!(all_lengths(&buffer).iter().all(|len| *len == 5)); + } + + #[test] + fn test_codes_of_the_trackpoints_are_used_as_they_are() { + let (mut segment, _) = computed("data/simple.gpx"); + let mut point = segment[0].clone(); + point.surface = Some(7); + point.highway = Some(254); + segment.splice(0, 1, vec![point]); + 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.surface[1], buffer.highway[1]), (0, 0)); + } + + #[test] + fn test_no_codes_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)); + // and they are reset by the next update + buffer.update(&[]); + assert!(buffer.surface.is_empty() && buffer.highway.is_empty()); + } + #[test] fn test_update_replaces_previous_content() { let (segment, s) = computed("data/simple.gpx"); @@ -344,7 +400,7 @@ mod tests { #[test] fn test_slice_keeps_the_bounds_and_averages_of_the_selection() { let data = std::fs::read("data/with_hr.gpx").unwrap(); - let file = parse(&data).unwrap(); + let file = parse(&data, &mut Default::default()).unwrap(); let segment = file.trk[0].trkseg[0].clone(); let s = Statistics::compute(&segment); let mut buffer = StatisticsBuffer::default(); diff --git a/gpx-rs/engine/src/engine/derived/statistics_cache.rs b/gpx-rs/engine/src/engine/derived/statistics_cache.rs index afa38df37..2f294435f 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_cache.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_cache.rs @@ -161,7 +161,7 @@ mod tests { fn state(path: &str) -> (StackEntry, Rc) { let data = std::fs::read(path).unwrap(); - let file = Rc::new(parse(&data).unwrap()); + let file = Rc::new(parse(&data, &mut Default::default()).unwrap()); let mut state = StackEntry::default(); state.insert(file.id, file.clone()); (state, file) @@ -273,7 +273,7 @@ mod tests { let (mut state, first) = state("data/simple.gpx"); let (other, second) = { let data = std::fs::read("data/with_time.gpx").unwrap(); - let file = Rc::new(parse(&data).unwrap()); + let file = Rc::new(parse(&data, &mut Default::default()).unwrap()); (file.clone(), file) }; state.insert(other.id, other); diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 713d54f32..b2efd6712 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -3,7 +3,7 @@ use crate::{ Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, FileStructureCache, GlobalStatistics, SelectMode, Selection, Stack, State, StatisticsBuffer, - StatisticsCache, TrackSegmentId, Trackpoint, Waypoint, WaypointId, + StatisticsCache, TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, WaypointId, }; #[derive(Debug, Default)] @@ -18,6 +18,7 @@ pub struct Engine { order_changed: bool, selection_changed: bool, statistics_buffer: StatisticsBuffer, + categories: TrackpointCategories, clipboard: Option, clipboard_changed: bool, } @@ -27,6 +28,11 @@ impl Engine { &self.statistics_buffer } + /// The names of the surfaces and highways that the trackpoints refer to by code. + pub fn categories(&self) -> &TrackpointCategories { + &self.categories + } + /// Files in display order. pub fn order(&self) -> &[FileId] { &self.order.0 @@ -216,6 +222,7 @@ impl Engine { fn edit(&mut self, command: Command) -> Option { self.stack.create_and_push_next(|files| { let mut state = State { + categories: &mut self.categories, files, selection: &mut self.selection, order: &mut self.order, @@ -1231,6 +1238,52 @@ mod tests { assert!(engine.waypoint(&file, &id).is_some()); } + #[test] + fn test_categories_are_shared_and_kept_by_the_history() { + let mut engine = Engine::default(); + assert!(engine.categories().surface.names().is_empty()); + + load(&mut engine, "data/with_highway.gpx"); + assert_eq!(engine.categories().surface.names(), ["asphalt", "gravel"]); + assert_eq!( + engine.categories().highway.names(), + ["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]); + + // a second file goes on with the same table + load(&mut engine, "data/with_surface.gpx"); + assert_eq!( + 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 + ); + + // undoing does not forget what was learned: the codes stay valid in every state + assert!(engine.execute(Action::Undo)); + assert!(engine.execute(Action::Undo)); + assert_eq!( + engine.categories().surface.names(), + ["asphalt", "gravel", "cobblestone"] + ); + 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]); + } + #[test] fn test_trackpoint() { let mut engine = Engine::default(); diff --git a/gpx-rs/engine/src/engine/state/mod.rs b/gpx-rs/engine/src/engine/state/mod.rs index a74fa9dc4..9a2475812 100644 --- a/gpx-rs/engine/src/engine/state/mod.rs +++ b/gpx-rs/engine/src/engine/state/mod.rs @@ -9,6 +9,7 @@ pub use selection::*; pub use stack::*; pub struct State<'a> { + pub categories: &'a mut crate::TrackpointCategories, pub files: &'a mut StackEntry, pub selection: &'a mut Selection, pub order: &'a mut FileOrder, diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 9eeba0e6e..0182537e0 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}; +use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array}; #[wasm_bindgen] #[derive(Clone, Copy)] @@ -164,6 +164,28 @@ stats_getter!(cad, Float64Array); stats_getter!(atemp, Float64Array); stats_getter!(power, Float64Array); +// Surface and highway of the trackpoints: 0 when unknown, else 1 + the index in `surfaces()` or +// `highways()`. +stats_getter!(surface, Uint8Array); +stats_getter!(highway, Uint8Array); + +/// The names of the surfaces, in the order of their codes (see `surface`). The list only grows: +/// a name keeps its index. +#[wasm_bindgen] +pub fn surfaces() -> StringList { + with_engine(|e| array(e.categories().surface.names(), |name| name.as_str().into())) + .unwrap_or_default() + .unchecked_into() +} + +/// The names of the highways, in the order of their codes (see `highway`). +#[wasm_bindgen] +pub fn highways() -> StringList { + with_engine(|e| array(e.categories().highway.names(), |name| name.as_str().into())) + .unwrap_or_default() + .unchecked_into() +} + // File commands #[wasm_bindgen] @@ -803,6 +825,8 @@ extern "C" { pub type GlobalStatistics; #[wasm_bindgen(typescript_type = "string[]")] pub type FileOrder; + #[wasm_bindgen(typescript_type = "string[]")] + pub type StringList; } fn set(object: &Object, key: &str, value: impl Into) { diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index a0cf7d270..aced894b3 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -25,6 +25,7 @@ import type { Coordinates } from 'gpx'; import { NO_TIME, type SelectionStatistics } from '$lib/engine'; import type { SlicedStatistics } from '$lib/logic/selection-statistics'; import { mode } from 'mode-watcher'; +import { categoryAt } from '$lib/trackpoint-categories'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; const { distanceUnits, velocityUnits, temperatureUnits } = settings; @@ -41,7 +42,8 @@ interface ElevationProfilePoint { segment: number; length: number; }; - extensions: Record; + surface?: string; + highway?: string; coordinates: Coordinates; index: number; } @@ -185,14 +187,11 @@ export class ElevationProfile { segment: point.slope.segment.toFixed(1), length: getDistanceWithUnits(point.slope.length), }; - let surface = point.extensions.surface - ? point.extensions.surface - : 'unknown'; - let highway = point.extensions.highway - ? point.extensions.highway - : 'unknown'; - let sacScale = point.extensions.sac_scale; - let mtbScale = point.extensions.mtb_scale; + let surface = point.surface ?? 'unknown'; + let highway = point.highway ?? 'unknown'; + // TODO the engine does not store the sac and mtb scales yet + let sacScale: string | undefined = undefined; + let mtbScale: string | undefined = undefined; let labels = [ ` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`, @@ -413,8 +412,8 @@ export class ElevationProfile { segment: data.slopeSegmentSlope[index], length: data.slopeSegmentDistance[index], }, - // TODO the engine does not store the extensions (surface, highway...) yet - extensions: data.extensions[index] ?? {}, + surface: categoryAt(data.surface, data.surfaces, index), + highway: categoryAt(data.highway, data.highways, index), coordinates: { lat: data.lat[index], lon: data.lng[index] }, index, }); @@ -602,16 +601,13 @@ export class ElevationProfile { surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { const point = context.p0.raw as ElevationProfilePoint; - return getSurfaceColor(point.extensions.surface); + return getSurfaceColor(point.surface ?? ''); } highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { const point = context.p0.raw as ElevationProfilePoint; - return getHighwayColor( - point.extensions.highway, - point.extensions.sac_scale, - point.extensions.mtb_scale - ); + // TODO the engine does not store the sac and mtb scales yet + return getHighwayColor(point.highway ?? '', undefined, undefined); } destroy() { diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index bd8a5234b..87d473ccd 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -140,11 +140,14 @@ export type SelectionStatistics = { cad: Float64Array; atemp: Float64Array; power: Float64Array; - /** - * Extensions of the trackpoints (surface, highway...), or an empty array when they are not - * known. TODO the engine does not store them yet. - */ - extensions: Record[]; + /** 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[]; /** * 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 @@ -174,7 +177,10 @@ const EMPTY_SELECTION_STATISTICS: SelectionStatistics = { cad: new Float64Array(), atemp: new Float64Array(), power: new Float64Array(), - extensions: [], + surface: new Uint8Array(), + surfaces: [], + highway: new Uint8Array(), + highways: [], slice: () => undefined, }; @@ -625,7 +631,10 @@ class Engine { cad: wasm.cad().slice(), atemp: wasm.atemp().slice(), power: wasm.power().slice(), - extensions: [], + surface: wasm.surface().slice(), + surfaces: wasm.surfaces(), + highway: wasm.highway().slice(), + highways: wasm.highways(), slice: (start, end) => version === this._statisticsVersion ? wasm.slice_statistics(start, end) : undefined, }; diff --git a/website/src/lib/trackpoint-categories.test.ts b/website/src/lib/trackpoint-categories.test.ts new file mode 100644 index 000000000..1114ee79d --- /dev/null +++ b/website/src/lib/trackpoint-categories.test.ts @@ -0,0 +1,24 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { categoryAt } from './trackpoint-categories'; + +describe('categoryAt', () => { + const codes = new Uint8Array([1, 1, 0, 2, 0]); + const 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 undefined when the value is unknown', () => { + assert.equal(categoryAt(codes, names, 2), undefined); + assert.equal(categoryAt(codes, names, 4), undefined); + }); + + 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); + }); +}); diff --git a/website/src/lib/trackpoint-categories.ts b/website/src/lib/trackpoint-categories.ts new file mode 100644 index 000000000..058d31708 --- /dev/null +++ b/website/src/lib/trackpoint-categories.ts @@ -0,0 +1,12 @@ +/** + * 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`. + */ +export function categoryAt( + codes: ArrayLike, + names: readonly string[], + index: number +): string | undefined { + const code = codes[index]; + return code ? names[code - 1] : undefined; +} diff --git a/website/src/routes/[[language]]/+page.svelte b/website/src/routes/[[language]]/+page.svelte index e1d1d5a06..4e8636653 100644 --- a/website/src/routes/[[language]]/+page.svelte +++ b/website/src/routes/[[language]]/+page.svelte @@ -45,7 +45,10 @@ cad: new Float64Array(), atemp: new Float64Array(), power: new Float64Array(), - extensions: [], + surface: new Uint8Array(), + surfaces: [], + highway: new Uint8Array(), + highways: [], slice: () => undefined, }); let slicedStatistics = writable(undefined);