diff --git a/gpx-rs/engine/src/core/algorithm/anchors.rs b/gpx-rs/engine/src/core/algorithm/anchors.rs new file mode 100644 index 000000000..3a78daf32 --- /dev/null +++ b/gpx-rs/engine/src/core/algorithm/anchors.rs @@ -0,0 +1,112 @@ +use std::f64::consts::PI; + +use crate::{ + TrackSegment, TrackSegmentIndex, core::utils::crossarc_lnglat, ramer_douglas_peucker_by, +}; + +/// Highest zoom level of the map at which an anchor can appear. +pub const MAX_ANCHOR_ZOOM: u8 = 22; + +/// Details smaller than this (in meters) do not get an anchor. +const ANCHOR_EPSILON: f64 = 1.0; + +const EARTH_RADIUS_METERS: f64 = 6371008.8; + +/// Lowest map zoom level at which an anchor selected at `distance` meters from the line of its +/// neighbours is worth showing: the smaller the detail, the higher the zoom. The ends of a +/// segment (no distance) are always shown. +pub fn anchor_zoom(lat: f64, distance: Option) -> u8 { + let Some(distance) = distance else { + return 0; + }; + let width = EARTH_RADIUS_METERS * (lat * PI / 180.0).cos() / distance; + width.log2().round().clamp(0.0, MAX_ANCHOR_ZOOM as f64) as u8 +} + +/// The anchors of a segment, as `(index, zoom)` sorted by index: the trackpoints kept by the +/// Ramer-Douglas-Peucker simplification of its path, the ones for smaller details being shown at +/// higher zoom levels only. +pub fn compute_anchors(trkseg: &TrackSegment) -> Vec<(usize, u8)> { + let measure = |start: TrackSegmentIndex, end: TrackSegmentIndex, idx: TrackSegmentIndex| { + crossarc_lnglat( + trkseg[start].coordinates, + trkseg[end].coordinates, + trkseg[idx].coordinates, + ) + }; + ramer_douglas_peucker_by(trkseg, &measure, ANCHOR_EPSILON) + .into_iter() + .map(|(idx, distance)| (idx.flat, anchor_zoom(trkseg[idx].coordinates.lat, distance))) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::{LngLat, Trackpoint, TrackpointChunk}; + + use super::*; + + fn segment(points: &[(f64, f64)]) -> TrackSegment { + let mut trkseg = TrackSegment::default(); + let mut chunk = TrackpointChunk::default(); + for (lng, lat) in points { + chunk.trkpt.push(Trackpoint { + coordinates: LngLat { + lng: *lng, + lat: *lat, + }, + ..Default::default() + }); + } + trkseg.push(chunk); + trkseg + } + + #[test] + fn test_zoom_depends_on_the_size_of_the_detail() { + assert_eq!(anchor_zoom(45.0, None), 0); + // 2^13 meters is close to the radius of the earth divided by 1 km + assert_eq!(anchor_zoom(0.0, Some(1_000.0)), 13); + assert!(anchor_zoom(0.0, Some(10.0)) > anchor_zoom(0.0, Some(1_000.0))); + assert_eq!(anchor_zoom(0.0, Some(1e-9)), MAX_ANCHOR_ZOOM); + assert_eq!(anchor_zoom(0.0, Some(1e9)), 0); + // the poles do not give NaN or a panic + assert_eq!(anchor_zoom(90.0, Some(10.0)), 0); + } + + #[test] + fn test_anchors_of_a_straight_line_are_its_ends() { + let line: Vec<_> = (0..10).map(|i| (i as f64 * 0.001, 0.0)).collect(); + assert_eq!(compute_anchors(&segment(&line)), vec![(0, 0), (9, 0)]); + } + + #[test] + fn test_anchors_keep_the_corners() { + // a big corner and a small bump + let points = [ + (0.0, 0.0), + (0.01, 0.0), + (0.02, 0.0), + (0.02, 0.01), + (0.02, 0.02), + (0.02, 0.020005), + (0.02, 0.03), + ]; + let anchors = compute_anchors(&segment(&points)); + let indices: Vec<_> = anchors.iter().map(|(i, _)| *i).collect(); + assert_eq!(indices, vec![0, 2, 6]); + assert_eq!(anchors[0].1, 0); + assert_eq!(anchors[2].1, 0); + assert!(anchors[1].1 > 0); + } + + #[test] + fn test_anchors_of_short_segments() { + assert!(compute_anchors(&segment(&[])).is_empty()); + assert_eq!(compute_anchors(&segment(&[(1.0, 2.0)])), vec![(0, 0)]); + assert_eq!( + compute_anchors(&segment(&[(1.0, 2.0), (1.0, 2.1)])), + vec![(0, 0), (1, 0)] + ); + } +} diff --git a/gpx-rs/engine/src/core/algorithm/mod.rs b/gpx-rs/engine/src/core/algorithm/mod.rs index 95fdaf8de..5fa253462 100644 --- a/gpx-rs/engine/src/core/algorithm/mod.rs +++ b/gpx-rs/engine/src/core/algorithm/mod.rs @@ -1,4 +1,8 @@ +mod anchors; mod simplify; mod smooth; +mod timestamps; +pub use anchors::*; pub use simplify::*; +pub use timestamps::*; diff --git a/gpx-rs/engine/src/core/algorithm/simplify.rs b/gpx-rs/engine/src/core/algorithm/simplify.rs index 5f67a1091..1960211d6 100644 --- a/gpx-rs/engine/src/core/algorithm/simplify.rs +++ b/gpx-rs/engine/src/core/algorithm/simplify.rs @@ -10,47 +10,67 @@ pub fn ramer_douglas_peucker( ) -> Vec where F: Fn(TrackSegmentIndex) -> (f64, f64), +{ + let measure = |start, end, idx| { + let (start, end, pt) = (mapping(start), mapping(end), mapping(idx)); + crossarc(start.0, start.1, end.0, end.1, pt.0, pt.1) + }; + ramer_douglas_peucker_by(trkseg, &measure, epsilon) + .into_iter() + .map(|(idx, _)| idx) + .collect() +} + +/// Ramer-Douglas-Peucker with any distance from a point (the last argument) to the line made of +/// two others (the first ones). Gives the kept points, with the distance at which each of them +/// was selected (`None` for the ends of the segment, which are always kept). +pub fn ramer_douglas_peucker_by( + trkseg: &TrackSegment, + measure: &M, + epsilon: f64, +) -> Vec<(TrackSegmentIndex, Option)> +where + M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64, { match trkseg.len() { 0 => vec![], - 1 => vec![trkseg.first_index().unwrap()], - 2 => vec![trkseg.first_index().unwrap(), trkseg.last_index().unwrap()], + 1 => vec![(trkseg.first_index().unwrap(), None)], + 2 => vec![ + (trkseg.first_index().unwrap(), None), + (trkseg.last_index().unwrap(), None), + ], _ => { let first = trkseg.first_index().unwrap(); let last = trkseg.last_index().unwrap(); - let mut indices = vec![first]; - ramer_douglas_peucker_helper(trkseg, first, last, mapping, epsilon, &mut indices); - indices.push(last); - indices + let mut kept = vec![(first, None)]; + ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept); + kept.push((last, None)); + kept } } } -fn ramer_douglas_peucker_helper( +fn ramer_douglas_peucker_helper( trkseg: &TrackSegment, start: TrackSegmentIndex, end: TrackSegmentIndex, - mapping: &F, + measure: &M, epsilon: f64, - indices: &mut Vec, + kept: &mut Vec<(TrackSegmentIndex, Option)>, ) where - F: Fn(TrackSegmentIndex) -> (f64, f64), + M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64, { let mut max_idx = None; let mut max_dist = 0.0; - let start_pt = mapping(start); - let end_pt = mapping(end); - let mut cur = trkseg.next_index(Some(start)); while let Some(idx) = cur { if idx == end { break; } - let pt = mapping(idx); - let dist = crossarc(start_pt.0, start_pt.1, end_pt.0, end_pt.1, pt.0, pt.1); + let dist = measure(start, end, idx); if dist > max_dist { max_idx = Some(idx); max_dist = dist; @@ -60,9 +80,9 @@ fn ramer_douglas_peucker_helper( } if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) { - ramer_douglas_peucker_helper(trkseg, start, idx, mapping, epsilon, indices); - indices.push(idx); - ramer_douglas_peucker_helper(trkseg, idx, end, mapping, epsilon, indices); + ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept); + kept.push((idx, Some(max_dist))); + ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept); } } diff --git a/gpx-rs/engine/src/core/algorithm/timestamps.rs b/gpx-rs/engine/src/core/algorithm/timestamps.rs new file mode 100644 index 000000000..08c0e1bd5 --- /dev/null +++ b/gpx-rs/engine/src/core/algorithm/timestamps.rs @@ -0,0 +1,346 @@ +use crate::{Trackpoint, distance}; + +/// Replaces the trackpoints `start..end` of `trkpt` by `points`, keeping the timestamps of the +/// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library. +/// +/// The timestamps only matter if the path has some, or if `speed` (in km/h) is given: then the +/// new points that have none get some (a trackpoint after the previous one at `speed`, or one +/// second after it without a speed), and so do the points before or after the range if they +/// had none. `start_time` is the time of the first trackpoint when nothing else gives it. +/// New points that have timestamps are shifted if they are too early to follow the previous +/// trackpoint, and so are the points after the range. With `remove_gaps`, the new points are +/// also brought closer to the previous trackpoint if they come long after it. +/// +/// Panics if `start..end` is not inside `trkpt`. +pub fn replace_trackpoints( + trkpt: &mut Vec, + start: usize, + end: usize, + mut points: Vec, + speed: Option, + start_time: Option, + remove_gaps: bool, +) { + assert!(start <= end && end <= trkpt.len()); + let has_times = trkpt.first().is_some_and(|first| first.time.is_some()); + + if speed.is_some() || has_times { + if start > 0 && trkpt[0].time.is_none() { + // the points before the range have no timestamps + let before: Vec<_> = trkpt.drain(..start).collect(); + trkpt.splice(0..0, with_timestamps(before, speed, None, start_time)); + } + + if !points.is_empty() { + let last = start.checked_sub(1).map(|i| &trkpt[i]); + let missing = + points[0].time.is_none() || points.get(1).is_some_and(|p| p.time.is_none()); + if missing { + points = with_timestamps(points, speed, last, start_time); + } else if let Some(last) = last { + if is_before(points[0].time, last.time) { + // too early + points = shifted_and_compressed(points, speed, 1.0, last); + } else if remove_gaps { + if same_place(&points[0], last) { + // the same place: the new points start at the previous trackpoint, which + // is the new first one + if is_before(last.time, points[0].time) { + points = shifted_and_compressed(points, speed, 1.0, last); + points.remove(0); + } + } else if matches!((points[0].time, last.time), (Some(new), Some(old)) if new - old > 1000) + { + // another place: the new points start one second after the previous one + let mut artificial_last = points[0].clone(); + artificial_last.time = last.time.map(|time| time + 1000); + points = shifted_and_compressed(points, speed, 1.0, &artificial_last); + } + } + } + } + + if end < trkpt.len() { + // the points after the range + let last = points + .last() + .or_else(|| start.checked_sub(1).map(|i| &trkpt[i])) + .cloned(); + if trkpt[end].time.is_none() { + let after: Vec<_> = trkpt.drain(end..).collect(); + let after = with_timestamps(after, speed, last.as_ref(), start_time); + trkpt.extend(after); + } else if let Some(last) = last + && is_before(trkpt[end].time, last.time) + { + let after: Vec<_> = trkpt.drain(end..).collect(); + trkpt.extend(shifted_and_compressed(after, speed, 1.0, &last)); + } + } + } + + trkpt.splice(start..end, points); +} + +fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool { + a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat +} + +/// Whether both times are known and `a` is before `b`. +fn is_before(a: Option, b: Option) -> bool { + matches!((a, b), (Some(a), Some(b)) if a < b) +} + +/// The time at which `b` is reached after `a`, at `speed` km/h: one second later without a +/// speed, unknown if `a` has no time. +/// +/// The old code gave an invalid date for a null or negative speed: it is as if there was none. +fn timestamp_after(a: &Trackpoint, b: &Trackpoint, speed: Option) -> Option { + let time = a.time?; + match speed.filter(|speed| speed.is_finite() && *speed > 0.0) { + None => Some(time + 1000), + Some(speed) => { + let km = distance(a.coordinates, b.coordinates); + Some(time + (3_600_000.0 * km / speed) as i64) + } + } +} + +/// `points` with the timestamps that follow `last` (the trackpoint before them), one after the +/// other. Without `last`, the first one is at `start_time`. +/// +/// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy +/// used here. +fn with_timestamps( + points: Vec, + speed: Option, + last: Option<&Trackpoint>, + start_time: Option, +) -> Vec { + let Some(first) = points.first() else { + return points; + }; + let mut last = match last { + Some(last) if last.time.is_some() => last.clone(), + other => { + let mut last = other.unwrap_or(first).clone(); + last.time = start_time; + last + } + }; + points + .into_iter() + .map(|mut point| { + point.time = timestamp_after(&last, &point, speed); + last = point.clone(); + point + }) + .collect() +} + +/// `points` with their timestamps moved so that the first one follows `last`, and their durations +/// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in +/// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known. +fn shifted_and_compressed( + points: Vec, + speed: Option, + ratio: f64, + last: &Trackpoint, +) -> Vec { + let Some(first) = points.first() else { + return points; + }; + let start = timestamp_after(last, first, speed); + let first_time = first.time; + let mut previous = first.clone(); + points + .into_iter() + .map(|point| { + let mut shifted = point.clone(); + shifted.time = match point.time { + None => timestamp_after(&previous, &point, speed), + Some(time) => start + .zip(first_time) + .map(|(start, first)| start + (ratio * (time - first) as f64) as i64), + }; + previous = shifted.clone(); + shifted + }) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::LngLat; + + use super::*; + + /// A point every 1 km along the equator... about, at 0.009 degrees of longitude. + fn point(i: usize, time: Option) -> Trackpoint { + Trackpoint { + coordinates: LngLat { + lng: i as f64 * 0.009, + lat: 0.0, + }, + time, + ..Default::default() + } + } + + fn timed(range: std::ops::Range, start: i64, step: i64) -> Vec { + range + .map(|i| point(i, Some(start + step * i as i64))) + .collect() + } + + fn untimed(range: std::ops::Range) -> Vec { + range.map(|i| point(i, None)).collect() + } + + fn times(trkpt: &[Trackpoint]) -> Vec> { + trkpt.iter().map(|p| p.time).collect() + } + + #[test] + fn test_without_times_nor_speed_it_is_a_splice() { + let mut trkpt = untimed(0..5); + replace_trackpoints(&mut trkpt, 1, 3, untimed(10..13), None, None, false); + assert_eq!(trkpt.len(), 6); + assert!(trkpt.iter().all(|p| p.time.is_none())); + assert_eq!(trkpt[1].coordinates.lng, point(10, None).coordinates.lng); + assert_eq!(trkpt[4].coordinates.lng, point(3, None).coordinates.lng); + } + + #[test] + fn test_new_points_follow_the_previous_one_at_the_speed() { + // 1 km in 10 s is 360 km/h + let mut trkpt = timed(0..4, 0, 10_000); + let km = distance(point(0, None).coordinates, point(1, None).coordinates); + let speed = Some(36.0 * km / 0.01); // the time of 1 point to the next is 100 s + replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), speed, None, false); + let expected = 10_000 + (3_600_000.0 * km / speed.unwrap()) as i64; + assert_eq!(trkpt[2].time, Some(expected)); + assert!(trkpt[2].time > trkpt[1].time); + // the points after follow the new ones, they were not too early + assert_eq!(trkpt.len(), 5); + assert_eq!(trkpt[4].time, Some(30_000)); + } + + #[test] + fn test_without_a_speed_new_points_are_one_second_apart() { + let mut trkpt = timed(0..3, 5_000, 60_000); + replace_trackpoints(&mut trkpt, 1, 2, untimed(10..13), None, None, false); + assert_eq!( + times(&trkpt[..4]), + [Some(5_000), Some(6_000), Some(7_000), Some(8_000)] + ); + // what comes after was after, it is kept + assert_eq!(trkpt[4].time, Some(125_000)); + } + + #[test] + fn test_the_points_after_are_shifted_if_they_are_too_early() { + let mut trkpt = timed(0..4, 0, 10_000); + // 30 new points, one second apart: the last one is at 30 s, but the next original + // trackpoint is at 20 s + replace_trackpoints(&mut trkpt, 1, 2, untimed(10..40), None, None, false); + assert_eq!(trkpt.len(), 33); + assert_eq!(trkpt[1].time, Some(1_000)); + assert_eq!(trkpt[30].time, Some(30_000)); + // the following points keep the duration between them, after the new ones + assert_eq!(trkpt[31].time, Some(31_000)); + assert_eq!(trkpt[32].time, Some(41_000)); + } + + #[test] + fn test_a_path_without_times_gets_them_from_the_speed_and_start_time() { + let mut trkpt = untimed(0..4); + replace_trackpoints( + &mut trkpt, + 3, + 4, + untimed(3..6), + Some(10.0), + Some(1_000), + false, + ); + let times: Vec<_> = times(&trkpt); + assert_eq!(times[0], Some(1_000)); + assert!(times.iter().all(Option::is_some)); + assert!(times.windows(2).all(|w| w[0].unwrap() < w[1].unwrap())); + } + + #[test] + fn test_an_unusable_speed_is_as_if_there_was_none() { + for speed in [0.0, -5.0, f64::NAN, f64::INFINITY] { + let mut trkpt = timed(0..2, 0, 10_000); + replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(speed), None, false); + assert_eq!( + times(&trkpt), + [Some(0), Some(10_000), Some(11_000), Some(12_000)], + "{speed}" + ); + } + } + + #[test] + fn test_new_points_with_times_that_are_too_early_are_shifted() { + let mut trkpt = timed(0..3, 100_000, 10_000); + // they are at 0, 5 s: they are moved to follow the trackpoint at 110 s + let new = vec![point(5, Some(0)), point(6, Some(5_000))]; + replace_trackpoints(&mut trkpt, 2, 3, new, None, None, false); + // they follow the previous trackpoint after one second, keeping their own durations + assert_eq!(trkpt[2].time, Some(111_000)); + assert_eq!(trkpt[3].time, Some(116_000)); + } + + #[test] + fn test_remove_gaps_at_the_same_place_and_elsewhere() { + // same place: the first new point is the previous trackpoint + let mut trkpt = timed(0..3, 0, 10_000); + let new = vec![point(2, Some(500_000)), point(3, Some(510_000))]; + replace_trackpoints(&mut trkpt, 3, 3, new.clone(), None, None, true); + assert_eq!(trkpt.len(), 4); + // without a speed, it is one second after the previous trackpoint, not at its time + assert_eq!(trkpt[3].time, Some(31_000)); + // with a speed, the distance is zero + let mut trkpt = timed(0..3, 0, 10_000); + replace_trackpoints(&mut trkpt, 3, 3, new, Some(10.0), None, true); + assert_eq!(trkpt[3].time, Some(30_000)); + + // another place: one second after the previous one + let mut trkpt = timed(0..3, 0, 10_000); + let new = vec![point(5, Some(500_000)), point(6, Some(510_000))]; + replace_trackpoints(&mut trkpt, 3, 3, new, None, None, true); + assert_eq!(trkpt.len(), 5); + // the old code adds the second twice with no speed, see `timestamp_after` + assert_eq!(trkpt[3].time, Some(22_000)); + assert_eq!(trkpt[4].time, Some(32_000)); + + // without removing the gaps they stay + let mut trkpt = timed(0..3, 0, 10_000); + let new = vec![point(5, Some(500_000)), point(6, Some(510_000))]; + replace_trackpoints(&mut trkpt, 3, 3, new, None, None, false); + assert_eq!(trkpt[3].time, Some(500_000)); + } + + #[test] + fn test_removing_points_adapts_nothing_if_the_next_one_is_after() { + let mut trkpt = timed(0..5, 0, 10_000); + replace_trackpoints(&mut trkpt, 1, 3, vec![], None, None, false); + assert_eq!(times(&trkpt), [Some(0), Some(30_000), Some(40_000)]); + // from the beginning + replace_trackpoints(&mut trkpt, 0, 2, vec![], None, None, false); + assert_eq!(times(&trkpt), [Some(40_000)]); + replace_trackpoints(&mut trkpt, 0, 1, vec![], None, None, false); + assert!(trkpt.is_empty()); + } + + #[test] + fn test_points_without_a_known_previous_time_get_none() { + // nothing gives a time to start with + let mut trkpt = untimed(0..2); + replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(10.0), None, false); + assert!(trkpt.iter().all(|p| p.time.is_none())); + } +} diff --git a/gpx-rs/engine/src/core/gpx/segment.rs b/gpx-rs/engine/src/core/gpx/segment.rs index 639d20cd1..d178fa827 100644 --- a/gpx-rs/engine/src/core/gpx/segment.rs +++ b/gpx-rs/engine/src/core/gpx/segment.rs @@ -2,7 +2,7 @@ use std::{ops::Index, rc::Rc}; use uuid::Uuid; -use crate::{Trackpoint, TrackpointChunk}; +use crate::{Trackpoint, TrackpointChunk, compute_anchors}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct TrackSegmentId(pub Uuid); @@ -86,6 +86,72 @@ impl TrackSegment { self.fill(&mut pending, points); } self.flush(&mut pending); + self.ensure_end_anchors(); + } + + /// The first and the last trackpoints are always anchors, shown at every zoom level. + /// + /// It is done by [`TrackSegment::splice`]; commands that build segments in another way need to + /// call it. + pub fn ensure_end_anchors(&mut self) { + let Some(last) = self.len().checked_sub(1) else { + return; + }; + for index in [0, last] { + if self[index].anchor != Some(0) { + self.point_mut(index).anchor = Some(0); + } + } + } + + /// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if + /// there is no such trackpoint. + pub fn set_anchor(&mut self, index: usize, zoom: u8) { + self.point_mut(index).anchor = Some(zoom); + } + + /// Sets the anchors of the trackpoints from the details of the path of the segment (see + /// [`compute_anchors`]), forgetting the previous ones. + pub fn compute_anchors(&mut self) { + let anchors = compute_anchors(self); + let mut anchors = anchors.into_iter().peekable(); + self.map_points(|index, trkpt| { + trkpt.anchor = match anchors.peek() { + Some(&(anchor, zoom)) if anchor == index => { + anchors.next(); + Some(zoom) + } + _ => None, + }; + }); + } + + /// A chunk that can be modified: the shared chunks are copied first. + fn chunk_mut(&mut self, chunk: usize) -> &mut TrackpointChunk { + let shared = &mut self.chunks[chunk]; + if Rc::get_mut(shared).is_none() { + *shared = Rc::new(TrackpointChunk { + trkpt: shared.trkpt.clone(), + ..Default::default() + }); + } + Rc::get_mut(shared).unwrap() + } + + fn point_mut(&mut self, index: usize) -> &mut Trackpoint { + let TrackSegmentIndex { chunk, pos, .. } = self.locate(index).unwrap(); + &mut self.chunk_mut(chunk).trkpt[pos] + } + + /// Applies `f` to every trackpoint, with its index in the segment. + fn map_points(&mut self, mut f: impl FnMut(usize, &mut Trackpoint)) { + let mut offset = 0; + for chunk in 0..self.chunks.len() { + for (i, trkpt) in self.chunk_mut(chunk).trkpt.iter_mut().enumerate() { + f(offset + i, trkpt); + } + offset += self.chunks[chunk].trkpt.len(); + } } fn fill( @@ -309,9 +375,58 @@ mod tests { assert!(trkseg.first_index().is_none()); } + fn anchors(trkseg: &TrackSegment) -> Vec> { + trkseg.iter().map(|p| p.anchor).collect() + } + + #[test] + fn test_splice_keeps_the_ends_anchors() { + let mut trkseg = TrackSegment::default(); + assert_eq!(anchors(&trkseg), vec![]); + trkseg.splice(0, 0, points(&[0.0, 1.0, 2.0])); + assert_eq!(anchors(&trkseg), [Some(0), None, Some(0)]); + + // new ends: the previous ones stay anchors + trkseg.splice(3, 3, points(&[3.0])); + trkseg.splice(0, 0, points(&[-1.0])); + assert_eq!(anchors(&trkseg), [Some(0), Some(0), None, Some(0), Some(0)]); + + // removing the ends: the new ones become anchors + trkseg.splice(4, 5, vec![]); + trkseg.splice(0, 2, vec![]); + assert_eq!(anchors(&trkseg), [Some(0), Some(0)]); + + // a single point is both ends + trkseg.splice(1, 2, vec![]); + assert_eq!(anchors(&trkseg), [Some(0)]); + } + + #[test] + fn test_compute_anchors_replaces_the_previous_ones() { + let mut trkseg = TrackSegment::default(); + let line: Vec<_> = (0..10) + .map(|i| Trackpoint { + coordinates: crate::LngLat { + lng: i as f64 * 0.001, + lat: 0.0, + }, + anchor: Some(5), + ..Default::default() + }) + .collect(); + trkseg.splice(0, 0, line); + trkseg.compute_anchors(); + let mut expected = vec![None; 10]; + expected[0] = Some(0); + expected[9] = Some(0); + assert_eq!(anchors(&trkseg), expected); + } + #[test] fn test_splice_keeps_untouched_chunks_shared() { let mut trkseg = create_track_segment(5); + // the ends are anchors already, or their chunks would be copied + trkseg.ensure_end_anchors(); let before = trkseg.chunks.clone(); // inside the third chunk only trkseg.splice(4, 5, points(&[-1.0])); diff --git a/gpx-rs/engine/src/core/gpx/trackpoint.rs b/gpx-rs/engine/src/core/gpx/trackpoint.rs index fd2ca9eb5..625acb652 100644 --- a/gpx-rs/engine/src/core/gpx/trackpoint.rs +++ b/gpx-rs/engine/src/core/gpx/trackpoint.rs @@ -17,4 +17,8 @@ pub struct Trackpoint { pub sac_scale: Option, /// Code of the mountain biking scale, see [`crate::TrackpointCategories`]. pub mtb_scale: Option, + /// Whether the trackpoint is an anchor of the routing tool (a point the user can drag to + /// reroute the segment): the lowest map zoom level at which it is shown. The first and last + /// trackpoints of a segment are always anchors that are always shown (zoom 0). + pub anchor: Option, } diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index 4650c3738..07ba40635 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -179,6 +179,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { NewTrack(NewTrack), NewTrackSegment(NewTrackSegment), Reverse(Reverse), - SpliceTrackpoints(SpliceTrackpoints<'a>), + Route(Route<'a>), NewWaypoint(NewWaypoint<'a>), EditWaypoint(EditWaypoint<'a>), MoveWaypoint(MoveWaypoint), @@ -35,8 +35,10 @@ pub enum Command<'a> { Time(Time), Merge(Merge), Extract(Extract), + InsertAnchor(InsertAnchor), Elevation(Elevation<'a>), Clean(Clean), + ChangeLoopStart(ChangeLoopStart), } impl Apply for Command<'_> { @@ -56,7 +58,7 @@ impl Apply for Command<'_> { Command::NewTrack(c) => c.apply(state), Command::NewTrackSegment(c) => c.apply(state), Command::Reverse(c) => c.apply(state), - Command::SpliceTrackpoints(c) => c.apply(state), + Command::Route(c) => c.apply(state), Command::NewWaypoint(c) => c.apply(state), Command::EditWaypoint(c) => c.apply(state), Command::MoveWaypoint(c) => c.apply(state), @@ -65,8 +67,10 @@ impl Apply for Command<'_> { Command::Time(c) => c.apply(state), Command::Merge(c) => c.apply(state), Command::Extract(c) => c.apply(state), + Command::InsertAnchor(c) => c.apply(state), Command::Elevation(c) => c.apply(state), Command::Clean(c) => c.apply(state), + Command::ChangeLoopStart(c) => c.apply(state), } } } diff --git a/gpx-rs/engine/src/engine/command/edit/metadata.rs b/gpx-rs/engine/src/engine/command/edit/metadata.rs index 72fa76d4d..aa337704f 100644 --- a/gpx-rs/engine/src/engine/command/edit/metadata.rs +++ b/gpx-rs/engine/src/engine/command/edit/metadata.rs @@ -42,8 +42,18 @@ mod tests { #[test] fn test_metadata_applies_to_selected_file() { let mut fx = Fixture::default(); - New { name: "first" }.apply(&mut fx.state()).unwrap(); - New { name: "second" }.apply(&mut fx.state()).unwrap(); // now selected + New { + name: "first", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); + New { + name: "second", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); // now selected let selected = *fx.selected_files().iter().next().unwrap(); Metadata { @@ -95,7 +105,12 @@ mod tests { /// A new file with `nb_tracks` tracks (without name), selected. fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) { - New { name: "file" }.apply(&mut fx.state()).unwrap(); + New { + name: "file", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); for _ in 0..nb_tracks { NewTrack.apply(&mut fx.state()).unwrap(); } diff --git a/gpx-rs/engine/src/engine/command/edit/new_track.rs b/gpx-rs/engine/src/engine/command/edit/new_track.rs index b6642a88d..c9cb844b6 100644 --- a/gpx-rs/engine/src/engine/command/edit/new_track.rs +++ b/gpx-rs/engine/src/engine/command/edit/new_track.rs @@ -37,7 +37,12 @@ mod tests { #[test] fn test_new_track() { let mut fx = Fixture::default(); - New { name: "file" }.apply(&mut fx.state()).unwrap(); + New { + name: "file", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); NewTrack.apply(&mut fx.state()).unwrap(); NewTrack.apply(&mut fx.state()).unwrap(); let file = fx.files.values().next().unwrap(); @@ -48,7 +53,12 @@ mod tests { #[test] fn test_new_track_inherits_agreeing_style_only() { let mut fx = Fixture::default(); - New { name: "file" }.apply(&mut fx.state()).unwrap(); + New { + name: "file", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); NewTrack.apply(&mut fx.state()).unwrap(); NewTrack.apply(&mut fx.state()).unwrap(); let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap()); diff --git a/gpx-rs/engine/src/engine/command/edit/new_track_segment.rs b/gpx-rs/engine/src/engine/command/edit/new_track_segment.rs index a91594730..fa8a15d8d 100644 --- a/gpx-rs/engine/src/engine/command/edit/new_track_segment.rs +++ b/gpx-rs/engine/src/engine/command/edit/new_track_segment.rs @@ -26,7 +26,12 @@ mod tests { #[test] fn test_new_track_segment_on_each_track_of_selected_file() { let mut fx = Fixture::default(); - New { name: "file" }.apply(&mut fx.state()).unwrap(); + New { + name: "file", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); NewTrack.apply(&mut fx.state()).unwrap(); NewTrack.apply(&mut fx.state()).unwrap(); NewTrackSegment.apply(&mut fx.state()).unwrap(); diff --git a/gpx-rs/engine/src/engine/command/file/delete.rs b/gpx-rs/engine/src/engine/command/file/delete.rs index f394a81ab..0918c4b9e 100644 --- a/gpx-rs/engine/src/engine/command/file/delete.rs +++ b/gpx-rs/engine/src/engine/command/file/delete.rs @@ -130,7 +130,12 @@ mod tests { #[test] fn test_delete_selected_files() { let (mut fx, id) = loaded(); - crate::New { name: "keep" }.apply(&mut fx.state()).unwrap(); + crate::New { + name: "keep", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let keep = fx.order.0[1]; fx.selection = Selection::File { file_ids: HashSet::from([id]), @@ -263,7 +268,12 @@ mod tests { }, ] { let (mut fx, id) = loaded(); - crate::New { name: "keep" }.apply(&mut fx.state()).unwrap(); + crate::New { + name: "keep", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let keep = fx.order.0[1]; fx.selection = selection(id, &fx); Delete { whole_files: true }.apply(&mut fx.state()).unwrap(); diff --git a/gpx-rs/engine/src/engine/command/file/delete_all.rs b/gpx-rs/engine/src/engine/command/file/delete_all.rs index d57cc8f2f..badc81d93 100644 --- a/gpx-rs/engine/src/engine/command/file/delete_all.rs +++ b/gpx-rs/engine/src/engine/command/file/delete_all.rs @@ -28,8 +28,18 @@ mod tests { DeleteAll.apply(&mut fx.state()), Err(CommandError::NothingToDo) ); - crate::New { name: "a" }.apply(&mut fx.state()).unwrap(); - crate::New { name: "b" }.apply(&mut fx.state()).unwrap(); + crate::New { + name: "a", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); + crate::New { + name: "b", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); assert!(DeleteAll.apply(&mut fx.state()).is_ok()); assert!(fx.files.is_empty()); assert!(fx.order.0.is_empty()); diff --git a/gpx-rs/engine/src/engine/command/file/duplicate.rs b/gpx-rs/engine/src/engine/command/file/duplicate.rs index aa4183de1..afea09546 100644 --- a/gpx-rs/engine/src/engine/command/file/duplicate.rs +++ b/gpx-rs/engine/src/engine/command/file/duplicate.rs @@ -195,7 +195,12 @@ mod tests { fn test_duplicate_files_go_after_last_selected() { let mut fx = Fixture::default(); for name in ["a", "b", "c"] { - crate::New { name }.apply(&mut fx.state()).unwrap(); + crate::New { + name, + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); } let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]]; fx.selection = Selection::File { diff --git a/gpx-rs/engine/src/engine/command/file/load.rs b/gpx-rs/engine/src/engine/command/file/load.rs index 40f98da96..327b1ffd8 100644 --- a/gpx-rs/engine/src/engine/command/file/load.rs +++ b/gpx-rs/engine/src/engine/command/file/load.rs @@ -82,7 +82,12 @@ mod tests { #[test] fn test_load_invalid_file_changes_nothing() { let mut fx = Fixture::default(); - crate::New { name: "keep" }.apply(&mut fx.state()).unwrap(); + crate::New { + name: "keep", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let selected = fx.selected_files(); let result = Load { @@ -132,9 +137,12 @@ mod tests { #[test] fn test_load_several_files() { let mut fx = Fixture::default(); - crate::New { name: "before" } - .apply(&mut fx.state()) - .unwrap(); + crate::New { + name: "before", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let before = fx.order.0[0]; let (a, b, c) = (gpx("a"), gpx("b"), gpx("c")); @@ -178,7 +186,12 @@ mod tests { #[test] fn test_load_several_invalid_files_changes_nothing() { let mut fx = Fixture::default(); - crate::New { name: "keep" }.apply(&mut fx.state()).unwrap(); + crate::New { + name: "keep", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let selected = fx.selected_files(); let result = LoadFiles { diff --git a/gpx-rs/engine/src/engine/command/file/new.rs b/gpx-rs/engine/src/engine/command/file/new.rs index 0be364e30..7ecf31437 100644 --- a/gpx-rs/engine/src/engine/command/file/new.rs +++ b/gpx-rs/engine/src/engine/command/file/new.rs @@ -1,8 +1,19 @@ -use crate::{Apply, CommandError, File, State, produce}; +use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce}; +/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a +/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single +/// undo step. #[derive(Debug)] pub struct New<'a> { pub name: &'a str, + pub trackpoint: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct NewTrackpoint { + pub lng: f64, + pub lat: f64, + pub ele: f64, } impl Apply for New<'_> { @@ -10,6 +21,26 @@ impl Apply for New<'_> { produce(state, |_| { let mut file = File::default(); file.info.name = self.name.to_owned(); + if let Some(point) = self.trackpoint { + let mut segment = TrackSegment::default(); + // the ends of a segment are anchors of the routing tool + segment.splice( + 0, + 0, + vec![Trackpoint { + coordinates: LngLat { + lng: point.lng, + lat: point.lat, + }, + ele: point.ele, + ..Default::default() + }], + ); + file.trk.push(Track { + trkseg: vec![segment], + ..Default::default() + }); + } vec![file] }); Ok(()) @@ -25,7 +56,14 @@ mod tests { #[test] fn test_new() { let mut fx = Fixture::default(); - assert!(New { name: "new" }.apply(&mut fx.state()).is_ok()); + assert!( + New { + name: "new", + trackpoint: None + } + .apply(&mut fx.state()) + .is_ok() + ); assert_eq!(fx.files.len(), 1); let file = fx.files.values().next().unwrap(); assert_eq!(file.info.name, "new"); @@ -33,4 +71,31 @@ mod tests { assert_eq!(fx.selected_files(), [file.id].into()); assert_eq!(fx.order.0, vec![file.id]); } + + #[test] + fn test_new_with_a_trackpoint() { + let mut fx = Fixture::default(); + let point = NewTrackpoint { + lng: 1.0, + lat: 2.0, + ele: 3.0, + }; + New { + name: "new", + trackpoint: Some(point), + } + .apply(&mut fx.state()) + .unwrap(); + let file = fx.files.values().next().unwrap(); + assert_eq!(file.trk.len(), 1); + assert_eq!(file.trk[0].trkseg.len(), 1); + let segment = &file.trk[0].trkseg[0]; + assert_eq!(segment.len(), 1); + assert_eq!(segment[0].coordinates.lng, 1.0); + assert_eq!(segment[0].coordinates.lat, 2.0); + assert_eq!(segment[0].ele, 3.0); + // the only trackpoint is an anchor of the routing tool + assert_eq!(segment[0].anchor, Some(0)); + assert_eq!(fx.selected_files(), [file.id].into()); + } } diff --git a/gpx-rs/engine/src/engine/command/tools/change_loop_start.rs b/gpx-rs/engine/src/engine/command/tools/change_loop_start.rs new file mode 100644 index 000000000..9cea0f451 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/change_loop_start.rs @@ -0,0 +1,144 @@ +use std::rc::Rc; + +use crate::{Apply, CommandError, State, Trackpoint, replace_trackpoints}; + +use super::route::find_target; + +/// Makes the trackpoint `index` of the selection (see `RoutingBuffer`) the start of its segment, +/// which is a loop: the trackpoints before it, and itself, are copied after the last trackpoint +/// of the segment, and the original ones are removed. The trackpoint is both the first and the +/// last one, which closes the loop on it. +/// +/// The copies keep their durations, and start one second after the previous last trackpoint if +/// the segment has timestamps (see [`replace_trackpoints`]). +/// +/// It is how the routing tool starts a loop somewhere else. The first and last trackpoints are +/// anchors shown at every zoom level, as any end of a segment. +#[derive(Debug)] +pub struct ChangeLoopStart { + pub index: u32, +} + +impl Apply for ChangeLoopStart { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let index = self.index as usize; + let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?; + let mut file = (*state.files[&target.location.file_id]).clone(); + let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg]; + let (start, len) = (target.start, segment.len()); + if start == 0 || start >= len { + // it already is the start, or it is not a trackpoint + return Err(CommandError::NothingToDo); + } + + // the trackpoint itself ends the loop as well + let moved: Vec = segment.iter().take(start + 1).cloned().collect(); + // the copies are shifted after the last trackpoint, which is all that matters of the rest + let mut tail = vec![segment[len - 1].clone()]; + replace_trackpoints(&mut tail, 1, 1, moved, None, None, false); + tail.remove(0); + + // only the chunks at the ends of the segment change: the others stay shared + segment.splice(len, len, tail); + segment.splice(0, start, vec![]); + segment.rev_id = Default::default(); + state.files.insert(target.location.file_id, Rc::new(file)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{FileId, Load, Selection, TrackSegment, engine::command::fixture::Fixture}; + + use super::*; + + fn loaded(path: &str) -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read(path).unwrap(); + Load { + data: &data, + name: "file", + } + .apply(&mut fx.state()) + .unwrap(); + let id = fx.order.0[0]; + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + (fx, id) + } + + fn first_segment(fx: &Fixture, id: FileId) -> TrackSegment { + fx.files[&id].trk[0].trkseg[0].clone() + } + + #[test] + fn test_the_points_before_move_to_the_end_and_the_loop_closes_on_the_start() { + let (mut fx, id) = loaded("data/simple.gpx"); + let before = first_segment(&fx, id); + let n = before.len(); + assert!(n > 10); + + ChangeLoopStart { index: 4 }.apply(&mut fx.state()).unwrap(); + let after = first_segment(&fx, id); + // the trackpoint is repeated at the end + assert_eq!(after.len(), n + 1); + assert_ne!(after.rev_id, before.rev_id); + let lng = |s: &TrackSegment, i: usize| s[i].coordinates.lng; + for i in 0..=n { + assert_eq!(lng(&after, i), lng(&before, (i + 4) % n), "{i}"); + } + assert_eq!(lng(&after, n), lng(&after, 0)); + // the start and the end of the new segment are anchors, whatever they were + assert_eq!(after[0].anchor, Some(0)); + assert_eq!(after[n].anchor, Some(0)); + } + + #[test] + fn test_the_moved_part_is_shifted_after_the_last_point() { + let (mut fx, id) = loaded("data/with_time.gpx"); + let before = first_segment(&fx, id); + let n = before.len(); + ChangeLoopStart { index: 30 } + .apply(&mut fx.state()) + .unwrap(); + let after = first_segment(&fx, id); + assert_eq!(after.len(), n + 1); + let times: Vec<_> = after.iter().map(|p| p.time.unwrap()).collect(); + assert!(times.windows(2).all(|w| w[0] < w[1]), "{times:?}"); + // the part that was after the index is untouched + for i in 0..n - 30 { + assert_eq!(after[i].time, before[30 + i].time); + } + // the copies follow the last point by a second, and keep their durations + let last = before[n - 1].time.unwrap(); + assert_eq!(after[n - 30].time, Some(last + 1000)); + for i in n - 30..n { + let original = i - (n - 30); + assert_eq!( + after[i + 1].time.unwrap() - after[i].time.unwrap(), + before[original + 1].time.unwrap() - before[original].time.unwrap(), + "{i}" + ); + } + } + + #[test] + fn test_nothing_to_do_on_the_start_or_out_of_the_trackpoints() { + let (mut fx, id) = loaded("data/simple.gpx"); + let before = fx.files[&id].clone(); + assert_eq!( + ChangeLoopStart { index: 0 }.apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + let total = first_segment(&fx, id).len() as u32; + assert!(matches!( + ChangeLoopStart { index: total + 5 }.apply(&mut fx.state()), + Err(CommandError::InvalidData(_)) + )); + assert!(Rc::ptr_eq(&fx.files[&id], &before)); + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/insert_anchor.rs b/gpx-rs/engine/src/engine/command/tools/insert_anchor.rs new file mode 100644 index 000000000..54907729b --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/insert_anchor.rs @@ -0,0 +1,230 @@ +use std::rc::Rc; + +use crate::{Apply, CommandError, LngLat, SegmentLocation, State, Trackpoint, distance, projected}; + +/// Under this distance (in meters) to a trackpoint, that trackpoint becomes the anchor instead of +/// a new one being inserted next to it. +const SNAP_DISTANCE: f64 = 1.0; + +/// Makes an anchor of the routing tool, shown at every zoom level, of the point of the selected +/// segments that is the closest to the given coordinates. That is the trackpoint itself if the +/// coordinates are on one, otherwise a new trackpoint is inserted on the path, at the position +/// of the coordinates projected on it, with an elevation and a time in between the ones of its +/// neighbours. +#[derive(Debug)] +pub struct InsertAnchor { + pub lng: f64, + pub lat: f64, +} + +/// Where the anchor goes: between the trackpoints `before` and `before + 1` of a segment, at +/// `ratio` of the distance from the first one, which is `projection`. `distance` is the one from +/// there to the coordinates, in meters. +struct Position { + location: SegmentLocation, + before: usize, + ratio: f64, + projection: LngLat, + distance: f64, +} + +impl Apply for InsertAnchor { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let target = LngLat { + lng: self.lng, + lat: self.lat, + }; + let mut closest: Option = None; + for location in state + .selection + .segment_locations(state.files, &state.order.0) + { + let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg]; + let mut points = segment.iter(); + let Some(mut previous) = points.next() else { + continue; + }; + if segment.len() == 1 { + // a single point is its own path + let d = distance(previous.coordinates, target) * 1000.0; + if closest.as_ref().is_none_or(|c| d < c.distance) { + closest = Some(Position { + location, + before: 0, + ratio: 0.0, + projection: previous.coordinates, + distance: d, + }); + } + } + for (i, point) in points.enumerate() { + let projection = projected(previous.coordinates, point.coordinates, target); + let d = distance(projection, target) * 1000.0; + if closest.as_ref().is_none_or(|c| d < c.distance) { + let length = distance(previous.coordinates, point.coordinates); + let ratio = if length > 0.0 { + distance(previous.coordinates, projection) / length + } else { + 0.0 + }; + closest = Some(Position { + location, + before: i, + ratio, + projection, + distance: d, + }); + } + previous = point; + } + } + let closest = closest.ok_or(CommandError::NothingToDo)?; + + let mut file = (*state.files[&closest.location.file_id]).clone(); + let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg]; + let before = closest.before; + // on a trackpoint already, if it is closer than `SNAP_DISTANCE` + let snapped = if segment.len() == 1 { + Some(0) + } else { + let (a, b) = (&segment[before], &segment[before + 1]); + let meters = 1000.0 * distance(a.coordinates, b.coordinates); + if closest.ratio * meters < SNAP_DISTANCE { + Some(before) + } else if (1.0 - closest.ratio) * meters < SNAP_DISTANCE { + Some(before + 1) + } else { + None + } + }; + if let Some(index) = snapped { + if segment[index].anchor == Some(0) { + return Err(CommandError::NothingToDo); + } + segment.set_anchor(index, 0); + } else { + let (a, b) = (&segment[before], &segment[before + 1]); + let ratio = closest.ratio; + let mut point: Trackpoint = a.clone(); + point.coordinates = closest.projection; + point.ele = (1.0 - ratio) * a.ele + ratio * b.ele; + point.time = a + .time + .zip(b.time) + .map(|(a, b)| ((1.0 - ratio) * a as f64 + ratio * b as f64) as i64); + point.anchor = Some(0); + segment.splice(before + 1, before + 1, vec![point]); + } + segment.rev_id = Default::default(); + state.files.insert(closest.location.file_id, Rc::new(file)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{FileId, Load, Selection, engine::command::fixture::Fixture}; + + use super::*; + + fn loaded(path: &str) -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read(path).unwrap(); + Load { + data: &data, + name: "file", + } + .apply(&mut fx.state()) + .unwrap(); + let id = fx.order.0[0]; + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + (fx, id) + } + + fn first_segment(fx: &Fixture, id: FileId) -> crate::TrackSegment { + fx.files[&id].trk[0].trkseg[0].clone() + } + + #[test] + fn test_a_point_is_inserted_between_its_neighbours() { + let (mut fx, id) = loaded("data/with_time.gpx"); + let before = first_segment(&fx, id); + let (a, b) = (&before[10], &before[11]); + // a bit off the line, closer to the middle of the two points + let target = LngLat { + lng: (a.coordinates.lng + b.coordinates.lng) / 2.0 + 1e-6, + lat: (a.coordinates.lat + b.coordinates.lat) / 2.0 + 1e-6, + }; + InsertAnchor { + lng: target.lng, + lat: target.lat, + } + .apply(&mut fx.state()) + .unwrap(); + + let after = first_segment(&fx, id); + assert_eq!(after.len(), before.len() + 1); + assert_ne!(after.rev_id, before.rev_id); + let point = &after[11]; + assert_eq!(point.anchor, Some(0)); + assert_eq!(after[10].coordinates.lng, a.coordinates.lng); + assert_eq!(after[12].coordinates.lng, b.coordinates.lng); + // between its neighbours, with the interpolated elevation and time + let ratio = (point.time.unwrap() - a.time.unwrap()) as f64 + / (b.time.unwrap() - a.time.unwrap()) as f64; + assert!((0.3..0.7).contains(&ratio), "{ratio}"); + let expected = a.ele + ratio * (b.ele - a.ele); + assert!( + (point.ele - expected).abs() < 0.5, + "{} {expected}", + point.ele + ); + } + + #[test] + fn test_a_trackpoint_that_is_hit_becomes_the_anchor() { + let (mut fx, id) = loaded("data/with_time.gpx"); + let before = first_segment(&fx, id); + assert_eq!(before[10].anchor.is_some(), before[10].anchor == Some(0)); + let point = &before[10]; + InsertAnchor { + lng: point.coordinates.lng, + lat: point.coordinates.lat, + } + .apply(&mut fx.state()) + .unwrap(); + let after = first_segment(&fx, id); + assert_eq!(after.len(), before.len()); + let anchors = (0..after.len()) + .filter(|i| after[*i].anchor == Some(0)) + .count(); + assert!(anchors >= 3, "{anchors}"); + assert!( + after + .iter() + .zip(before.iter()) + .all(|(a, b)| a.coordinates.lng == b.coordinates.lng) + ); + // doing it again changes nothing + let again = InsertAnchor { + lng: point.coordinates.lng, + lat: point.coordinates.lat, + } + .apply(&mut fx.state()); + assert!(again.is_ok() || again == Err(CommandError::NothingToDo)); + } + + #[test] + fn test_nothing_to_do_without_a_segment() { + let (mut fx, _) = loaded("data/with_time.gpx"); + fx.selection = Selection::Empty; + assert_eq!( + InsertAnchor { lng: 1.0, lat: 1.0 }.apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/mod.rs b/gpx-rs/engine/src/engine/command/tools/mod.rs index b4f088c18..b9256ab3e 100644 --- a/gpx-rs/engine/src/engine/command/tools/mod.rs +++ b/gpx-rs/engine/src/engine/command/tools/mod.rs @@ -1,25 +1,29 @@ +mod change_loop_start; mod clean; mod crop; mod edit_waypoint; mod elevation; mod extract; +mod insert_anchor; mod merge; mod move_waypoint; mod new_waypoint; mod reverse; -mod splice_trackpoints; +mod route; mod split; mod time; +pub use change_loop_start::*; pub use clean::*; pub use crop::*; pub use edit_waypoint::*; pub use elevation::*; pub use extract::*; +pub use insert_anchor::*; pub use merge::*; pub use move_waypoint::*; pub use new_waypoint::*; pub use reverse::*; -pub use splice_trackpoints::*; +pub use route::*; pub use split::*; pub use time::*; diff --git a/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs index 6499ff0d4..563b7fd17 100644 --- a/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs +++ b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs @@ -114,8 +114,18 @@ mod tests { #[test] fn test_new_waypoint_in_the_selected_file() { let mut fx = Fixture::default(); - New { name: "a" }.apply(&mut fx.state()).unwrap(); - New { name: "b" }.apply(&mut fx.state()).unwrap(); + New { + name: "a", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); + New { + name: "b", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let (a, b) = (fx.order.0[0], fx.order.0[1]); let rev = fx.files[&b].wpt_rev_id; @@ -155,7 +165,12 @@ mod tests { #[test] fn test_empty_fields_are_not_set() { let mut fx = Fixture::default(); - New { name: "a" }.apply(&mut fx.state()).unwrap(); + New { + name: "a", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let a = fx.order.0[0]; NewWaypoint { lng: 1.0, @@ -177,7 +192,12 @@ mod tests { fn test_each_selected_file_gets_its_own_waypoint() { let mut fx = Fixture::default(); for name in ["a", "b", "c"] { - New { name }.apply(&mut fx.state()).unwrap(); + New { + name, + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); } let ids = fx.order.0.clone(); fx.selection = Selection::File { @@ -210,7 +230,12 @@ mod tests { }, ] { let mut fx = Fixture::default(); - New { name: "a" }.apply(&mut fx.state()).unwrap(); + New { + name: "a", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); let a = fx.order.0[0]; fx.selection = selection(a); new_waypoint("w").apply(&mut fx.state()).unwrap(); @@ -221,7 +246,12 @@ mod tests { #[test] fn test_nothing_selected_or_unknown_file() { let mut fx = Fixture::default(); - New { name: "a" }.apply(&mut fx.state()).unwrap(); + New { + name: "a", + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); fx.selection = Selection::Empty; assert_eq!( new_waypoint("w").apply(&mut fx.state()), diff --git a/gpx-rs/engine/src/engine/command/tools/route.rs b/gpx-rs/engine/src/engine/command/tools/route.rs new file mode 100644 index 000000000..176f4128e --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/route.rs @@ -0,0 +1,810 @@ +use std::rc::Rc; + +use crate::{ + Apply, Categories, CommandError, FileId, SegmentLocation, Selection, State, Statistics, Track, + TrackSegment, Trackpoint, distance, replace_trackpoints, +}; + +/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Route`], as +/// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first +/// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index +/// of its name in `names`. No intervals means that the attribute is unknown everywhere. +#[derive(Debug, Default, Clone, Copy)] +pub struct RouteCategory<'a> { + pub starts: &'a [u32], + pub values: &'a [u8], + pub names: &'a [String], +} + +impl RouteCategory<'_> { + /// The code in `categories` of the value of each of the `len` new trackpoints. A name is + /// added to the table of the categories if it is new. + fn codes( + &self, + categories: &mut Categories, + len: usize, + ) -> Result>, CommandError> { + if self.starts.len() != self.values.len() { + return Err(invalid("intervals of different lengths")); + } + let mut codes = vec![None; len]; + for (i, (start, value)) in self.starts.iter().zip(self.values).enumerate() { + let end = self.starts.get(i + 1).map_or(len, |end| *end as usize); + let start = *start as usize; + if start > end || end > len { + return Err(invalid("intervals out of the trackpoints")); + } + if *value == 0 { + continue; + } + let name = self + .names + .get(usize::from(*value) - 1) + .ok_or_else(|| invalid("unknown name of category"))?; + codes[start..end].fill(categories.code(name)); + } + Ok(codes) + } +} + +/// Replaces the trackpoints `start..end` of a segment of the selection by the given ones (a pure +/// insertion when `start == end`, a pure removal when there are no new points). It is how the +/// routing tool reroutes a part of a segment, extends it, or removes the end of it. +/// +/// `start` and `end` are indices in the trackpoints of the selection (see `RoutingBuffer`): the +/// range has to be in a single segment, and `start == end == the number of trackpoints` appends +/// to the last one. If the selection has no trackpoints at all, they go to its last segment, which +/// is created if needed (in the last track, or in a new one, of the last selected file). +/// +/// The timestamps follow: the new points get some if the segment has, at the speed it had on the +/// replaced part (see [`replace_trackpoints`]). +/// +/// The new points listed in `anchors` become anchors of the routing tool, shown at every zoom +/// level. The anchors of the segment out of the range are kept, and the ends of the segment are +/// anchors anyway. The ones right before and after the range, between which the route goes, become +/// shown at every zoom level as well. +#[derive(Debug)] +pub struct Route<'a> { + pub start: u32, + pub end: u32, + pub lng: &'a [f64], + pub lat: &'a [f64], + pub ele: &'a [f64], + pub surface: RouteCategory<'a>, + pub highway: RouteCategory<'a>, + pub sac_scale: RouteCategory<'a>, + pub mtb_scale: RouteCategory<'a>, + /// Indices among the new trackpoints. + pub anchors: &'a [u32], +} + +fn invalid(message: &str) -> CommandError { + CommandError::InvalidData(message.into()) +} + +/// A segment of the selection, and the range of its trackpoints that is concerned. +pub(super) struct Target { + pub location: SegmentLocation, + pub start: usize, + pub end: usize, +} + +impl Apply for Route<'_> { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let (start, end) = (self.start as usize, self.end as usize); + let len = self.lng.len(); + if start > end || self.lat.len() != len || self.ele.len() != len { + return Err(invalid("invalid trackpoints range")); + } + if self.anchors.iter().any(|anchor| *anchor as usize >= len) { + return Err(invalid("anchor out of the new trackpoints")); + } + if start == end && len == 0 { + return Err(CommandError::NothingToDo); + } + + let categories = &mut *state.categories; + let surface = self.surface.codes(&mut categories.surface, len)?; + let highway = self.highway.codes(&mut categories.highway, len)?; + let sac_scale = self.sac_scale.codes(&mut categories.sac_scale, len)?; + let mtb_scale = self.mtb_scale.codes(&mut categories.mtb_scale, len)?; + let points: Vec = (0..len) + .map(|i| Trackpoint { + coordinates: crate::LngLat { + lng: self.lng[i], + lat: self.lat[i], + }, + ele: self.ele[i], + surface: surface[i], + highway: highway[i], + sac_scale: sac_scale[i], + mtb_scale: mtb_scale[i], + ..Default::default() + }) + .collect(); + let mut points = points; + for anchor in self.anchors { + points[*anchor as usize].anchor = Some(0); + } + + let mut file; + let target = match find_target(state, start, end)? { + Some(target) => { + file = (*state.files[&target.location.file_id]).clone(); + target + } + None => { + let (file_id, trk) = container(state).ok_or(CommandError::NothingToDo)?; + if start != 0 || end != 0 { + return Err(invalid("trackpoints range out of bounds")); + } + file = (*state.files[&file_id]).clone(); + // the segment of the last track, which may be new + let trk = match trk.or_else(|| file.trk.len().checked_sub(1)) { + Some(trk) => trk, + None => { + file.trk.push(Track::default()); + file.trk.len() - 1 + } + }; + if file.trk[trk].trkseg.is_empty() { + file.trk[trk].trkseg.push(TrackSegment::default()); + } + let seg = file.trk[trk].trkseg.len() - 1; + Target { + location: SegmentLocation { file_id, trk, seg }, + start: 0, + end: 0, + } + } + }; + + let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg]; + let new_len = points.len(); + let (speed, start_time) = speed_and_start_time(segment, &target, &points); + let has_times = !segment.is_empty() && segment[0].time.is_some(); + if speed.is_some() || has_times { + // the timestamps of the whole segment can change + let mut all: Vec = segment.iter().cloned().collect(); + let old_len = all.len(); + replace_trackpoints( + &mut all, + target.start, + target.end, + points, + speed, + start_time, + false, + ); + segment.splice(0, old_len, all); + } else { + segment.splice(target.start, target.end, points); + } + // the anchors next to the new points are the ones that were moved or removed around: + // they become permanent + for index in [target.start.checked_sub(1), Some(target.start + new_len)] + .into_iter() + .flatten() + { + if index < segment.len() && segment[index].anchor.is_some_and(|zoom| zoom != 0) { + segment.set_anchor(index, 0); + } + } + segment.rev_id = Default::default(); + state.files.insert(target.location.file_id, Rc::new(file)); + Ok(()) + } +} + +/// The segment of the trackpoints `start..end` of the selection. +pub(super) fn find_target( + state: &State, + start: usize, + end: usize, +) -> Result, CommandError> { + let locations = state + .selection + .segment_locations(state.files, &state.order.0); + let segments: Vec<(SegmentLocation, usize)> = locations + .into_iter() + .map(|location| { + let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg]; + (location, segment.len()) + }) + .collect(); + let total: usize = segments.iter().map(|(_, len)| len).sum(); + + if total == 0 { + // an empty segment of the selection, if there is one + return match segments.last() { + Some((location, _)) if start == 0 && end == 0 => Ok(Some(Target { + location: *location, + start: 0, + end: 0, + })), + Some(_) => Err(invalid("trackpoints range out of bounds")), + None => Ok(None), + }; + } + + let mut offset = 0; + for (location, len) in segments.iter().filter(|(_, len)| *len > 0) { + // appending to the selection is appending to its last segment + let appends = offset + len == total && start == total; + if start >= offset && (start < offset + len || appends) { + if end > offset + len { + return Err(invalid("trackpoints range over several segments")); + } + return Ok(Some(Target { + location: *location, + start: start - offset, + end: end - offset, + })); + } + offset += len; + } + Err(invalid("trackpoints range out of bounds")) +} + +/// The file, and the track (if the selection is one), where to create a segment in a selection +/// that has none. +fn container(state: &State) -> Option<(FileId, Option)> { + match &*state.selection { + Selection::File { file_ids } => { + let id = state + .order + .0 + .iter() + .rev() + .find(|id| file_ids.contains(id)) + .or_else(|| file_ids.iter().next())?; + state.files.contains_key(id).then_some((*id, None)) + } + Selection::Track { file_id, trk_ids } => { + let trk = state + .files + .get(file_id)? + .trk + .iter() + .rposition(|trk| trk_ids.contains(&trk.id))?; + Some((*file_id, Some(trk))) + } + _ => None, + } +} + +/// The speed at which the new points are reached and the time of the first one, when the segment +/// has times to keep consistent: the speed is the one that the segment had on the part that is +/// replaced, adjusted so that the moving speed of the whole segment stays what it was. +/// +/// The replaced part goes from the trackpoint before the range (the anchor that stays) to the one +/// after it, or to the ends of the segment: the new points are the route between them. +/// +/// This is what the routing tool computed from the statistics of the segment before. +fn speed_and_start_time( + segment: &TrackSegment, + target: &Target, + points: &[Trackpoint], +) -> (Option, Option) { + let time_at = |index: usize| { + (index < segment.len()) + .then(|| segment[index].time) + .flatten() + }; + let start_time = time_at(target.start); + if points.is_empty() || segment.is_empty() { + return (None, start_time); + } + let stats = Statistics::compute(segment); + let Some(moving_speed) = stats.global.moving_speed().filter(|speed| *speed > 0.0) else { + return (None, start_time); + }; + + // the statistics where the replaced part starts and ends + let first_index = target.start.saturating_sub(1); + let last_index = target.end.min(segment.len() - 1); + let (first, last) = (&stats.local[first_index], &stats.local[last_index]); + let moving_distance = + |stats: &crate::TrackpointStatistics| stats.moving_distance.unwrap_or(0.0); + let moving_time = |stats: &crate::TrackpointStatistics| stats.moving_time.unwrap_or(0); + let total_time = |stats: &crate::TrackpointStatistics| stats.total_time.unwrap_or(0); + + let replacing_distance: f64 = points + .windows(2) + .map(|pair| distance(pair[0].coordinates, pair[1].coordinates)) + .sum(); + let replaced_distance = moving_distance(last) - moving_distance(first); + let global_moving_distance = stats.global.moving_distance.unwrap_or(0.0); + let new_distance = global_moving_distance + replacing_distance - replaced_distance; + // in milliseconds + let new_time = new_distance / moving_speed * 3_600_000.0; + let remaining_time = stats.global.moving_time.unwrap_or(0) as f64 + - (moving_time(last) - moving_time(first)) as f64; + let mut replacing_time = new_time - remaining_time; + if replacing_time <= 0.0 { + // fall back to the time that the replaced part took + replacing_time = (total_time(last) - total_time(first)) as f64; + } + let speed = replacing_distance / (replacing_time / 3_600_000.0); + + let start_time = start_time.or_else(|| { + // The first trackpoint has no time: the new points end when the last replaced one did, + // after the time they take and the time that the segment was not moving until then. + // (Suspicious: without a time for the last one either, it is "0", the year 1970.) + let end_time = time_at(last_index); + let stopped = (total_time(last) - moving_time(last)) as f64; + Some(end_time.unwrap_or(0) - (replacing_time + stopped) as i64) + }); + (Some(speed), start_time) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{File, Load, TrackSegmentId, engine::command::fixture::Fixture}; + + use super::*; + + fn load(fx: &mut Fixture, path: &str) -> FileId { + let data = std::fs::read(path).unwrap(); + Load { + data: &data, + name: "file", + } + .apply(&mut fx.state()) + .unwrap(); + *fx.order.0.last().unwrap() + } + + fn select_file(fx: &mut Fixture, id: FileId) { + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + } + + /// The first segment of the file with segments, selected. + fn loaded() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let id = load(&mut fx, "data/with_tracks_and_segments.gpx"); + let trk = &fx.files[&id].trk[0]; + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id: trk.id, + trkseg_ids: HashSet::from([trk.trkseg[0].id]), + }; + (fx, id) + } + + fn route<'a>( + start: u32, + end: u32, + lng: &'a [f64], + lat: &'a [f64], + ele: &'a [f64], + ) -> Route<'a> { + Route { + start, + end, + lng, + lat, + ele, + surface: Default::default(), + highway: Default::default(), + sac_scale: Default::default(), + mtb_scale: Default::default(), + anchors: &[], + } + } + + fn segment<'a>(fx: &'a Fixture, id: FileId, trk: usize, seg: usize) -> &'a TrackSegment { + &fx.files[&id].trk[trk].trkseg[seg] + } + + #[test] + fn test_route_replaces_points_of_the_selected_segment_only() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let len = before.trk[0].trkseg[0].len(); + assert!(len >= 3); + + route(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0]) + .apply(&mut fx.state()) + .unwrap(); + + let after = &fx.files[&id]; + let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]); + assert_eq!(new.len(), len + 1); + assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng); + assert_eq!(new[1].coordinates.lng, 1.0); + assert_eq!(new[3].coordinates.lat, 6.0); + assert_eq!(new[3].ele, 9.0); + assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng); + assert_ne!(new.rev_id, old.rev_id); + assert_eq!(new.id, old.id); + // other segments and tracks keep their revision + for (b, a) in before.trk.iter().zip(&after.trk) { + for (b, a) in b.trkseg.iter().zip(&a.trkseg) { + if a.id != new.id { + assert_eq!(a.rev_id, b.rev_id); + } + } + } + } + + #[test] + fn test_route_append_and_remove() { + let (mut fx, id) = loaded(); + let len = fx.files[&id].trk[0].trkseg[0].len() as u32; + route(len, len, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + let seg = segment(&fx, id, 0, 0); + assert_eq!(seg.len(), len as usize + 1); + assert_eq!(seg[len as usize].ele, 3.0); + + route(0, len + 1, &[], &[], &[]) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(segment(&fx, id, 0, 0).len(), 0); + } + + #[test] + fn test_route_invalid_arguments_change_nothing() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let len = before.trk[0].trkseg[0].len() as u32; + let results = [ + route(2, 1, &[], &[], &[]).apply(&mut fx.state()), + route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()), + route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()), + route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()), + Route { + anchors: &[1], + ..route(0, 0, &[1.0], &[1.0], &[1.0]) + } + .apply(&mut fx.state()), + ]; + assert!( + results + .iter() + .all(|r| matches!(r, Err(CommandError::InvalidData(_)))), + "{results:?}" + ); + assert_eq!( + route(0, 0, &[], &[], &[]).apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + assert!(Rc::ptr_eq(&fx.files[&id], &before)); + } + + #[test] + fn test_route_needs_a_selection_with_a_file() { + let (mut fx, _) = loaded(); + fx.selection = Selection::Empty; + assert_eq!( + route(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + } + + #[test] + fn test_route_indices_are_in_the_selection() { + let (mut fx, id) = loaded(); + let file = fx.files[&id].clone(); + select_file(&mut fx, id); + let lens: Vec<_> = file + .trk + .iter() + .flat_map(|trk| trk.trkseg.iter().map(TrackSegment::len)) + .collect(); + assert!(lens.len() >= 2); + + // inside the second segment + let second = lens[0] as u32; + route(second + 1, second + 2, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + let (trk, seg) = (0, 1); + let segments_before: Vec<_> = file.trk.iter().flat_map(|t| &t.trkseg).collect(); + let files = fx.files[&id].clone(); + let segments_after: Vec<_> = files.trk.iter().flat_map(|t| &t.trkseg).collect(); + for (i, (b, a)) in segments_before.iter().zip(&segments_after).enumerate() { + assert_eq!(a.rev_id != b.rev_id, i == 1, "segment {i}"); + } + assert_eq!(segment(&fx, id, trk, seg)[1].coordinates.lng, 1.0); + + // a range over two segments + let total: usize = lens.iter().sum(); + assert!(matches!( + route(second - 1, second + 1, &[], &[], &[]).apply(&mut fx.state()), + Err(CommandError::InvalidData(_)) + )); + // after the last point: the last segment + let before = segments_after.last().unwrap().len(); + let total = total as u32; + route(total, total, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + let last = fx.files[&id].trk.last().unwrap().trkseg.last().unwrap(); + assert_eq!(last.len(), before + 1); + } + + #[test] + fn test_route_creates_the_segment_of_a_selection_that_has_none() { + let mut fx = Fixture::default(); + let file = File::default(); + let id = file.id; + fx.files.insert(id, Rc::new(file)); + fx.order.0.push(id); + select_file(&mut fx, id); + + route(0, 0, &[1.0, 2.0], &[1.0, 2.0], &[3.0, 4.0]) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(fx.files[&id].trk.len(), 1); + assert_eq!(segment(&fx, id, 0, 0).len(), 2); + // the ends are anchors + assert_eq!(segment(&fx, id, 0, 0)[0].anchor, Some(0)); + assert_eq!(segment(&fx, id, 0, 0)[1].anchor, Some(0)); + + // a track with no segments + let mut file = (*fx.files[&id]).clone(); + file.trk[0].trkseg.clear(); + let trk_id = file.trk[0].id; + fx.files.insert(id, Rc::new(file)); + fx.selection = Selection::Track { + file_id: id, + trk_ids: HashSet::from([trk_id]), + }; + route(0, 0, &[5.0], &[5.0], &[5.0]) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(fx.files[&id].trk.len(), 1); + assert_eq!(segment(&fx, id, 0, 0).len(), 1); + + // an empty segment is the target as well + let mut file = (*fx.files[&id]).clone(); + file.trk[0].trkseg[0] = TrackSegment::default(); + let seg_id: TrackSegmentId = file.trk[0].trkseg[0].id; + fx.files.insert(id, Rc::new(file)); + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id, + trkseg_ids: HashSet::from([seg_id]), + }; + route(0, 0, &[6.0], &[6.0], &[6.0]) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(segment(&fx, id, 0, 0).len(), 1); + assert_eq!(fx.files[&id].trk[0].trkseg.len(), 1); + } + + #[test] + fn test_route_anchors() { + let (mut fx, id) = loaded(); + let len = segment(&fx, id, 0, 0).len(); + assert!(len > 4); + let anchors_before: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect(); + + // 5 new points replacing the 2 in the middle, two of them are anchors + let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]); + Route { + anchors: &[1, 3], + ..route(1, 3, &lng, &lat, &ele) + } + .apply(&mut fx.state()) + .unwrap(); + let anchors: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect(); + assert_eq!(anchors.len(), len + 3); + assert_eq!(anchors[2], Some(0)); + assert_eq!(anchors[4], Some(0)); + assert_eq!(anchors[1], None); + assert_eq!(anchors[3], None); + assert_eq!(anchors[5], None); + // the others stay as they were, the ends are always anchors + assert_eq!(anchors[0], Some(0)); + assert_eq!(anchors[len + 2], Some(0)); + assert_eq!(anchors[len + 2 - 1], anchors_before[len - 2]); + } + + #[test] + fn test_route_makes_the_anchors_around_the_range_permanent() { + let (mut fx, id) = loaded(); + let mut file = (*fx.files[&id]).clone(); + let len = file.trk[0].trkseg[0].len(); + assert!(len > 8); + // anchors shown from different zoom levels, around and away from the range 4..6 + for (index, zoom) in [(1, 7), (3, 9), (6, 12), (8, 14)] { + file.trk[0].trkseg[0].set_anchor(index, zoom); + } + fx.files.insert(id, Rc::new(file)); + + route(4, 6, &[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0]) + .apply(&mut fx.state()) + .unwrap(); + let anchors: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect(); + assert_eq!(anchors.len(), len); + // right before and after the new points: permanent + assert_eq!(anchors[3], Some(0)); + assert_eq!(anchors[6], Some(0)); + // the others are as they were + assert_eq!(anchors[1], Some(7)); + assert_eq!(anchors[8], Some(14)); + } + + #[test] + fn test_route_categories() { + let (mut fx, id) = loaded(); + let names = ["asphalt".to_string(), "gravel".to_string()]; + let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]); + // asphalt, asphalt, unknown, gravel + Route { + surface: RouteCategory { + starts: &[0, 2, 3], + values: &[1, 0, 2], + names: &names, + }, + highway: RouteCategory { + starts: &[0], + values: &[1], + names: &["track".to_string()], + }, + ..route(0, 0, &lng, &lat, &ele) + } + .apply(&mut fx.state()) + .unwrap(); + let file = fx.files[&id].clone(); + let seg = &file.trk[0].trkseg[0]; + let asphalt = fx.categories.surface.code("asphalt"); + let gravel = fx.categories.surface.code("gravel"); + let surfaces: Vec<_> = (0..4).map(|i| seg[i].surface).collect(); + assert_eq!(surfaces, [asphalt, asphalt, None, gravel]); + let track = fx.categories.highway.code("track"); + assert!((0..4).all(|i| seg[i].highway == track)); + assert!((0..4).all(|i| seg[i].sac_scale.is_none() && seg[i].mtb_scale.is_none())); + + // broken intervals or names + let broken = [ + RouteCategory { + starts: &[0, 1], + values: &[1], + names: &names, + }, + RouteCategory { + starts: &[5], + values: &[1], + names: &names, + }, + RouteCategory { + starts: &[0], + values: &[3], + names: &names, + }, + ]; + for surface in broken { + let result = Route { + surface, + ..route(0, 0, &lng, &lat, &ele) + } + .apply(&mut fx.state()); + assert!(matches!(result, Err(CommandError::InvalidData(_)))); + } + } + + /// A segment of `data/with_time.gpx`, selected: 80 trackpoints at 20 km/h. + fn timed() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let id = load(&mut fx, "data/with_time.gpx"); + select_file(&mut fx, id); + (fx, id) + } + + fn times(seg: &TrackSegment) -> Vec { + seg.iter().map(|p| p.time.unwrap()).collect() + } + + #[test] + fn test_route_gives_times_to_the_new_points() { + let (mut fx, id) = timed(); + let seg = segment(&fx, id, 0, 0); + let (n, before) = (seg.len(), times(seg)); + let duration = before[n - 1] - before[0]; + + // replace the points 20..40 by the straight line between them, with 5 points + let (a, b) = (seg[20].coordinates, seg[39].coordinates); + let at = |t: f64| (a.lng + t * (b.lng - a.lng), a.lat + t * (b.lat - a.lat)); + let positions: Vec<_> = (0..5).map(|i| at(i as f64 / 4.0)).collect(); + let lng: Vec<_> = positions.iter().map(|p| p.0).collect(); + let lat: Vec<_> = positions.iter().map(|p| p.1).collect(); + route(20, 40, &lng, &lat, &[0.0; 5]) + .apply(&mut fx.state()) + .unwrap(); + + let seg = segment(&fx, id, 0, 0); + assert_eq!(seg.len(), n - 15); + let after = times(seg); + assert!(after.windows(2).all(|w| w[0] < w[1]), "{after:?}"); + // the points before are untouched + assert_eq!(after[..20], before[..20]); + // the speed of the replaced part is the one of the segment: the duration of the segment + // is about the same as on the same distance + let stats = Statistics::compute(seg); + let speed = stats.global.moving_speed().unwrap(); + assert!((speed - 20.0).abs() < 1.0, "{speed}"); + assert!(after[after.len() - 1] - after[0] <= duration); + } + + #[test] + fn test_route_between_anchors_that_stay_keeps_them_untouched() { + let (mut fx, id) = timed(); + let seg = segment(&fx, id, 0, 0); + let (n, before) = (seg.len(), times(seg)); + let (a, b) = (seg[20].clone(), seg[39].clone()); + // 5 points on the line between the anchors 20 and 39, which are not replaced + let at = |t: f64| { + ( + a.coordinates.lng + t * (b.coordinates.lng - a.coordinates.lng), + a.coordinates.lat + t * (b.coordinates.lat - a.coordinates.lat), + ) + }; + let positions: Vec<_> = (1..=5).map(|i| at(i as f64 / 6.0)).collect(); + let lng: Vec<_> = positions.iter().map(|p| p.0).collect(); + let lat: Vec<_> = positions.iter().map(|p| p.1).collect(); + route(21, 39, &lng, &lat, &[0.0; 5]) + .apply(&mut fx.state()) + .unwrap(); + + let seg = segment(&fx, id, 0, 0); + assert_eq!(seg.len(), n - 18 + 5); + let after = times(seg); + assert!(after.windows(2).all(|w| w[0] < w[1]), "{after:?}"); + // the anchors keep their coordinates and times, and what is before them + assert_eq!(after[..=20], before[..=20]); + assert_eq!(seg[26].coordinates.lng, b.coordinates.lng); + assert_eq!(after[26], before[39]); + // and what comes after is unchanged as well + assert_eq!(after[27..], before[40..]); + let speed = Statistics::compute(seg).global.moving_speed().unwrap(); + assert!((speed - 20.0).abs() < 1.0, "{speed}"); + } + + #[test] + fn test_route_appending_to_a_segment_with_times() { + let (mut fx, id) = timed(); + let seg = segment(&fx, id, 0, 0); + let n = seg.len(); + let last = seg[n - 1].clone(); + // replace the last point and go 1 km further east + let lng = [last.coordinates.lng, last.coordinates.lng + 0.009]; + let lat = [last.coordinates.lat; 2]; + route(n as u32 - 1, n as u32, &lng, &lat, &[0.0; 2]) + .apply(&mut fx.state()) + .unwrap(); + let seg = segment(&fx, id, 0, 0); + assert_eq!(seg.len(), n + 1); + // the replaced point is reached at the speed of the segment: about at its time + assert!((seg[n - 1].time.unwrap() - last.time.unwrap()).abs() < 1000); + let (a, b) = (seg[n - 1].time.unwrap(), seg[n].time.unwrap()); + // the new point is reached at about 20 km/h + let km = distance(seg[n - 1].coordinates, seg[n].coordinates); + let seconds = (b - a) as f64 / 1000.0; + let expected = 3600.0 * km / 20.0; + assert!( + (seconds - expected).abs() < 0.1 * expected, + "{seconds} {expected}" + ); + } + + #[test] + fn test_route_does_not_invent_times() { + let mut fx = Fixture::default(); + let id = load(&mut fx, "data/simple.gpx"); + select_file(&mut fx, id); + assert!(segment(&fx, id, 0, 0).iter().all(|p| p.time.is_none())); + route(0, 2, &[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0]) + .apply(&mut fx.state()) + .unwrap(); + assert!(segment(&fx, id, 0, 0).iter().all(|p| p.time.is_none())); + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/splice_trackpoints.rs b/gpx-rs/engine/src/engine/command/tools/splice_trackpoints.rs deleted file mode 100644 index d439d1531..000000000 --- a/gpx-rs/engine/src/engine/command/tools/splice_trackpoints.rs +++ /dev/null @@ -1,315 +0,0 @@ -use std::rc::Rc; - -use crate::{Apply, CommandError, File, FileId, LngLat, Selection, State, Trackpoint}; - -/// Replaces the trackpoints `start..end` of the last track segment of the selection by the -/// given ones (a pure insertion when `start == end`, a pure removal when there are no new -/// points). -/// -/// The selection can be made of several files, tracks or track segments: the indices are -/// meant for its very last segment, in file order, then track order, then segment order. -#[derive(Debug)] -pub struct SpliceTrackpoints<'a> { - pub start: u32, - pub end: u32, - pub lng: &'a [f64], - pub lat: &'a [f64], - pub ele: &'a [f64], -} - -impl Apply for SpliceTrackpoints<'_> { - fn apply(self, state: &mut State) -> Result<(), CommandError> { - let (start, end) = (self.start as usize, self.end as usize); - if start > end || self.lng.len() != self.lat.len() || self.lat.len() != self.ele.len() { - return Err(CommandError::InvalidData( - "invalid trackpoints range".into(), - )); - } - let (file_id, trk, seg) = last_segment(state).ok_or(CommandError::NothingToDo)?; - let mut file = (*state.files[&file_id]).clone(); - let segment = &mut file.trk[trk].trkseg[seg]; - if end > segment.len() { - return Err(CommandError::InvalidData( - "trackpoints range out of bounds".into(), - )); - } - if start == end && self.lng.is_empty() { - return Err(CommandError::NothingToDo); - } - let points = (0..self.lng.len()) - .map(|i| Trackpoint { - coordinates: LngLat { - lng: self.lng[i], - lat: self.lat[i], - }, - ele: self.ele[i], - ..Default::default() - }) - .collect(); - segment.splice(start, end, points); - segment.rev_id = Default::default(); - state.files.insert(file_id, Rc::new(file)); - Ok(()) - } -} - -/// Position (file, track index, segment index) of the last segment of the selection. -fn last_segment(state: &State) -> Option<(FileId, usize, usize)> { - let last_of_track = |file: &File, trk: usize| file.trk[trk].trkseg.len().checked_sub(1); - let last_of_file = |file: &File, filter: &dyn Fn(usize) -> bool| { - (0..file.trk.len()) - .rev() - .filter(|&trk| filter(trk)) - .find_map(|trk| last_of_track(file, trk).map(|seg| (trk, seg))) - }; - match &*state.selection { - Selection::File { file_ids } => state - .order - .0 - .iter() - .rev() - .filter(|id| file_ids.contains(id)) - .find_map(|id| { - let (trk, seg) = last_of_file(state.files.get(id)?, &|_| true)?; - Some((*id, trk, seg)) - }), - Selection::Track { file_id, trk_ids } => { - let file = state.files.get(file_id)?; - let (trk, seg) = last_of_file(file, &|trk| trk_ids.contains(&file.trk[trk].id))?; - Some((*file_id, trk, seg)) - } - Selection::TrackSegment { - file_id, - trk_id, - trkseg_ids, - } => { - let file = state.files.get(file_id)?; - let trk = file.trk.iter().position(|trk| trk.id == *trk_id)?; - let seg = file.trk[trk] - .trkseg - .iter() - .rposition(|seg| trkseg_ids.contains(&seg.id))?; - Some((*file_id, trk, seg)) - } - Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => None, - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use crate::{FileId, Load, engine::command::fixture::Fixture}; - - use super::*; - - fn loaded() -> (Fixture, FileId) { - let mut fx = Fixture::default(); - let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); - Load { - data: &data, - name: "file", - } - .apply(&mut fx.state()) - .unwrap(); - let id = fx.order.0[0]; - let trk = &fx.files[&id].trk[0]; - fx.selection = Selection::TrackSegment { - file_id: id, - trk_id: trk.id, - trkseg_ids: HashSet::from([trk.trkseg[0].id]), - }; - (fx, id) - } - - fn splice<'a>( - start: u32, - end: u32, - lng: &'a [f64], - lat: &'a [f64], - ele: &'a [f64], - ) -> SpliceTrackpoints<'a> { - SpliceTrackpoints { - start, - end, - lng, - lat, - ele, - } - } - - #[test] - fn test_splice_replaces_points_of_selected_segment_only() { - let (mut fx, id) = loaded(); - let before = fx.files[&id].clone(); - let len = before.trk[0].trkseg[0].len(); - assert!(len >= 3); - - splice(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0]) - .apply(&mut fx.state()) - .unwrap(); - - let after = &fx.files[&id]; - let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]); - assert_eq!(new.len(), len + 1); - assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng); - assert_eq!(new[1].coordinates.lng, 1.0); - assert_eq!(new[3].coordinates.lat, 6.0); - assert_eq!(new[3].ele, 9.0); - assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng); - assert_ne!(new.rev_id, old.rev_id); - assert_eq!(new.id, old.id); - // other segments and tracks keep their revision - for (b, a) in before.trk.iter().zip(&after.trk) { - for (b, a) in b.trkseg.iter().zip(&a.trkseg) { - if a.id != new.id { - assert_eq!(a.rev_id, b.rev_id); - } - } - } - } - - #[test] - fn test_splice_append_and_remove() { - let (mut fx, id) = loaded(); - let len = fx.files[&id].trk[0].trkseg[0].len() as u32; - splice(len, len, &[1.0], &[2.0], &[3.0]) - .apply(&mut fx.state()) - .unwrap(); - let seg = &fx.files[&id].trk[0].trkseg[0]; - assert_eq!(seg.len(), len as usize + 1); - assert_eq!(seg[len as usize].ele, 3.0); - - splice(0, len + 1, &[], &[], &[]) - .apply(&mut fx.state()) - .unwrap(); - assert_eq!(fx.files[&id].trk[0].trkseg[0].len(), 0); - } - - #[test] - fn test_splice_invalid_arguments_change_nothing() { - let (mut fx, id) = loaded(); - let before = fx.files[&id].clone(); - let len = before.trk[0].trkseg[0].len() as u32; - let results = [ - splice(2, 1, &[], &[], &[]).apply(&mut fx.state()), - splice(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()), - splice(0, len + 1, &[], &[], &[]).apply(&mut fx.state()), - ]; - assert!( - results - .iter() - .all(|r| matches!(r, Err(CommandError::InvalidData(_)))) - ); - assert_eq!( - splice(0, 0, &[], &[], &[]).apply(&mut fx.state()), - Err(CommandError::NothingToDo) - ); - assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before)); - } - - #[test] - fn test_splice_needs_a_segment_selection() { - let (mut fx, _) = loaded(); - fx.selection = Selection::Empty; - assert_eq!( - splice(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()), - Err(CommandError::NothingToDo) - ); - } - - #[test] - fn test_splice_targets_last_segment_of_the_selection() { - let (mut fx, id) = loaded(); - let file = fx.files[&id].clone(); - assert!(file.trk.len() >= 2); - let last_trk = file.trk.len() - 1; - let last_seg = file.trk[last_trk].trkseg.len() - 1; - - // whole file selected: last segment of its last track - fx.selection = Selection::File { - file_ids: HashSet::from([id]), - }; - splice(0, 0, &[1.0], &[2.0], &[3.0]) - .apply(&mut fx.state()) - .unwrap(); - let after = &fx.files[&id]; - for (t, (b, a)) in file.trk.iter().zip(&after.trk).enumerate() { - for (s, (b, a)) in b.trkseg.iter().zip(&a.trkseg).enumerate() { - let target = (t, s) == (last_trk, last_seg); - assert_eq!(a.len(), b.len() + usize::from(target)); - assert_eq!(a.rev_id != b.rev_id, target); - } - } - - // several tracks selected: last selected one (in file order, whatever the set order) - let ids: Vec<_> = file.trk.iter().map(|t| t.id).collect(); - fx.selection = Selection::Track { - file_id: id, - trk_ids: HashSet::from([ids[1], ids[0]]), - }; - let len = |fx: &Fixture, t: usize| { - let trk = &fx.files[&id].trk[t]; - trk.trkseg.last().unwrap().len() - }; - let (l0, l1) = (len(&fx, 0), len(&fx, 1)); - splice(0, 0, &[1.0], &[2.0], &[3.0]) - .apply(&mut fx.state()) - .unwrap(); - assert_eq!(len(&fx, 0), l0); - assert_eq!(len(&fx, 1), l1 + 1); - - // several segments selected - let trk = &fx.files[&id].trk[0]; - let seg_ids: Vec<_> = trk.trkseg.iter().map(|s| s.id).collect(); - if seg_ids.len() >= 2 { - fx.selection = Selection::TrackSegment { - file_id: id, - trk_id: trk.id, - trkseg_ids: seg_ids.iter().copied().collect(), - }; - let lens: Vec<_> = trk.trkseg.iter().map(|s| s.len()).collect(); - splice(0, 0, &[1.0], &[2.0], &[3.0]) - .apply(&mut fx.state()) - .unwrap(); - let after: Vec<_> = fx.files[&id].trk[0] - .trkseg - .iter() - .map(|s| s.len()) - .collect(); - let n = lens.len(); - assert_eq!(after[..n - 1], lens[..n - 1]); - assert_eq!(after[n - 1], lens[n - 1] + 1); - } - } - - #[test] - fn test_splice_on_multiple_files_uses_last_in_file_order() { - let (mut fx, first) = loaded(); - let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); - Load { - data: &data, - name: "file", - } - .apply(&mut fx.state()) - .unwrap(); - let second = fx.order.0[1]; - fx.selection = Selection::File { - file_ids: HashSet::from([second, first]), - }; - let total = |fx: &Fixture, id: FileId| -> usize { - fx.files[&id] - .trk - .iter() - .flat_map(|t| &t.trkseg) - .map(|s| s.len()) - .sum() - }; - let (a, b) = (total(&fx, first), total(&fx, second)); - splice(0, 0, &[1.0], &[2.0], &[3.0]) - .apply(&mut fx.state()) - .unwrap(); - assert_eq!(total(&fx, first), a); - assert_eq!(total(&fx, second), b + 1); - } -} diff --git a/gpx-rs/engine/src/engine/derived/mod.rs b/gpx-rs/engine/src/engine/derived/mod.rs index 953aa22f6..b46e3a83d 100644 --- a/gpx-rs/engine/src/engine/derived/mod.rs +++ b/gpx-rs/engine/src/engine/derived/mod.rs @@ -1,11 +1,13 @@ mod coordinates_cache; mod file_structure; mod file_structure_cache; +mod routing_buffer; mod statistics_buffer; mod statistics_cache; pub use coordinates_cache::*; pub use file_structure::*; pub use file_structure_cache::*; +pub use routing_buffer::*; pub use statistics_buffer::*; pub use statistics_cache::*; diff --git a/gpx-rs/engine/src/engine/derived/routing_buffer.rs b/gpx-rs/engine/src/engine/derived/routing_buffer.rs new file mode 100644 index 000000000..5c7df7c57 --- /dev/null +++ b/gpx-rs/engine/src/engine/derived/routing_buffer.rs @@ -0,0 +1,162 @@ +use crate::{FileId, Selection, StackEntry, TrackSegmentId, TrackSegmentRevisionId}; + +/// What the routing tool needs about the selected segments (see +/// [`Selection::segment_locations`]): where their anchors are among their trackpoints. +/// +/// Trackpoints are numbered like in the [`crate::StatisticsBuffer`], over all the selected +/// segments one after the other, except that the segments that are not covered by the routing +/// tool (waypoints) are left out. +#[derive(Debug, Default)] +pub struct RoutingBuffer { + /// The index of each anchor in the selection, in order. The first and last trackpoints of a + /// segment always are anchors. + pub anchor_indices: Vec, + /// The lowest map zoom level at which each anchor is shown. + pub anchor_zooms: Vec, + /// Index in the selection of the first trackpoint of each selected segment that has some, + /// which tells which segment an anchor or a trackpoint belongs to. + pub segment_starts: Vec, + /// Id of each selected segment that has trackpoints, as `segment_starts`. + pub segment_ids: Vec, + /// Changes when the selected segments, or their trackpoints, change. Commands that refer to + /// trackpoints by their index in the selection carry it, to be refused if the selection is + /// not the one the indices were taken from anymore. + pub revision: u32, + /// What the revision stands for. + signature: Vec<(TrackSegmentId, TrackSegmentRevisionId)>, +} + +impl RoutingBuffer { + pub fn update(&mut self, files: Option<&StackEntry>, selection: &Selection, order: &[FileId]) { + self.anchor_indices.clear(); + self.anchor_zooms.clear(); + self.segment_starts.clear(); + self.segment_ids.clear(); + + let mut signature = vec![]; + let mut index = 0; + for location in files + .map(|files| selection.segment_locations(files, order)) + .unwrap_or_default() + { + let segment = &files.unwrap()[&location.file_id].trk[location.trk].trkseg[location.seg]; + signature.push((segment.id, segment.rev_id)); + if segment.is_empty() { + continue; + } + self.segment_starts.push(index as u32); + self.segment_ids.push(segment.id); + for trkpt in segment.iter() { + if let Some(zoom) = trkpt.anchor { + self.anchor_indices.push(index as u32); + self.anchor_zooms.push(zoom); + } + index += 1; + } + } + + if signature != self.signature { + self.revision = self.revision.wrapping_add(1); + self.signature = signature; + } + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashSet, rc::Rc}; + + use crate::{TrackSegment, parse}; + + use super::*; + + fn loaded() -> (StackEntry, FileId) { + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + let file = parse(&data, &mut Default::default()).unwrap(); + let id = file.id; + let mut files = StackEntry::default(); + files.insert(id, Rc::new(file)); + (files, id) + } + + fn file_selection(id: FileId) -> Selection { + Selection::File { + file_ids: HashSet::from([id]), + } + } + + #[test] + fn test_anchors_and_starts_follow_the_selected_segments() { + let (files, id) = loaded(); + let lengths: Vec = files[&id] + .trk + .iter() + .flat_map(|trk| trk.trkseg.iter().map(TrackSegment::len)) + .collect(); + let mut buffer = RoutingBuffer::default(); + buffer.update(Some(&files), &file_selection(id), &[id]); + + // an empty segment has no start + assert_eq!(buffer.segment_ids.len(), buffer.segment_starts.len()); + let mut start = 0; + let expected: Vec = lengths + .iter() + .filter(|len| **len > 0) + .map(|len| { + start += len; + (start - len) as u32 + }) + .collect(); + assert_eq!(buffer.segment_starts, expected); + // the ends of the segments are always anchors, shown at every zoom level + assert_eq!(buffer.anchor_indices.len(), buffer.anchor_zooms.len()); + assert!(buffer.anchor_indices.windows(2).all(|w| w[0] < w[1])); + let total: usize = lengths.iter().sum(); + let ends = expected + .iter() + .copied() + .chain(expected.iter().skip(1).map(|start| start - 1)) + .chain([total as u32 - 1]); + for end in ends { + let position = buffer.anchor_indices.iter().position(|i| *i == end); + assert_eq!(position.map(|p| buffer.anchor_zooms[p]), Some(0), "{end}"); + } + } + + #[test] + fn test_nothing_without_segments() { + let (files, id) = loaded(); + let mut buffer = RoutingBuffer::default(); + buffer.update(Some(&files), &Selection::Empty, &[id]); + assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty()); + // waypoints are not covered by the routing tool + buffer.update(Some(&files), &Selection::Waypoints { file_id: id }, &[id]); + assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty()); + buffer.update(None, &file_selection(id), &[id]); + assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty()); + } + + #[test] + fn test_revision_changes_with_the_selected_segments() { + let (mut files, id) = loaded(); + let mut buffer = RoutingBuffer::default(); + buffer.update(Some(&files), &file_selection(id), &[id]); + let first = buffer.revision; + + // the same segments: the indices still mean the same trackpoints + buffer.update(Some(&files), &file_selection(id), &[id]); + assert_eq!(buffer.revision, first); + + // other trackpoints in the same segment + let mut file = (*files[&id]).clone(); + file.trk[0].trkseg[0].rev_id = Default::default(); + files.insert(id, Rc::new(file)); + buffer.update(Some(&files), &file_selection(id), &[id]); + assert_ne!(buffer.revision, first); + let second = buffer.revision; + + // another selection + buffer.update(Some(&files), &Selection::Empty, &[id]); + assert_ne!(buffer.revision, second); + } +} diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index e59983258..dc7537732 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -2,8 +2,9 @@ use crate::{ Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, - FileStructureCache, GlobalStatistics, SelectMode, Selection, Stack, State, StatisticsBuffer, - StatisticsCache, TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, WaypointId, + FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection, Stack, State, + StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, + WaypointId, }; #[derive(Debug, Default)] @@ -18,6 +19,7 @@ pub struct Engine { order_changed: bool, selection_changed: bool, statistics_buffer: StatisticsBuffer, + routing_buffer: RoutingBuffer, categories: TrackpointCategories, clipboard: Option, clipboard_changed: bool, @@ -28,6 +30,11 @@ impl Engine { &self.statistics_buffer } + /// The anchors of the routing tool among the trackpoints of the selection. + pub fn routing(&self) -> &RoutingBuffer { + &self.routing_buffer + } + /// The names of the surfaces and highways that the trackpoints refer to by code. pub fn categories(&self) -> &TrackpointCategories { &self.categories @@ -249,6 +256,8 @@ impl Engine { self.statistics_cache.update(current); self.coordinates_cache.update(current); self.structure_cache.update(current, self.diff.as_ref()); + self.routing_buffer + .update(current, &self.selection, &self.order.0); self.statistics_buffer .update( &self @@ -283,7 +292,13 @@ mod tests { } fn new(engine: &mut Engine, name: &str) -> bool { - edit(engine, Command::New(New { name })) + edit( + engine, + Command::New(New { + name, + trackpoint: None, + }), + ) } fn load(engine: &mut Engine, path: &str) -> bool { diff --git a/gpx-rs/engine/src/engine/state/selection.rs b/gpx-rs/engine/src/engine/state/selection.rs index b3447c1ce..eab395f07 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -40,7 +40,59 @@ pub enum Selection { }, } +/// Where a track segment is: its file, then its track and its own position in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SegmentLocation { + pub file_id: FileId, + pub trk: usize, + pub seg: usize, +} + impl Selection { + /// The track segments covered by the selection, in order: the segments of several selected + /// files follow `order` (the files missing from it come last), then the tracks and segments + /// follow the file. Waypoints are not segments: selecting only waypoints covers none. + pub fn segment_locations(&self, files: &StackEntry, order: &[FileId]) -> Vec { + let mut locations = vec![]; + let mut add = |file_id: FileId, + trk_filter: &dyn Fn(&crate::Track) -> bool, + seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| { + let Some(file) = files.get(&file_id) else { + return; + }; + for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) { + for (seg, segment) in track.trkseg.iter().enumerate() { + if seg_filter(segment) { + locations.push(SegmentLocation { file_id, trk, seg }); + } + } + } + }; + match self { + Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => (), + Selection::File { file_ids } => { + let ordered = order + .iter() + .filter(|id| file_ids.contains(id)) + .chain(file_ids.iter().filter(|id| !order.contains(id))); + for id in ordered { + add(*id, &|_| true, &|_| true); + } + } + Selection::Track { file_id, trk_ids } => { + add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true) + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => add(*file_id, &|trk| trk.id == *trk_id, &|seg| { + trkseg_ids.contains(&seg.id) + }), + } + locations + } + /// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments /// and waypoints of the files that remain. A selection left with nothing becomes empty. pub fn retain_existing(&mut self, files: &StackEntry) { diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 509a279e4..ba867b12b 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -237,11 +237,63 @@ pub fn mtb_scales() -> StringList { .unchecked_into() } +// Routing buffers +// +// The anchors of the routing tool among the trackpoints of the selection (the numbering is the one +// of the statistics buffers). The ends of the segments are always anchors. + +fn with_routing(f: impl FnOnce(&engine::RoutingBuffer) -> T) -> Option { + ENGINE.with(|engine| engine.borrow().as_ref().map(|e| f(e.routing()))) +} + +/// The index in the selection of each anchor. +#[wasm_bindgen] +pub fn anchor_indices() -> Uint32Array { + with_routing(|r| Uint32Array::from(&r.anchor_indices[..])) + .unwrap_or_else(|| Uint32Array::new_with_length(0)) +} + +/// The lowest map zoom level at which each anchor is shown. +#[wasm_bindgen] +pub fn anchor_zooms() -> Uint8Array { + with_routing(|r| Uint8Array::from(&r.anchor_zooms[..])) + .unwrap_or_else(|| Uint8Array::new_with_length(0)) +} + +/// Index in the selection of the first trackpoint of each selected segment that has some. +#[wasm_bindgen] +pub fn segment_starts() -> Uint32Array { + with_routing(|r| Uint32Array::from(&r.segment_starts[..])) + .unwrap_or_else(|| Uint32Array::new_with_length(0)) +} + +/// The id of each selected segment that has trackpoints, as `segment_starts`. +#[wasm_bindgen] +pub fn segment_ids() -> StringList { + with_routing(|r| array(&r.segment_ids, |id| id.0.to_string().into())) + .unwrap_or_default() + .unchecked_into() +} + +/// Changes when the selected segments or their trackpoints change: the indices in the selection +/// are only valid while it stays the same. +#[wasm_bindgen] +pub fn routing_revision() -> u32 { + with_routing(|r| r.revision).unwrap_or_default() +} + // File commands +/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that +/// hold that trackpoint (at `ele`, 0 by default). #[wasm_bindgen] -pub fn new_file(name: &str) -> bool { - edit(Command::New(engine::New { name })) +pub fn new_file(name: &str, lng: Option, lat: Option, ele: Option) -> bool { + let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint { + lng, + lat, + ele: ele.unwrap_or_default(), + }); + edit(Command::New(engine::New { name, trackpoint })) } /// Loads files as a single command (one undo step). The files are in `data`, one after the @@ -323,19 +375,114 @@ pub fn reverse() -> bool { edit(Command::Reverse(engine::Reverse)) } +// Routing +// +// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only +// mean something for the `revision` they were read with (`routing_revision`), and the commands +// that use them do nothing if the selection is not the one of that revision anymore. + +fn routing_revision_is(revision: u32) -> bool { + with_routing(|r| r.revision == revision).unwrap_or(false) +} + +/// A category of the trackpoints of a route: see `RouteCategory` in `engine`. +struct ParsedCategory { + starts: Vec, + values: Vec, + names: Vec, +} + +impl ParsedCategory { + fn parse(attributes: &JsValue, key: &str) -> Option { + let Some(category) = property(attributes, key) else { + return Some(Self { + starts: vec![], + values: vec![], + names: vec![], + }); + }; + Some(Self { + starts: property(&category, "starts")? + .unchecked_into::() + .to_vec(), + values: property(&category, "values")? + .unchecked_into::() + .to_vec(), + names: Array::from(&property(&category, "names")?) + .iter() + .map(|name| name.as_string()) + .collect::>()?, + }) + } + + fn view(&self) -> engine::RouteCategory<'_> { + engine::RouteCategory { + starts: &self.starts, + values: &self.values, + names: &self.names, + } + } +} + +/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment +/// (see `RoutingBuffer`). A pure insertion if `start == end`, a pure removal without new points. +/// `attributes` holds the surface, highway, SAC scale and MTB scale of the new points, as +/// intervals, and `anchors` the indices among the new points of the ones that become anchors. +/// Does nothing, and returns false, if the revision of the routing buffers is not the given one. #[wasm_bindgen] -pub fn splice_trackpoints(start: u32, end: u32, lng: &[f64], lat: &[f64], ele: &[f64]) -> bool { - start <= end +#[allow(clippy::too_many_arguments)] +pub fn route( + revision: u32, + start: u32, + end: u32, + lng: &[f64], + lat: &[f64], + ele: &[f64], + attributes: RouteAttributes, + anchors: &[u32], +) -> bool { + let parse = |key| ParsedCategory::parse(&attributes, key); + let (Some(surface), Some(highway), Some(sac_scale), Some(mtb_scale)) = ( + parse("surface"), + parse("highway"), + parse("sacScale"), + parse("mtbScale"), + ) else { + return false; + }; + routing_revision_is(revision) && same_len(lng, lat, ele) - && edit(Command::SpliceTrackpoints(engine::SpliceTrackpoints { + && edit(Command::Route(engine::Route { start, end, lng, lat, ele, + surface: surface.view(), + highway: highway.view(), + sac_scale: sac_scale.view(), + mtb_scale: mtb_scale.view(), + anchors, })) } +/// Makes an anchor of the point of the selected segments that is the closest to the coordinates, +/// inserting a trackpoint on the path if none is there. Does nothing, and returns false, if the +/// revision of the routing buffers is not the given one. +#[wasm_bindgen] +pub fn insert_anchor(revision: u32, lng: f64, lat: f64) -> bool { + routing_revision_is(revision) && edit(Command::InsertAnchor(engine::InsertAnchor { lng, lat })) +} + +/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the +/// trackpoints before it go to the end, and the loop closes on it). Does nothing, and returns false, if the revision of the +/// routing buffers is not the given one. +#[wasm_bindgen] +pub fn change_loop_start(revision: u32, index: u32) -> bool { + routing_revision_is(revision) + && edit(Command::ChangeLoopStart(engine::ChangeLoopStart { index })) +} + #[wasm_bindgen] pub fn new_waypoint( lng: f64, @@ -739,6 +886,27 @@ pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], mode: SelectMo // trackpoints of a segment and the waypoints of a file are in the same order as the coordinates // of their buffers (see below). +#[wasm_bindgen(typescript_custom_section)] +const ROUTE_TS: &str = r#" +/** + * An OSM attribute of the new trackpoints of a route, as the intervals of trackpoints that share + * a value: `starts[i]` is the index of the first trackpoint of the interval `i`, and `values[i]` + * its value, 0 when unknown, else 1 + the index of its name in `names`. + */ +export interface RouteCategory { + starts: Uint32Array; + values: Uint8Array; + names: string[]; +} +/** The attributes of the new trackpoints of a route. The ones that are missing are unknown. */ +export interface RouteAttributes { + surface?: RouteCategory; + highway?: RouteCategory; + sacScale?: RouteCategory; + mtbScale?: RouteCategory; +} +"#; + #[wasm_bindgen(typescript_custom_section)] const FILE_STRUCTURE_TS: &str = r#" export type Selection = @@ -866,6 +1034,9 @@ extern "C" { pub type TrackpointDetails; #[wasm_bindgen(typescript_type = "MoveTarget")] pub type MoveTarget; + + #[wasm_bindgen(typescript_type = "RouteAttributes")] + pub type RouteAttributes; #[wasm_bindgen(typescript_type = "Clipboard | undefined")] pub type Clipboard; #[wasm_bindgen(typescript_type = "FilesUpdate")] diff --git a/website/src/lib/closest-point.test.ts b/website/src/lib/closest-point.test.ts index 2928d86bd..b4766e934 100644 --- a/website/src/lib/closest-point.test.ts +++ b/website/src/lib/closest-point.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { closestPointIndex } from './closest-point'; +import { closestPointIndex, closestPointIndexIn } from './closest-point'; describe('closestPointIndex', () => { const line = [0, 0, 1, 0, 2, 0, 2, 1]; @@ -27,3 +27,24 @@ describe('closestPointIndex', () => { assert.equal(closestPointIndex(coordinates, { lng: 3, lat: 0.01 }), 0); }); }); + +describe('closestPointIndexIn', () => { + const lng = [0, 1, 2, 2, 5, 6]; + const lat = [0, 0, 0, 1, 5, 5]; + + it('gives the index in the arrays, looking at the given range only', () => { + assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 0.8, lat: 0.1 }), 1); + // the range starts after the closest points + assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 0.8, lat: 0.1 }), 4); + assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 5.9, lat: 5.1 }), 5); + }); + + it('has no point in an empty range', () => { + assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined); + assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined); + }); + + it('is the point itself for a range of one point', () => { + assert.equal(closestPointIndexIn(lng, lat, 3, 4, { lng: 9, lat: 9 }), 3); + }); +}); diff --git a/website/src/lib/closest-point.ts b/website/src/lib/closest-point.ts index d73f48bc1..a43d387c5 100644 --- a/website/src/lib/closest-point.ts +++ b/website/src/lib/closest-point.ts @@ -9,7 +9,40 @@ export function closestPointIndex( coordinates: ArrayLike, point: { lng: number; lat: number } ): number | undefined { - const count = Math.floor(coordinates.length / 2); + return closestIndex( + Math.floor(coordinates.length / 2), + (i) => coordinates[2 * i], + (i) => coordinates[2 * i + 1], + point + ); +} + +/** + * Like `closestPointIndex`, for the points `from` (included) to `to` (excluded) of a polyline + * given as the arrays of its longitudes and latitudes. The index is the one in the arrays. + */ +export function closestPointIndexIn( + lng: ArrayLike, + lat: ArrayLike, + from: number, + to: number, + point: { lng: number; lat: number } +): number | undefined { + const closest = closestIndex( + Math.max(0, to - from), + (i) => lng[from + i], + (i) => lat[from + i], + point + ); + return closest === undefined ? undefined : from + closest; +} + +function closestIndex( + count: number, + lngAt: (index: number) => number, + latAt: (index: number) => number, + point: { lng: number; lat: number } +): number | undefined { if (count === 0) { return undefined; } @@ -17,8 +50,8 @@ export function closestPointIndex( const ky = metersPerDegree; const px = point.lng * kx; const py = point.lat * ky; - const x = (i: number) => coordinates[2 * i] * kx - px; - const y = (i: number) => coordinates[2 * i + 1] * ky - py; + const x = (i: number) => lngAt(i) * kx - px; + const y = (i: number) => latAt(i) * ky - py; let best = 0; let bestDistance = Number.MAX_VALUE; diff --git a/website/src/lib/components/toolbar/tools/routing/Routing.svelte b/website/src/lib/components/toolbar/tools/routing/Routing.svelte index 891185b33..f0cec8696 100644 --- a/website/src/lib/components/toolbar/tools/routing/Routing.svelte +++ b/website/src/lib/components/toolbar/tools/routing/Routing.svelte @@ -24,23 +24,15 @@ import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing'; import { i18n } from '$lib/i18n.svelte'; import { slide } from 'svelte/transition'; - import { - ListFileItem, - ListRootItem, - ListTrackItem, - ListTrackSegmentItem, - type ListItem, - } from '$lib/components/file-list/file-list'; + import { get } from 'svelte/store'; import { getURLForLanguage } from '$lib/utils'; import { onDestroy, onMount } from 'svelte'; - import { TrackPoint } from 'gpx'; import { settings } from '$lib/logic/settings'; import { map } from '$lib/components/map/map'; - import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state'; - import { selection } from '$lib/logic/selection'; - import { fileActions, getFileIds, newGPXFile } from '$lib/logic/file-actions'; + import { engine } from '$lib/engine'; + import { fileActions, newFileName } from '$lib/logic/file-actions'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; - import { RoutingControls, routingControls } from './routing-controls'; + import { RoutingControls } from './routing-controls'; let { minimized = $bindable(false), @@ -58,52 +50,24 @@ const { privateRoads, routing, routingProfile } = settings; - let fileStateCollectionObserver: GPXFileStateCollectionObserver; + const selection = engine.selection; + let routingControls: RoutingControls | undefined = undefined; + // the tool works on files, tracks and segments, not on waypoints let validSelection = $derived( - $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) + $selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment' ); function createFileWithPoint(e: any) { - if ($selection.size === 0) { - let file = newGPXFile(); - file.replaceTrackPoints(0, 0, 0, 0, [ - new TrackPoint({ - attributes: { - lat: e.lngLat.lat, - lon: e.lngLat.lng, - }, - }), - ]); - file._data.id = getFileIds(1)[0]; - fileActions.add(file); - selection.selectFileWhenLoaded(file._data.id); + if ($selection.type === 'empty') { + // the engine selects the new file, which starts with the trackpoint: one undo step + engine.newFile(newFileName(), { lng: e.lngLat.lng, lat: e.lngLat.lat }); } } onMount(() => { if ($map && popup && popupElement) { - fileStateCollectionObserver = new GPXFileStateCollectionObserver( - (newFiles) => { - newFiles.forEach((fileState, fileId) => { - routingControls.set( - fileId, - new RoutingControls(fileId, fileState, popup, popupElement) - ); - }); - }, - (fileId) => { - const controls = routingControls.get(fileId); - if (controls) { - controls.destroy(); - routingControls.delete(fileId); - } - }, - () => { - routingControls.forEach((controls) => controls.destroy()); - routingControls.clear(); - } - ); + routingControls = new RoutingControls(popup, popupElement); mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, true); $map.on('click', createFileWithPoint); @@ -112,9 +76,8 @@ onDestroy(() => { if ($map) { - if (fileStateCollectionObserver) { - fileStateCollectionObserver.destroy(); - } + routingControls?.destroy(); + routingControls = undefined; mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false); $map.off('click', createFileWithPoint); @@ -203,31 +166,10 @@ class="gap-1 text-xs px-1.5 py-1.5 h-fit" disabled={!validSelection} onclick={() => { - const selected = selection.getOrderedSelection(); - if (selected.length > 0) { - const firstFileId = selected[0].getFileId(); - const firstFile = fileStateCollection.getFile(firstFileId); - if (firstFile) { - let start = (() => { - if (selected[0] instanceof ListFileItem) { - return firstFile.trk[0]?.trkseg[0]?.trkpt[0]; - } else if (selected[0] instanceof ListTrackItem) { - return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0] - ?.trkpt[0]; - } else if (selected[0] instanceof ListTrackSegmentItem) { - return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[ - selected[0].getSegmentIndex() - ]?.trkpt[0]; - } - })(); - - if (start !== undefined) { - const lastFileId = selected[selected.length - 1].getFileId(); - routingControls - .get(lastFileId) - ?.appendAnchorWithCoordinates(start.getCoordinates()); - } - } + // the first trackpoint of the selection + const { length, lng, lat } = get(engine.statistics); + if (length > 0) { + routingControls?.appendAnchorWithCoordinates({ lng: lng[0], lat: lat[0] }); } }} > diff --git a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts index dc976594f..31a66c8a3 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts @@ -1,5 +1,5 @@ -import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx'; -import { get, writable, type Readable } from 'svelte/store'; +import { distance, type Coordinates } from 'gpx'; +import { get, writable } from 'svelte/store'; import maplibregl, { type MapMouseEvent, type GeoJSONSource, @@ -8,59 +8,86 @@ import maplibregl, { } from 'maplibre-gl'; import { route } from './routing'; import { toast } from 'svelte-sonner'; -import { - ListFileItem, - ListTrackItem, - ListTrackSegmentItem, -} from '$lib/components/file-list/file-list'; -import { getClosestLinePoint, loadSVGIcon } from '$lib/utils'; -import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree'; +import { loadSVGIcon } from '$lib/utils'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; -import { settings } from '$lib/logic/settings'; -import { selection } from '$lib/logic/selection'; import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { streetViewEnabled } from '$lib/components/map/street-view-control/utils'; -import { fileActionManager } from '$lib/logic/file-action-manager'; import { i18n } from '$lib/i18n.svelte'; import { map } from '$lib/components/map/map'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; -import { MAX_ANCHOR_ZOOM, MIN_ANCHOR_ZOOM } from './simplify'; +import { settings } from '$lib/logic/settings'; +import { closestPointIndexIn } from '$lib/closest-point'; +import { + engine, + type Selection, + type SelectionAnchors, + type SelectionStatistics, + type StatisticsRequest, +} from '$lib/engine'; const { streetViewSource } = settings; + +/** Whether the popup of an anchor can offer to start the loop there. */ export const canChangeStart = writable(false); -type AnchorProperties = { - trackIndex: number; - segmentIndex: number; - pointIndex: number; - anchorIndex: number; - minZoom: number; -}; -type Anchor = GeoJSON.Feature; +const MIN_ANCHOR_ZOOM = 0; +const MAX_ANCHOR_ZOOM = 22; +/** An anchor of a segment of the selection, a trackpoint that can be dragged to reroute it. */ +type Anchor = { + /** Position among the anchors of the selection, which identifies its feature on the map. */ + id: number; + /** Index of the trackpoint in the selection. */ + index: number; + /** Position among the segments of the selection. */ + segment: number; + /** Lowest map zoom level at which the anchor is shown. */ + zoom: number; + lng: number; + lat: number; +}; + +type AnchorProperties = { anchorIndex: number; minZoom: number }; +type AnchorFeature = GeoJSON.Feature; + +type Position = { lng: number; lat: number }; + +/** Whether the tool has to show controls for a selection: it covers segments, or can get some. */ +function isRoutable(selection: Selection) { + return selection.type === 'file' || selection.type === 'track' || selection.type === 'segment'; +} + +function toCoordinates({ lng, lat }: Position): Coordinates { + return { lat, lon: lng }; +} + +/** + * The anchors of the selection of the engine, shown on the map where the user can drag them to + * reroute the segments, or click on the segments to add some. Every interaction is turned into a + * command of the engine, which finds the files and segments by the indices of the trackpoints in + * the selection. + */ export class RoutingControls { - active: boolean = false; - fileId: string = ''; - file: Readable; - layers: Map< - number, - { - id: string; - anchors: GeoJSON.Feature[]; - } - > = new Map(); - anchors: GeoJSON.Feature[] = []; + active = false; + layers: Map = new Map(); + anchors: Anchor[] = []; + statistics: SelectionStatistics | undefined = undefined; + /** What the tool needs from the engine, while it shows the anchors. */ + request: StatisticsRequest = engine.requestStatistics(); + selection: Selection = { type: 'empty' }; + /** Ids of the files whose line layer is listened to. */ + listenedFileIds: string[] = []; popup: maplibregl.Popup; popupElement: HTMLElement; - fileUnsubscribe: () => void = () => {}; unsubscribes: Function[] = []; - updateControlsBinded: () => void = this.updateControls.bind(this); + updateBinded: () => void = this.update.bind(this); appendAnchorBinded: (e: MapMouseEvent) => void = this.appendAnchor.bind(this); addIntermediateAnchorBinded: (e: MapMouseEvent) => void = this.addIntermediateAnchor.bind(this); - draggedAnchorIndex: number | null = null; - lastDraggedAnchorEventTime: number = 0; + /** Id of the anchor that is dragged: `anchors.length` for the temporary anchor. */ + draggedAnchorId: number | null = null; + lastDraggedAnchorEventTime = 0; draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0); onMouseEnterBinded: () => void = this.onMouseEnter.bind(this); onMouseLeaveBinded: () => void = this.onMouseLeave.bind(this); @@ -72,53 +99,55 @@ export class RoutingControls { onMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void = this.onMouseUp.bind(this); - temporaryAnchor: GeoJSON.Feature | null = null; + /** + * Where the pointer hovers a segment of the selection: an anchor that can be dragged or + * clicked to be added. + */ + temporaryAnchor: (Position & { segment: number }) | null = null; showTemporaryAnchorBinded: (e: MapLayerMouseEvent) => void = this.showTemporaryAnchor.bind(this); updateTemporaryAnchorBinded: (e: MapMouseEvent) => void = this.updateTemporaryAnchor.bind(this); - constructor( - fileId: string, - file: Readable, - popup: maplibregl.Popup, - popupElement: HTMLElement - ) { - this.fileId = fileId; - this.file = file; + constructor(popup: maplibregl.Popup, popupElement: HTMLElement) { for (let zoom = MIN_ANCHOR_ZOOM; zoom <= MAX_ANCHOR_ZOOM; zoom++) { - this.layers.set(zoom, { - id: `routing-controls-${this.fileId}-${zoom}`, - anchors: [], - }); + this.layers.set(zoom, { id: `routing-controls-${zoom}`, features: [] }); } this.popup = popup; this.popupElement = popupElement; - this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this))); - this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this))); + this.unsubscribes.push(engine.selection.subscribe(this.onSelection.bind(this))); + this.unsubscribes.push(currentTool.subscribe(this.updateBinded)); + this.unsubscribes.push(engine.statistics.subscribe(this.onStatistics.bind(this))); + this.unsubscribes.push(engine.files.subscribe(this.updateFileListeners.bind(this))); } - addIfNeeded() { - let routing = get(currentTool) === Tool.ROUTING; + onSelection(selection: Selection) { + this.selection = selection; + this.update(); + } + + onStatistics(statistics: SelectionStatistics) { + this.statistics = statistics; + this.update(); + } + + get anchorData(): SelectionAnchors | undefined { + return this.statistics?.anchors; + } + + update() { + const routing = get(currentTool) === Tool.ROUTING && isRoutable(this.selection); + this.request.set(routing ? ['anchors'] : []); if (!routing) { if (this.active) { this.remove(); } return; } - - let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, [ - 'waypoints', - ]); - if (selected) { - if (this.active) { - this.updateControls(); - } else { - this.add(); - } - } else if (this.active) { - this.remove(); + if (!this.active) { + this.add(); } + this.updateControls(); } add() { @@ -132,72 +161,103 @@ export class RoutingControls { this.loadIcons(); - map_.on('style.load', this.updateControlsBinded); + map_.on('style.load', this.updateBinded); map_.on('click', this.appendAnchorBinded); - layerEventManager.on('mousemove', this.fileId, this.showTemporaryAnchorBinded); - layerEventManager.on('click', this.fileId, this.addIntermediateAnchorBinded); + this.updateFileListeners(); + } - this.fileUnsubscribe = this.file.subscribe(this.updateControlsBinded); + /** Listens to the lines of the files, to add anchors where the pointer hovers a segment. */ + updateFileListeners() { + const layerEventManager = map.layerEventManager; + if (!layerEventManager) { + return; + } + const fileIds = this.active ? [...get(engine.files).keys()] : []; + for (const fileId of this.listenedFileIds) { + if (!fileIds.includes(fileId)) { + layerEventManager.off('mousemove', fileId, this.showTemporaryAnchorBinded); + layerEventManager.off('click', fileId, this.addIntermediateAnchorBinded); + } + } + for (const fileId of fileIds) { + if (!this.listenedFileIds.includes(fileId)) { + layerEventManager.on('mousemove', fileId, this.showTemporaryAnchorBinded); + layerEventManager.on('click', fileId, this.addIntermediateAnchorBinded); + } + } + this.listenedFileIds = fileIds; + } + + /** The anchors of the selection, from the statistics of the engine. */ + readAnchors(): Anchor[] { + const statistics = this.statistics; + const data = this.anchorData; + if (!statistics || !data) { + return []; + } + const anchors: Anchor[] = []; + let segment = 0; + for (let id = 0; id < data.indices.length; id++) { + const index = data.indices[id]; + // the anchors are sorted: the segment can only be the same or a following one + while ( + segment + 1 < data.segmentStarts.length && + data.segmentStarts[segment + 1] <= index + ) { + segment++; + } + anchors.push({ + id, + index, + segment, + zoom: data.zooms[id], + lng: statistics.lng[index], + lat: statistics.lat[index], + }); + } + return anchors; + } + + /** First trackpoint of a segment of the selection, and the one after its last one. */ + segmentRange(segment: number): [number, number] { + const starts = this.anchorData?.segmentStarts; + const length = this.statistics?.length ?? 0; + if (!starts) { + return [0, 0]; + } + return [starts[segment], segment + 1 < starts.length ? starts[segment + 1] : length]; } updateControls() { const map_ = get(map); const layerEventManager = map.layerEventManager; - const file = get(this.file)?.file; - if (!map_ || !layerEventManager || !file) { + if (!map_ || !layerEventManager || !this.active) { return; } - this.layers.forEach((layer) => (layer.anchors = [])); - this.anchors = []; - - file.forEachSegment((segment, trackIndex, segmentIndex) => { - if ( - get(selection).hasAnyParent( - new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) - ) - ) { - for (let i = 0; i < segment.trkpt.length; i++) { - const point = segment.trkpt[i]; - if (point._data.anchor) { - const anchor: Anchor = { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [point.getLongitude(), point.getLatitude()], - }, - properties: { - trackIndex: trackIndex, - segmentIndex: segmentIndex, - pointIndex: i, - anchorIndex: this.anchors.length, - minZoom: point._data.zoom, - }, - }; - this.layers.get(point._data.zoom)?.anchors.push(anchor); - this.anchors.push(anchor); - } - } - } - }); + this.layers.forEach((layer) => (layer.features = [])); + this.anchors = this.readAnchors(); + for (const anchor of this.anchors) { + this.layers.get(anchor.zoom)?.features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [anchor.lng, anchor.lat] }, + properties: { anchorIndex: anchor.id, minZoom: anchor.zoom }, + }); + } + // the anchors have changed, so has what is hovered + this.temporaryAnchor = null; this.layers.forEach((layer, zoom) => { try { - let source = map_.getSource(layer.id) as maplibregl.GeoJSONSource | undefined; + const source = map_.getSource(layer.id) as maplibregl.GeoJSONSource | undefined; + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: layer.features, + }; if (source) { - source.setData({ - type: 'FeatureCollection', - features: layer.anchors, - }); + source.setData(data); } else { - map_.addSource(layer.id, { - type: 'geojson', - data: { - type: 'FeatureCollection', - features: layer.anchors, - }, - promoteId: 'anchorIndex', - }); + map_.addSource(layer.id, { type: 'geojson', data, promoteId: 'anchorIndex' }); } if (!map_.getLayer(layer.id)) { @@ -236,12 +296,13 @@ export class RoutingControls { const layerEventManager = map.layerEventManager; this.active = false; + this.anchors = []; + this.temporaryAnchor = null; - map_?.off('style.load', this.updateControlsBinded); + map_?.off('style.load', this.updateBinded); map_?.off('click', this.appendAnchorBinded); - layerEventManager?.off('mousemove', this.fileId, this.showTemporaryAnchorBinded); - layerEventManager?.off('click', this.fileId, this.addIntermediateAnchorBinded); map_?.off('mousemove', this.updateTemporaryAnchorBinded); + this.updateFileListeners(); this.layers.forEach((layer) => { try { @@ -265,150 +326,79 @@ export class RoutingControls { }); this.popup.remove(); - - this.fileUnsubscribe(); } - async moveAnchor(anchor: Anchor, coordinates: Coordinates) { + position(index: number): Position { + return { lng: this.statistics!.lng[index], lat: this.statistics!.lat[index] }; + } + + async moveAnchor(anchor: Anchor, coordinates: Position) { // Move the anchor and update the route from and to the neighbouring anchors - if (anchor === this.temporaryAnchor) { + const initialAnchor = anchor; + const initialCoordinates = { lng: anchor.lng, lat: anchor.lat }; + if (anchor.id === this.anchors.length) { // Temporary anchor, need to find the closest point of the segment and create an anchor for it - anchor = this.getPermanentAnchor(this.temporaryAnchor); + const permanent = this.getPermanentAnchor(); this.removeTemporaryAnchor(); - } - const file = get(this.file)?.file; - if (!file) { - return; + if (!permanent) { + return; + } + anchor = permanent; } - const segment = file.getSegment( - anchor.properties.trackIndex, - anchor.properties.segmentIndex - ); - const initialAnchorCoordinates = - segment.trkpt[anchor.properties.pointIndex].getCoordinates(); + const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor); - let [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor); - - let anchors = []; - let targetTrackpoints = []; - - if (previousAnchor !== null) { - anchors.push(previousAnchor); - targetTrackpoints.push(segment.trkpt[previousAnchor.properties.pointIndex]); + const chain: Anchor[] = []; + const targets: Position[] = []; + if (previousAnchor) { + chain.push(previousAnchor); + targets.push(this.position(previousAnchor.index)); + } + chain.push(anchor); + targets.push(coordinates); + if (nextAnchor) { + chain.push(nextAnchor); + targets.push(this.position(nextAnchor.index)); } - anchors.push(anchor); - targetTrackpoints.push( - new TrackPoint({ - attributes: coordinates, - }) - ); + const success = await this.routeBetweenAnchors(chain, targets); - if (nextAnchor !== null) { - anchors.push(nextAnchor); - targetTrackpoints.push(segment.trkpt[nextAnchor.properties.pointIndex]); - } - - let success = await this.routeBetweenAnchors(anchors, targetTrackpoints); - - if (!success && anchor.properties.anchorIndex != this.anchors.length) { + if (!success && initialAnchor.id !== this.anchors.length) { // Route failed, revert the anchor to the previous position - this.moveAnchorFeature(anchor.properties.anchorIndex, initialAnchorCoordinates); + this.moveAnchorFeature(initialAnchor, initialCoordinates); } } - getPermanentAnchor(anchor: Anchor): Anchor { - const file = get(this.file)?.file; - if (!file) { - return anchor; + /** The trackpoint of the hovered segment that is the closest to the temporary anchor. */ + getPermanentAnchor(): Anchor | undefined { + const temporary = this.temporaryAnchor; + const statistics = this.statistics; + if (!temporary || !statistics) { + return undefined; } - const segment = file.getSegment( - anchor.properties.trackIndex, - anchor.properties.segmentIndex - ); - // Find the point closest to the temporary anchor - const anchorPoint = new TrackPoint({ - attributes: { - lon: anchor.geometry.coordinates[0], - lat: anchor.geometry.coordinates[1], - }, - }); - let details: any = {}; - let closest = getClosestLinePoint(segment.trkpt, anchorPoint, details); - - let permanentAnchor: Anchor = { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [closest.getLongitude(), closest.getLatitude()], - }, - properties: { - trackIndex: anchor.properties.trackIndex, - segmentIndex: anchor.properties.segmentIndex, - pointIndex: closest._data.index, - anchorIndex: this.anchors.length, - minZoom: 0, - }, + const [start, end] = this.segmentRange(temporary.segment); + const index = closestPointIndexIn(statistics.lng, statistics.lat, start, end, temporary); + if (index === undefined) { + return undefined; + } + return { + id: this.anchors.length, + index, + segment: temporary.segment, + zoom: 0, + ...this.position(index), }; - - return permanentAnchor; } + /** Makes an anchor of the hovered point of a segment. */ turnIntoPermanentAnchor() { - const file = get(this.file)?.file; - if (!file || !this.temporaryAnchor) { + const temporary = this.temporaryAnchor; + const revision = this.anchorData?.revision; + if (!temporary || revision === undefined) { return; } - const segment = file.getSegment( - this.temporaryAnchor.properties.trackIndex, - this.temporaryAnchor.properties.segmentIndex - ); - // Find the point closest to the temporary anchor - const anchorPoint = new TrackPoint({ - attributes: { - lon: this.temporaryAnchor.geometry.coordinates[0], - lat: this.temporaryAnchor.geometry.coordinates[1], - }, - }); - let details: any = {}; - getClosestLinePoint(segment.trkpt, anchorPoint, details); - - let before = details.before ? details.index : details.index - 1; - - let projectedPt = projectedPoint( - segment.trkpt[before], - segment.trkpt[before + 1], - anchorPoint - ); - let ratio = - distance(segment.trkpt[before], projectedPt) / - distance(segment.trkpt[before], segment.trkpt[before + 1]); - - let point = segment.trkpt[before].clone(); - point.setCoordinates(projectedPt); - point.ele = - (1 - ratio) * (segment.trkpt[before].ele ?? 0) + - ratio * (segment.trkpt[before + 1].ele ?? 0); - point.time = - segment.trkpt[before].time && segment.trkpt[before + 1].time - ? new Date( - (1 - ratio) * segment.trkpt[before].time.getTime() + - ratio * segment.trkpt[before + 1].time!.getTime() - ) - : undefined; - point._data = { - anchor: true, - zoom: 0, - }; - - const trackIndex = this.temporaryAnchor!.properties.trackIndex; - const segmentIndex = this.temporaryAnchor!.properties.segmentIndex; - fileActionManager.applyToFile(this.fileId, (file) => - file.replaceTrackPoints(trackIndex, segmentIndex, before + 1, before, [point]) - ); - - this.temporaryAnchor = null; + engine.insertAnchor(revision, temporary.lng, temporary.lat); + this.removeTemporaryAnchor(); } getDeleteAnchor(anchor: Anchor) { @@ -419,61 +409,28 @@ export class RoutingControls { // Remove the anchor and route between the neighbouring anchors if they exist this.popup.remove(); - let [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor); + const revision = this.anchorData?.revision; + if (revision === undefined) { + return; + } + const [segmentStart, segmentEnd] = this.segmentRange(anchor.segment); + const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor); + const noPoints = { lng: [], lat: [], ele: [] }; if (previousAnchor === null && nextAnchor === null) { // Only one point, remove it - fileActionManager.applyToFile(this.fileId, (file) => - file.replaceTrackPoints( - anchor.properties.trackIndex, - anchor.properties.segmentIndex, - 0, - 0, - [] - ) - ); + engine.route(revision, anchor.index, anchor.index + 1, noPoints); } else if (previousAnchor === null && nextAnchor !== null) { // First point, remove trackpoints until nextAnchor - fileActionManager.applyToFile(this.fileId, (file) => - file.replaceTrackPoints( - anchor.properties.trackIndex, - anchor.properties.segmentIndex, - 0, - nextAnchor.properties.pointIndex - 1, - [] - ) - ); + engine.route(revision, segmentStart, nextAnchor.index, noPoints); } else if (nextAnchor === null && previousAnchor !== null) { // Last point, remove trackpoints from previousAnchor - fileActionManager.applyToFile(this.fileId, (file) => { - const segment = file.getSegment( - anchor.properties.trackIndex, - anchor.properties.segmentIndex - ); - file.replaceTrackPoints( - anchor.properties.trackIndex, - anchor.properties.segmentIndex, - previousAnchor.properties.pointIndex + 1, - segment.trkpt.length - 1, - [] - ); - }); + engine.route(revision, previousAnchor.index + 1, segmentEnd, noPoints); } else if (previousAnchor !== null && nextAnchor !== null) { // Route between previousAnchor and nextAnchor - const file = get(this.file)?.file; - if (!file) { - return; - } - const segment = file.getSegment( - anchor.properties.trackIndex, - anchor.properties.segmentIndex - ); this.routeBetweenAnchors( [previousAnchor, nextAnchor], - [ - segment.trkpt[previousAnchor.properties.pointIndex], - segment.trkpt[nextAnchor.properties.pointIndex], - ] + [this.position(previousAnchor.index), this.position(nextAnchor.index)] ); } } @@ -485,39 +442,22 @@ export class RoutingControls { startLoopAtAnchor(anchor: Anchor) { this.popup.remove(); - const fileWithStats = get(this.file); - if (!fileWithStats) { - return; + const revision = this.anchorData?.revision; + if (revision !== undefined) { + engine.changeLoopStart(revision, anchor.index); } + } - const speed = fileWithStats.statistics.getStatisticsFor( - new ListTrackSegmentItem( - this.fileId, - anchor.properties.trackIndex, - anchor.properties.segmentIndex - ) - ).global.speed.moving; - - const segment = fileWithStats.file.getSegment( - anchor.properties.trackIndex, - anchor.properties.segmentIndex - ); - fileActionManager.applyToFile(this.fileId, (file) => { - file.replaceTrackPoints( - anchor.properties.trackIndex, - anchor.properties.segmentIndex, - segment.trkpt.length, - segment.trkpt.length - 1, - segment.trkpt.slice(0, anchor.properties.pointIndex), - speed > 0 ? speed : undefined - ); - file.crop( - anchor.properties.pointIndex, - anchor.properties.pointIndex + segment.trkpt.length - 1, - [anchor.properties.trackIndex], - [anchor.properties.segmentIndex] - ); - }); + /** Whether the segment of the anchor is a loop that can start at the anchor. */ + canStartLoopAt(anchor: Anchor): boolean { + const [start, end] = this.segmentRange(anchor.segment); + if (!this.statistics || anchor.index === start || end <= start) { + return false; + } + const first = this.position(start); + const last = this.position(end - 1); + // the end of the segment has to be close to its start, up to a kilometer + return distance(toCoordinates(first), toCoordinates(last)) <= 1000; } async appendAnchor(e: maplibregl.MapMouseEvent) { @@ -525,88 +465,48 @@ export class RoutingControls { if (get(streetViewEnabled) && get(streetViewSource) === 'google') { return; } - if ( - this.draggedAnchorIndex !== null || - Date.now() - this.lastDraggedAnchorEventTime < 100 - ) { + if (this.draggedAnchorId !== null || Date.now() - this.lastDraggedAnchorEventTime < 100) { // Exit if anchor is being dragged return; } if ( e.target.queryRenderedFeatures(e.point, { - layers: [this.fileId, ...[...this.layers.values()].map((layer) => layer.id)], + layers: [...this.listenedFileIds, ...[...this.layers.values()].map((l) => l.id)], }).length ) { // Clicked on routing control or layer, ignoring return; } - this.appendAnchorWithCoordinates({ - lat: e.lngLat.lat, - lon: e.lngLat.lng, - }); + this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat }); } - async appendAnchorWithCoordinates(coordinates: Coordinates) { + async appendAnchorWithCoordinates(coordinates: Position) { // Add a new anchor to the end of the last segment - let newAnchorPoint = new TrackPoint({ - attributes: coordinates, - }); + const statistics = this.statistics; + const revision = this.anchorData?.revision; + if (!statistics || revision === undefined) { + return; + } - if (this.anchors.length == 0) { - this.routeBetweenAnchors( - [ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [ - newAnchorPoint.getLongitude(), - newAnchorPoint.getLatitude(), - ], - }, - properties: { - trackIndex: 0, - segmentIndex: 0, - pointIndex: 0, - anchorIndex: 0, - minZoom: 0, - }, - }, - ], - [newAnchorPoint] + if (this.anchors.length === 0) { + // Nothing in the selection yet: the first trackpoint, in a new segment if needed + engine.route( + revision, + 0, + 0, + { lng: [coordinates.lng], lat: [coordinates.lat], ele: [0] }, + [0] ); return; } - let lastAnchor = this.anchors[this.anchors.length - 1]; - - const file = get(this.file)?.file; - if (!file) { - return; - } - - const segment = file.getSegment( - lastAnchor.properties.trackIndex, - lastAnchor.properties.segmentIndex + // Route from the last trackpoint (an anchor), as if the new anchor was also the last one + const last = this.anchors[this.anchors.length - 1]; + const lastIndex = statistics.length - 1; + await this.routeBetweenAnchors( + [last, { ...last, id: 0, index: lastIndex }], + [this.position(last.index), coordinates] ); - const lastAnchorPoint = segment.trkpt[lastAnchor.properties.pointIndex]; - - let newAnchor: Anchor = { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [newAnchorPoint.getLongitude(), newAnchorPoint.getLatitude()], - }, - properties: { - trackIndex: lastAnchor.properties.trackIndex, - segmentIndex: lastAnchor.properties.segmentIndex, - pointIndex: segment.trkpt.length - 1, // Do as if the point was the last point in the segment - anchorIndex: 0, - minZoom: 0, - }, - }; - - await this.routeBetweenAnchors([lastAnchor, newAnchor], [lastAnchorPoint, newAnchorPoint]); } addIntermediateAnchor(e: maplibregl.MapMouseEvent) { @@ -624,25 +524,15 @@ export class RoutingControls { const zoom = get(map)?.getZoom() ?? 20; - for (let i = 0; i < this.anchors.length; i++) { - if ( - this.anchors[i].properties.trackIndex === anchor.properties.trackIndex && - this.anchors[i].properties.segmentIndex === anchor.properties.segmentIndex && - zoom >= this.anchors[i].properties.minZoom - ) { - if (this.anchors[i].properties.pointIndex < anchor.properties.pointIndex) { - if ( - !previousAnchor || - this.anchors[i].properties.pointIndex > previousAnchor.properties.pointIndex - ) { - previousAnchor = this.anchors[i]; + for (const other of this.anchors) { + if (other.segment === anchor.segment && zoom >= other.zoom) { + if (other.index < anchor.index) { + if (!previousAnchor || other.index > previousAnchor.index) { + previousAnchor = other; } - } else if (this.anchors[i].properties.pointIndex > anchor.properties.pointIndex) { - if ( - !nextAnchor || - this.anchors[i].properties.pointIndex < nextAnchor.properties.pointIndex - ) { - nextAnchor = this.anchors[i]; + } else if (other.index > anchor.index) { + if (!nextAnchor || other.index < nextAnchor.index) { + nextAnchor = other; } } } @@ -651,160 +541,83 @@ export class RoutingControls { return [previousAnchor, nextAnchor]; } - async routeBetweenAnchors( - anchors: Anchor[], - targetTrackPoints: TrackPoint[] - ): Promise { - const fileWithStats = get(this.file); - if (!fileWithStats) { + /** + * Routes through the positions `targets`, which are the ones of the anchors `anchors` (in a + * single segment) or where they were moved to, and replaces what is between the first and + * the last anchor by the route. + */ + async routeBetweenAnchors(anchors: Anchor[], targets: Position[]): Promise { + const revision = this.anchorData?.revision; + if (revision === undefined) { return false; } if (anchors.length <= 1) { - // Only one anchor, update the point in the segment - targetTrackPoints[0]._data.anchor = true; - targetTrackPoints[0]._data.zoom = 0; - let selected = selection.getOrderedSelection(); - if ( - selected.length === 0 || - selected[selected.length - 1].getFileId() !== this.fileId - ) { - return false; - } - let item = selected[selected.length - 1]; - fileActionManager.applyToFile(this.fileId, (file) => { - let trackIndex = file.trk.length > 0 ? file.trk.length - 1 : 0; - if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) { - trackIndex = item.getTrackIndex(); - } - let segmentIndex = - file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0 - ? file.trk[trackIndex].trkseg.length - 1 - : 0; - if (item instanceof ListTrackSegmentItem) { - segmentIndex = item.getSegmentIndex(); - } - if (file.trk.length === 0) { - let track = new Track(); - track.replaceTrackPoints(0, 0, 0, targetTrackPoints); - file.replaceTracks(0, 0, [track]); - } else if (file.trk[trackIndex].trkseg.length === 0) { - let segment = new TrackSegment(); - segment.replaceTrackPoints(0, 0, targetTrackPoints); - file.replaceTrackSegments(trackIndex, 0, 0, [segment]); - } else { - file.replaceTrackPoints(trackIndex, segmentIndex, 0, 0, targetTrackPoints); - } - }); - return true; + // Only one anchor: it moves + return engine.route( + revision, + anchors[0].index, + anchors[0].index + 1, + { lng: [targets[0].lng], lat: [targets[0].lat], ele: [0] }, + [0] + ); } - let response: TrackPoint[]; + let response; try { - response = await route(targetTrackPoints.map((trkpt) => trkpt.getCoordinates())); + response = await route(targets.map(toCoordinates)); } catch (e: any) { toast.error(i18n._(e.message, e.message)); return false; } - const segment = fileWithStats.file.getSegment( - anchors[0].properties.trackIndex, - anchors[0].properties.segmentIndex - ); + const first = anchors[0]; + const last = anchors[anchors.length - 1]; + const [segmentStart, segmentEnd] = this.segmentRange(first.segment); + const lastIndex = segmentEnd - 1; - if ( - anchors[0].properties.pointIndex !== 0 && - (anchors[0].properties.pointIndex !== segment.trkpt.length - 1 || - distance(targetTrackPoints[0].getCoordinates(), response[0].getCoordinates()) > 1) - ) { - response.splice(0, 0, targetTrackPoints[0].clone()); // Keep the current first anchor + // The anchors at the ends are kept as they are, except if they are the ends of the + // segment: then the route goes in their place. + const keepFirst = + first.index !== segmentStart && + (first.index !== lastIndex || + distance(toCoordinates(targets[0]), { + lat: response.lat[0], + lon: response.lng[0], + }) > 1); + const keepLast = last.index !== lastIndex; + + const start = keepFirst ? first.index + 1 : first.index; + const end = keepLast ? last.index : last.index + 1; + + // Anchors among the new points: the ends that are not kept, and the point of the route + // that is the closest to each intermediate anchor. + const newAnchors: number[] = []; + if (!keepFirst) { + newAnchors.push(0); } - - if (anchors[anchors.length - 1].properties.pointIndex !== segment.trkpt.length - 1) { - response.push(targetTrackPoints[anchors.length - 1].clone()); // Keep the current last anchor + if (!keepLast) { + newAnchors.push(response.lng.length - 1); } - - let anchorTrackPoints = [response[0], response[response.length - 1]]; for (let i = 1; i < anchors.length - 1; i++) { - // Find the closest point to the intermediate anchor, which will become an anchor - anchorTrackPoints.push( - getClosestLinePoint(response.slice(1, -1), targetTrackPoints[i]) + const closest = closestPointIndexIn( + response.lng, + response.lat, + 1, + response.lng.length - 1, + targets[i] ); - } - - anchorTrackPoints.forEach((trkpt) => { - // Turn them into permanent anchors - trkpt._data.anchor = true; - trkpt._data.zoom = 0; - }); - - const stats = fileWithStats.statistics.getStatisticsFor( - new ListTrackSegmentItem( - this.fileId, - anchors[0].properties.trackIndex, - anchors[0].properties.segmentIndex - ) - ); - let speed: number | undefined = undefined; - let startTime = segment.trkpt[anchors[0].properties.pointIndex].time; - - if (stats.global.speed.moving > 0) { - let replacingDistance = 0; - for (let i = 1; i < response.length; i++) { - replacingDistance += - distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000; - } - let startAnchorStats = stats.getTrackPoint(anchors[0].properties.pointIndex)!; - let endAnchorStats = stats.getTrackPoint( - anchors[anchors.length - 1].properties.pointIndex - )!; - - let replacedDistance = - endAnchorStats.distance.moving - startAnchorStats.distance.moving; - - let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance; - let newTime = (newDistance / stats.global.speed.moving) * 3600; - - let remainingTime = - stats.global.time.moving - - (endAnchorStats.time.moving - startAnchorStats.time.moving); - let replacingTime = newTime - remainingTime; - - if (replacingTime <= 0) { - // Fallback to simple time difference - replacingTime = endAnchorStats.time.total - startAnchorStats.time.total; - } - - speed = (replacingDistance / replacingTime) * 3600; - - if (startTime === undefined) { - // Replacing the first point - let endIndex = anchors[anchors.length - 1].properties.pointIndex; - startTime = new Date( - (segment.trkpt[endIndex].time?.getTime() ?? 0) - - (replacingTime + endAnchorStats.time.total - endAnchorStats.time.moving) * - 1000 - ); + if (closest !== undefined) { + newAnchors.push(closest); } } - fileActionManager.applyToFile(this.fileId, (file) => - file.replaceTrackPoints( - anchors[0].properties.trackIndex, - anchors[0].properties.segmentIndex, - anchors[0].properties.pointIndex, - anchors[anchors.length - 1].properties.pointIndex, - response, - speed, - startTime - ) - ); - - return true; + return engine.route(revision, start, end, response, newAnchors); } destroy() { this.remove(); + this.request.release(); this.unsubscribes.forEach((unsubscribe) => unsubscribe()); } @@ -838,46 +651,28 @@ export class RoutingControls { onClick(e: MapLayerMouseEvent) { e.preventDefault(); - if ( - this.draggedAnchorIndex !== null || - Date.now() - this.lastDraggedAnchorEventTime < 100 - ) { + if (this.draggedAnchorId !== null || Date.now() - this.lastDraggedAnchorEventTime < 100) { // Exit if anchor is being dragged return; } const anchor = this.anchors[e.features![0].properties.anchorIndex]; + if (!anchor) { + return; + } if (e.originalEvent.shiftKey) { this.deleteAnchor(anchor); return; } - canChangeStart.update(() => { - if (anchor.properties.pointIndex === 0) { - return false; - } - const segment = get(this.file)?.file.getSegment( - anchor.properties.trackIndex, - anchor.properties.segmentIndex - ); - if ( - !segment || - distance( - segment.trkpt[0].getCoordinates(), - segment.trkpt[segment.trkpt.length - 1].getCoordinates() - ) > 1000 - ) { - return false; - } - return true; - }); + canChangeStart.set(this.canStartLoopAt(anchor)); this.popup.setLngLat(e.lngLat); this.popup.addTo(e.target); - let deleteThisAnchor = this.getDeleteAnchor(anchor); + const deleteThisAnchor = this.getDeleteAnchor(anchor); this.popupElement.addEventListener('delete', deleteThisAnchor); // Register the delete event for this anchor - let startLoopAtThisAnchor = this.getStartLoopAtAnchor(anchor); + const startLoopAtThisAnchor = this.getStartLoopAtAnchor(anchor); this.popupElement.addEventListener('change-start', startLoopAtThisAnchor); // Register the start loop event for this anchor this.popup.once('close', () => { this.popupElement.removeEventListener('delete', deleteThisAnchor); @@ -894,7 +689,7 @@ export class RoutingControls { e.preventDefault(); _map.dragPan.disable(); - this.draggedAnchorIndex = e.features![0].properties.anchorIndex; + this.draggedAnchorId = e.features![0].properties.anchorIndex; this.draggingStartingPosition = e.point; _map.on('mousemove', this.onMouseMoveBinded); @@ -910,7 +705,7 @@ export class RoutingControls { return; } - this.draggedAnchorIndex = e.features![0].properties.anchorIndex; + this.draggedAnchorId = e.features![0].properties.anchorIndex; this.draggingStartingPosition = e.point; e.preventDefault(); @@ -920,17 +715,25 @@ export class RoutingControls { _map.once('touchend', this.onMouseUpBinded); } + /** The anchor with this id, which is the temporary one if it is `anchors.length`. */ + anchorWithId(id: number): Anchor | undefined { + if (id === this.anchors.length && this.temporaryAnchor) { + return { id, index: 0, zoom: 0, ...this.temporaryAnchor }; + } + return this.anchors[id]; + } + onMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) { - if (this.draggedAnchorIndex === null || e.point.equals(this.draggingStartingPosition)) { + if (this.draggedAnchorId === null || e.point.equals(this.draggingStartingPosition)) { return; } mapCursor.notify(MapCursorState.ANCHOR_DRAGGING, true); - this.moveAnchorFeature(this.draggedAnchorIndex, { - lat: e.lngLat.lat, - lon: e.lngLat.lng, - }); + const anchor = this.anchorWithId(this.draggedAnchorId); + if (anchor) { + this.moveAnchorFeature(anchor, { lng: e.lngLat.lng, lat: e.lngLat.lat }); + } this.lastDraggedAnchorEventTime = Date.now(); } @@ -948,39 +751,31 @@ export class RoutingControls { _map.off('mousemove', this.onMouseMoveBinded); _map.off('touchmove', this.onMouseMoveBinded); - if (this.draggedAnchorIndex === null) { + if (this.draggedAnchorId === null) { return; } if (e.point.equals(this.draggingStartingPosition)) { - this.draggedAnchorIndex = null; + this.draggedAnchorId = null; return; } - if (this.draggedAnchorIndex === this.anchors.length) { - if (this.temporaryAnchor) { - this.moveAnchor(this.temporaryAnchor, { - lat: e.lngLat.lat, - lon: e.lngLat.lng, - }); - } - } else { - this.moveAnchor(this.anchors[this.draggedAnchorIndex], { - lat: e.lngLat.lat, - lon: e.lngLat.lng, - }); + const anchor = this.anchorWithId(this.draggedAnchorId); + if (anchor) { + this.moveAnchor(anchor, { lng: e.lngLat.lng, lat: e.lngLat.lat }); } - this.draggedAnchorIndex = null; + this.draggedAnchorId = null; this.lastDraggedAnchorEventTime = Date.now(); } showTemporaryAnchor(e: MapLayerMouseEvent) { const map_ = get(map); - if (!map_) { + const segmentIds = this.anchorData?.segmentIds; + if (!map_ || !segmentIds) { return; } - if (this.draggedAnchorIndex !== null) { + if (this.draggedAnchorId !== null) { // Do not not change the source point if it is already being dragged return; } @@ -989,15 +784,9 @@ export class RoutingControls { return; } - if ( - !get(selection).hasAnyParent( - new ListTrackSegmentItem( - this.fileId, - e.features![0].properties.trackIndex, - e.features![0].properties.segmentIndex - ) - ) - ) { + // only the segments of the selection + const segment = segmentIds.indexOf(e.features![0].properties.segmentId); + if (segment < 0) { return; } @@ -1005,20 +794,7 @@ export class RoutingControls { return; } - this.temporaryAnchor = { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [e.lngLat.lng, e.lngLat.lat], - }, - properties: { - trackIndex: e.features![0].properties.trackIndex, - segmentIndex: e.features![0].properties.segmentIndex, - pointIndex: 0, - anchorIndex: this.anchors.length, - minZoom: 0, - }, - }; + this.temporaryAnchor = { lng: e.lngLat.lng, lat: e.lngLat.lat, segment }; this.addTemporaryAnchor(); mapCursor.notify(MapCursorState.ANCHOR_HOVER, true); @@ -1032,16 +808,14 @@ export class RoutingControls { return; } - if (this.draggedAnchorIndex !== null) { + if (this.draggedAnchorId !== null) { // Do not hide if it is being dragged, and stop listening for mousemove map_.off('mousemove', this.updateTemporaryAnchorBinded); return; } if ( - e.point.dist( - map_.project(this.temporaryAnchor.geometry.coordinates as [number, number]) - ) > 20 || + e.point.dist(map_.project([this.temporaryAnchor.lng, this.temporaryAnchor.lat])) > 20 || this.temporaryAnchorCloseToOtherAnchor(e) ) { // Hide if too far from the layer @@ -1050,10 +824,8 @@ export class RoutingControls { } // Update the position of the temporary anchor - this.moveAnchorFeature(this.anchors.length, { - lat: e.lngLat.lat, - lon: e.lngLat.lng, - }); + const position = { lng: e.lngLat.lng, lat: e.lngLat.lat }; + this.moveAnchorFeature(this.anchorWithId(this.anchors.length)!, position); } temporaryAnchorCloseToOtherAnchor(e: any) { @@ -1063,31 +835,26 @@ export class RoutingControls { } const zoom = map_.getZoom(); - for (let anchor of this.anchors) { - if ( - zoom >= anchor.properties.minZoom && - e.point.dist(map_.project(anchor.geometry.coordinates as [number, number])) < 10 - ) { + for (const anchor of this.anchors) { + if (zoom >= anchor.zoom && e.point.dist(map_.project([anchor.lng, anchor.lat])) < 10) { return true; } } return false; } - moveAnchorFeature(anchorIndex: number, coordinates: Coordinates) { - const anchor = - anchorIndex === this.anchors.length ? this.temporaryAnchor : this.anchors[anchorIndex]; - let source = get(map)?.getSource( - this.layers.get(anchor?.properties.minZoom ?? MIN_ANCHOR_ZOOM)?.id ?? '' + moveAnchorFeature(anchor: Anchor, coordinates: Position) { + const source = get(map)?.getSource( + this.layers.get(anchor.id === this.anchors.length ? 0 : anchor.zoom)?.id ?? '' ) as GeoJSONSource | undefined; if (source) { source.updateData({ update: [ { - id: anchorIndex, + id: anchor.id, newGeometry: { type: 'Point', - coordinates: [coordinates.lon, coordinates.lat], + coordinates: [coordinates.lng, coordinates.lat], }, }, ], @@ -1099,16 +866,20 @@ export class RoutingControls { if (!this.temporaryAnchor) { return; } - let source = get(map)?.getSource(`routing-controls-${this.fileId}-0`) as - | GeoJSONSource - | undefined; - if (source) { - if (this.temporaryAnchor) { - source.updateData({ - add: [this.temporaryAnchor], - }); - } - } + const source = get(map)?.getSource(this.layers.get(0)!.id) as GeoJSONSource | undefined; + source?.updateData({ + add: [ + { + type: 'Feature', + id: this.anchors.length, + geometry: { + type: 'Point', + coordinates: [this.temporaryAnchor.lng, this.temporaryAnchor.lat], + }, + properties: { anchorIndex: this.anchors.length, minZoom: 0 }, + }, + ], + }); } removeTemporaryAnchor() { @@ -1116,20 +887,10 @@ export class RoutingControls { return; } const map_ = get(map); - let source = map_?.getSource(`routing-controls-${this.fileId}-0`) as - | GeoJSONSource - | undefined; - if (source) { - if (this.temporaryAnchor) { - source.updateData({ - remove: [this.temporaryAnchor.properties.anchorIndex], - }); - } - } + const source = map_?.getSource(this.layers.get(0)!.id) as GeoJSONSource | undefined; + source?.updateData({ remove: [this.anchors.length] }); map_?.off('mousemove', this.updateTemporaryAnchorBinded); mapCursor.notify(MapCursorState.ANCHOR_HOVER, false); this.temporaryAnchor = null; } } - -export const routingControls: Map = new Map(); diff --git a/website/src/lib/components/toolbar/tools/routing/routing.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index eb34a3e49..ed1592c9a 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -1,5 +1,4 @@ -import type { Coordinates } from 'gpx'; -import { TrackPoint, distance } from 'gpx'; +import { distance, type Coordinates } from 'gpx'; import { settings } from '$lib/logic/settings'; import { getElevation } from '$lib/utils'; import { get } from 'svelte/store'; @@ -22,7 +21,36 @@ export const routingProfiles: { [key: string]: RoutingProfile } = { railway: { engine: 'brouter', profile: 'rail' }, }; -export function route(points: Coordinates[]): Promise { +/** + * The trackpoints of a route, as arrays with an entry per trackpoint. The OSM attributes are + * `undefined` where the router does not know them. + */ +export type RoutedPoints = { + lng: number[]; + lat: number[]; + ele: number[]; + surface: (string | undefined)[]; + highway: (string | undefined)[]; + sacScale: (string | undefined)[]; + mtbScale: (string | undefined)[]; +}; + +function emptyRoute(): RoutedPoints { + return { lng: [], lat: [], ele: [], surface: [], highway: [], sacScale: [], mtbScale: [] }; +} + +/** Adds a trackpoint, which has the elevation of the previous one if it has none. */ +function addPoint(route: RoutedPoints, lng: number, lat: number, ele: number | undefined) { + route.lng.push(lng); + route.lat.push(lat); + route.ele.push(ele ?? route.ele[route.ele.length - 1] ?? 0); + route.surface.push(undefined); + route.highway.push(undefined); + route.sacScale.push(undefined); + route.mtbScale.push(undefined); +} + +export function route(points: Coordinates[]): Promise { if (get(routing)) { const profile = routingProfiles[get(routingProfile)]; if (profile.engine === 'graphhopper') { @@ -108,7 +136,7 @@ async function getGraphHopperRoute( points: Coordinates[], graphHopperProfile: string, privateRoads: boolean -): Promise { +): Promise { let response = await fetch('https://graphhopper.gpx.studio/route', { method: 'POST', headers: { @@ -147,42 +175,27 @@ async function getGraphHopperRoute( let json = await response.json(); - let route: TrackPoint[] = []; - let coordinates = json.paths[0].points.coordinates; - let details = json.paths[0].details; + const route = emptyRoute(); + const coordinates = json.paths[0].points.coordinates; + const details = json.paths[0].details; for (let i = 0; i < coordinates.length; i++) { - route.push( - new TrackPoint({ - attributes: { - lat: coordinates[i][1], - lon: coordinates[i][0], - }, - ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0), - extensions: {}, - }) - ); + addPoint(route, coordinates[i][0], coordinates[i][1], coordinates[i][2]); } - for (let key of graphhopperDetails) { - let detail = details[key]; + for (const key of graphhopperDetails) { + const detail = details[key]; for (let i = 0; i < detail.length; i++) { - for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) { + for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1 ? 1 : 0); j++) { if (detail[i][2] !== undefined && detail[i][2] !== 'missing') { if (key === 'road_class') { - route[j].setExtension('highway', detail[i][2]); + route.highway[j] = detail[i][2]; } else if (key === 'hike_rating') { - const sacScale = hikeRatingToSACScale[detail[i][2]]; - if (sacScale) { - route[j].setExtension('sac_scale', sacScale); - } + route.sacScale[j] = hikeRatingToSACScale[detail[i][2]]; } else if (key === 'mtb_rating') { - const mtbScale = mtbRatingToScale[detail[i][2]]; - if (mtbScale) { - route[j].setExtension('mtb_scale', mtbScale); - } + route.mtbScale[j] = mtbRatingToScale[detail[i][2]]; } else if (key === 'surface' && detail[i][2] !== 'other') { - route[j].setExtension('surface', detail[i][2]); + route.surface[j] = detail[i][2]; } } } @@ -195,7 +208,7 @@ async function getGraphHopperRoute( async function getBRouterRoute( points: Coordinates[], brouterProfile: string -): Promise { +): Promise { let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`; let response = await fetch(url); @@ -217,9 +230,9 @@ async function getBRouterRoute( let geojson = await response.json(); - let route: TrackPoint[] = []; - let coordinates = geojson.features[0].geometry.coordinates; - let messages = geojson.features[0].properties.messages; + const route = emptyRoute(); + const coordinates = geojson.features[0].geometry.coordinates; + const messages = geojson.features[0].properties.messages; const lngIdx = messages[0].indexOf('Longitude'); const latIdx = messages[0].indexOf('Latitude'); @@ -228,15 +241,7 @@ async function getBRouterRoute( let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {}; for (let i = 0; i < coordinates.length; i++) { - route.push( - new TrackPoint({ - attributes: { - lat: coordinates[i][1], - lon: coordinates[i][0], - }, - ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0), - }) - ); + addPoint(route, coordinates[i][0], coordinates[i][1], coordinates[i][2]); if ( messageIdx < messages.length && @@ -249,7 +254,10 @@ async function getBRouterRoute( else tags = getTags(messages[messageIdx][tagIdx]); } - route[route.length - 1].setExtensions(tags); + route.surface[i] = tags.surface; + route.highway[i] = tags.highway; + route.sacScale[i] = tags.sac_scale; + route.mtbScale[i] = tags.mtb_scale; } return route; @@ -266,40 +274,27 @@ function getTags(message: string): { [key: string]: string } { return tags; } -function getIntermediatePoints(points: Coordinates[]): Promise { - let route: TrackPoint[] = []; - let step = 0.05; +function getIntermediatePoints(points: Coordinates[]): Promise { + const route = emptyRoute(); + const step = 0.05; for (let i = 0; i < points.length - 1; i++) { // Add intermediate points between each pair of points - let dist = distance(points[i], points[i + 1]) / 1000; + const dist = distance(points[i], points[i + 1]) / 1000; for (let d = 0; d < dist; d += step) { - let lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat); - let lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon); - route.push( - new TrackPoint({ - attributes: { - lat: lat, - lon: lon, - }, - }) - ); + const lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat); + const lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon); + addPoint(route, lon, lat, 0); } } - route.push( - new TrackPoint({ - attributes: { - lat: points[points.length - 1].lat, - lon: points[points.length - 1].lon, - }, - }) - ); + const last = points[points.length - 1]; + addPoint(route, last.lon, last.lat, 0); - return getElevation(route).then((elevations) => { - route.forEach((point, i) => { - point.ele = elevations[i]; - }); - return route; - }); + return getElevation(route.lng.map((lng, i) => ({ lon: lng, lat: route.lat[i] }))).then( + (elevations) => { + route.ele = elevations; + return route; + } + ); } diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 205d29996..80097c195 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -2,7 +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 { categoryIntervals, type CategoryIntervals } from '$lib/trackpoint-categories'; import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers'; import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; import type { @@ -13,6 +13,7 @@ import type { Selection, WaypointDetails, TrackpointDetails, + RouteAttributes, } from 'gpx-rs'; export type { @@ -101,6 +102,20 @@ export type FileState = { const EMPTY_STATISTICS: GlobalStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 }; +/** + * New trackpoints for `Engine.route`, as arrays with an entry per trackpoint. The OSM attributes + * are the names of the values (`undefined` when unknown), they are left unknown if missing. + */ +export type RoutePoints = { + lng: ArrayLike; + lat: ArrayLike; + ele: ArrayLike; + surface?: readonly (string | undefined)[]; + highway?: readonly (string | undefined)[]; + sacScale?: readonly (string | undefined)[]; + mtbScale?: readonly (string | undefined)[]; +}; + /** * Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest * 64-bit integer, like `NO_TIME` in the engine). @@ -121,7 +136,34 @@ export type StatisticsMetric = | 'slopeSegment' | 'surface' /** `highway`, `sacScale` and `mtbScale`. */ - | 'highway'; + | 'highway' + /** `anchors`. */ + | 'anchors'; + +/** + * The anchors of the routing tool among the trackpoints of the selection: the points that can be + * dragged to reroute a segment, which are few of them. The arrays are as long as the number of + * anchors, in the order of the trackpoints. + */ +export type SelectionAnchors = { + /** Index of the anchor in the trackpoints of the selection. */ + indices: Uint32Array; + /** Lowest map zoom level at which the anchor is shown (0 for the ends of the segments). */ + zooms: Uint8Array; + /** + * Index of the first trackpoint of each segment of the selection that has some: anchors that + * are between two consecutive starts belong to the same segment. + */ + segmentStarts: Uint32Array; + /** Id of each of these segments. */ + segmentIds: string[]; + /** + * Changes when the selected segments, or their trackpoints, change. The indices in the + * selection (of trackpoints, of anchors) only mean something for one revision, which commands + * that use them have to give. + */ + revision: number; +}; /** * The statistics of the selection: its global statistics, and the values at each of its @@ -162,6 +204,7 @@ export type SelectionStatistics = { sacScale?: CategoryIntervals; /** Mountain biking scale. */ mtbScale?: CategoryIntervals; + anchors?: SelectionAnchors; /** * Global statistics of the trackpoints from `start` to `end` (both included), for example @@ -275,8 +318,12 @@ class Engine { // Actions. Each one resolves to whether the engine changed something. - newFile(name: string) { - return this.run((w) => w.new_file(name)); + /** + * Creates a file, which gets selected. With a `trackpoint`, the file starts with a track and a + * segment that hold it, in the same undo step. + */ + newFile(name: string, trackpoint?: { lng: number; lat: number; ele?: number }) { + return this.run((w) => w.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele)); } /** @@ -515,6 +562,65 @@ class Engine { ); } + /** + * Replaces the trackpoints `start` to `end` (excluded) of the selection by `points` (a pure + * insertion if `start === end`, a pure removal without points), in a single segment: the + * indices are the ones of `SelectionAnchors`, and `revision` the one they were read with. The + * indices in `anchors`, among the new points, become anchors of the routing tool. Resolves to + * `false` if the selection changed since the revision, or if the range is not valid. + */ + route( + revision: number, + start: number, + end: number, + points: RoutePoints, + anchors: readonly number[] = [] + ) { + const attributes = (values?: readonly (string | undefined)[]) => + values && { ...categoryIntervals(values) }; + const routeAttributes: RouteAttributes = {}; + for (const [key, values] of [ + ['surface', points.surface], + ['highway', points.highway], + ['sacScale', points.sacScale], + ['mtbScale', points.mtbScale], + ] as const) { + const intervals = attributes(values); + if (intervals && intervals.names.length > 0) { + routeAttributes[key] = intervals; + } + } + return this.run((w) => + w.route( + revision, + start, + end, + Float64Array.from(points.lng), + Float64Array.from(points.lat), + Float64Array.from(points.ele), + routeAttributes, + Uint32Array.from(anchors) + ) + ); + } + + /** + * Makes an anchor of the point of the selected segments that is the closest to the + * coordinates, inserting a trackpoint on the path if there is none there. `revision` is the + * one of `SelectionAnchors`. + */ + insertAnchor(revision: number, lng: number, lat: number) { + return this.run((w) => w.insert_anchor(revision, lng, lat)); + } + + /** + * Makes the trackpoint `index` of the selection the start of its segment, which is a loop: + * the trackpoints before it go to the end, and the loop closes on it. `revision` is the one of `SelectionAnchors`. + */ + changeLoopStart(revision: number, index: number) { + return this.run((w) => w.change_loop_start(revision, index)); + } + /** A trackpoint of a segment, `undefined` if it does not exist. */ trackpoint(fileId: string, segmentId: string, index: number): TrackpointDetails | undefined { return this.wasm?.trackpoint(fileId, segmentId, index); @@ -717,6 +823,15 @@ class Engine { wasm.surfaces ); break; + case 'anchors': + statistics.anchors = { + indices: wasm.anchor_indices(), + zooms: wasm.anchor_zooms(), + segmentStarts: wasm.segment_starts(), + segmentIds: wasm.segment_ids(), + revision: wasm.routing_revision(), + }; + break; case 'highway': statistics.highway = categories( wasm.highway_starts(), diff --git a/website/src/lib/trackpoint-categories.test.ts b/website/src/lib/trackpoint-categories.test.ts index e7137841b..6ac8df9c0 100644 --- a/website/src/lib/trackpoint-categories.test.ts +++ b/website/src/lib/trackpoint-categories.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { categoryValues, type CategoryIntervals } from './trackpoint-categories'; +import { categoryIntervals, categoryValues, type CategoryIntervals } from './trackpoint-categories'; describe('categoryValues', () => { // asphalt, asphalt, unknown, gravel, gravel, gravel, unknown @@ -43,3 +43,24 @@ describe('categoryValues', () => { assert.deepEqual(categoryValues(unnamed, 2), [undefined, undefined]); }); }); + +describe('categoryIntervals', () => { + it('groups the trackpoints that share a value', () => { + const values = ['asphalt', 'asphalt', undefined, 'gravel', 'gravel', 'asphalt', undefined]; + const intervals = categoryIntervals(values); + assert.deepEqual([...intervals.starts], [0, 2, 3, 5, 6]); + assert.deepEqual([...intervals.values], [1, 0, 2, 1, 0]); + assert.deepEqual(intervals.names, ['asphalt', 'gravel']); + // and it goes back to the values + assert.deepEqual(categoryValues(intervals, values.length), values); + }); + + it('has no intervals when the value is unknown everywhere', () => { + for (const values of [[], [undefined, undefined]]) { + const intervals = categoryIntervals(values); + assert.equal(intervals.starts.length, 0); + assert.equal(intervals.values.length, 0); + assert.deepEqual(intervals.names, []); + } + }); +}); diff --git a/website/src/lib/trackpoint-categories.ts b/website/src/lib/trackpoint-categories.ts index 2942c069a..06c9c1d12 100644 --- a/website/src/lib/trackpoint-categories.ts +++ b/website/src/lib/trackpoint-categories.ts @@ -35,3 +35,35 @@ export function categoryValues( } return result; } + +/** + * 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 + * appearance. A value that is unknown everywhere gives no intervals. + */ +export function categoryIntervals(values: readonly (string | undefined)[]): CategoryIntervals { + const starts: number[] = []; + const codes: number[] = []; + const names: string[] = []; + const known = new Map(); + let last: number | undefined = undefined; + values.forEach((value, index) => { + let code = 0; + if (value !== undefined) { + if (!known.has(value)) { + names.push(value); + known.set(value, names.length); + } + code = known.get(value)!; + } + if (code !== last) { + starts.push(index); + codes.push(code); + last = code; + } + }); + if (names.length === 0) { + return { starts: new Uint32Array(), values: new Uint8Array(), names }; + } + return { starts: Uint32Array.from(starts), values: Uint8Array.from(codes), names }; +}