mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
Migration to new Rust-based engine (#401)
* wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
No files matched your search
@@ -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<f64>) -> 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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod anchors;
|
||||
mod reverse;
|
||||
mod simplify;
|
||||
mod smooth;
|
||||
mod timestamps;
|
||||
|
||||
pub use anchors::*;
|
||||
pub use reverse::*;
|
||||
pub use simplify::*;
|
||||
pub use timestamps::*;
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Reversing a path: the order of its trackpoints, and of the segments and tracks that hold them.
|
||||
//!
|
||||
//! The timestamps are mirrored, so that the reversed path goes through the same period of time:
|
||||
//! a trackpoint at time `t` of a part that spans `start..end` ends up at `start + end - t`. When
|
||||
//! the part is made of several ones (the segments of a track, the tracks of a file), the gaps
|
||||
//! between them are kept: every part comes after the previous one in the new order, as late as the
|
||||
//! original one came after the part that followed it.
|
||||
|
||||
use crate::{Route, Track, TrackSegment, Trackpoint};
|
||||
|
||||
/// The time of the first trackpoint of a segment.
|
||||
pub fn start_time(segment: &TrackSegment) -> Option<i64> {
|
||||
segment.iter().next().and_then(|trkpt| trkpt.time)
|
||||
}
|
||||
|
||||
/// The time of the last trackpoint of a segment.
|
||||
pub fn end_time(segment: &TrackSegment) -> Option<i64> {
|
||||
segment.last_index().and_then(|idx| segment[idx].time)
|
||||
}
|
||||
|
||||
/// The time of the first trackpoint of a track.
|
||||
pub fn track_start_time(track: &Track) -> Option<i64> {
|
||||
track.trkseg.first().and_then(start_time)
|
||||
}
|
||||
|
||||
/// The time of the last trackpoint of a track.
|
||||
pub fn track_end_time(track: &Track) -> Option<i64> {
|
||||
track.trkseg.last().and_then(end_time)
|
||||
}
|
||||
|
||||
/// Reverses the trackpoints of a segment.
|
||||
///
|
||||
/// `original_next` is the time at which what came after the segment started, and `new_previous`
|
||||
/// the time at which what comes before it now ends: the segment is mirrored to start there. Both
|
||||
/// are those of the segment itself if the segment is reversed on its own, which are the ones
|
||||
/// used when they are missing (`new_previous` is the start of the segment, and `original_next`
|
||||
/// follows from the duration of the segment). Without timestamps, the trackpoints are only put
|
||||
/// in the opposite order.
|
||||
pub fn reverse_segment(
|
||||
segment: &mut TrackSegment,
|
||||
original_next: Option<i64>,
|
||||
new_previous: Option<i64>,
|
||||
) {
|
||||
let (original_start, original_end) = (start_time(segment), end_time(segment));
|
||||
let new_previous = new_previous.or(original_start);
|
||||
let original_next = original_next.or_else(|| {
|
||||
// the segment lasts as long as before
|
||||
Some(new_previous? + original_end? - original_start?)
|
||||
});
|
||||
|
||||
let len = segment.len();
|
||||
let mut points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
if let (Some(next), Some(previous), Some(end)) = (original_next, new_previous, original_end) {
|
||||
let new_start = previous + next - end;
|
||||
for point in points.iter_mut() {
|
||||
point.time = point.time.map(|time| new_start + (end - time));
|
||||
}
|
||||
}
|
||||
points.reverse();
|
||||
segment.splice(0, len, points);
|
||||
segment.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Makes a segment go back where it started: a reversed copy of it is added after its last
|
||||
/// trackpoint, without the first trackpoint of the copy, which is the last one of the segment.
|
||||
///
|
||||
/// The timestamps of the copy go on from the end of the segment: it would start at the time of the
|
||||
/// last trackpoint, and it lasts as long as the segment. Only the end of the segment changes, so
|
||||
/// its other chunks stay shared.
|
||||
pub fn round_trip(segment: &mut TrackSegment) {
|
||||
let len = segment.len();
|
||||
let end = end_time(segment);
|
||||
let mut back = segment.clone();
|
||||
reverse_segment(&mut back, end, end);
|
||||
segment.splice(len, len, back.iter().skip(1).cloned().collect());
|
||||
segment.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Reverses the segments of a track, and their trackpoints: see [`reverse_segment`] for the
|
||||
/// meaning of the times, which are the ones of the track as a whole.
|
||||
pub fn reverse_segments(
|
||||
segments: &mut [TrackSegment],
|
||||
original_next: Option<i64>,
|
||||
new_previous: Option<i64>,
|
||||
) {
|
||||
segments.reverse();
|
||||
let (mut next, mut previous) = (original_next, new_previous);
|
||||
for segment in segments.iter_mut() {
|
||||
// the next one is the one that was before, which started here
|
||||
let original_start = start_time(segment);
|
||||
reverse_segment(segment, next, previous);
|
||||
next = original_start;
|
||||
previous = end_time(segment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverses the routes of a file, and their trackpoints, like the segments of a track (see
|
||||
/// [`reverse_segments`]).
|
||||
pub fn reverse_routes(routes: &mut [Route], original_next: Option<i64>, new_previous: Option<i64>) {
|
||||
routes.reverse();
|
||||
let (mut next, mut previous) = (original_next, new_previous);
|
||||
for route in routes.iter_mut() {
|
||||
let original_start = start_time(&route.seg);
|
||||
reverse_segment(&mut route.seg, next, previous);
|
||||
next = original_start;
|
||||
previous = end_time(&route.seg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverses the tracks of a file, and their segments and trackpoints.
|
||||
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
|
||||
tracks.reverse();
|
||||
let (mut next, mut previous) = (original_next, new_previous);
|
||||
for track in tracks.iter_mut() {
|
||||
let original_start = track_start_time(track);
|
||||
reverse_segments(&mut track.trkseg, next, previous);
|
||||
next = original_start;
|
||||
previous = track_end_time(track);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::LngLat;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn segment(points: &[(f64, Option<i64>)]) -> TrackSegment {
|
||||
let mut segment = TrackSegment::default();
|
||||
let points = points
|
||||
.iter()
|
||||
.map(|(lng, time)| Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: *lng,
|
||||
lat: 0.0,
|
||||
},
|
||||
time: *time,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
segment.splice(0, 0, points);
|
||||
segment
|
||||
}
|
||||
|
||||
fn lngs(segment: &TrackSegment) -> Vec<f64> {
|
||||
segment.iter().map(|p| p.coordinates.lng).collect()
|
||||
}
|
||||
|
||||
fn times(segment: &TrackSegment) -> Vec<Option<i64>> {
|
||||
segment.iter().map(|p| p.time).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_segment_is_mirrored_in_its_own_time_span() {
|
||||
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
|
||||
let (start, end) = (start_time(&s), end_time(&s));
|
||||
reverse_segment(&mut s, end, start);
|
||||
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
|
||||
// same span, the durations between points follow the points
|
||||
assert_eq!(times(&s), [Some(0), Some(30), Some(40)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_segment_defaults() {
|
||||
// without the times around, the segment is mirrored where it is
|
||||
let mut s = segment(&[(0.0, Some(100)), (1.0, Some(130))]);
|
||||
reverse_segment(&mut s, None, None);
|
||||
assert_eq!(times(&s), [Some(100), Some(130)]);
|
||||
assert_eq!(lngs(&s), [1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_segment_without_times_is_only_reversed() {
|
||||
let mut s = segment(&[(0.0, None), (1.0, None), (2.0, None)]);
|
||||
reverse_segment(&mut s, None, None);
|
||||
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
|
||||
assert_eq!(times(&s), [None, None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_goes_back_in_time_from_the_end() {
|
||||
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
|
||||
let first = s.rev_id;
|
||||
round_trip(&mut s);
|
||||
assert_ne!(s.rev_id, first);
|
||||
// the last trackpoint is the turning point, it is not repeated
|
||||
assert_eq!(lngs(&s), [0.0, 1.0, 2.0, 1.0, 0.0]);
|
||||
// the way back lasts as long as the way there, and starts when it ended
|
||||
assert_eq!(times(&s), [Some(0), Some(10), Some(40), Some(70), Some(80)]);
|
||||
// and it is a segment whose ends are anchors
|
||||
assert_eq!(s[0].anchor, Some(0));
|
||||
assert_eq!(s[4].anchor, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_without_times() {
|
||||
let mut s = segment(&[(0.0, None), (1.0, None)]);
|
||||
round_trip(&mut s);
|
||||
assert_eq!(lngs(&s), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(times(&s), [None; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_segments_keep_the_gaps_between_them() {
|
||||
// 0..20, then 100..120: a gap of 80
|
||||
let mut segments = vec![
|
||||
segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(20))]),
|
||||
segment(&[(3.0, Some(100)), (4.0, Some(110)), (5.0, Some(120))]),
|
||||
];
|
||||
let (end, start) = (end_time(&segments[1]), start_time(&segments[0]));
|
||||
reverse_segments(&mut segments, end, start);
|
||||
assert_eq!(lngs(&segments[0]), [5.0, 4.0, 3.0]);
|
||||
assert_eq!(lngs(&segments[1]), [2.0, 1.0, 0.0]);
|
||||
assert_eq!(times(&segments[0]), [Some(0), Some(10), Some(20)]);
|
||||
// the gap is still 80
|
||||
assert_eq!(times(&segments[1]), [Some(100), Some(110), Some(120)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tracks_are_reversed_like_segments() {
|
||||
let track = |segments: Vec<TrackSegment>| Track {
|
||||
trkseg: segments,
|
||||
..Default::default()
|
||||
};
|
||||
let mut tracks = vec![
|
||||
track(vec![
|
||||
segment(&[(0.0, Some(0)), (1.0, Some(10))]),
|
||||
segment(&[(2.0, Some(50)), (3.0, Some(60))]),
|
||||
]),
|
||||
track(vec![segment(&[(4.0, Some(200)), (5.0, Some(230))])]),
|
||||
];
|
||||
let (end, start) = (track_end_time(&tracks[1]), track_start_time(&tracks[0]));
|
||||
reverse_tracks(&mut tracks, end, start);
|
||||
let all: Vec<f64> = tracks
|
||||
.iter()
|
||||
.flat_map(|t| t.trkseg.iter().flat_map(lngs))
|
||||
.collect();
|
||||
assert_eq!(all, [5.0, 4.0, 3.0, 2.0, 1.0, 0.0]);
|
||||
// 3 segments: durations 10, 10, 30 and gaps 40 and 140 are reversed to 30, 10, 10 and
|
||||
// gaps 140 and 40 (the first part of the time is the longest)
|
||||
assert_eq!(times(&tracks[0].trkseg[0]), [Some(0), Some(30)]);
|
||||
assert_eq!(times(&tracks[1].trkseg[0]), [Some(170), Some(180)]);
|
||||
assert_eq!(times(&tracks[1].trkseg[1]), [Some(220), Some(230)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use crate::{
|
||||
core::gpx::{TrackSegment, TrackSegmentIndex},
|
||||
core::utils::{crossarc, crossarc_lnglat},
|
||||
};
|
||||
|
||||
pub fn ramer_douglas_peucker<F>(
|
||||
trkseg: &TrackSegment,
|
||||
mapping: &F,
|
||||
epsilon: f64,
|
||||
) -> Vec<TrackSegmentIndex>
|
||||
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<M>(
|
||||
trkseg: &TrackSegment,
|
||||
measure: &M,
|
||||
epsilon: f64,
|
||||
) -> Vec<(TrackSegmentIndex, Option<f64>)>
|
||||
where
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
match trkseg.len() {
|
||||
0 => vec![],
|
||||
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 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<M>(
|
||||
trkseg: &TrackSegment,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
measure: &M,
|
||||
epsilon: f64,
|
||||
kept: &mut Vec<(TrackSegmentIndex, Option<f64>)>,
|
||||
) where
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
let mut max_idx = None;
|
||||
let mut max_dist = 0.0;
|
||||
|
||||
let mut cur = trkseg.next_index(Some(start));
|
||||
while let Some(idx) = cur {
|
||||
if idx == end {
|
||||
break;
|
||||
}
|
||||
|
||||
let dist = measure(start, end, idx);
|
||||
if dist > max_dist {
|
||||
max_idx = Some(idx);
|
||||
max_dist = dist;
|
||||
}
|
||||
|
||||
cur = trkseg.next_index(cur);
|
||||
}
|
||||
|
||||
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`].
|
||||
pub const MIN_REDUCE_TOLERANCE: f64 = 0.1;
|
||||
|
||||
/// The distance (in meters) from a trackpoint to the line between two others of a segment.
|
||||
fn lnglat_measure(
|
||||
trkseg: &TrackSegment,
|
||||
) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ {
|
||||
move |start, end, idx| {
|
||||
crossarc_lnglat(
|
||||
trkseg[start].coordinates,
|
||||
trkseg[end].coordinates,
|
||||
trkseg[idx].coordinates,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance`
|
||||
/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept.
|
||||
pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec<usize> {
|
||||
ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance)
|
||||
.into_iter()
|
||||
.map(|(idx, _)| idx.flat)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by
|
||||
/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that
|
||||
/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its
|
||||
/// value is greater than it.
|
||||
///
|
||||
/// A trackpoint is only selected if the ones that split the path before it are, so its value is
|
||||
/// the smallest distance at which it or one of them was selected.
|
||||
pub fn reduction_distances(trkseg: &TrackSegment) -> Vec<f64> {
|
||||
let mut distances = vec![0.0; trkseg.len()];
|
||||
let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else {
|
||||
return distances;
|
||||
};
|
||||
distances[first.flat] = f64::INFINITY;
|
||||
distances[last.flat] = f64::INFINITY;
|
||||
if trkseg.len() > 2 {
|
||||
let measure = lnglat_measure(trkseg);
|
||||
reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances);
|
||||
}
|
||||
distances
|
||||
}
|
||||
|
||||
fn reduction_distances_helper<M>(
|
||||
trkseg: &TrackSegment,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
measure: &M,
|
||||
bound: f64,
|
||||
distances: &mut [f64],
|
||||
) where
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
let mut max_idx = None;
|
||||
let mut max_dist = 0.0;
|
||||
let mut cur = trkseg.next_index(Some(start));
|
||||
while let Some(idx) = cur {
|
||||
if idx == end {
|
||||
break;
|
||||
}
|
||||
let dist = measure(start, end, idx);
|
||||
if dist > max_dist {
|
||||
max_idx = Some(idx);
|
||||
max_dist = dist;
|
||||
}
|
||||
cur = trkseg.next_index(cur);
|
||||
}
|
||||
|
||||
if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) {
|
||||
let distance = max_dist.min(bound);
|
||||
distances[idx.flat] = distance;
|
||||
reduction_distances_helper(trkseg, start, idx, measure, distance, distances);
|
||||
reduction_distances_helper(trkseg, idx, end, measure, distance, distances);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Trackpoint, TrackpointChunk};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn segment(eles: &[f64]) -> TrackSegment {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
// several small chunks, to also exercise the chunk boundaries
|
||||
for part in eles.chunks(3) {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
for ele in part {
|
||||
chunk.trkpt.push(Trackpoint {
|
||||
ele: *ele,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
trkseg.push(chunk);
|
||||
}
|
||||
trkseg
|
||||
}
|
||||
|
||||
fn simplify(eles: &[f64], epsilon: f64) -> Vec<usize> {
|
||||
let trkseg = segment(eles);
|
||||
ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon)
|
||||
.iter()
|
||||
.map(|idx| idx.flat)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_and_single_point() {
|
||||
assert!(simplify(&[], 1.0).is_empty());
|
||||
assert_eq!(simplify(&[5.0], 1.0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collinear_points_are_removed() {
|
||||
let eles: Vec<f64> = (0..20).map(|i| i as f64 * 2.0).collect();
|
||||
assert_eq!(simplify(&eles, 0.1), vec![0, 19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peak_is_kept_when_above_epsilon() {
|
||||
let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0];
|
||||
assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peak_is_dropped_when_below_epsilon() {
|
||||
let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0];
|
||||
assert_eq!(simplify(&eles, 1.0), vec![0, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_indices_are_sorted_and_keep_extremities() {
|
||||
let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0];
|
||||
let kept = simplify(&eles, 0.5);
|
||||
assert_eq!(kept.first(), Some(&0));
|
||||
assert_eq!(kept.last(), Some(&9));
|
||||
assert!(kept.windows(2).all(|w| w[0] < w[1]));
|
||||
// a larger epsilon never keeps more points
|
||||
assert!(simplify(&eles, 5.0).len() <= kept.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#[macro_export]
|
||||
macro_rules! for_each_window {
|
||||
(
|
||||
$trkseg:expr,
|
||||
$left:expr,
|
||||
$right:expr,
|
||||
$window:expr,
|
||||
|$a:ident, $b:ident| $distance:expr,
|
||||
|$i:ident, $l:ident, $r:ident| $body:block,
|
||||
) => {{
|
||||
let mut window_start = $left.unwrap_or_default();
|
||||
let mut window_center = $left;
|
||||
let mut window_end = $left.unwrap_or_default();
|
||||
while let Some(idx) = window_center {
|
||||
// advance left if needed
|
||||
while window_start != idx && {
|
||||
let $a = window_start;
|
||||
let $b = idx;
|
||||
$distance
|
||||
} > $window {
|
||||
if let Some(next) = $trkseg.next_index(Some(window_start)) {
|
||||
if next == idx {
|
||||
break;
|
||||
}
|
||||
window_start = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// advance right if needed
|
||||
window_end = window_end.max(idx);
|
||||
while let Some(next) = $trkseg.next_index(Some(window_end)) {
|
||||
if window_end != idx && {
|
||||
let $a = idx;
|
||||
let $b = next;
|
||||
$distance
|
||||
} > $window {
|
||||
break;
|
||||
}
|
||||
window_end = next;
|
||||
}
|
||||
// apply body
|
||||
let $i = idx;
|
||||
let $l = window_start;
|
||||
let $r = window_end;
|
||||
$body
|
||||
// go next
|
||||
if window_center == $right {
|
||||
break;
|
||||
}
|
||||
window_center = $trkseg.next_index(window_center);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{TrackSegment, Trackpoint, TrackpointChunk};
|
||||
|
||||
fn segment(eles: &[f64]) -> TrackSegment {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
// several small chunks, to also exercise the chunk boundaries
|
||||
for part in eles.chunks(3) {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
for ele in part {
|
||||
chunk.trkpt.push(Trackpoint {
|
||||
ele: *ele,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
trkseg.push(chunk);
|
||||
}
|
||||
trkseg
|
||||
}
|
||||
|
||||
/// Returns (center, left, right) flat indices for each visited center.
|
||||
fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> {
|
||||
let trkseg = segment(&vec![0.0; len]);
|
||||
let mut visited = vec![];
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
trkseg.first_index(),
|
||||
trkseg.last_index(),
|
||||
window,
|
||||
|a, b| (b.flat - a.flat) as f64,
|
||||
|i, l, r| {
|
||||
visited.push((i.flat, l.flat, r.flat));
|
||||
},
|
||||
);
|
||||
visited
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_bounds() {
|
||||
let visited = windows(10, 2.0);
|
||||
assert_eq!(visited.len(), 10);
|
||||
for (i, l, r) in visited {
|
||||
assert_eq!(l, i.saturating_sub(2), "left of {i}");
|
||||
assert_eq!(r, (i + 2).min(9), "right of {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_always_includes_neighbours() {
|
||||
for (i, l, r) in windows(6, 0.0) {
|
||||
assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_larger_than_segment() {
|
||||
for (_, l, r) in windows(5, 100.0) {
|
||||
assert_eq!((l, r), (0, 4));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_sub_range() {
|
||||
let trkseg = segment(&[0.0; 10]);
|
||||
let mut centers = vec![];
|
||||
let mut first = trkseg.first_index();
|
||||
for _ in 0..3 {
|
||||
first = trkseg.next_index(first);
|
||||
}
|
||||
let mut last = first;
|
||||
for _ in 0..2 {
|
||||
last = trkseg.next_index(last);
|
||||
}
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
first,
|
||||
last,
|
||||
1.0,
|
||||
|a, b| (b.flat - a.flat) as f64,
|
||||
|i, _l, _r| {
|
||||
centers.push(i.flat);
|
||||
},
|
||||
);
|
||||
assert_eq!(centers, vec![3, 4, 5]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
use crate::{Statistics, TrackSegment, Trackpoint, distance};
|
||||
|
||||
/// The new timestamps of the trackpoints of `segments`, one after the other: each segment goes on
|
||||
/// from the end of the previous one, the first one starts at `start_time` (in milliseconds).
|
||||
///
|
||||
/// A segment that has timestamps keeps its durations multiplied by `ratio`; one that has none
|
||||
/// (judged by its first trackpoint) follows the previous trackpoint at `speed` (km/h). See
|
||||
/// [`shifted_and_compressed`] and [`with_timestamps`].
|
||||
pub fn changed_segment_times(
|
||||
segments: &[&TrackSegment],
|
||||
start_time: i64,
|
||||
speed: f64,
|
||||
ratio: f64,
|
||||
) -> Vec<Vec<Option<i64>>> {
|
||||
let mut last: Option<Trackpoint> = None;
|
||||
segments
|
||||
.iter()
|
||||
.map(|segment| {
|
||||
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
let Some(first) = points.first() else {
|
||||
return vec![];
|
||||
};
|
||||
let start = last.clone().unwrap_or_else(|| {
|
||||
let mut start = first.clone();
|
||||
start.time = Some(start_time);
|
||||
start
|
||||
});
|
||||
let points = if first.time.is_none() {
|
||||
with_timestamps(points, Some(speed), Some(&start), Some(start_time))
|
||||
} else {
|
||||
shifted_and_compressed(points, Some(speed), ratio, &start)
|
||||
};
|
||||
last = points.last().cloned();
|
||||
points.iter().map(|point| point.time).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Makes up timestamps for the trackpoints of `segments`, one after the other: they start at
|
||||
/// `start_time` (in milliseconds) and last `total_time` seconds together, the longer and the
|
||||
/// steeper an interval the more of it. Each segment goes on from the end of the previous one.
|
||||
pub fn artificial_segment_times(
|
||||
segments: &[&TrackSegment],
|
||||
start_time: i64,
|
||||
total_time: f64,
|
||||
) -> Vec<Vec<Option<i64>>> {
|
||||
let mut weights: Vec<Vec<f64>> = segments
|
||||
.iter()
|
||||
.map(|segment| {
|
||||
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
let slopes: Vec<f64> = Statistics::compute(segment)
|
||||
.local
|
||||
.iter()
|
||||
.map(|l| l.slope)
|
||||
.collect();
|
||||
artificial_weights(&points, &slopes)
|
||||
})
|
||||
.collect();
|
||||
let mut total: f64 = weights.iter().flatten().sum();
|
||||
if total.is_nan() || total <= 0.0 {
|
||||
// no distance to share the time by: every interval takes as long
|
||||
for weights in &mut weights {
|
||||
weights.iter_mut().for_each(|w| *w = 1.0);
|
||||
}
|
||||
total = weights.iter().map(Vec::len).sum::<usize>() as f64;
|
||||
}
|
||||
let ms_per_weight = if total > 0.0 {
|
||||
total_time * 1000.0 / total
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let mut start = start_time;
|
||||
segments
|
||||
.iter()
|
||||
.zip(&weights)
|
||||
.map(|(segment, weights)| {
|
||||
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
if points.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let points = with_artificial_timestamps(points, weights, ms_per_weight, start);
|
||||
// the next segment goes on from the end of this one
|
||||
start = points.last().and_then(|point| point.time).unwrap_or(start);
|
||||
points.iter().map(|point| point.time).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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<Trackpoint>,
|
||||
start: usize,
|
||||
end: usize,
|
||||
mut points: Vec<Trackpoint>,
|
||||
speed: Option<f64>,
|
||||
start_time: Option<i64>,
|
||||
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);
|
||||
}
|
||||
|
||||
/// How long each interval between two consecutive trackpoints of a segment should last, relative
|
||||
/// to the others, for a made-up pace: the longer the interval, and the steeper it goes up, the
|
||||
/// longer. `slopes` has the slope (in %) at each trackpoint.
|
||||
pub fn artificial_weights(points: &[Trackpoint], slopes: &[f64]) -> Vec<f64> {
|
||||
points
|
||||
.windows(2)
|
||||
.zip(slopes)
|
||||
.map(|(pair, slope)| {
|
||||
let km = distance(pair[0].coordinates, pair[1].coordinates);
|
||||
km * (0.5 + 1.0 / (1.0 + (-0.2 * slope).exp()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `points` with made-up timestamps: the first one is at `start`, and each next one follows the
|
||||
/// previous one after `weights[i]` times `ms_per_weight` milliseconds (see [`artificial_weights`]).
|
||||
pub fn with_artificial_timestamps(
|
||||
mut points: Vec<Trackpoint>,
|
||||
weights: &[f64],
|
||||
ms_per_weight: f64,
|
||||
start: i64,
|
||||
) -> Vec<Trackpoint> {
|
||||
let mut elapsed = 0.0;
|
||||
for (i, point) in points.iter_mut().enumerate() {
|
||||
if i > 0 {
|
||||
elapsed += weights[i - 1] * ms_per_weight;
|
||||
}
|
||||
point.time = Some(start + elapsed as i64);
|
||||
}
|
||||
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<i64>, b: Option<i64>) -> 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<f64>) -> Option<i64> {
|
||||
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.
|
||||
pub fn with_timestamps(
|
||||
points: Vec<Trackpoint>,
|
||||
speed: Option<f64>,
|
||||
last: Option<&Trackpoint>,
|
||||
start_time: Option<i64>,
|
||||
) -> Vec<Trackpoint> {
|
||||
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.
|
||||
pub fn shifted_and_compressed(
|
||||
points: Vec<Trackpoint>,
|
||||
speed: Option<f64>,
|
||||
ratio: f64,
|
||||
last: &Trackpoint,
|
||||
) -> Vec<Trackpoint> {
|
||||
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<i64>) -> Trackpoint {
|
||||
Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: i as f64 * 0.009,
|
||||
lat: 0.0,
|
||||
},
|
||||
time,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn timed(range: std::ops::Range<usize>, start: i64, step: i64) -> Vec<Trackpoint> {
|
||||
range
|
||||
.map(|i| point(i, Some(start + step * i as i64)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn untimed(range: std::ops::Range<usize>) -> Vec<Trackpoint> {
|
||||
range.map(|i| point(i, None)).collect()
|
||||
}
|
||||
|
||||
fn times(trkpt: &[Trackpoint]) -> Vec<Option<i64>> {
|
||||
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 = 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), Some(speed), None, false);
|
||||
let expected = 10_000 + (3_600_000.0 * km / speed) 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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes,
|
||||
/// so that the trackpoints only hold a `u8` instead of a string.
|
||||
///
|
||||
/// The codes are the positions of the names in the order of first appearance. The table only
|
||||
/// grows: a code never changes meaning, so it stays valid in every state of the history.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Categories(Vec<String>);
|
||||
|
||||
impl Categories {
|
||||
/// Maximum number of distinct values.
|
||||
pub const CAPACITY: usize = u8::MAX as usize;
|
||||
|
||||
/// The code of `name`, which is added to the table if it is new. `None` if the table is full.
|
||||
pub fn code(&mut self, name: &str) -> Option<u8> {
|
||||
let index = match self.0.iter().position(|known| known == name) {
|
||||
Some(index) => index,
|
||||
None if self.0.len() < Self::CAPACITY => {
|
||||
self.0.push(name.to_owned());
|
||||
self.0.len() - 1
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
u8::try_from(index).ok()
|
||||
}
|
||||
|
||||
/// The name of a code, `None` if it is unknown.
|
||||
pub fn name(&self, code: u8) -> Option<&str> {
|
||||
self.0.get(usize::from(code)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// All the names, in the order of their codes.
|
||||
pub fn names(&self) -> &[String] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
|
||||
/// its files.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct TrackpointCategories {
|
||||
pub surface: Categories,
|
||||
pub highway: Categories,
|
||||
pub sac_scale: Categories,
|
||||
pub mtb_scale: Categories,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_codes_follow_the_order_of_appearance() {
|
||||
let mut categories = Categories::default();
|
||||
assert_eq!(categories.code("asphalt"), Some(0));
|
||||
assert_eq!(categories.code("gravel"), Some(1));
|
||||
// a known name keeps its code
|
||||
assert_eq!(categories.code("asphalt"), Some(0));
|
||||
assert_eq!(categories.names(), ["asphalt", "gravel"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_names_of_codes() {
|
||||
let mut categories = Categories::default();
|
||||
let code = categories.code("asphalt").unwrap();
|
||||
assert_eq!(categories.name(code), Some("asphalt"));
|
||||
assert_eq!(categories.name(code + 1), None);
|
||||
assert_eq!(Categories::default().name(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_full_table_gives_no_code_to_new_names() {
|
||||
let mut categories = Categories::default();
|
||||
for i in 0..Categories::CAPACITY {
|
||||
assert_eq!(categories.code(&i.to_string()), Some(i as u8));
|
||||
}
|
||||
assert_eq!(categories.code("one too many"), None);
|
||||
assert_eq!(categories.names().len(), Categories::CAPACITY);
|
||||
// the known ones still have their code, the last one included
|
||||
assert_eq!(categories.code("0"), Some(0));
|
||||
assert_eq!(categories.code("254"), Some(254));
|
||||
assert_eq!(categories.name(254), Some("254"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_categories_are_independent() {
|
||||
let mut categories = TrackpointCategories::default();
|
||||
categories.surface.code("asphalt");
|
||||
assert_eq!(categories.highway.code("path"), Some(0));
|
||||
assert_eq!(categories.sac_scale.code("hiking"), Some(0));
|
||||
assert_eq!(categories.mtb_scale.code("1"), Some(0));
|
||||
assert_eq!(categories.surface.names(), ["asphalt"]);
|
||||
assert_eq!(categories.highway.names(), ["path"]);
|
||||
assert_eq!(categories.sac_scale.names(), ["hiking"]);
|
||||
assert_eq!(categories.mtb_scale.names(), ["1"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::{Trackpoint, Waypoint};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
/// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk
|
||||
/// that is not touched by an edit is not copied.
|
||||
///
|
||||
/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new
|
||||
/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is.
|
||||
pub trait Chunk {
|
||||
type Item: Clone;
|
||||
|
||||
/// Maximum number of items of a chunk.
|
||||
const MAX_SIZE: usize;
|
||||
|
||||
/// A chunk with a new identity, holding `items`.
|
||||
fn new(items: Vec<Self::Item>) -> Self;
|
||||
|
||||
/// Gives the chunk a new identity, after its items were changed in place.
|
||||
fn renew(&mut self);
|
||||
|
||||
fn items(&self) -> &Vec<Self::Item>;
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<Self::Item>;
|
||||
|
||||
fn is_full(&self) -> bool {
|
||||
self.items().len() >= Self::MAX_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
|
||||
|
||||
uuid_id!(TrackpointChunkId);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TrackpointChunk {
|
||||
pub id: TrackpointChunkId,
|
||||
pub trkpt: Vec<Trackpoint>,
|
||||
}
|
||||
|
||||
impl Chunk for TrackpointChunk {
|
||||
type Item = Trackpoint;
|
||||
const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE;
|
||||
|
||||
fn new(trkpt: Vec<Trackpoint>) -> Self {
|
||||
Self {
|
||||
trkpt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn renew(&mut self) {
|
||||
self.id = Default::default();
|
||||
}
|
||||
|
||||
fn items(&self) -> &Vec<Trackpoint> {
|
||||
&self.trkpt
|
||||
}
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<Trackpoint> {
|
||||
&mut self.trkpt
|
||||
}
|
||||
}
|
||||
|
||||
uuid_id!(WaypointChunkId);
|
||||
|
||||
const MAX_WPT_CHUNK_SIZE: usize = 128;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WaypointChunk {
|
||||
pub id: WaypointChunkId,
|
||||
pub wpt: Vec<Waypoint>,
|
||||
}
|
||||
|
||||
impl Chunk for WaypointChunk {
|
||||
type Item = Waypoint;
|
||||
const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
|
||||
|
||||
fn new(wpt: Vec<Waypoint>) -> Self {
|
||||
Self {
|
||||
wpt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn renew(&mut self) {
|
||||
self.id = Default::default();
|
||||
}
|
||||
|
||||
fn items(&self) -> &Vec<Waypoint> {
|
||||
&self.wpt
|
||||
}
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<Waypoint> {
|
||||
&mut self.wpt
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trackpoint_chunk_full() {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
assert!(!chunk.is_full());
|
||||
for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 {
|
||||
chunk.trkpt.push(Trackpoint::default());
|
||||
}
|
||||
assert!(!chunk.is_full());
|
||||
chunk.trkpt.push(Trackpoint::default());
|
||||
assert!(chunk.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_waypoint_chunk_full() {
|
||||
let mut chunk = WaypointChunk::default();
|
||||
assert!(!chunk.is_full());
|
||||
for _ in 0..MAX_WPT_CHUNK_SIZE {
|
||||
chunk.wpt.push(Waypoint::default());
|
||||
}
|
||||
assert!(chunk.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_chunks_have_their_own_identity() {
|
||||
let a = TrackpointChunk::new(vec![Trackpoint::default()]);
|
||||
let b = TrackpointChunk::new(vec![Trackpoint::default()]);
|
||||
assert_ne!(a.id, b.id);
|
||||
assert_eq!(a.items().len(), 1);
|
||||
assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
use std::{ops::Index, rc::Rc};
|
||||
|
||||
use crate::Chunk;
|
||||
|
||||
/// A list of items, stored in [`Chunk`]s that are shared between its versions: editing it only
|
||||
/// copies the chunks around the change, so that a version costs little more than the change.
|
||||
///
|
||||
/// There are no empty chunks.
|
||||
#[derive(Debug)]
|
||||
pub struct Chunked<C: Chunk> {
|
||||
chunks: Vec<Rc<C>>,
|
||||
cumul_length: Vec<usize>,
|
||||
}
|
||||
|
||||
impl<C: Chunk> Clone for Chunked<C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
chunks: self.chunks.clone(),
|
||||
cumul_length: self.cumul_length.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Chunk> Default for Chunked<C> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chunks: vec![],
|
||||
cumul_length: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of an item: in which chunk, where in it, and among all the items.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
|
||||
pub struct ChunkIndex {
|
||||
pub chunk: usize,
|
||||
pub pos: usize,
|
||||
pub flat: usize,
|
||||
}
|
||||
|
||||
impl<C: Chunk> Chunked<C> {
|
||||
/// Adds a chunk at the end, which is dropped if it is empty.
|
||||
pub fn push(&mut self, chunk: C) {
|
||||
self.push_shared(Rc::new(chunk));
|
||||
}
|
||||
|
||||
pub fn push_shared(&mut self, chunk: Rc<C>) {
|
||||
if chunk.items().is_empty() {
|
||||
return;
|
||||
}
|
||||
self.cumul_length
|
||||
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.items().len());
|
||||
self.chunks.push(chunk);
|
||||
}
|
||||
|
||||
pub fn chunks(&self) -> &[Rc<C>] {
|
||||
&self.chunks
|
||||
}
|
||||
|
||||
/// Replaces the items in `start..end` by `items`. Panics if the range is out of bounds.
|
||||
///
|
||||
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the
|
||||
/// range are copied and refilled.
|
||||
pub fn splice(&mut self, start: usize, end: usize, items: Vec<C::Item>) {
|
||||
assert!(
|
||||
start <= end && end <= self.len(),
|
||||
"splice range out of bounds"
|
||||
);
|
||||
let old = std::mem::take(&mut self.chunks);
|
||||
self.cumul_length.clear();
|
||||
|
||||
let mut items = Some(items);
|
||||
let mut pending = vec![];
|
||||
let mut offset = 0;
|
||||
for chunk in old {
|
||||
let (lo, hi) = (offset, offset + chunk.items().len());
|
||||
offset = hi;
|
||||
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
|
||||
if hi < start || (hi == start && chunk.is_full()) {
|
||||
self.push_shared(chunk);
|
||||
continue;
|
||||
}
|
||||
if let Some(items) = items.take() {
|
||||
self.fill(&mut pending, chunk.items()[..start - lo].iter().cloned());
|
||||
self.fill(&mut pending, items);
|
||||
}
|
||||
if hi <= end {
|
||||
continue;
|
||||
}
|
||||
if lo >= end {
|
||||
self.flush(&mut pending);
|
||||
self.push_shared(chunk);
|
||||
} else {
|
||||
self.fill(&mut pending, chunk.items()[end - lo..].iter().cloned());
|
||||
}
|
||||
}
|
||||
if let Some(items) = items {
|
||||
self.fill(&mut pending, items);
|
||||
}
|
||||
self.flush(&mut pending);
|
||||
}
|
||||
|
||||
fn fill(&mut self, pending: &mut Vec<C::Item>, items: impl IntoIterator<Item = C::Item>) {
|
||||
for item in items {
|
||||
pending.push(item);
|
||||
if pending.len() >= C::MAX_SIZE {
|
||||
self.flush(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self, pending: &mut Vec<C::Item>) {
|
||||
self.push(C::new(std::mem::take(pending)));
|
||||
}
|
||||
|
||||
/// Edits the items of the chunks that contain an item accepted by `filter`.
|
||||
///
|
||||
/// `f` receives a copy of the items of such a chunk and returns whether it changed them. A
|
||||
/// changed chunk is replaced by a new one (so that what is derived from it is computed
|
||||
/// again) and the chunks left empty are dropped. The other chunks are kept. Returns whether
|
||||
/// anything changed.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
filter: impl Fn(&C::Item) -> bool,
|
||||
mut f: impl FnMut(&mut Vec<C::Item>) -> bool,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let old = std::mem::take(&mut self.chunks);
|
||||
self.cumul_length.clear();
|
||||
for chunk in old {
|
||||
if !chunk.items().iter().any(&filter) {
|
||||
self.push_shared(chunk);
|
||||
continue;
|
||||
}
|
||||
let mut items = chunk.items().clone();
|
||||
if !f(&mut items) {
|
||||
self.push_shared(chunk);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
self.push(C::new(items));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Changes what is in the chunk `chunk`, but not how many items there are. A chunk that is
|
||||
/// shared is copied first, so that the other versions do not change.
|
||||
fn replace_chunk(&mut self, chunk: usize, f: impl FnOnce(&mut Vec<C::Item>)) {
|
||||
let len = self.chunks[chunk].items().len();
|
||||
match Rc::get_mut(&mut self.chunks[chunk]) {
|
||||
Some(unique) => {
|
||||
f(unique.items_mut());
|
||||
unique.renew();
|
||||
}
|
||||
None => {
|
||||
let mut items = self.chunks[chunk].items().clone();
|
||||
f(&mut items);
|
||||
self.chunks[chunk] = Rc::new(C::new(items));
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(self.chunks[chunk].items().len(), len);
|
||||
}
|
||||
|
||||
/// Applies `f` to the item at `index`, which is copied in a new chunk. Panics if there is no
|
||||
/// such item.
|
||||
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut C::Item)) {
|
||||
let ChunkIndex { chunk, pos, .. } = self.locate(index).unwrap();
|
||||
self.replace_chunk(chunk, |items| f(&mut items[pos]));
|
||||
}
|
||||
|
||||
/// Applies `f` to every item, with its index, in new chunks.
|
||||
pub fn update_all(&mut self, mut f: impl FnMut(usize, &mut C::Item)) {
|
||||
let mut offset = 0;
|
||||
for chunk in 0..self.chunks.len() {
|
||||
let len = self.chunks[chunk].items().len();
|
||||
self.replace_chunk(chunk, |items| {
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
f(offset + i, item);
|
||||
}
|
||||
});
|
||||
offset += len;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.cumul_length.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.chunks.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> ChunkedIter<'_, C> {
|
||||
ChunkedIter::new(self)
|
||||
}
|
||||
|
||||
pub fn first_index(&self) -> Option<ChunkIndex> {
|
||||
self.next_index(None)
|
||||
}
|
||||
|
||||
pub fn last_index(&self) -> Option<ChunkIndex> {
|
||||
self.prev_index(None)
|
||||
}
|
||||
|
||||
pub fn next_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
|
||||
let mut next = cur.map_or_default(|idx| ChunkIndex {
|
||||
chunk: idx.chunk,
|
||||
pos: idx.pos + 1,
|
||||
flat: idx.flat + 1,
|
||||
});
|
||||
loop {
|
||||
if next.chunk >= self.chunks.len() {
|
||||
return None;
|
||||
}
|
||||
if next.pos == self.chunks[next.chunk].items().len() {
|
||||
next.chunk += 1;
|
||||
next.pos = 0;
|
||||
} else {
|
||||
return Some(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
|
||||
let mut prev = cur.unwrap_or(ChunkIndex {
|
||||
chunk: self.chunks.len(),
|
||||
pos: 0,
|
||||
flat: self.cumul_length.last().copied().unwrap_or_default(),
|
||||
});
|
||||
if prev.pos == 0 {
|
||||
while prev.chunk > 0 {
|
||||
prev.chunk -= 1;
|
||||
if !self.chunks[prev.chunk].items().is_empty() {
|
||||
prev.pos = self.chunks[prev.chunk].items().len() - 1;
|
||||
prev.flat -= 1;
|
||||
return Some(prev);
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
prev.pos -= 1;
|
||||
prev.flat -= 1;
|
||||
Some(prev)
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of the item `idx` among all the items.
|
||||
pub fn locate(&self, idx: usize) -> Option<ChunkIndex> {
|
||||
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
|
||||
if chunk >= self.chunks.len() {
|
||||
return None;
|
||||
}
|
||||
let pos = if chunk > 0 {
|
||||
idx - self.cumul_length[chunk - 1]
|
||||
} else {
|
||||
idx
|
||||
};
|
||||
if pos >= self.chunks[chunk].items().len() {
|
||||
None
|
||||
} else {
|
||||
Some(ChunkIndex {
|
||||
chunk,
|
||||
pos,
|
||||
flat: idx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Chunk> Index<ChunkIndex> for Chunked<C> {
|
||||
type Output = C::Item;
|
||||
|
||||
fn index(&self, idx: ChunkIndex) -> &Self::Output {
|
||||
&self.chunks[idx.chunk].items()[idx.pos]
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Chunk> Index<usize> for Chunked<C> {
|
||||
type Output = C::Item;
|
||||
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
&self[self.locate(idx).unwrap()]
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, C: Chunk> IntoIterator for &'a Chunked<C> {
|
||||
type Item = &'a C::Item;
|
||||
type IntoIter = ChunkedIter<'a, C>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChunkedIter<'a, C: Chunk> {
|
||||
chunked: &'a Chunked<C>,
|
||||
/// The item returned last.
|
||||
idx: Option<ChunkIndex>,
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
impl<C: Chunk> Clone for ChunkedIter<'_, C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
chunked: self.chunked,
|
||||
idx: self.idx,
|
||||
exhausted: self.exhausted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, C: Chunk> ChunkedIter<'a, C> {
|
||||
pub fn new(chunked: &'a Chunked<C>) -> Self {
|
||||
Self {
|
||||
chunked,
|
||||
idx: None,
|
||||
exhausted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn move_to(&mut self, idx: Option<ChunkIndex>) -> Option<&'a C::Item> {
|
||||
self.exhausted = idx.is_none();
|
||||
self.idx = idx.or(self.idx);
|
||||
idx.map(|idx| &self.chunked[idx])
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, C: Chunk> Iterator for ChunkedIter<'a, C> {
|
||||
type Item = &'a C::Item;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.exhausted {
|
||||
return None;
|
||||
}
|
||||
let next = self.chunked.next_index(self.idx);
|
||||
self.move_to(next)
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
if self.exhausted {
|
||||
return None;
|
||||
}
|
||||
let target = self.idx.map_or(0, |idx| idx.flat + 1).saturating_add(n);
|
||||
let next = self.chunked.locate(target);
|
||||
self.move_to(next)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Chunks of at most 3 numbers, with an identity like the real ones.
|
||||
#[derive(Debug)]
|
||||
struct Numbers {
|
||||
id: usize,
|
||||
items: Vec<u32>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static NEXT_ID: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn next_id() -> usize {
|
||||
NEXT_ID.with(|id| id.replace(id.get() + 1))
|
||||
}
|
||||
|
||||
impl Chunk for Numbers {
|
||||
type Item = u32;
|
||||
const MAX_SIZE: usize = 3;
|
||||
|
||||
fn new(items: Vec<u32>) -> Self {
|
||||
Self {
|
||||
id: next_id(),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
fn renew(&mut self) {
|
||||
self.id = next_id();
|
||||
}
|
||||
|
||||
fn items(&self) -> &Vec<u32> {
|
||||
&self.items
|
||||
}
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<u32> {
|
||||
&mut self.items
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbers `0..n` in chunks of 3.
|
||||
fn numbers(n: u32) -> Chunked<Numbers> {
|
||||
let mut chunked = Chunked::default();
|
||||
for start in (0..n).step_by(3) {
|
||||
chunked.push(Numbers::new((start..(start + 3).min(n)).collect()));
|
||||
}
|
||||
chunked
|
||||
}
|
||||
|
||||
fn values(chunked: &Chunked<Numbers>) -> Vec<u32> {
|
||||
chunked.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn ids(chunked: &Chunked<Numbers>) -> Vec<usize> {
|
||||
chunked.chunks().iter().map(|chunk| chunk.id).collect()
|
||||
}
|
||||
|
||||
fn assert_consistent(chunked: &Chunked<Numbers>) {
|
||||
let sizes: Vec<usize> = chunked.chunks().iter().map(|c| c.items.len()).collect();
|
||||
assert!(
|
||||
sizes
|
||||
.iter()
|
||||
.all(|&size| 0 < size && size <= Numbers::MAX_SIZE)
|
||||
);
|
||||
assert_eq!(chunked.len(), sizes.iter().sum::<usize>());
|
||||
for index in 0..chunked.len() {
|
||||
assert_eq!(chunked.locate(index).unwrap().flat, index);
|
||||
}
|
||||
assert!(chunked.locate(chunked.len()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_drops_empty_chunks() {
|
||||
let mut chunked = numbers(4);
|
||||
chunked.push(Numbers::new(vec![]));
|
||||
assert_eq!(chunked.chunks().len(), 2);
|
||||
assert_eq!(chunked.len(), 4);
|
||||
assert!(Chunked::<Numbers>::default().is_empty());
|
||||
assert!(!chunked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locate_index_and_iterate() {
|
||||
let chunked = numbers(7);
|
||||
assert_eq!(
|
||||
chunked.locate(4),
|
||||
Some(ChunkIndex {
|
||||
chunk: 1,
|
||||
pos: 1,
|
||||
flat: 4
|
||||
})
|
||||
);
|
||||
assert_eq!(chunked[4], 4);
|
||||
assert_eq!(chunked[chunked.locate(6).unwrap()], 6);
|
||||
assert!(chunked.locate(7).is_none());
|
||||
assert_eq!(values(&chunked), (0..7).collect::<Vec<_>>());
|
||||
assert_eq!(chunked.iter().count(), 7);
|
||||
assert_eq!((&chunked).into_iter().last(), Some(&6));
|
||||
assert_eq!(values(&Chunked::default()), Vec::<u32>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_last_next_and_previous_index() {
|
||||
let chunked = numbers(7);
|
||||
let first = chunked.first_index().unwrap();
|
||||
let last = chunked.last_index().unwrap();
|
||||
assert_eq!((first.flat, last.flat), (0, 6));
|
||||
assert_eq!((last.chunk, last.pos), (2, 0));
|
||||
|
||||
let mut forward = vec![];
|
||||
let mut cur = None;
|
||||
while let Some(next) = chunked.next_index(cur) {
|
||||
forward.push(chunked[next]);
|
||||
cur = Some(next);
|
||||
}
|
||||
assert_eq!(forward, (0..7).collect::<Vec<_>>());
|
||||
|
||||
let mut backward = vec![];
|
||||
let mut cur = None;
|
||||
while let Some(prev) = chunked.prev_index(cur) {
|
||||
backward.push(chunked[prev]);
|
||||
cur = Some(prev);
|
||||
}
|
||||
assert_eq!(backward, (0..7).rev().collect::<Vec<_>>());
|
||||
|
||||
let empty = Chunked::<Numbers>::default();
|
||||
assert!(empty.first_index().is_none() && empty.last_index().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::iter_nth_zero)]
|
||||
fn test_iterator_nth_goes_on_from_the_current_item() {
|
||||
let chunked = numbers(10);
|
||||
let mut iter = chunked.iter();
|
||||
assert_eq!(iter.nth(1), Some(&1));
|
||||
assert_eq!(iter.next(), Some(&2));
|
||||
// skips 3 and 4
|
||||
assert_eq!(iter.nth(2), Some(&5));
|
||||
assert_eq!(iter.nth(0), Some(&6));
|
||||
assert_eq!(iter.nth(2), Some(&9));
|
||||
assert_eq!(iter.nth(0), None);
|
||||
assert_eq!(iter.next(), None, "an exhausted iterator stays exhausted");
|
||||
|
||||
assert_eq!(values(&numbers(10)).iter().skip(4).count(), 6);
|
||||
assert_eq!(
|
||||
chunked.iter().skip(4).copied().collect::<Vec<_>>(),
|
||||
(4..10).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
chunked.iter().step_by(4).copied().collect::<Vec<_>>(),
|
||||
vec![0, 4, 8]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_replaces_inserts_deletes_and_appends() {
|
||||
let mut chunked = numbers(10);
|
||||
chunked.splice(2, 5, vec![100, 101]);
|
||||
assert_eq!(values(&chunked), [0, 1, 100, 101, 5, 6, 7, 8, 9]);
|
||||
assert_consistent(&chunked);
|
||||
|
||||
chunked.splice(0, 0, vec![50]);
|
||||
assert_eq!(values(&chunked)[..3], [50, 0, 1]);
|
||||
chunked.splice(chunked.len(), chunked.len(), vec![7, 7, 7, 7]);
|
||||
assert_eq!(values(&chunked)[chunked.len() - 5..], [9, 7, 7, 7, 7]);
|
||||
assert_consistent(&chunked);
|
||||
|
||||
let len = chunked.len();
|
||||
chunked.splice(0, len, vec![]);
|
||||
assert!(chunked.is_empty());
|
||||
assert_eq!(chunked.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "splice range out of bounds")]
|
||||
fn test_splice_out_of_bounds_panics() {
|
||||
numbers(4).splice(2, 5, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_keeps_the_chunks_it_does_not_touch() {
|
||||
let mut chunked = numbers(12);
|
||||
let before = ids(&chunked);
|
||||
// inside the second chunk
|
||||
chunked.splice(4, 5, vec![40]);
|
||||
let after = ids(&chunked);
|
||||
assert_eq!(after.len(), before.len());
|
||||
assert_eq!(
|
||||
(after[0], after[2], after[3]),
|
||||
(before[0], before[2], before[3])
|
||||
);
|
||||
assert_ne!(after[1], before[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_replaces_only_the_changed_chunks() {
|
||||
let mut chunked = numbers(9);
|
||||
let before = ids(&chunked);
|
||||
|
||||
// nothing is changed: nothing is replaced
|
||||
assert!(!chunked.edit(|n| *n == 4, |_| false));
|
||||
assert_eq!(ids(&chunked), before);
|
||||
|
||||
// the filter picks the chunk, the hook changes it
|
||||
assert!(chunked.edit(
|
||||
|n| *n == 4,
|
||||
|items| {
|
||||
items[1] = 400;
|
||||
true
|
||||
}
|
||||
));
|
||||
let after = ids(&chunked);
|
||||
assert_eq!((after[0], after[2]), (before[0], before[2]));
|
||||
assert_ne!(after[1], before[1]);
|
||||
assert_eq!(values(&chunked), [0, 1, 2, 3, 400, 5, 6, 7, 8]);
|
||||
|
||||
// a chunk left empty is dropped
|
||||
assert!(chunked.edit(
|
||||
|n| *n == 0,
|
||||
|items| {
|
||||
items.clear();
|
||||
true
|
||||
}
|
||||
));
|
||||
assert_eq!(chunked.chunks().len(), 2);
|
||||
assert_consistent(&chunked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_copies_the_chunk_so_that_other_versions_do_not_change() {
|
||||
let mut chunked = numbers(6);
|
||||
let version = chunked.clone();
|
||||
let before = ids(&chunked);
|
||||
chunked.update(4, |n| *n = 40);
|
||||
assert_eq!(values(&chunked), [0, 1, 2, 3, 40, 5]);
|
||||
assert_eq!(values(&version), (0..6).collect::<Vec<_>>());
|
||||
assert_eq!(ids(&version), before);
|
||||
assert_eq!(ids(&chunked)[0], before[0]);
|
||||
assert_ne!(ids(&chunked)[1], before[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_updated_chunks_get_a_new_identity_even_when_nothing_shares_them() {
|
||||
// what is derived from or kept of a chunk is keyed by its identity
|
||||
let mut chunked = numbers(6);
|
||||
let before = ids(&chunked);
|
||||
chunked.update(1, |n| *n += 1);
|
||||
assert_ne!(ids(&chunked)[0], before[0]);
|
||||
assert_eq!(ids(&chunked)[1], before[1]);
|
||||
|
||||
let before = ids(&chunked);
|
||||
chunked.update_all(|_, n| *n += 1);
|
||||
let after = ids(&chunked);
|
||||
assert!(before.iter().zip(&after).all(|(a, b)| a != b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_all_gives_the_index_of_each_item() {
|
||||
let mut chunked = numbers(8);
|
||||
let mut seen = vec![];
|
||||
chunked.update_all(|index, n| {
|
||||
seen.push(index);
|
||||
*n = 10 * index as u32;
|
||||
});
|
||||
assert_eq!(seen, (0..8).collect::<Vec<_>>());
|
||||
assert_eq!(values(&chunked), [0, 10, 20, 30, 40, 50, 60, 70]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/// Defines an identifier made of a UUID, which is a new one by default.
|
||||
macro_rules! uuid_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct $name(pub uuid::Uuid);
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use uuid_id;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Link {
|
||||
pub href: String,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LngLat {
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LngLatBounds {
|
||||
pub sw: LngLat,
|
||||
pub ne: LngLat,
|
||||
}
|
||||
|
||||
impl Default for LngLatBounds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sw: LngLat {
|
||||
lng: 180.0,
|
||||
lat: 90.0,
|
||||
},
|
||||
ne: LngLat {
|
||||
lng: -180.0,
|
||||
lat: -90.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LngLatBounds {
|
||||
pub fn extend(&mut self, coordinates: LngLat) {
|
||||
self.sw.lng = self.sw.lng.min(coordinates.lng);
|
||||
self.sw.lat = self.sw.lat.min(coordinates.lat);
|
||||
self.ne.lng = self.ne.lng.max(coordinates.lng);
|
||||
self.ne.lat = self.ne.lat.max(coordinates.lat);
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &LngLatBounds) {
|
||||
self.sw.lng = self.sw.lng.min(other.sw.lng);
|
||||
self.sw.lat = self.sw.lat.min(other.sw.lat);
|
||||
self.ne.lng = self.ne.lng.max(other.ne.lng);
|
||||
self.ne.lat = self.ne.lat.max(other.ne.lat);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bounds_extend() {
|
||||
let mut bounds = LngLatBounds::default();
|
||||
bounds.extend(LngLat {
|
||||
lng: 4.0,
|
||||
lat: 50.0,
|
||||
});
|
||||
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0));
|
||||
assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0));
|
||||
|
||||
bounds.extend(LngLat {
|
||||
lng: 6.0,
|
||||
lat: 45.0,
|
||||
});
|
||||
bounds.extend(LngLat {
|
||||
lng: 5.0,
|
||||
lat: 48.0,
|
||||
});
|
||||
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0));
|
||||
assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bounds_merge() {
|
||||
let mut a = LngLatBounds::default();
|
||||
a.extend(LngLat { lng: 0.0, lat: 0.0 });
|
||||
a.extend(LngLat { lng: 1.0, lat: 1.0 });
|
||||
let mut b = LngLatBounds::default();
|
||||
b.extend(LngLat {
|
||||
lng: -2.0,
|
||||
lat: 0.5,
|
||||
});
|
||||
b.extend(LngLat { lng: 0.5, lat: 3.0 });
|
||||
|
||||
a.merge(&b);
|
||||
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
|
||||
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
|
||||
|
||||
// merging an empty bounds changes nothing
|
||||
a.merge(&LngLatBounds::default());
|
||||
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
|
||||
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use crate::{Link, Route, Track, TrackSegment, Waypoints};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(FileId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct File {
|
||||
pub id: FileId,
|
||||
pub info: FileInfo,
|
||||
/// The routes come before the tracks, as in GPX: everything that goes through the segments
|
||||
/// of a file (statistics, the elevation profile...) takes the ones of the routes first.
|
||||
pub rte: Vec<Route>,
|
||||
pub trk: Vec<Track>,
|
||||
pub wpt: Waypoints,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct FileInfo {
|
||||
pub name: String,
|
||||
pub desc: Option<String>,
|
||||
pub author: Option<Author>,
|
||||
pub links: Vec<Link>,
|
||||
pub time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Author {
|
||||
pub name: Option<String>,
|
||||
/// `id@domain`, which the file gives as the two attributes of an `email` element.
|
||||
pub email: Option<String>,
|
||||
/// The author has a single link, unlike the other elements.
|
||||
pub link: Option<Link>,
|
||||
}
|
||||
|
||||
/// Where a segment is in its file: the one of a route, or one of a track.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SegmentIndex {
|
||||
Route(usize),
|
||||
Track { trk: usize, seg: usize },
|
||||
}
|
||||
|
||||
impl File {
|
||||
/// The segments of the routes, then the ones of the tracks.
|
||||
pub fn segments(&self) -> impl Iterator<Item = &TrackSegment> {
|
||||
self.rte
|
||||
.iter()
|
||||
.map(|rte| &rte.seg)
|
||||
.chain(self.trk.iter().flat_map(|trk| &trk.trkseg))
|
||||
}
|
||||
|
||||
/// Where the segments are, in the order of [`File::segments`].
|
||||
pub fn segment_indices(&self) -> impl Iterator<Item = SegmentIndex> + '_ {
|
||||
(0..self.rte.len())
|
||||
.map(SegmentIndex::Route)
|
||||
.chain(self.trk.iter().enumerate().flat_map(|(trk, track)| {
|
||||
(0..track.trkseg.len()).map(move |seg| SegmentIndex::Track { trk, seg })
|
||||
}))
|
||||
}
|
||||
|
||||
/// The segment at `index`. Panics if there is none.
|
||||
pub fn segment(&self, index: SegmentIndex) -> &TrackSegment {
|
||||
match index {
|
||||
SegmentIndex::Route(rte) => &self.rte[rte].seg,
|
||||
SegmentIndex::Track { trk, seg } => &self.trk[trk].trkseg[seg],
|
||||
}
|
||||
}
|
||||
|
||||
/// The segment at `index`. Panics if there is none.
|
||||
pub fn segment_mut(&mut self, index: SegmentIndex) -> &mut TrackSegment {
|
||||
match index {
|
||||
SegmentIndex::Route(rte) => &mut self.rte[rte].seg,
|
||||
SegmentIndex::Track { trk, seg } => &mut self.trk[trk].trkseg[seg],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the file has no route nor track.
|
||||
pub fn has_no_path(&self) -> bool {
|
||||
self.rte.is_empty() && self.trk.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
mod categories;
|
||||
mod chunk;
|
||||
mod chunked;
|
||||
mod common;
|
||||
mod file;
|
||||
mod route;
|
||||
mod segment;
|
||||
mod track;
|
||||
mod trackpoint;
|
||||
mod waypoint;
|
||||
mod waypoints;
|
||||
|
||||
pub use categories::*;
|
||||
pub use chunk::*;
|
||||
pub use chunked::*;
|
||||
pub use common::*;
|
||||
pub use file::*;
|
||||
pub use route::*;
|
||||
pub use segment::*;
|
||||
pub use track::*;
|
||||
pub use trackpoint::*;
|
||||
pub use waypoint::*;
|
||||
pub use waypoints::*;
|
||||
@@ -0,0 +1,15 @@
|
||||
use crate::{TrackInfo, TrackSegment};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(RouteId);
|
||||
|
||||
/// A route of the file (`rte`): like a track, but with a single segment, its points.
|
||||
///
|
||||
/// It has the information of a track: GPX gives the same to both.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Route {
|
||||
pub id: RouteId,
|
||||
pub info: TrackInfo,
|
||||
pub seg: TrackSegment,
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(TrackSegmentId);
|
||||
|
||||
uuid_id!(TrackSegmentRevisionId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TrackSegment {
|
||||
pub id: TrackSegmentId,
|
||||
pub rev_id: TrackSegmentRevisionId,
|
||||
points: Chunked<TrackpointChunk>,
|
||||
}
|
||||
|
||||
/// The position of a trackpoint in a segment.
|
||||
pub type TrackSegmentIndex = ChunkIndex;
|
||||
|
||||
pub type TrackSegmentIterator<'a> = ChunkedIter<'a, TrackpointChunk>;
|
||||
|
||||
impl Deref for TrackSegment {
|
||||
type Target = Chunked<TrackpointChunk>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.points
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackSegment {
|
||||
/// Adds a chunk after the last trackpoint, which is dropped if it is empty.
|
||||
pub fn push(&mut self, chunk: TrackpointChunk) {
|
||||
self.points.push(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Adds a chunk that is shared with something else after the last trackpoint.
|
||||
pub fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
|
||||
self.points.push_shared(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no
|
||||
/// such trackpoint.
|
||||
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) {
|
||||
self.points.update(index, f);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Applies `f` to every trackpoint, with its index, see [`Chunked::update_all`].
|
||||
pub fn update_all(&mut self, f: impl FnMut(usize, &mut Trackpoint)) {
|
||||
self.points.update_all(f);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Replaces the points in `start..end` by `points`, see [`Chunked::splice`]. The first and
|
||||
/// last trackpoints are anchors afterwards.
|
||||
pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
|
||||
self.points.splice(start, end, points);
|
||||
self.rev_id = Default::default();
|
||||
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.set_anchor(index, 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.update(index, |trkpt| trkpt.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.update_all(|index, trkpt| {
|
||||
trkpt.anchor = match anchors.peek() {
|
||||
Some(&(anchor, zoom)) if anchor == index => {
|
||||
anchors.next();
|
||||
Some(zoom)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::Chunk;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_track_segment(nb_chunks: usize) -> TrackSegment {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
let mut count = 0;
|
||||
for n in 1..=nb_chunks {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
for _ in 0..n {
|
||||
chunk.trkpt.push(Trackpoint {
|
||||
ele: count as f64,
|
||||
..Default::default()
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
trkseg.push(chunk);
|
||||
}
|
||||
trkseg
|
||||
}
|
||||
|
||||
fn eles(trkseg: &TrackSegment) -> Vec<f64> {
|
||||
trkseg.iter().map(|p| p.ele).collect()
|
||||
}
|
||||
|
||||
fn points(eles: &[f64]) -> Vec<Trackpoint> {
|
||||
eles.iter()
|
||||
.map(|&ele| Trackpoint {
|
||||
ele,
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_replace_insert_delete_append() {
|
||||
// chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14]
|
||||
let mut trkseg = create_track_segment(5);
|
||||
trkseg.splice(4, 8, points(&[-1.0, -2.0]));
|
||||
let mut expected: Vec<f64> = (0..4).map(f64::from).collect();
|
||||
expected.extend([-1.0, -2.0]);
|
||||
expected.extend((8..15).map(f64::from));
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
assert_eq!(trkseg.len(), expected.len());
|
||||
for i in 0..trkseg.len() {
|
||||
assert_eq!(trkseg[i].ele, expected[i]);
|
||||
}
|
||||
|
||||
trkseg.splice(2, 2, points(&[100.0]));
|
||||
expected.insert(2, 100.0);
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
|
||||
trkseg.splice(0, 3, vec![]);
|
||||
expected.drain(..3);
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
|
||||
let len = trkseg.len();
|
||||
trkseg.splice(len, len, points(&[7.0, 8.0]));
|
||||
expected.extend([7.0, 8.0]);
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
|
||||
let len = trkseg.len();
|
||||
trkseg.splice(0, len, vec![]);
|
||||
assert_eq!(trkseg.len(), 0);
|
||||
assert!(trkseg.first_index().is_none());
|
||||
}
|
||||
|
||||
fn anchors(trkseg: &TrackSegment) -> Vec<Option<u8>> {
|
||||
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().to_vec();
|
||||
// inside the third chunk only
|
||||
trkseg.splice(4, 5, points(&[-1.0]));
|
||||
assert!(Rc::ptr_eq(&trkseg.chunks()[0], &before[0]));
|
||||
assert!(Rc::ptr_eq(&trkseg.chunks()[1], &before[1]));
|
||||
assert!(Rc::ptr_eq(trkseg.chunks().last().unwrap(), &before[4]));
|
||||
assert!(Rc::ptr_eq(
|
||||
&trkseg.chunks()[trkseg.chunks().len() - 2],
|
||||
&before[3]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_append_extends_last_chunk_and_respects_max_size() {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
for i in 0..10_000 {
|
||||
let len = trkseg.len();
|
||||
trkseg.splice(len, len, points(&[i as f64]));
|
||||
}
|
||||
assert_eq!(trkseg.len(), 10_000);
|
||||
assert!(trkseg.chunks().len() <= 3);
|
||||
assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096));
|
||||
assert_eq!(trkseg[9_999].ele, 9_999.0);
|
||||
assert_eq!(trkseg.len(), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_len() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
assert_eq!(trkseg.len(), nb_chunks * (nb_chunks + 1) / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locate() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for i in 0..trkseg.len() {
|
||||
let idx = trkseg.locate(i);
|
||||
assert!(idx.is_some());
|
||||
assert_eq!(trkseg[idx.unwrap()].ele, i as f64);
|
||||
}
|
||||
|
||||
assert!(trkseg.locate(trkseg.len()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for i in 0..trkseg.len() {
|
||||
assert_eq!(trkseg[i].ele, i as f64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_index_out_of_bounds_1() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
let _ = trkseg[trkseg.len()];
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_index_out_of_bounds_2() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
let _ = trkseg[TrackSegmentIndex {
|
||||
chunk: trkseg.chunks().len(),
|
||||
pos: 0,
|
||||
flat: 0,
|
||||
}];
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for (i, trkpt) in trkseg.iter().enumerate() {
|
||||
assert!(std::ptr::eq(&trkseg[i], trkpt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter_nth() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
let tenth = trkseg.iter().nth(10);
|
||||
assert!(tenth.is_some());
|
||||
assert!(std::ptr::eq(&trkseg[10], tenth.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter_skip() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for (i, trkpt) in trkseg.iter().enumerate().skip(5) {
|
||||
assert!(std::ptr::eq(&trkseg[i], trkpt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_last_index() {
|
||||
assert!(TrackSegment::default().first_index().is_none());
|
||||
assert!(TrackSegment::default().last_index().is_none());
|
||||
|
||||
let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5
|
||||
let first = trkseg.first_index().unwrap();
|
||||
let last = trkseg.last_index().unwrap();
|
||||
assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0));
|
||||
assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14));
|
||||
assert_eq!(trkseg[last].ele, 14.0);
|
||||
assert_eq!(trkseg.locate(14), Some(last));
|
||||
assert!(trkseg.next_index(Some(last)).is_none());
|
||||
assert!(trkseg.prev_index(Some(first)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prev_next_are_inverse() {
|
||||
let trkseg = create_track_segment(6);
|
||||
let mut idx = trkseg.last_index();
|
||||
let mut visited = vec![];
|
||||
while let Some(i) = idx {
|
||||
assert_eq!(trkseg.locate(i.flat), Some(i));
|
||||
visited.push(i.flat);
|
||||
idx = trkseg.prev_index(Some(i));
|
||||
}
|
||||
assert_eq!(visited, (0..trkseg.len()).rev().collect::<Vec<_>>());
|
||||
|
||||
let mut idx = trkseg.first_index();
|
||||
while let Some(i) = idx {
|
||||
let next = trkseg.next_index(Some(i));
|
||||
if let Some(n) = next {
|
||||
assert_eq!(trkseg.prev_index(Some(n)), Some(i));
|
||||
}
|
||||
idx = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn points_of(n: usize) -> Vec<Trackpoint> {
|
||||
(0..n).map(|_| Trackpoint::default()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_change_gives_a_new_revision() {
|
||||
type Change = (&'static str, fn(&mut TrackSegment));
|
||||
let changes: [Change; 7] = [
|
||||
("push", |s| s.push(TrackpointChunk::new(points_of(1)))),
|
||||
("push_shared", |s| {
|
||||
s.push_shared(Rc::new(TrackpointChunk::new(points_of(1))))
|
||||
}),
|
||||
("update", |s| s.update(0, |p| p.ele += 1.0)),
|
||||
("update_all", |s| s.update_all(|_, p| p.ele += 1.0)),
|
||||
("splice", |s| s.splice(1, 2, points_of(2))),
|
||||
("set_anchor", |s| s.set_anchor(1, 5)),
|
||||
("compute_anchors", |s| s.compute_anchors()),
|
||||
];
|
||||
for (name, change) in changes {
|
||||
let mut trkseg = create_track_segment(3);
|
||||
let before = trkseg.rev_id;
|
||||
change(&mut trkseg);
|
||||
assert_ne!(trkseg.rev_id, before, "{name}");
|
||||
}
|
||||
// reading does not
|
||||
let trkseg = create_track_segment(3);
|
||||
let before = trkseg.rev_id;
|
||||
let _ = trkseg.iter().count();
|
||||
assert_eq!(trkseg.rev_id, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ends_are_anchors_after_a_splice() {
|
||||
let mut trkseg = create_track_segment(3);
|
||||
trkseg.splice(0, 1, points_of(2));
|
||||
let last = trkseg.len() - 1;
|
||||
assert_eq!(trkseg[0].anchor, Some(0));
|
||||
assert_eq!(trkseg[last].anchor, Some(0));
|
||||
|
||||
// nothing to anchor in an empty segment
|
||||
let mut empty = TrackSegment::default();
|
||||
empty.ensure_end_anchors();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::{Link, TrackSegment};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(TrackId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Track {
|
||||
pub id: TrackId,
|
||||
pub info: TrackInfo,
|
||||
pub trkseg: Vec<TrackSegment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct TrackInfo {
|
||||
pub name: Option<String>,
|
||||
pub cmt: Option<String>,
|
||||
pub desc: Option<String>,
|
||||
pub src: Option<String>,
|
||||
pub links: Vec<Link>,
|
||||
pub type_: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub opacity: Option<f64>,
|
||||
pub width: Option<f64>,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::LngLat;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Trackpoint {
|
||||
pub coordinates: LngLat,
|
||||
pub ele: f64,
|
||||
pub time: Option<i64>,
|
||||
pub atemp: Option<i16>,
|
||||
pub hr: Option<u16>,
|
||||
pub cad: Option<u16>,
|
||||
pub power: Option<u16>,
|
||||
/// Code of the surface, see [`crate::TrackpointCategories`].
|
||||
pub surface: Option<u8>,
|
||||
/// Code of the highway, see [`crate::TrackpointCategories`].
|
||||
pub highway: Option<u8>,
|
||||
/// Code of the SAC hiking scale, see [`crate::TrackpointCategories`].
|
||||
pub sac_scale: Option<u8>,
|
||||
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
|
||||
pub mtb_scale: Option<u8>,
|
||||
/// 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<u8>,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::{Link, LngLat};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(WaypointId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Waypoint {
|
||||
pub id: WaypointId,
|
||||
pub coordinates: LngLat,
|
||||
pub ele: f64,
|
||||
pub time: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub cmt: Option<String>,
|
||||
pub desc: Option<String>,
|
||||
pub links: Vec<Link>,
|
||||
pub sym: Option<String>,
|
||||
pub type_: Option<String>,
|
||||
}
|
||||
|
||||
impl Waypoint {
|
||||
/// Whether the waypoints are the same, whatever their ids.
|
||||
pub fn same_content(&self, other: &Waypoint) -> bool {
|
||||
self.coordinates.lng == other.coordinates.lng
|
||||
&& self.coordinates.lat == other.coordinates.lat
|
||||
&& self.ele == other.ele
|
||||
&& self.time == other.time
|
||||
&& self.name == other.name
|
||||
&& self.cmt == other.cmt
|
||||
&& self.desc == other.desc
|
||||
&& self.links == other.links
|
||||
&& self.sym == other.sym
|
||||
&& self.type_ == other.type_
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use crate::{Chunked, Waypoint, WaypointChunk, WaypointId};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(FileWaypointsRevisionId);
|
||||
|
||||
/// The waypoints of a file, in chunks that are shared between the versions of the file (see
|
||||
/// [`Chunked`]).
|
||||
///
|
||||
/// Everything that changes the waypoints changes the revision too, which is why there is no
|
||||
/// mutable access to the list itself: the revision tells what is derived from the waypoints
|
||||
/// (coordinates for the map...) that it is out of date.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Waypoints {
|
||||
chunks: Chunked<WaypointChunk>,
|
||||
/// Changes when the waypoints do.
|
||||
pub rev_id: FileWaypointsRevisionId,
|
||||
}
|
||||
|
||||
impl Deref for Waypoints {
|
||||
type Target = Chunked<WaypointChunk>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.chunks
|
||||
}
|
||||
}
|
||||
|
||||
impl Waypoints {
|
||||
/// Waypoints made of the given chunks, which are used as they are.
|
||||
pub fn new(chunks: impl IntoIterator<Item = WaypointChunk>) -> Self {
|
||||
let mut waypoints = Self::default();
|
||||
for chunk in chunks {
|
||||
waypoints.push(chunk);
|
||||
}
|
||||
waypoints
|
||||
}
|
||||
|
||||
/// Adds a chunk after the last waypoint.
|
||||
pub fn push(&mut self, chunk: WaypointChunk) {
|
||||
self.chunks.push(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Adds a chunk that is shared with something else after the last waypoint.
|
||||
pub fn push_shared(&mut self, chunk: std::rc::Rc<WaypointChunk>) {
|
||||
self.chunks.push_shared(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`].
|
||||
pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec<Waypoint>) {
|
||||
self.chunks.splice(start, end, waypoints);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Inserts waypoints, so that the first one is at `index` among the waypoints (at the end if
|
||||
/// the index is past it). Only the chunks around the insertion are copied.
|
||||
pub fn insert_at(&mut self, index: usize, waypoints: Vec<Waypoint>) {
|
||||
if waypoints.is_empty() {
|
||||
return;
|
||||
}
|
||||
let index = index.min(self.len());
|
||||
self.splice(index, index, waypoints);
|
||||
}
|
||||
|
||||
/// Inserts waypoints right after the waypoint `after`, or at the end if there is none (or if
|
||||
/// it is not there). See [`Waypoints::insert_at`].
|
||||
pub fn insert_after(&mut self, after: Option<WaypointId>, waypoints: Vec<Waypoint>) {
|
||||
let index = after
|
||||
.and_then(|after| self.position(after))
|
||||
.map_or(usize::MAX, |i| i + 1);
|
||||
self.insert_at(index, waypoints);
|
||||
}
|
||||
|
||||
/// The position of the waypoint among the waypoints.
|
||||
pub fn position(&self, id: WaypointId) -> Option<usize> {
|
||||
self.iter().position(|wpt| wpt.id == id)
|
||||
}
|
||||
|
||||
/// Edits the waypoints of the chunks that contain one accepted by `filter`, see
|
||||
/// [`Chunked::edit`]. Returns whether anything changed.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
f: impl FnMut(&mut Vec<Waypoint>) -> bool,
|
||||
) -> bool {
|
||||
let changed = self.chunks.edit(filter, f);
|
||||
if changed {
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn waypoint() -> Waypoint {
|
||||
Waypoint::default()
|
||||
}
|
||||
|
||||
fn waypoints_with(chunks: &[usize]) -> (Waypoints, Vec<WaypointId>) {
|
||||
let mut waypoints = Waypoints::default();
|
||||
let mut ids = vec![];
|
||||
for n in chunks {
|
||||
let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect();
|
||||
ids.extend(wpt.iter().map(|w| w.id));
|
||||
waypoints.push(WaypointChunk {
|
||||
wpt,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
(waypoints, ids)
|
||||
}
|
||||
|
||||
fn ids(waypoints: &Waypoints) -> Vec<WaypointId> {
|
||||
waypoints.iter().map(|w| w.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_and_push() {
|
||||
let (waypoints, ids_) = waypoints_with(&[2, 1]);
|
||||
assert_eq!(waypoints.len(), 3);
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert_eq!(ids(&waypoints), ids_);
|
||||
assert_eq!(waypoints.position(ids_[2]), Some(2));
|
||||
assert_eq!(waypoints.position(WaypointId::default()), None);
|
||||
// empty chunks are dropped
|
||||
let waypoints = Waypoints::new([WaypointChunk::default()]);
|
||||
assert!(waypoints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_at_the_end() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||
let first = waypoints.chunks()[0].clone();
|
||||
let rev = waypoints.rev_id;
|
||||
let new = vec![waypoint(), waypoint()];
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
waypoints.insert_after(None, new);
|
||||
assert_eq!(ids(&waypoints), [before.clone(), new_ids].concat());
|
||||
// the chunk that is not touched is kept, the last one is extended
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
|
||||
assert_ne!(waypoints.rev_id, rev);
|
||||
// an unknown waypoint is an insertion at the end too
|
||||
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_after(Some(WaypointId::default()), vec![one]);
|
||||
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_next_ones() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 2]);
|
||||
let last = waypoints.chunks()[1].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_after(Some(before[1]), vec![one]);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..2], &[one_id], &before[2..]].concat()
|
||||
);
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_in_the_middle_of_a_chunk_cuts_it() {
|
||||
let (mut waypoints, before) = waypoints_with(&[3, 1]);
|
||||
let last = waypoints.chunks()[1].clone();
|
||||
let new = vec![waypoint(), waypoint()];
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
waypoints.insert_after(Some(before[0]), new);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..1], &new_ids[..], &before[1..]].concat()
|
||||
);
|
||||
// the cut chunk is refilled, the other one is kept
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_nothing() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||
let rev = waypoints.rev_id;
|
||||
waypoints.insert_after(Some(before[0]), vec![]);
|
||||
assert_eq!(ids(&waypoints), before);
|
||||
assert_eq!(waypoints.rev_id, rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_many_fills_chunks() {
|
||||
let mut waypoints = Waypoints::default();
|
||||
let new: Vec<_> = (0..300).map(|_| waypoint()).collect();
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
waypoints.insert_after(None, new);
|
||||
assert_eq!(ids(&waypoints), new_ids);
|
||||
assert_eq!(waypoints.chunks().len(), 3);
|
||||
assert!(waypoints.chunks()[..2].iter().all(|chunk| {
|
||||
use crate::Chunk;
|
||||
chunk.is_full()
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_at_an_index() {
|
||||
// at the start
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||
let first = waypoints.chunks()[0].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(0, vec![one]);
|
||||
assert_eq!(ids(&waypoints), [vec![one_id], before.clone()].concat());
|
||||
// the existing chunks are kept
|
||||
assert_eq!(waypoints.chunks().len(), 3);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &first));
|
||||
|
||||
// between two chunks
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||
let last = waypoints.chunks()[1].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(2, vec![one]);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..2], &[one_id], &before[2..]].concat()
|
||||
);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||
|
||||
// inside a chunk
|
||||
let (mut waypoints, before) = waypoints_with(&[3]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(1, vec![one]);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..1], &[one_id], &before[1..]].concat()
|
||||
);
|
||||
|
||||
// at the end, or past it
|
||||
for index in [3, 100, usize::MAX] {
|
||||
let (mut waypoints, before) = waypoints_with(&[3]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(index, vec![one]);
|
||||
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
|
||||
}
|
||||
|
||||
// in a file without waypoints
|
||||
let mut waypoints = Waypoints::default();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(0, vec![one]);
|
||||
assert_eq!(ids(&waypoints), vec![one_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_replaces_waypoints() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 2]);
|
||||
let rev = waypoints.rev_id;
|
||||
waypoints.splice(1, 3, vec![]);
|
||||
assert_eq!(ids(&waypoints), [before[0], before[3]]);
|
||||
assert_ne!(waypoints.rev_id, rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_changes_the_chunks_with_a_match_only() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 2, 1]);
|
||||
let (first, last) = (waypoints.chunks()[0].clone(), waypoints.chunks()[2].clone());
|
||||
let rev = waypoints.rev_id;
|
||||
let target = before[2];
|
||||
let changed = waypoints.edit(
|
||||
|wpt| wpt.id == target,
|
||||
|wpts| {
|
||||
wpts.retain(|wpt| wpt.id != target);
|
||||
true
|
||||
},
|
||||
);
|
||||
assert!(changed);
|
||||
assert_ne!(waypoints.rev_id, rev);
|
||||
assert_eq!(ids(&waypoints), [&before[..2], &before[3..]].concat());
|
||||
// the chunks without a match are the same ones, the changed one is new
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[2], &last));
|
||||
assert_eq!(waypoints.chunks().len(), 3);
|
||||
|
||||
// a chunk left empty is dropped
|
||||
let (mut waypoints, before) = waypoints_with(&[1, 2]);
|
||||
waypoints.edit(
|
||||
|wpt| wpt.id == before[0],
|
||||
|wpts| {
|
||||
wpts.clear();
|
||||
true
|
||||
},
|
||||
);
|
||||
assert_eq!(waypoints.chunks().len(), 1);
|
||||
assert_eq!(ids(&waypoints), before[1..]);
|
||||
|
||||
// a chunk that is not changed is kept, and so is the revision
|
||||
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||
let (chunk, rev) = (waypoints.chunks()[0].clone(), waypoints.rev_id);
|
||||
assert!(!waypoints.edit(|wpt| wpt.id == before[0], |_| false));
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &chunk));
|
||||
assert_eq!(waypoints.rev_id, rev);
|
||||
assert!(!waypoints.edit(|_| false, |_| true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod parse;
|
||||
mod write;
|
||||
|
||||
pub use parse::*;
|
||||
pub use write::*;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,803 @@
|
||||
use std::io;
|
||||
|
||||
use chrono::DateTime;
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{BytesDecl, BytesText, Event};
|
||||
|
||||
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
|
||||
|
||||
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
||||
/// The same type tells which data a file has, see [`File::exportable_data`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExportOptions {
|
||||
pub time: bool,
|
||||
pub hr: bool,
|
||||
pub cad: bool,
|
||||
pub atemp: bool,
|
||||
pub power: bool,
|
||||
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
||||
pub osm: bool,
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
pub const ALL: Self = Self::of(true);
|
||||
pub const NONE: Self = Self::of(false);
|
||||
|
||||
const fn of(value: bool) -> Self {
|
||||
Self {
|
||||
time: value,
|
||||
hr: value,
|
||||
cad: value,
|
||||
atemp: value,
|
||||
power: value,
|
||||
osm: value,
|
||||
}
|
||||
}
|
||||
|
||||
/// The options that are on in `self` or in `other`.
|
||||
pub fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
time: self.time || other.time,
|
||||
hr: self.hr || other.hr,
|
||||
cad: self.cad || other.cad,
|
||||
atemp: self.atemp || other.atemp,
|
||||
power: self.power || other.power,
|
||||
osm: self.osm || other.osm,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl File {
|
||||
/// The data that the trackpoints of the file have (and waypoints for the time), which the
|
||||
/// options of [`write`] can leave out.
|
||||
pub fn exportable_data(&self) -> ExportOptions {
|
||||
let mut data = ExportOptions::NONE;
|
||||
for point in self.segments().flat_map(|seg| seg.iter()) {
|
||||
data.time |= point.time.is_some();
|
||||
data.hr |= point.hr.is_some();
|
||||
data.cad |= point.cad.is_some();
|
||||
data.atemp |= point.atemp.is_some();
|
||||
data.power |= point.power.is_some();
|
||||
data.osm |= point.surface.is_some()
|
||||
|| point.highway.is_some()
|
||||
|| point.sac_scale.is_some()
|
||||
|| point.mtb_scale.is_some();
|
||||
if data == ExportOptions::ALL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest zoom level from which an anchor is shown that makes it a route point, see [`write`]:
|
||||
/// the anchors that only appear when zooming in further are details of the path.
|
||||
pub const ROUTE_POINT_MAX_ZOOM: u8 = 12;
|
||||
|
||||
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
|
||||
|
||||
type XmlWriter = Writer<Vec<u8>>;
|
||||
|
||||
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
||||
/// the names of their codes in `categories`.
|
||||
///
|
||||
/// The routes are written as routes (`rte`): the anchors of their segment that are shown from the
|
||||
/// zoom level [`ROUTE_POINT_MAX_ZOOM`] or a lower one are the `rtept`, and the trackpoints between
|
||||
/// two of them are the `gpxx:rpt` of the `rtept` before them (which only have a position, unlike
|
||||
/// the `rtept`). The first and last trackpoints always are `rtept`.
|
||||
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
||||
// writing to memory does not fail
|
||||
write_file(&mut writer, file, categories, options).expect("writing to memory");
|
||||
writer.into_inner()
|
||||
}
|
||||
|
||||
fn write_file<'a>(
|
||||
w: &mut XmlWriter,
|
||||
file: &'a File,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
|
||||
w.create_element("gpx")
|
||||
.with_attributes([
|
||||
("creator", "https://gpx.studio"),
|
||||
("version", "1.1"),
|
||||
("xmlns", "http://www.topografix.com/GPX/1/1"),
|
||||
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
|
||||
("xsi:schemaLocation", SCHEMA_LOCATION),
|
||||
(
|
||||
"xmlns:gpxtpx",
|
||||
"http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
|
||||
),
|
||||
(
|
||||
"xmlns:gpxx",
|
||||
"http://www.garmin.com/xmlschemas/GpxExtensions/v3",
|
||||
),
|
||||
(
|
||||
"xmlns:gpxpx",
|
||||
"http://www.garmin.com/xmlschemas/PowerExtension/v1",
|
||||
),
|
||||
(
|
||||
"xmlns:gpx_style",
|
||||
"http://www.topografix.com/GPX/gpx_style/0/2",
|
||||
),
|
||||
])
|
||||
.write_inner_content(|w| {
|
||||
write_metadata(w, file, options)?;
|
||||
for wpt in file.wpt.iter() {
|
||||
write_waypoint(w, wpt, options)?;
|
||||
}
|
||||
// a single unnamed route or track is named like the file
|
||||
let alone = file.rte.len() + file.trk.len() == 1;
|
||||
let name = |info: &'a crate::TrackInfo| match &info.name {
|
||||
Some(name) if !name.is_empty() => Some(name.as_str()),
|
||||
_ if alone => Some(file.info.name.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
for rte in &file.rte {
|
||||
write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?;
|
||||
}
|
||||
for trk in &file.trk {
|
||||
write_track(w, trk, name(&trk.info), categories, options)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> {
|
||||
if !value.is_empty() {
|
||||
w.create_element(name)
|
||||
.write_text_content(BytesText::new(value))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn optional_text(w: &mut XmlWriter, name: &str, value: &Option<String>) -> io::Result<()> {
|
||||
text(w, name, value.as_deref().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> {
|
||||
text(w, name, &value.to_string())
|
||||
}
|
||||
|
||||
fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
|
||||
if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) {
|
||||
text(
|
||||
w,
|
||||
"time",
|
||||
&time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
|
||||
if !link.href.is_empty() {
|
||||
w.create_element("link")
|
||||
.with_attribute(("href", link.href.as_str()))
|
||||
.write_inner_content(|w| optional_text(w, "text", &link.text))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> {
|
||||
links.iter().try_for_each(|l| link(w, l))
|
||||
}
|
||||
|
||||
fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
|
||||
let info = &file.info;
|
||||
let time = options.time.then_some(info.time).flatten();
|
||||
if info.name.is_empty()
|
||||
&& info.desc.is_none()
|
||||
&& info.author.is_none()
|
||||
&& info.links.is_empty()
|
||||
&& time.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
w.create_element("metadata").write_inner_content(|w| {
|
||||
text(w, "name", &info.name)?;
|
||||
optional_text(w, "desc", &info.desc)?;
|
||||
if let Some(author) = &info.author {
|
||||
w.create_element("author").write_inner_content(|w| {
|
||||
optional_text(w, "name", &author.name)?;
|
||||
if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@'))
|
||||
{
|
||||
w.create_element("email")
|
||||
.with_attributes([("id", id), ("domain", domain)])
|
||||
.write_empty()?;
|
||||
}
|
||||
author.link.iter().try_for_each(|l| link(w, l))
|
||||
})?;
|
||||
}
|
||||
links(w, &info.links)?;
|
||||
self::time(w, time)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_coordinates<'a>(
|
||||
w: &'a mut XmlWriter,
|
||||
name: &'a str,
|
||||
coordinates: crate::LngLat,
|
||||
) -> quick_xml::writer::ElementWriter<'a, Vec<u8>> {
|
||||
w.create_element(name).with_attributes([
|
||||
("lat", coordinates.lat.to_string().as_str()),
|
||||
("lon", coordinates.lng.to_string().as_str()),
|
||||
])
|
||||
}
|
||||
|
||||
fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> {
|
||||
write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| {
|
||||
number(w, "ele", wpt.ele)?;
|
||||
if options.time {
|
||||
time(w, wpt.time)?;
|
||||
}
|
||||
optional_text(w, "name", &wpt.name)?;
|
||||
optional_text(w, "cmt", &wpt.cmt)?;
|
||||
optional_text(w, "desc", &wpt.desc)?;
|
||||
links(w, &wpt.links)?;
|
||||
optional_text(w, "sym", &wpt.sym)?;
|
||||
optional_text(w, "type", &wpt.type_)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The information of a track that its routes have too: name, comment, description, source,
|
||||
/// links, type and style.
|
||||
fn write_track_info(
|
||||
w: &mut XmlWriter,
|
||||
name: Option<&str>,
|
||||
info: &crate::TrackInfo,
|
||||
) -> io::Result<()> {
|
||||
text(w, "name", name.unwrap_or_default())?;
|
||||
optional_text(w, "cmt", &info.cmt)?;
|
||||
optional_text(w, "desc", &info.desc)?;
|
||||
optional_text(w, "src", &info.src)?;
|
||||
links(w, &info.links)?;
|
||||
optional_text(w, "type", &info.type_)?;
|
||||
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
|
||||
w.create_element("extensions").write_inner_content(|w| {
|
||||
w.create_element("gpx_style:line")
|
||||
.write_inner_content(|w| {
|
||||
optional_text(w, "gpx_style:color", &info.color)?;
|
||||
if let Some(opacity) = info.opacity {
|
||||
number(w, "gpx_style:opacity", opacity)?;
|
||||
}
|
||||
if let Some(width) = info.width {
|
||||
number(w, "gpx_style:width", width)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_track(
|
||||
w: &mut XmlWriter,
|
||||
trk: &Track,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.create_element("trk").write_inner_content(|w| {
|
||||
write_track_info(w, name, &trk.info)?;
|
||||
for segment in &trk.trkseg {
|
||||
w.create_element("trkseg").write_inner_content(|w| {
|
||||
for point in segment.iter() {
|
||||
write_point(w, "trkpt", point, &[], categories, options)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_route(
|
||||
w: &mut XmlWriter,
|
||||
info: &crate::TrackInfo,
|
||||
segment: &crate::TrackSegment,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.create_element("rte").write_inner_content(|w| {
|
||||
write_track_info(w, name, info)?;
|
||||
let points: Vec<&Trackpoint> = segment.iter().collect();
|
||||
let last = points.len().saturating_sub(1);
|
||||
let is_route_point = |i: usize| {
|
||||
i == 0
|
||||
|| i == last
|
||||
|| points[i]
|
||||
.anchor
|
||||
.is_some_and(|zoom| zoom <= ROUTE_POINT_MAX_ZOOM)
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < points.len() {
|
||||
// the trackpoints up to the next route point
|
||||
let mut next = i + 1;
|
||||
while next < points.len() && !is_route_point(next) {
|
||||
next += 1;
|
||||
}
|
||||
write_point(
|
||||
w,
|
||||
"rtept",
|
||||
points[i],
|
||||
&points[i + 1..next],
|
||||
categories,
|
||||
options,
|
||||
)?;
|
||||
i = next;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one,
|
||||
/// of which only the positions are written.
|
||||
fn write_point(
|
||||
w: &mut XmlWriter,
|
||||
tag: &str,
|
||||
point: &Trackpoint,
|
||||
path: &[&Trackpoint],
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
let atemp = point.atemp.filter(|_| options.atemp);
|
||||
let hr = point.hr.filter(|_| options.hr);
|
||||
let cad = point.cad.filter(|_| options.cad);
|
||||
let power = point.power.filter(|_| options.power);
|
||||
let osm = [
|
||||
("surface", point.surface, &categories.surface),
|
||||
("highway", point.highway, &categories.highway),
|
||||
("sac_scale", point.sac_scale, &categories.sac_scale),
|
||||
("mtb_scale", point.mtb_scale, &categories.mtb_scale),
|
||||
]
|
||||
.map(|(name, code, categories)| {
|
||||
let value = code
|
||||
.filter(|_| options.osm)
|
||||
.and_then(|code| categories.name(code));
|
||||
(name, value)
|
||||
});
|
||||
let has_osm = osm.iter().any(|(_, value)| value.is_some());
|
||||
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
|
||||
|
||||
write_coordinates(w, tag, point.coordinates).write_inner_content(|w| {
|
||||
number(w, "ele", point.ele)?;
|
||||
if options.time {
|
||||
time(w, point.time)?;
|
||||
}
|
||||
if !has_track_point_extension && power.is_none() && path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
w.create_element("extensions").write_inner_content(|w| {
|
||||
if has_track_point_extension {
|
||||
w.create_element("gpxtpx:TrackPointExtension")
|
||||
.write_inner_content(|w| {
|
||||
if let Some(atemp) = atemp {
|
||||
number(w, "gpxtpx:atemp", atemp)?;
|
||||
}
|
||||
if let Some(hr) = hr {
|
||||
number(w, "gpxtpx:hr", hr)?;
|
||||
}
|
||||
if let Some(cad) = cad {
|
||||
number(w, "gpxtpx:cad", cad)?;
|
||||
}
|
||||
if has_osm {
|
||||
w.create_element("gpxtpx:Extensions")
|
||||
.write_inner_content(|w| {
|
||||
for (name, value) in osm {
|
||||
text(w, name, value.unwrap_or_default())?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
// `PowerInWatts` is itself the root element of Garmin's power extension, without a
|
||||
// wrapper
|
||||
if let Some(power) = power {
|
||||
number(w, "gpxpx:PowerInWatts", power)?;
|
||||
}
|
||||
if !path.is_empty() {
|
||||
w.create_element("gpxx:RoutePointExtension")
|
||||
.write_inner_content(|w| {
|
||||
for point in path {
|
||||
write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read(name: &str) -> (File, TrackpointCategories) {
|
||||
let mut categories = TrackpointCategories::default();
|
||||
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
|
||||
(parse(&data, &mut categories).unwrap(), categories)
|
||||
}
|
||||
|
||||
fn written_text(
|
||||
file: &File,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> String {
|
||||
String::from_utf8(write(file, categories, options)).unwrap()
|
||||
}
|
||||
|
||||
/// Writes a file and reads it again.
|
||||
fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) {
|
||||
let (file, mut categories) = read(name);
|
||||
let written = write(&file, &categories, options);
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
(file, again, categories)
|
||||
}
|
||||
|
||||
fn points(file: &File) -> Vec<Trackpoint> {
|
||||
file.segments()
|
||||
.flat_map(|seg| seg.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_points(a: &[Trackpoint], b: &[Trackpoint]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||
assert_eq!(a.ele, b.ele);
|
||||
assert_eq!(a.time, b.time);
|
||||
assert_eq!(a.atemp, b.atemp);
|
||||
assert_eq!(a.hr, b.hr);
|
||||
assert_eq!(a.cad, b.cad);
|
||||
assert_eq!(a.power, b.power);
|
||||
assert_eq!(a.surface, b.surface);
|
||||
assert_eq!(a.highway, b.highway);
|
||||
assert_eq!(a.sac_scale, b.sac_scale);
|
||||
assert_eq!(a.mtb_scale, b.mtb_scale);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_links_and_the_email_are_written() {
|
||||
let data = std::fs::read("data/with_links.gpx").unwrap();
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let again = parse(
|
||||
&write(&file, &Default::default(), ExportOptions::ALL),
|
||||
&mut Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(again.info.links, file.info.links);
|
||||
assert_eq!(again.info.links.len(), 2);
|
||||
assert_eq!(again.info.author, file.info.author);
|
||||
assert_eq!(
|
||||
again.info.author.as_ref().unwrap().email.as_deref(),
|
||||
Some("someone@example.com")
|
||||
);
|
||||
let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(waypoints(&again), waypoints(&file));
|
||||
assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3);
|
||||
for (a, b) in again.trk.iter().zip(&file.trk) {
|
||||
assert_eq!(a.info.links, b.info.links);
|
||||
assert_eq!(a.info.links.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_survive_a_round_trip() {
|
||||
for name in [
|
||||
"simple",
|
||||
"with_links",
|
||||
"with_time",
|
||||
"with_hr",
|
||||
"with_cad",
|
||||
"with_temp",
|
||||
"with_power_1",
|
||||
"with_power_2",
|
||||
"with_power_3",
|
||||
"with_surface",
|
||||
"with_highway",
|
||||
"with_style",
|
||||
"with_waypoint",
|
||||
"with_tracks_and_segments",
|
||||
"self_closing_points",
|
||||
] {
|
||||
let (file, again, _) = round_trip(name, ExportOptions::ALL);
|
||||
assert_eq!(file.info.name, again.info.name, "{name}");
|
||||
assert_eq!(file.info.desc, again.info.desc, "{name}");
|
||||
assert_eq!(file.info.author, again.info.author, "{name}");
|
||||
assert_eq!(file.trk.len(), again.trk.len(), "{name}");
|
||||
for (a, b) in file.trk.iter().zip(&again.trk) {
|
||||
assert_eq!(a.info, b.info, "{name}");
|
||||
assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}");
|
||||
}
|
||||
same_points(&points(&file), &points(&again));
|
||||
let waypoints: Vec<_> = file.wpt.iter().collect();
|
||||
let again_waypoints: Vec<_> = again.wpt.iter().collect();
|
||||
assert_eq!(waypoints.len(), again_waypoints.len(), "{name}");
|
||||
for (a, b) in waypoints.iter().zip(&again_waypoints) {
|
||||
assert!(a.same_content(b), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_options_leave_data_out() {
|
||||
let (file, categories) = read("with_hr");
|
||||
assert!(points(&file).iter().any(|p| p.hr.is_some()));
|
||||
let options = ExportOptions {
|
||||
hr: false,
|
||||
..ExportOptions::ALL
|
||||
};
|
||||
let written = written_text(&file, &categories, options);
|
||||
assert!(!written.contains("hr>"));
|
||||
|
||||
let (file, categories) = read("with_time");
|
||||
let options = ExportOptions {
|
||||
time: false,
|
||||
..ExportOptions::ALL
|
||||
};
|
||||
assert!(!written_text(&file, &categories, options).contains("<time>"));
|
||||
assert!(
|
||||
written_text(&file, &categories, ExportOptions::ALL)
|
||||
.contains("<time>2023-12-31T23:00:00.000Z</time>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_is_escaped_and_the_single_track_gets_the_name_of_the_file() {
|
||||
let (mut file, categories) = read("simple");
|
||||
file.info.name = "a <b> & c".into();
|
||||
file.trk.truncate(1);
|
||||
file.trk[0].info.name = None;
|
||||
let written = write(&file, &categories, ExportOptions::ALL);
|
||||
let text = String::from_utf8(written.clone()).unwrap();
|
||||
assert!(text.contains("a <b> & c"));
|
||||
// the track has the name of the file
|
||||
assert_eq!(text.matches("a <b> & c").count(), 2);
|
||||
// and it is read back as it was
|
||||
let mut categories = categories;
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
assert_eq!(again.info.name, "a <b> & c");
|
||||
assert_eq!(again.trk[0].info.name.as_deref(), Some("a <b> & c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_power_is_written_without_a_wrapper() {
|
||||
let (file, categories) = read("with_power_2");
|
||||
let text: String = written_text(&file, &categories, ExportOptions::ALL)
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
assert!(text.contains("<extensions><gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>"));
|
||||
assert!(!text.contains("<gpxpx:PowerExtension>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exportable_data() {
|
||||
let (file, _) = read("with_time");
|
||||
let data = file.exportable_data();
|
||||
assert!(data.time && !data.hr && !data.osm);
|
||||
let (file, _) = read("with_surface");
|
||||
assert!(file.exportable_data().osm);
|
||||
let (file, _) = read("with_power_2");
|
||||
assert!(file.exportable_data().power);
|
||||
}
|
||||
|
||||
/// The file with each segment of its tracks as a route, which has the information of its
|
||||
/// track.
|
||||
fn as_routes(mut file: File) -> File {
|
||||
file.rte = std::mem::take(&mut file.trk)
|
||||
.into_iter()
|
||||
.flat_map(|trk| {
|
||||
trk.trkseg.into_iter().map(move |seg| crate::Route {
|
||||
info: trk.info.clone(),
|
||||
seg,
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
file
|
||||
}
|
||||
|
||||
fn count(text: &str, pattern: &str) -> usize {
|
||||
text.matches(pattern).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_are_written_with_their_route_points_and_their_path() {
|
||||
let (file, categories) = read("with_route_points");
|
||||
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||
|
||||
assert_eq!(count(&text, "<rte>"), 1);
|
||||
assert_eq!(count(&text, "<trk>"), 0);
|
||||
assert_eq!(count(&text, "<trkpt"), 0);
|
||||
// the anchors are the route points, the trackpoints between them the points of their path
|
||||
assert_eq!(count(&text, "<rtept"), 3);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), 4);
|
||||
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
|
||||
assert!(text.contains("<name>Lausanne</name>"));
|
||||
assert!(text.contains("<cmt>comment</cmt>"));
|
||||
assert!(text.contains("<desc>description</desc>"));
|
||||
assert!(text.contains("<type>Cycling</type>"));
|
||||
// and its points only have a position in the path, and an elevation as route points
|
||||
let first_rtept = &text[text.find("<rtept").unwrap()..];
|
||||
let (lat, lng) = (46.54462809674442_f64, 6.658079791814089_f64);
|
||||
assert!(first_rtept.starts_with(&format!("<rtept lat=\"{lat}\" lon=\"{lng}\">")));
|
||||
assert!(first_rtept.contains("<ele>500</ele>"));
|
||||
let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64);
|
||||
assert!(text.contains(&format!("<gpxx:rpt lat=\"{lat}\" lon=\"{lng}\"/>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_route_survives_being_written() {
|
||||
let (file, mut categories) = read("with_route_points");
|
||||
let written = write(&file, &categories, ExportOptions::ALL);
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
|
||||
assert_eq!(again.rte.len(), 1);
|
||||
assert_eq!(again.rte[0].info, file.rte[0].info);
|
||||
let (a, b) = (&file.rte[0].seg, &again.rte[0].seg);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||
assert_eq!(a.anchor, b.anchor);
|
||||
}
|
||||
// the elevation of the route points is kept, the other ones are worked out again
|
||||
let anchors = |s: &crate::TrackSegment| {
|
||||
s.iter()
|
||||
.filter(|p| p.anchor.is_some())
|
||||
.map(|p| p.ele)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(anchors(a), anchors(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_anchors_shown_up_to_zoom_12_are_the_route_points() {
|
||||
let (file, mut categories) = read("with_tracks_and_segments");
|
||||
let file = as_routes(file);
|
||||
assert!(file.rte.len() > 2);
|
||||
let written = write(&file, &categories, ExportOptions::ALL);
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
assert_eq!(again.rte.len(), file.rte.len());
|
||||
let anchors = |s: &crate::TrackSegment, max_zoom: u8| {
|
||||
s.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.anchor.is_some_and(|zoom| zoom <= max_zoom))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for (a, b) in file.rte.iter().zip(&again.rte) {
|
||||
assert_eq!(a.info.name, b.info.name);
|
||||
assert_eq!(a.seg.len(), b.seg.len());
|
||||
// the route points come back as anchors, the other anchors as points of the path
|
||||
assert_eq!(
|
||||
anchors(&a.seg, ROUTE_POINT_MAX_ZOOM),
|
||||
anchors(&b.seg, u8::MAX)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchors_shown_from_a_higher_zoom_are_points_of_the_path() {
|
||||
let (file, categories) = read("simple");
|
||||
let mut file = as_routes(file);
|
||||
let segment = &mut file.rte[0].seg;
|
||||
segment.update_all(|_, point| point.anchor = None);
|
||||
let len = segment.len();
|
||||
segment.set_anchor(5, ROUTE_POINT_MAX_ZOOM);
|
||||
segment.set_anchor(10, ROUTE_POINT_MAX_ZOOM + 1);
|
||||
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||
// the ends, and the anchor shown from zoom 12
|
||||
assert_eq!(count(&text, "<rtept"), 3);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), len - 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
|
||||
let (file, categories) = read("simple");
|
||||
let mut file = as_routes(file);
|
||||
file.rte[0].seg.update_all(|_, point| point.anchor = None);
|
||||
let len = file.rte[0].seg.len();
|
||||
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||
assert_eq!(count(&text, "<rtept"), 2);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
|
||||
// and a single trackpoint is a route of one point
|
||||
file.rte[0].seg.splice(1, len, vec![]);
|
||||
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||
assert_eq!(count(&text, "<rtept"), 1);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_keep_their_style() {
|
||||
let (file, categories) = read("with_style");
|
||||
let file = as_routes(file);
|
||||
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||
assert!(count(&text, "<gpx_style:line>") >= 1);
|
||||
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
|
||||
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||
assert_eq!(again.rte[0].info.color, file.rte[0].info.color);
|
||||
assert_eq!(again.rte[0].info.width, file.rte[0].info.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_are_written_as_routes_before_the_tracks() {
|
||||
let (mut file, categories) = read("with_tracks");
|
||||
let (routes, _) = read("with_routes");
|
||||
file.rte = routes.rte.clone();
|
||||
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||
assert_eq!(count(&text, "<rte>"), routes.rte.len());
|
||||
assert_eq!(count(&text, "<trk>"), file.trk.len());
|
||||
assert!(text.rfind("</rte>").unwrap() < text.find("<trk>").unwrap());
|
||||
|
||||
let again = parse(text.as_bytes(), &mut categories.clone()).unwrap();
|
||||
assert_eq!(again.rte.len(), file.rte.len());
|
||||
assert_eq!(again.trk.len(), file.trk.len());
|
||||
for (a, b) in file.rte.iter().zip(&again.rte) {
|
||||
assert_eq!(a.info, b.info);
|
||||
same_points(
|
||||
&a.seg.iter().cloned().collect::<Vec<_>>(),
|
||||
&b.seg.iter().cloned().collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
same_points(&points(&file), &points(&again));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_empty_route_is_written_and_a_single_route_is_named_like_the_file() {
|
||||
let mut file = File::default();
|
||||
file.info.name = "file".into();
|
||||
file.rte.push(crate::Route::default());
|
||||
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
|
||||
assert_eq!(count(&text, "<rte>"), 1);
|
||||
assert_eq!(count(&text, "<rtept"), 0);
|
||||
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||
assert_eq!(again.rte.len(), 1);
|
||||
assert_eq!(again.rte[0].info.name.as_deref(), Some("file"));
|
||||
|
||||
// not when there are others
|
||||
file.trk.push(Track::default());
|
||||
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
|
||||
// the metadata only
|
||||
assert_eq!(count(&text, "<name>file</name>"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
|
||||
let (file, categories) = read("with_hr");
|
||||
let file = as_routes(file);
|
||||
let with_all = written_text(&file, &categories, ExportOptions::ALL);
|
||||
assert!(with_all.contains("<gpxtpx:hr>"));
|
||||
let without = written_text(
|
||||
&file,
|
||||
&categories,
|
||||
ExportOptions {
|
||||
hr: false,
|
||||
time: false,
|
||||
..ExportOptions::ALL
|
||||
},
|
||||
);
|
||||
assert!(!without.contains("<gpxtpx:hr>"));
|
||||
assert!(!without.contains("<time>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod algorithm;
|
||||
pub(crate) mod gpx;
|
||||
mod io;
|
||||
pub(crate) mod statistics;
|
||||
mod utils;
|
||||
|
||||
pub use algorithm::*;
|
||||
pub use gpx::*;
|
||||
pub use io::*;
|
||||
pub use statistics::*;
|
||||
pub use utils::*;
|
||||
@@ -0,0 +1,527 @@
|
||||
use crate::{
|
||||
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
|
||||
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
|
||||
};
|
||||
|
||||
/// Time between two trackpoints, to size the windows of a smoothing. It is unknown when one of
|
||||
/// them has no time, which is longer than any known time span: the window does not go there.
|
||||
#[derive(Clone, Copy, PartialEq, PartialOrd)]
|
||||
enum TimeSpan {
|
||||
Known(i64),
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TimeSpan {
|
||||
fn between(end: Option<i64>, start: Option<i64>) -> Self {
|
||||
time_diff(end, start).map_or(Self::Unknown, Self::Known)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Statistics {
|
||||
pub global: GlobalStatistics,
|
||||
pub local: Vec<TrackpointStatistics>,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
pub fn total_speed(&self) -> Option<f64> {
|
||||
self.global.total_speed()
|
||||
}
|
||||
|
||||
pub fn moving_speed(&self) -> Option<f64> {
|
||||
self.global.moving_speed()
|
||||
}
|
||||
|
||||
pub fn compute(trkseg: &TrackSegment) -> Self {
|
||||
let mut stats = Self::default();
|
||||
if trkseg.is_empty() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
let mut prev = &trkseg[0];
|
||||
for cur in trkseg.iter() {
|
||||
stats.accumulate(prev, cur);
|
||||
prev = cur;
|
||||
}
|
||||
|
||||
stats.compute_smoothed_speed(trkseg);
|
||||
stats.compute_smoothed_elevation_segments(trkseg);
|
||||
stats.compute_smoothed_slope(trkseg);
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
|
||||
self.accumulate_distance_and_time(prev, cur);
|
||||
self.update_time_bounds(cur.time);
|
||||
self.update_bounds(cur.coordinates);
|
||||
self.local
|
||||
.push(TrackpointStatistics::from_partial_stats(self));
|
||||
}
|
||||
|
||||
fn accumulate_distance_and_time(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
|
||||
let dist = distance(prev.coordinates, cur.coordinates);
|
||||
let time = time_diff(cur.time, prev.time);
|
||||
|
||||
self.global.total_distance += dist;
|
||||
|
||||
let moving = time
|
||||
.zip(time.and_then(|time| speed(dist, time)))
|
||||
.filter(|(_, speed)| (0.5..=1500.0).contains(speed));
|
||||
if let Some((time, _)) = moving {
|
||||
self.global.moving_distance = self
|
||||
.global
|
||||
.moving_distance
|
||||
.map_or(Some(dist), |d| Some(d + dist));
|
||||
self.global.moving_time = self
|
||||
.global
|
||||
.moving_time
|
||||
.map_or(Some(time), |t| Some(t + time));
|
||||
}
|
||||
}
|
||||
|
||||
fn update_time_bounds(&mut self, time: Option<i64>) {
|
||||
if let Some(time) = time {
|
||||
if self.global.start_time.is_none() {
|
||||
self.global.start_time = Some(time);
|
||||
}
|
||||
self.global.end_time = Some(time);
|
||||
self.global.total_time = time_diff(self.global.end_time, self.global.start_time);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_bounds(&mut self, coordinates: LngLat) {
|
||||
self.global.bounds.extend(coordinates);
|
||||
}
|
||||
|
||||
/// Speed over a window of 10 seconds on each side of the trackpoints that have a time (the
|
||||
/// others have no speed). The windows do not extend to the trackpoints without time: if the
|
||||
/// previous or next trackpoint has none, the window ends at the trackpoint itself on that side.
|
||||
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
trkseg.first_index(),
|
||||
trkseg.last_index(),
|
||||
TimeSpan::Known(10_000),
|
||||
|i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|
||||
|i, left, right| {
|
||||
if trkseg[i].time.is_some() {
|
||||
let timed = |index: TrackSegmentIndex| {
|
||||
if trkseg[index].time.is_some() {
|
||||
index
|
||||
} else {
|
||||
i
|
||||
}
|
||||
};
|
||||
let (left, right) = (timed(left), timed(right));
|
||||
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
|
||||
.and_then(|time| {
|
||||
speed(
|
||||
self.local[right.flat].total_distance
|
||||
- self.local[left.flat].total_distance,
|
||||
time,
|
||||
)
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_smoothed_elevation_segments(&mut self, trkseg: &TrackSegment) {
|
||||
let simplified = self.get_elevation_extremas(trkseg);
|
||||
|
||||
for i in 0..(simplified.len() - 1) {
|
||||
let start = simplified[i];
|
||||
let end = simplified[i + 1];
|
||||
let last = i + 1 == simplified.len() - 1;
|
||||
|
||||
self.compute_smoothed_elevation_gain(trkseg, start, end, last);
|
||||
|
||||
let segment_dist =
|
||||
self.local[end.flat].total_distance - self.local[start.flat].total_distance;
|
||||
let segment_ele = trkseg[end].ele - trkseg[start].ele;
|
||||
let segment_slope = slope(segment_ele, segment_dist);
|
||||
for k in start.flat..(end.flat + last as usize) {
|
||||
self.local[k].slope_segment = SlopeSegment {
|
||||
slope: segment_slope,
|
||||
distance: segment_dist,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_smoothed_elevation_gain(
|
||||
&mut self,
|
||||
trkseg: &TrackSegment,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
last: bool,
|
||||
) {
|
||||
let mut cumul_ele = 0.0;
|
||||
let mut current_left = start;
|
||||
let mut current_right = Some(start);
|
||||
let mut prev_smoothed_ele = trkseg[start].ele;
|
||||
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
Some(start),
|
||||
Some(end),
|
||||
0.1,
|
||||
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|
||||
|i, left, right| {
|
||||
while current_left != left {
|
||||
cumul_ele -= trkseg[current_left].ele;
|
||||
current_left = trkseg.next_index(Some(current_left)).unwrap();
|
||||
}
|
||||
while let Some(current) = current_right {
|
||||
if current > right {
|
||||
break;
|
||||
}
|
||||
cumul_ele += trkseg[current].ele;
|
||||
current_right = trkseg.next_index(current_right);
|
||||
}
|
||||
|
||||
let smoothed_ele = if i == start || i == end {
|
||||
trkseg[i].ele
|
||||
} else {
|
||||
cumul_ele / (right.flat - left.flat + 1) as f64
|
||||
};
|
||||
|
||||
let delta = smoothed_ele - prev_smoothed_ele;
|
||||
if delta > 0.0 {
|
||||
self.global.elevation_gain += delta;
|
||||
} else if delta < 0.0 {
|
||||
self.global.elevation_loss -= delta;
|
||||
}
|
||||
|
||||
if i < end || last {
|
||||
self.local[i.flat].elevation_gain = self.global.elevation_gain;
|
||||
self.local[i.flat].elevation_loss = self.global.elevation_loss;
|
||||
}
|
||||
|
||||
prev_smoothed_ele = smoothed_ele;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_smoothed_slope(&mut self, trkseg: &TrackSegment) {
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
trkseg.first_index(),
|
||||
trkseg.last_index(),
|
||||
0.05,
|
||||
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|
||||
|i, left, right| {
|
||||
let dist =
|
||||
self.local[right.flat].total_distance - self.local[left.flat].total_distance;
|
||||
let ele = trkseg[right].ele - trkseg[left].ele;
|
||||
self.local[i.flat].slope = slope(ele, dist);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn get_elevation_extremas(&self, trkseg: &TrackSegment) -> Vec<TrackSegmentIndex> {
|
||||
ramer_douglas_peucker(
|
||||
trkseg,
|
||||
&|idx| {
|
||||
(
|
||||
self.local[idx.flat].total_distance * 1000.0,
|
||||
trkseg[idx].ele,
|
||||
)
|
||||
},
|
||||
20.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct GlobalStatistics {
|
||||
pub total_distance: f64,
|
||||
pub moving_distance: Option<f64>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
pub start_time: Option<i64>,
|
||||
pub end_time: Option<i64>,
|
||||
pub bounds: LngLatBounds,
|
||||
}
|
||||
|
||||
impl GlobalStatistics {
|
||||
/// Average speed over the total time, unknown if that is not a positive duration (for
|
||||
/// example a range with a single timestamp, or timestamps going backwards).
|
||||
pub fn total_speed(&self) -> Option<f64> {
|
||||
self.total_time.and_then(|t| speed(self.total_distance, t))
|
||||
}
|
||||
|
||||
/// Average speed over the moving time, unknown if there is no moving time.
|
||||
pub fn moving_speed(&self) -> Option<f64> {
|
||||
self.moving_distance
|
||||
.zip(self.moving_time)
|
||||
.and_then(|(d, t)| speed(d, t))
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &GlobalStatistics) {
|
||||
self.total_distance += other.total_distance;
|
||||
self.moving_distance = sum_options(self.moving_distance, other.moving_distance);
|
||||
self.total_time = sum_options(self.total_time, other.total_time);
|
||||
self.moving_time = sum_options(self.moving_time, other.moving_time);
|
||||
self.elevation_gain += other.elevation_gain;
|
||||
self.elevation_loss += other.elevation_loss;
|
||||
self.start_time = min_options(self.start_time, other.start_time);
|
||||
self.end_time = max_options(self.end_time, other.end_time);
|
||||
self.bounds.merge(&other.bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct TrackpointStatistics {
|
||||
pub total_distance: f64,
|
||||
pub moving_distance: Option<f64>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub speed: Option<f64>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
pub slope: f64,
|
||||
pub slope_segment: SlopeSegment,
|
||||
}
|
||||
|
||||
impl TrackpointStatistics {
|
||||
fn from_partial_stats(stats: &Statistics) -> Self {
|
||||
Self {
|
||||
total_distance: stats.global.total_distance,
|
||||
moving_distance: stats.global.moving_distance,
|
||||
total_time: stats.global.total_time,
|
||||
moving_time: stats.global.moving_time,
|
||||
// stats below are computed later
|
||||
speed: None,
|
||||
elevation_gain: Default::default(),
|
||||
elevation_loss: Default::default(),
|
||||
slope: Default::default(),
|
||||
slope_segment: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct SlopeSegment {
|
||||
pub slope: f64,
|
||||
pub distance: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::Read};
|
||||
|
||||
use crate::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_compute_smoothed_speed() {
|
||||
let mut f = File::open("data/with_time.gpx").unwrap();
|
||||
let mut data = String::new();
|
||||
let _ = f.read_to_string(&mut data);
|
||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||
|
||||
let trkseg = &gpx.trk[0].trkseg[0];
|
||||
let stats = Statistics::compute(trkseg);
|
||||
assert_eq!(stats.local.len(), trkseg.len());
|
||||
for trkpt_stats in stats.local.iter() {
|
||||
assert!(trkpt_stats.speed.is_some());
|
||||
let speed = trkpt_stats.speed.unwrap();
|
||||
assert_ne!(speed, f64::NAN);
|
||||
assert!((speed - 20.0).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The speeds of the segment of `data/with_time.gpx` (80 trackpoints at 20 km/h) once the
|
||||
/// trackpoints at `holes` have lost their time.
|
||||
fn speeds_with_holes(holes: &[usize]) -> Vec<Option<f64>> {
|
||||
let gpx = load("data/with_time.gpx");
|
||||
let mut segment = gpx.trk[0].trkseg[0].clone();
|
||||
let points: Vec<_> = segment
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, trkpt)| {
|
||||
let mut trkpt = trkpt.clone();
|
||||
if holes.contains(&i) {
|
||||
trkpt.time = None;
|
||||
}
|
||||
trkpt
|
||||
})
|
||||
.collect();
|
||||
segment.splice(0, points.len(), points);
|
||||
Statistics::compute(&segment)
|
||||
.local
|
||||
.iter()
|
||||
.map(|local| local.speed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Without time, there is no speed. With one, the speed is the one of the trackpoints around.
|
||||
fn assert_speeds_with_holes(holes: &[usize]) {
|
||||
let speeds = speeds_with_holes(holes);
|
||||
assert_eq!(speeds.len(), 80);
|
||||
for (i, speed) in speeds.iter().enumerate() {
|
||||
if holes.contains(&i) {
|
||||
assert_eq!(*speed, None, "trackpoint {i}");
|
||||
} else {
|
||||
let speed = speed.unwrap_or_else(|| panic!("no speed for trackpoint {i}"));
|
||||
assert!((speed - 20.0).abs() < 0.1, "trackpoint {i}: {speed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_without_holes() {
|
||||
assert_speeds_with_holes(&[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_at_the_beginning() {
|
||||
assert_speeds_with_holes(&[0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_at_the_end() {
|
||||
assert_speeds_with_holes(&[75, 76, 77, 78, 79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_at_both_ends() {
|
||||
assert_speeds_with_holes(&[0, 1, 2, 77, 78, 79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_in_the_middle() {
|
||||
assert_speeds_with_holes(&[40]);
|
||||
assert_speeds_with_holes(&[30, 31, 32, 33, 34]);
|
||||
assert_speeds_with_holes(&(20..60).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_scattered_holes() {
|
||||
let holes: Vec<_> = (0..80).step_by(7).collect();
|
||||
assert_speeds_with_holes(&holes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_without_any_time() {
|
||||
let holes: Vec<_> = (0..80).collect();
|
||||
assert!(speeds_with_holes(&holes).iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
fn load(path: &str) -> crate::File {
|
||||
let mut f = File::open(path).unwrap();
|
||||
let mut data = String::new();
|
||||
let _ = f.read_to_string(&mut data);
|
||||
parse(data.as_bytes(), &mut Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_empty_segment() {
|
||||
let stats = Statistics::compute(&TrackSegment::default());
|
||||
assert!(stats.local.is_empty());
|
||||
assert_eq!(stats.global.total_distance, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_distance_and_bounds() {
|
||||
let gpx = load("data/simple.gpx");
|
||||
let trkseg = &gpx.trk[0].trkseg[0];
|
||||
let stats = Statistics::compute(trkseg);
|
||||
|
||||
assert_eq!(stats.local.len(), trkseg.len());
|
||||
assert_eq!(stats.local[0].total_distance, 0.0);
|
||||
assert!(stats.global.total_distance > 0.0);
|
||||
// cumulative distance never decreases and ends at the global distance
|
||||
assert!(
|
||||
stats
|
||||
.local
|
||||
.windows(2)
|
||||
.all(|w| w[0].total_distance <= w[1].total_distance)
|
||||
);
|
||||
let last = stats.local.last().unwrap();
|
||||
assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9);
|
||||
assert!(stats.global.elevation_gain >= 0.0);
|
||||
assert!(stats.global.elevation_loss >= 0.0);
|
||||
|
||||
// every point lies inside the bounds
|
||||
let b = &stats.global.bounds;
|
||||
for trkpt in trkseg.iter() {
|
||||
assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng);
|
||||
assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_time() {
|
||||
let gpx = load("data/with_time.gpx");
|
||||
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
|
||||
let total_time = stats.global.total_time.unwrap();
|
||||
assert!(total_time > 0);
|
||||
let speed = stats.global.total_speed().unwrap();
|
||||
assert!((speed - 20.0).abs() < 0.5, "{speed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_time_without_timestamps() {
|
||||
let gpx = load("data/simple.gpx");
|
||||
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
|
||||
assert!(stats.global.total_time.is_none());
|
||||
assert!(stats.global.total_speed().is_none());
|
||||
assert!(stats.local.iter().all(|s| s.speed.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_merge() {
|
||||
let mut a = GlobalStatistics {
|
||||
total_distance: 1.0,
|
||||
elevation_gain: 10.0,
|
||||
moving_time: Some(5),
|
||||
..Default::default()
|
||||
};
|
||||
a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 });
|
||||
let mut b = GlobalStatistics {
|
||||
total_distance: 2.0,
|
||||
elevation_loss: 4.0,
|
||||
moving_distance: Some(1.5),
|
||||
moving_time: Some(7),
|
||||
..Default::default()
|
||||
};
|
||||
b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 });
|
||||
|
||||
a.merge(&b);
|
||||
assert_eq!(a.total_distance, 3.0);
|
||||
assert_eq!(a.elevation_gain, 10.0);
|
||||
assert_eq!(a.elevation_loss, 4.0);
|
||||
assert_eq!(a.moving_distance, Some(1.5));
|
||||
assert_eq!(a.moving_time, Some(12));
|
||||
assert_eq!(a.total_time, None);
|
||||
|
||||
let mut c = GlobalStatistics {
|
||||
start_time: Some(1_000),
|
||||
end_time: Some(4_000),
|
||||
total_time: Some(3_000),
|
||||
..Default::default()
|
||||
};
|
||||
let d = GlobalStatistics {
|
||||
start_time: Some(10_000),
|
||||
end_time: Some(12_000),
|
||||
total_time: Some(2_000),
|
||||
..Default::default()
|
||||
};
|
||||
c.merge(&d);
|
||||
// 3 s + 2 s, the gap between the two is ignored
|
||||
assert_eq!(c.total_time, Some(5_000));
|
||||
let mut cumul = GlobalStatistics::default();
|
||||
cumul.merge(&c);
|
||||
cumul.merge(&d);
|
||||
assert_eq!(cumul.start_time, Some(1_000));
|
||||
assert_eq!(cumul.end_time, Some(12_000));
|
||||
assert_eq!(cumul.total_time, Some(7_000));
|
||||
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod compute;
|
||||
mod utils;
|
||||
|
||||
pub use compute::*;
|
||||
pub use utils::*;
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::ops::Add;
|
||||
|
||||
pub fn combine_options<T>(a: Option<T>, b: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(f(a, b)),
|
||||
(Some(v), None) | (None, Some(v)) => Some(v),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||
where
|
||||
T: Add<Output = T>,
|
||||
{
|
||||
combine_options(a, b, |a, b| a + b)
|
||||
}
|
||||
|
||||
pub fn min_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||
where
|
||||
T: Ord,
|
||||
{
|
||||
combine_options(a, b, |a, b| a.min(b))
|
||||
}
|
||||
|
||||
pub fn max_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||
where
|
||||
T: Ord,
|
||||
{
|
||||
combine_options(a, b, |a, b| a.max(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sum_options() {
|
||||
assert_eq!(sum_options(Some(1), Some(2)), Some(3));
|
||||
assert_eq!(sum_options(Some(1), None), Some(1));
|
||||
assert_eq!(sum_options(None, Some(2)), Some(2));
|
||||
assert_eq!(sum_options::<i32>(None, None), None);
|
||||
assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::LngLat;
|
||||
|
||||
const TO_RADIANS: f64 = PI / 180.0;
|
||||
const EARTH_RADIUS: f64 = 6371.0088;
|
||||
|
||||
/// Computes the distance in kilometers between two coordinates using the Haversine formula
|
||||
pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
|
||||
let lat1 = p1.lat * TO_RADIANS;
|
||||
let lat2 = p2.lat * TO_RADIANS;
|
||||
let delta_lat = lat2 - lat1;
|
||||
let delta_lng = (p2.lng - p1.lng) * TO_RADIANS;
|
||||
|
||||
let a =
|
||||
(delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lng / 2.0).sin().powi(2);
|
||||
let c = 2.0 * a.min(1.0).sqrt().asin();
|
||||
EARTH_RADIUS * c
|
||||
}
|
||||
|
||||
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||
Some(a? - b?)
|
||||
}
|
||||
|
||||
/// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
|
||||
/// Unknown if the time is not a positive duration.
|
||||
pub fn speed(distance: f64, time: i64) -> Option<f64> {
|
||||
(time > 0).then(|| distance / (time as f64 / 3_600_000.0))
|
||||
}
|
||||
|
||||
pub fn slope(ele: f64, distance: f64) -> f64 {
|
||||
if distance == 0.0 {
|
||||
100.0
|
||||
} else {
|
||||
0.1 * ele / distance
|
||||
}
|
||||
}
|
||||
|
||||
const METERS_PER_LATITUDE_DEGREE: f64 = 111320.0;
|
||||
|
||||
/// Approximate planar projection, in meters, around the latitude of `origin` (ignores the
|
||||
/// curvature of the earth).
|
||||
struct Planar {
|
||||
meters_per_longitude_degree: f64,
|
||||
}
|
||||
|
||||
impl Planar {
|
||||
fn around(origin: LngLat) -> Self {
|
||||
Self {
|
||||
meters_per_longitude_degree: (origin.lat * TO_RADIANS).cos()
|
||||
* METERS_PER_LATITUDE_DEGREE,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_meters(&self, p: LngLat) -> (f64, f64) {
|
||||
(
|
||||
p.lng * self.meters_per_longitude_degree,
|
||||
p.lat * METERS_PER_LATITUDE_DEGREE,
|
||||
)
|
||||
}
|
||||
|
||||
fn to_degrees(&self, (x, y): (f64, f64)) -> LngLat {
|
||||
LngLat {
|
||||
lng: x / self.meters_per_longitude_degree,
|
||||
lat: y / METERS_PER_LATITUDE_DEGREE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Position, between 0 (at `(x1, y1)`) and 1 (at `(x2, y2)`), of the point of the segment that is
|
||||
/// the closest to `(x3, y3)`. A degenerate segment (same ends) is a point.
|
||||
fn closest_on_segment(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
|
||||
let dx = x2 - x1;
|
||||
let dy = y2 - y1;
|
||||
let segment_length_squared = dx * dx + dy * dy;
|
||||
if segment_length_squared == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the point on the line segment defined by p1 and p2 that is closest to the third
|
||||
/// point, p3. Uses simple planar geometry (ignores earth curvature).
|
||||
pub fn projected(p1: LngLat, p2: LngLat, p3: LngLat) -> LngLat {
|
||||
let planar = Planar::around(p1);
|
||||
let (x1, y1) = planar.to_meters(p1);
|
||||
let (x2, y2) = planar.to_meters(p2);
|
||||
let (x3, y3) = planar.to_meters(p3);
|
||||
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
|
||||
planar.to_degrees((x1 + t * (x2 - x1), y1 + t * (y2 - y1)))
|
||||
}
|
||||
|
||||
/// Calculates the perpendicular distance in meters between a line segment (defined by p1 and p2)
|
||||
/// and a third point, p3. Uses simple planar geometry (ignores earth curvature).
|
||||
pub fn crossarc_lnglat(p1: LngLat, p2: LngLat, p3: LngLat) -> f64 {
|
||||
let planar = Planar::around(p1);
|
||||
let (x1, y1) = planar.to_meters(p1);
|
||||
let (x2, y2) = planar.to_meters(p2);
|
||||
let (x3, y3) = planar.to_meters(p3);
|
||||
crossarc(x1, y1, x2, y2, x3, y3)
|
||||
}
|
||||
|
||||
/// Distance from the point `(x3, y3)` to the segment `(x1, y1)`-`(x2, y2)`, in the units of the
|
||||
/// coordinates (planar geometry).
|
||||
pub fn crossarc(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
|
||||
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
|
||||
(x3 - (x1 + t * (x2 - x1))).hypot(y3 - (y1 + t * (y2 - y1)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(lng: f64, lat: f64) -> LngLat {
|
||||
LngLat { lng, lat }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_diff_and_speed_with_long_durations() {
|
||||
// 40 days in milliseconds, more than an i32 can hold
|
||||
let forty_days = 40 * 24 * 3_600_000_i64;
|
||||
assert!(forty_days > i64::from(i32::MAX));
|
||||
assert_eq!(time_diff(Some(forty_days), Some(0)), Some(forty_days));
|
||||
assert_eq!(time_diff(Some(0), Some(forty_days)), Some(-forty_days));
|
||||
assert_eq!(time_diff(None, Some(1)), None);
|
||||
assert_eq!(time_diff(Some(1), None), None);
|
||||
// 1 km in one hour
|
||||
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distance() {
|
||||
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
|
||||
// one degree of latitude
|
||||
let d = distance(p(0.0, 0.0), p(0.0, 1.0));
|
||||
assert!((d - 111.195).abs() < 0.01, "{d}");
|
||||
// symmetric
|
||||
let a = p(4.40, 50.79);
|
||||
let b = p(6.13, 45.90);
|
||||
assert!((distance(a, b) - distance(b, a)).abs() < 1e-9);
|
||||
// half the Earth's circumference, without NaN from rounding
|
||||
let antipodal = distance(p(0.0, 0.0), p(180.0, 0.0));
|
||||
assert!((antipodal - PI * EARTH_RADIUS).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_diff() {
|
||||
assert_eq!(time_diff(Some(5000), Some(2000)), Some(3000));
|
||||
assert_eq!(time_diff(Some(2000), Some(5000)), Some(-3000));
|
||||
assert_eq!(time_diff(None, Some(1)), None);
|
||||
assert_eq!(time_diff(Some(1), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed() {
|
||||
// 1 km in 1 h
|
||||
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||
// 10 km in 30 min
|
||||
assert!((speed(10.0, 1_800_000).unwrap() - 20.0).abs() < 1e-12);
|
||||
// no duration, no speed
|
||||
assert_eq!(speed(1.0, 0), None);
|
||||
assert_eq!(speed(0.0, 0), None);
|
||||
assert_eq!(speed(1.0, -1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slope() {
|
||||
// 10 m of elevation over 100 m (distance is expressed in km, hence the 0.1 factor)
|
||||
assert!((slope(10.0, 0.1) - 10.0).abs() < 1e-12);
|
||||
assert!((slope(-5.0, 0.1) + 5.0).abs() < 1e-12);
|
||||
assert_eq!(slope(0.0, 1.0), 0.0);
|
||||
assert_eq!(slope(10.0, 0.0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crossarc() {
|
||||
// perpendicular distance to the segment (0,0)-(10,0)
|
||||
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 5.0, 3.0) - 3.0).abs() < 1e-12);
|
||||
// beyond the ends: distance to the closest endpoint
|
||||
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 13.0, 4.0) - 5.0).abs() < 1e-12);
|
||||
assert!((crossarc(0.0, 0.0, 10.0, 0.0, -3.0, 4.0) - 5.0).abs() < 1e-12);
|
||||
// point on the segment
|
||||
assert_eq!(crossarc(0.0, 0.0, 10.0, 0.0, 4.0, 0.0), 0.0);
|
||||
// degenerate segment
|
||||
assert!((crossarc(1.0, 1.0, 1.0, 1.0, 4.0, 5.0) - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_projected() {
|
||||
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
|
||||
assert!((proj.lng - 0.5).abs() < 1e-9);
|
||||
assert!(proj.lat.abs() < 1e-9);
|
||||
// clamped to the segment
|
||||
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(2.0, 1.0));
|
||||
assert!((proj.lng - 1.0).abs() < 1e-9);
|
||||
// degenerate segment
|
||||
let proj = projected(p(3.0, 4.0), p(3.0, 4.0), p(5.0, 6.0));
|
||||
assert_eq!((proj.lng, proj.lat), (3.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crossarc_lnglat() {
|
||||
// about 1 degree of latitude away from an east-west segment on the equator
|
||||
let d = crossarc_lnglat(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
|
||||
assert!((d - METERS_PER_LATITUDE_DEGREE).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user