19 Commits
Author SHA1 Message Date
vcoppe f1904bc12f cleaning 2026-10-03 20:54:05 +02:00
vcoppe 9afba53e5d progress 2026-10-03 19:50:38 +02:00
vcoppe fd76e0fa46 progress 2026-10-03 19:17:58 +02:00
vcoppe 438a87f06f progress 2026-10-03 19:08:56 +02:00
vcoppe a994e16f51 splice 2026-10-03 18:03:13 +02:00
vcoppe 639e7491eb merge methods into 1 2026-10-03 17:47:04 +02:00
vcoppe 891a08a3cc progress 2026-10-03 17:42:26 +02:00
vcoppe 0c07164dec remove (copy) in name 2026-10-03 17:26:53 +02:00
vcoppe f133d55889 progress 2026-10-03 17:21:16 +02:00
vcoppe fbb636cb06 progress 2026-10-03 16:52:21 +02:00
vcoppe bddceb28aa keep style info 2026-10-03 16:43:53 +02:00
vcoppe bff84fa24c progress 2026-10-03 16:40:17 +02:00
vcoppe 64914f9aa0 progress 2026-10-03 16:16:47 +02:00
vcoppe 2abf46c747 progress 2026-10-03 15:33:44 +02:00
vcoppe 888f41e979 refactoring 2026-10-03 13:56:16 +02:00
vcoppe 17533586ef progress 2026-10-03 13:41:58 +02:00
vcoppe c6ad088947 fix 2026-10-03 13:40:49 +02:00
vcoppe 87d6d5f30e change type 2026-10-03 11:49:10 +02:00
vcoppe dbe894b3a3 reorganize 2026-10-03 10:03:22 +02:00
102 changed files with 5755 additions and 1257 deletions

No files matched your search

+21 -2
View File
@@ -52,6 +52,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -100,13 +110,22 @@ dependencies = [
]
[[package]]
name = "gpx-rs"
name = "gpx-engine"
version = "0.1.0"
dependencies = [
"chrono",
"js-sys",
"quick-xml",
"uuid",
]
[[package]]
name = "gpx-rs"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"gpx-engine",
"js-sys",
"uuid",
"wasm-bindgen",
]
+5 -13
View File
@@ -1,14 +1,6 @@
[package]
name = "gpx-rs"
version = "0.1.0"
edition = "2024"
[workspace]
members = ["engine", "wasm"]
resolver = "3"
[lib]
crate-type = ["cdylib"]
[dependencies]
chrono = "0.4.45"
js-sys = "0.3.106"
quick-xml = "0.42.0"
uuid = {version = "1.26.1", features = ["v4", "js"]}
wasm-bindgen = "0.2"
[profile.release]
debug = true
+1 -1
View File
@@ -1,3 +1,3 @@
```
wasm-pack build --target bundler
wasm-pack build wasm --target bundler --out-dir ../pkg
```
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "gpx-engine"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.45"
quick-xml = "0.42.0"
uuid = { version = "1.26.1", features = ["v4"] }
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -2,4 +2,3 @@ mod simplify;
mod smooth;
pub use simplify::*;
pub use smooth::*;
@@ -0,0 +1,133 @@
use crate::{
core::gpx::{TrackSegment, TrackSegmentIndex},
core::utils::crossarc,
};
pub fn ramer_douglas_peucker<F>(
trkseg: &TrackSegment,
mapping: &F,
epsilon: f64,
) -> Vec<TrackSegmentIndex>
where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
match trkseg.len() {
0 => vec![],
1 => vec![trkseg.first_index().unwrap()],
2 => vec![trkseg.first_index().unwrap(), trkseg.last_index().unwrap()],
_ => {
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
let mut indices = vec![first];
ramer_douglas_peucker_helper(trkseg, first, last, mapping, epsilon, &mut indices);
indices.push(last);
indices
}
}
}
fn ramer_douglas_peucker_helper<F>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
mapping: &F,
epsilon: f64,
indices: &mut Vec<TrackSegmentIndex>,
) where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
let mut max_idx = None;
let mut max_dist = 0.0;
let start_pt = mapping(start);
let end_pt = mapping(end);
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let pt = mapping(idx);
let dist = crossarc(start_pt.0, start_pt.1, end_pt.0, end_pt.1, pt.0, pt.1);
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, mapping, epsilon, indices);
indices.push(idx);
ramer_douglas_peucker_helper(trkseg, idx, end, mapping, epsilon, indices);
}
}
#[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());
}
}
+141
View File
@@ -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]);
}
}
+100
View File
@@ -0,0 +1,100 @@
use uuid::Uuid;
use crate::{Trackpoint, Waypoint};
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
#[derive(Debug, PartialEq, Eq)]
pub struct TrackpointChunkId(Uuid);
impl Default for TrackpointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default)]
pub struct TrackpointChunk {
pub id: TrackpointChunkId,
pub trkpt: Vec<Trackpoint>,
}
impl PartialEq for TrackpointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl TrackpointChunk {
pub fn is_full(&self) -> bool {
self.trkpt.len() == MAX_TRKPT_CHUNK_SIZE
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaypointChunkId(Uuid);
impl Default for WaypointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
const MAX_WPT_CHUNK_SIZE: usize = 128;
#[derive(Debug, Default)]
pub struct WaypointChunk {
pub id: WaypointChunkId,
pub wpt: Vec<Waypoint>,
}
impl PartialEq for WaypointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl WaypointChunk {
pub fn is_full(&self) -> bool {
self.wpt.len() == MAX_WPT_CHUNK_SIZE
}
}
#[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_chunk_equality_is_by_id() {
let a = TrackpointChunk::default();
let b = TrackpointChunk::default();
assert_ne!(a, b);
let c = TrackpointChunk {
id: TrackpointChunkId(a.id.0),
trkpt: vec![Trackpoint::default()],
};
assert_eq!(a, c);
}
}
+97
View File
@@ -0,0 +1,97 @@
#[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)]
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));
}
}
@@ -2,7 +2,7 @@ use std::rc::Rc;
use uuid::Uuid;
use crate::gpx::{Link, Track, WaypointChunk};
use crate::{Link, Track, WaypointChunk};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileId(pub Uuid);
@@ -13,16 +13,26 @@ impl Default for FileId {
}
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileWaypointsRevisionId(pub Uuid);
impl Default for FileWaypointsRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
pub trk: Vec<Track>,
pub wpt: Vec<Rc<WaypointChunk>>,
pub wpt_rev_id: FileWaypointsRevisionId,
// TODO routes
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
@@ -31,7 +41,7 @@ pub struct FileInfo {
pub time: Option<i64>,
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Author {
pub name: Option<String>,
pub email: Option<String>,
File renamed without changes.
+454
View File
@@ -0,0 +1,454 @@
use std::{ops::Index, rc::Rc};
use uuid::Uuid;
use crate::{Trackpoint, TrackpointChunk};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackSegmentId(pub Uuid);
impl Default for TrackSegmentId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackSegmentRevisionId(pub Uuid);
impl Default for TrackSegmentRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackSegment {
pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId,
chunks: Vec<Rc<TrackpointChunk>>,
cumul_length: Vec<usize>,
}
impl TrackSegment {
pub fn push(&mut self, chunk: TrackpointChunk) {
self.push_shared(Rc::new(chunk));
}
fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
if chunk.trkpt.is_empty() {
return;
}
self.cumul_length
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.trkpt.len());
self.chunks.push(chunk);
}
/// Replaces the points in `start..end` by `points`. 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, points: Vec<Trackpoint>) {
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 pending = TrackpointChunk::default();
let mut inserted = false;
let mut offset = 0;
for chunk in old {
let (lo, hi) = (offset, offset + chunk.trkpt.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 !inserted {
self.fill(&mut pending, chunk.trkpt[..start - lo].iter().cloned());
self.fill(&mut pending, points.iter().cloned());
inserted = true;
}
if hi <= end {
continue;
}
if lo >= end {
self.flush(&mut pending);
self.push_shared(chunk);
} else {
self.fill(&mut pending, chunk.trkpt[end - lo..].iter().cloned());
}
}
if !inserted {
self.fill(&mut pending, points);
}
self.flush(&mut pending);
}
fn fill(
&mut self,
pending: &mut TrackpointChunk,
points: impl IntoIterator<Item = Trackpoint>,
) {
for trkpt in points {
pending.trkpt.push(trkpt);
if pending.is_full() {
self.flush(pending);
}
}
}
fn flush(&mut self, pending: &mut TrackpointChunk) {
self.push(std::mem::take(pending));
}
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) -> TrackSegmentIterator<'_> {
TrackSegmentIterator::new(self)
}
pub fn first_index(&self) -> Option<TrackSegmentIndex> {
self.next_index(None)
}
pub fn last_index(&self) -> Option<TrackSegmentIndex> {
self.prev_index(None)
}
pub fn next_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut next = cur.map_or_default(|idx| TrackSegmentIndex {
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].trkpt.len() {
next.chunk += 1;
next.pos = 0;
} else {
return Some(next);
}
}
}
pub fn prev_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut prev = cur.unwrap_or(TrackSegmentIndex {
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].trkpt.is_empty() {
prev.pos = self.chunks[prev.chunk].trkpt.len() - 1;
prev.flat -= 1;
return Some(prev);
}
}
None
} else {
prev.pos -= 1;
prev.flat -= 1;
Some(prev)
}
}
fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> {
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].trkpt.len() {
None
} else {
Some(TrackSegmentIndex {
chunk,
pos,
flat: idx,
})
}
}
}
impl Index<TrackSegmentIndex> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: TrackSegmentIndex) -> &Self::Output {
&self.chunks[idx.chunk].trkpt[idx.pos]
}
}
impl Index<usize> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: usize) -> &Self::Output {
&self[self.locate(idx).unwrap()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct TrackSegmentIndex {
pub chunk: usize,
pub pos: usize,
pub flat: usize,
}
#[derive(Debug, Clone)]
pub struct TrackSegmentIterator<'a> {
trkseg: &'a TrackSegment,
idx: Option<TrackSegmentIndex>,
}
impl<'a> TrackSegmentIterator<'a> {
pub fn new(trkseg: &'a TrackSegment) -> Self {
Self {
trkseg,
idx: Default::default(),
}
}
}
impl<'a> Iterator for TrackSegmentIterator<'a> {
type Item = &'a Trackpoint;
fn next(&mut self) -> Option<Self::Item> {
self.idx = self.trkseg.next_index(self.idx);
self.idx.map(|idx| &self.trkseg[idx])
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let idx = self.idx.map_or_default(|idx| idx.flat) + n;
self.idx = self.trkseg.locate(idx);
self.idx.map(|idx| &self.trkseg[idx])
}
}
#[cfg(test)]
mod tests {
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());
}
#[test]
fn test_splice_keeps_untouched_chunks_shared() {
let mut trkseg = create_track_segment(5);
let before = trkseg.chunks.clone();
// 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.cumul_length.last().unwrap(), 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;
}
}
}
@@ -1,9 +1,9 @@
use uuid::Uuid;
use crate::gpx::{Link, TrackSegment};
use crate::{Link, TrackSegment};
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct TrackId(Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackId(pub Uuid);
impl Default for TrackId {
fn default() -> Self {
@@ -11,14 +11,14 @@ impl Default for TrackId {
}
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Track {
pub id: TrackId,
pub info: TrackInfo,
pub trkseg: Vec<TrackSegment>,
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackInfo {
pub name: Option<String>,
pub cmt: Option<String>,
@@ -1,6 +1,6 @@
use crate::gpx::LngLat;
use crate::LngLat;
#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
pub struct Trackpoint {
pub coordinates: LngLat,
pub ele: f64,
@@ -1,9 +1,9 @@
use uuid::Uuid;
use crate::gpx::{Link, LngLat};
use crate::{Link, LngLat};
#[derive(Debug, PartialEq, Eq)]
pub struct WaypointId(Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WaypointId(pub Uuid);
impl Default for WaypointId {
fn default() -> Self {
@@ -11,7 +11,7 @@ impl Default for WaypointId {
}
}
#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
pub struct Waypoint {
pub id: WaypointId,
pub coordinates: LngLat,
File renamed without changes.
@@ -1,6 +1,6 @@
use std::rc::Rc;
use crate::gpx::{
use crate::{
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointChunk, Waypoint,
WaypointChunk,
};
@@ -38,13 +38,11 @@ enum GPXElement {
fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
let mut coordinates = LngLat::default();
for attr in attributes {
if let Ok(attr) = attr {
match attr.key.as_ref() {
"lat" => coordinates.lat = attr.value.parse().unwrap_or_default(),
"lon" => coordinates.lng = attr.value.parse().unwrap_or_default(),
_ => (),
}
for attr in attributes.flatten() {
match attr.key.as_ref() {
"lat" => coordinates.lat = attr.value.parse().unwrap_or_default(),
"lon" => coordinates.lng = attr.value.parse().unwrap_or_default(),
_ => (),
}
}
coordinates
@@ -68,11 +66,9 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
"author" => stack.push(GPXElement::Author(Author::default())),
"link" => {
let mut link = Link::default();
for attr in e.attributes() {
if let Ok(attr) = attr {
if attr.key.as_ref() == "href" {
link.href = attr.value.to_string();
}
for attr in e.attributes().flatten() {
if attr.key.as_ref() == "href" {
link.href = attr.value.to_string();
}
}
stack.push(GPXElement::Link(link));
@@ -83,14 +79,16 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
stack.push(GPXElement::Segment(TrackSegment::default()));
}
"trkpt" => {
let mut trkpt = Trackpoint::default();
trkpt.coordinates = parse_coordinates(e.attributes());
stack.push(GPXElement::Trackpoint(trkpt));
stack.push(GPXElement::Trackpoint(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
}));
}
"wpt" => {
let mut wpt = Waypoint::default();
wpt.coordinates = parse_coordinates(e.attributes());
stack.push(GPXElement::Waypoint(wpt));
stack.push(GPXElement::Waypoint(Waypoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
}));
}
"ele" => stack.push(GPXElement::Elevation),
"time" => stack.push(GPXElement::Time),
@@ -109,8 +107,7 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
Ok(Event::End(e)) => match e.name().as_ref() {
"gpx" => {
if !wpt_chunk.wpt.is_empty() {
gpx.wpt.push(Rc::new(wpt_chunk));
wpt_chunk = WaypointChunk::default();
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
"metadata" => {
@@ -143,24 +140,21 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
}
}
"trkseg" => {
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop() {
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
if !trkpt_chunk.trkpt.is_empty() {
trkseg.push(trkpt_chunk);
trkpt_chunk = TrackpointChunk::default();
}
trk.trkseg.push(trkseg);
}
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
{
// `push` ignores the chunk if it is empty
trkseg.push(std::mem::take(&mut trkpt_chunk));
trk.trkseg.push(trkseg);
}
}
"trkpt" => {
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop() {
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
trkpt_chunk.trkpt.push(trkpt);
if trkpt_chunk.is_full() {
trkseg.push(trkpt_chunk);
trkpt_chunk = TrackpointChunk::default();
}
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
&& let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
{
trkpt_chunk.trkpt.push(trkpt);
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
@@ -168,8 +162,7 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
wpt_chunk.wpt.push(wpt);
if wpt_chunk.is_full() {
gpx.wpt.push(Rc::new(wpt_chunk));
wpt_chunk = WaypointChunk::default();
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
}
@@ -223,11 +216,8 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
}
Some(GPXElement::Source) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.src = Some(e.to_string());
}
_ => (),
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.src = Some(e.to_string());
}
}
Some(GPXElement::Text) => {
@@ -251,9 +241,9 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
Some(GPXElement::Time) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
if let Ok(time) = DateTime::parse_from_rfc3339(e.as_ref()) {
trkpt.time = Some(time.timestamp_millis());
}
trkpt.time = DateTime::parse_from_rfc3339(e.as_ref())
.ok()
.map(|time| time.timestamp_millis());
}
}
Some(GPXElement::Temperature) => {
@@ -282,11 +272,8 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
}
Some(GPXElement::Symbol) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Waypoint(wpt)) => {
wpt.sym = Some(e.to_string());
}
_ => (),
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
wpt.sym = Some(e.to_string());
}
}
Some(GPXElement::Type) => {
@@ -303,31 +290,20 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
}
Some(GPXElement::Color) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
let mut color = "#".to_string();
color.push_str(&e);
trk.info.color = Some(color);
}
_ => (),
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(format!("#{}", &*e));
}
}
Some(GPXElement::Opacity) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.opacity = e.parse().ok();
}
_ => (),
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.opacity = e.parse().ok();
}
}
Some(GPXElement::Width) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.width = e.parse().ok();
}
_ => (),
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.width = e.parse().ok();
}
}
_ => (),
@@ -343,16 +319,15 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
#[cfg(test)]
mod tests {
use std::{fs::File, io::Read};
use super::*;
fn parse_data(name: &str) -> File {
parse(&std::fs::read(format!("data/{name}.gpx")).unwrap()).unwrap()
}
#[test]
fn test_parse_simple() {
let mut f = File::open("data/simple.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("simple");
assert_eq!(gpx.info.name, "simple");
assert!(gpx.info.desc.is_some_and(|d| d == "description"));
@@ -392,10 +367,7 @@ mod tests {
#[test]
fn test_parse_tracks() {
let mut f = File::open("data/with_tracks.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_tracks");
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
@@ -425,10 +397,7 @@ mod tests {
#[test]
fn test_parse_segments() {
let mut f = File::open("data/with_segments.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_segments");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
@@ -451,10 +420,7 @@ mod tests {
#[test]
fn test_parse_tracks_and_segments() {
let mut f = File::open("data/with_tracks_and_segments.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_tracks_and_segments");
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
@@ -494,10 +460,7 @@ mod tests {
#[test]
fn test_parse_waypoint() {
let mut f = File::open("data/with_waypoint.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_waypoint");
assert_eq!(gpx.wpt.len(), 1);
let chunk = &gpx.wpt[0];
@@ -526,106 +489,85 @@ mod tests {
#[test]
fn test_parse_trackpoint_time() {
let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_time");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(trkseg.len() > 0);
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.time.is_some_and(|t| t == 1704063600000));
}
#[test]
fn test_parse_trackpoint_hr() {
let mut f = File::open("data/with_hr.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_hr");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(trkseg.len() > 0);
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.hr.is_some_and(|h| h == 150));
}
#[test]
fn test_parse_trackpoint_cad() {
let mut f = File::open("data/with_cad.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_cad");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(trkseg.len() > 0);
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.cad.is_some_and(|c| c == 80));
}
#[test]
fn test_parse_trackpoint_power_1() {
let mut f = File::open("data/with_power_1.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_power_1");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(trkseg.len() > 0);
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.power.is_some_and(|p| p == 200));
}
#[test]
fn test_parse_trackpoint_power_2() {
let mut f = File::open("data/with_power_2.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_power_2");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(trkseg.len() > 0);
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.power.is_some_and(|p| p == 200));
}
#[test]
fn test_parse_trackpoint_atemp() {
let mut f = File::open("data/with_temp.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_temp");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(trkseg.len() > 0);
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.atemp.is_some_and(|t| t == 21));
}
#[test]
fn test_parse_track_style() {
let mut f = File::open("data/with_style.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let gpx = parse_data("with_style");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
+11
View File
@@ -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::*;
@@ -1,7 +1,5 @@
mod cache;
mod statistics;
mod utils;
pub use cache::*;
pub use statistics::*;
pub use utils::*;
@@ -1,9 +1,6 @@
use crate::{
algorithm::ramer_douglas_peucker,
for_each_window,
gpx::{LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint},
statistics::sum_options,
utils::{distance, slope, speed, time_diff},
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
};
#[derive(Default, Debug)]
@@ -13,10 +10,6 @@ pub struct Statistics {
}
impl Statistics {
pub fn total_time(&self) -> Option<i32> {
self.global.total_time()
}
pub fn total_speed(&self) -> Option<f64> {
self.global.total_speed()
}
@@ -27,7 +20,7 @@ impl Statistics {
pub fn compute(trkseg: &TrackSegment) -> Self {
let mut stats = Self::default();
if trkseg.len() == 0 {
if trkseg.is_empty() {
return stats;
}
@@ -49,18 +42,18 @@ impl Statistics {
self.update_time_bounds(cur.time);
self.update_bounds(cur.coordinates);
self.local
.push(TrackpointStatistics::from_partial_stats(&self));
.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);
let time = time_diff(cur.time, prev.time);
self.global.total_distance += dist;
if let Some(time) = time {
let speed = speed(dist, time);
if speed >= 0.5 && speed <= 1500.0 {
if (0.5..=1500.0).contains(&speed) {
self.global.moving_distance = self
.global
.moving_distance
@@ -79,6 +72,7 @@ impl Statistics {
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);
}
}
@@ -92,10 +86,10 @@ impl Statistics {
trkseg.first_index(),
trkseg.last_index(),
Some(10000),
|i, j| time_diff(&trkseg[i].time, &trkseg[j].time),
|i, j| time_diff(trkseg[i].time, trkseg[j].time),
|i, left, right| {
self.local[i.flat].speed =
time_diff(&trkseg[right].time, &trkseg[left].time).map(|t| {
time_diff(trkseg[right].time, trkseg[left].time).map(|t| {
speed(
self.local[right.flat].total_distance
- self.local[left.flat].total_distance,
@@ -217,6 +211,7 @@ impl Statistics {
pub struct GlobalStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
pub total_time: Option<i32>,
pub moving_time: Option<i32>,
pub elevation_gain: f64,
pub elevation_loss: f64,
@@ -226,12 +221,8 @@ pub struct GlobalStatistics {
}
impl GlobalStatistics {
pub fn total_time(&self) -> Option<i32> {
time_diff(&self.start_time, &self.end_time)
}
pub fn total_speed(&self) -> Option<f64> {
self.total_time().map(|t| speed(self.total_distance, t))
self.total_time.map(|t| speed(self.total_distance, t))
}
pub fn moving_speed(&self) -> Option<f64> {
@@ -243,9 +234,12 @@ impl GlobalStatistics {
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);
}
}
@@ -268,7 +262,7 @@ impl TrackpointStatistics {
Self {
total_distance: stats.global.total_distance,
moving_distance: stats.global.moving_distance,
total_time: stats.total_time(),
total_time: stats.global.total_time,
moving_time: stats.global.moving_time,
// stats below are computed later
speed: None,
@@ -290,7 +284,7 @@ pub struct SlopeSegment {
mod tests {
use std::{fs::File, io::Read};
use crate::io::parse;
use crate::parse;
use super::*;
@@ -312,5 +306,115 @@ mod tests {
}
}
// TODO more tests
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()).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,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));
}
}
+190
View File
@@ -0,0 +1,190 @@
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<i32> {
Some((a? - b?) as i32)
}
/// Computes the speed for a given distance in kilometers and a time in milliseconds
pub fn speed(distance: f64, time: i32) -> f64 {
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_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) - 1.0).abs() < 1e-12);
// 10 km in 30 min
assert!((speed(10.0, 1_800_000) - 20.0).abs() < 1e-12);
}
#[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);
}
}
+24
View File
@@ -0,0 +1,24 @@
use crate::{Command, FileId};
/// Everything the frontend can ask the engine to do.
///
/// Only [`Action::Edit`] changes the files (and records an undo step); the others act on the
/// history, on the selection or on the file order.
#[derive(Debug)]
pub enum Action<'a> {
Edit(Command<'a>),
Undo,
Redo,
Select {
file_ids: Vec<FileId>,
},
AddSelect {
file_ids: Vec<FileId>,
},
SelectAll,
/// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder {
file_ids: Vec<FileId>,
index: usize,
},
}
+33
View File
@@ -0,0 +1,33 @@
use std::fmt;
use crate::State;
/// The effect of a command on the engine state.
///
/// Each command is a small struct holding its arguments, with its effect implemented in its
/// own file. Effects are built from the reusable patterns in `pattern/`.
pub trait Apply {
fn apply(self, state: &mut State) -> Result<(), CommandError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
/// The command is not implemented yet.
NotImplemented(&'static str),
/// The command would not change anything.
NothingToDo,
/// The input data could not be parsed.
InvalidData(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotImplemented(name) => write!(f, "{name} is not implemented yet"),
Self::NothingToDo => write!(f, "nothing to do"),
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
}
}
}
impl std::error::Error for CommandError {}
@@ -0,0 +1,62 @@
use crate::{Apply, CommandError, State};
use crate::{
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata,
MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Reverse, SpliceTrackpoints, Split,
Style, Time,
};
/// A user action that edits the files. Each variant wraps the command's own struct, whose
/// effect is implemented in its own file (see `Apply`).
///
/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument
/// buffers, so a command is built without copying; the engine only copies what it keeps.
#[derive(Debug)]
pub enum Command<'a> {
New(New<'a>),
Load(Load<'a>),
Duplicate(Duplicate),
Delete(Delete),
DeleteAll(DeleteAll),
Metadata(Metadata<'a>),
Style(Style<'a>),
NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment),
Reverse(Reverse),
SpliceTrackpoints(SpliceTrackpoints<'a>),
NewWaypoint(NewWaypoint<'a>),
MoveWaypoint(MoveWaypoint),
Crop(Crop),
Split(Split),
Time(Time),
Merge(Merge),
Extract(Extract),
Elevation(Elevation<'a>),
Clean(Clean),
}
impl Apply for Command<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match self {
Command::New(c) => c.apply(state),
Command::Load(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state),
Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state),
Command::SpliceTrackpoints(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state),
Command::Crop(c) => c.apply(state),
Command::Split(c) => c.apply(state),
Command::Time(c) => c.apply(state),
Command::Merge(c) => c.apply(state),
Command::Extract(c) => c.apply(state),
Command::Elevation(c) => c.apply(state),
Command::Clean(c) => c.apply(state),
}
}
}
@@ -0,0 +1,86 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
#[derive(Debug)]
pub struct Metadata<'a> {
pub name: &'a str,
pub desc: &'a str,
}
impl Apply for Metadata<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned());
Edit::Changed
}
fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some(self.name.to_owned());
track.info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{New, Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_metadata_applies_to_selected_file() {
let mut fx = Fixture::default();
New { name: "first" }.apply(&mut fx.state()).unwrap();
New { name: "second" }.apply(&mut fx.state()).unwrap(); // now selected
let selected = *fx.selected_files().iter().next().unwrap();
Metadata {
name: "renamed",
desc: "description",
}
.apply(&mut fx.state())
.unwrap();
for (id, file) in fx.files.iter() {
if *id == selected {
assert_eq!(file.info.name, "renamed");
assert_eq!(file.info.desc.as_deref(), Some("description"));
} else {
assert_eq!(file.info.name, "first");
assert_eq!(file.info.desc, None);
}
}
}
#[test]
fn test_metadata_applies_to_selected_track() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap();
crate::Load { data: &data }.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap().clone();
fx.selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([file.trk[0].id]),
};
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&file.id];
assert_eq!(after.info.name, file.info.name);
assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed"));
assert_eq!(after.trk[1].info, file.trk[1].info);
}
}
@@ -0,0 +1,9 @@
mod metadata;
mod new_track;
mod new_track_segment;
mod style;
pub use metadata::*;
pub use new_track::*;
pub use new_track_segment::*;
pub use style::*;
@@ -0,0 +1,65 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
#[derive(Debug)]
pub struct NewTrack;
impl Apply for NewTrack {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit {
let mut track = Track::default();
track.info.color = common(file, |t| &t.info.color);
track.info.opacity = common(file, |t| &t.info.opacity);
track.info.width = common(file, |t| &t.info.width);
file.trk.push(track);
Edit::Changed
}
}
/// Returns the value of a style attribute if all existing tracks agree on it.
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> {
let mut tracks = file.trk.iter();
let first = get(tracks.next()?);
tracks.all(|t| get(t) == first).then(|| first.clone())?
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track() {
let mut fx = Fixture::default();
New { name: "file" }.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.trk.len(), 2);
assert_ne!(file.trk[0].id, file.trk[1].id);
}
#[test]
fn test_new_track_inherits_agreeing_style_only() {
let mut fx = Fixture::default();
New { name: "file" }.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
for (i, trk) in file.trk.iter_mut().enumerate() {
trk.info.color = Some("ff0000".into());
trk.info.opacity = Some(0.5 + i as f64 * 0.1);
}
NewTrack.apply(&mut fx.state()).unwrap();
let info = &fx.files.values().next().unwrap().trk[2].info;
assert_eq!(info.color.as_deref(), Some("ff0000"));
assert_eq!(info.opacity, None);
assert_eq!(info.width, None);
}
}
@@ -0,0 +1,36 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, TrackSegment, update_selected};
#[derive(Debug)]
pub struct NewTrackSegment;
impl Apply for NewTrackSegment {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrackSegment {
fn track(&mut self, track: &mut Track) -> Edit {
track.trkseg.push(TrackSegment::default());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, NewTrack, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track_segment_on_each_track_of_selected_file() {
let mut fx = Fixture::default();
New { name: "file" }.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrackSegment.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
}
}
@@ -0,0 +1,66 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected};
#[derive(Debug)]
pub struct Style<'a> {
pub color: Option<&'a str>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
impl Apply for Style<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for Style<'_> {
fn track(&mut self, track: &mut Track) -> Edit {
if let Some(color) = self.color {
track.info.color = Some(color.to_owned());
}
if self.opacity.is_some() {
track.info.opacity = self.opacity;
}
if self.width.is_some() {
track.info.width = self.width;
}
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_style.gpx").unwrap();
crate::Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = *fx.files.keys().next().unwrap();
let before = fx.files[&id].clone();
assert!(!before.trk.is_empty());
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Style {
color: Some("ff0000"),
opacity: None,
width: Some(7.0),
}
.apply(&mut fx.state())
.unwrap();
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
assert_eq!(a.info.color.as_deref(), Some("ff0000"));
assert_eq!(a.info.width, Some(7.0));
assert_eq!(a.info.opacity, b.info.opacity);
}
}
}
@@ -0,0 +1,222 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks,
};
#[derive(Debug)]
pub struct Delete;
impl Apply for Delete {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection {
Selection::File { file_ids } => {
state.files.retain(|id, _| !file_ids.contains(id));
state.order.0.retain(|id| !file_ids.contains(id));
Selection::Empty
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let len = file.trk.len();
file.trk.retain(|trk| !trk_ids.contains(&trk.id));
if file.trk.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::File {
file_ids: [*file_id].into(),
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !trkseg_ids.contains(&seg.id));
if trk.trkseg.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
}
Selection::Waypoints { file_id } => delete_waypoints(state.files, *file_id, |_| true)?,
Selection::Waypoint { file_id, wpt_ids } => {
delete_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
fn delete_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = edit_waypoint_chunks(&mut file, &filter, |wpts| {
wpts.retain(|wpt| !filter(wpt));
true
});
if !changed {
return Err(CommandError::NothingToDo);
}
files.insert(file_id, Rc::new(file));
Ok(Selection::File {
file_ids: [file_id].into(),
})
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use crate::{FileId, Load};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_delete_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_selected_files() {
let (mut fx, id) = loaded();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let keep = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Delete.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
#[test]
fn test_delete_selected_tracks() {
let (mut fx, id) = loaded();
let before = fx.files[&id].trk.len();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
Delete.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before - 1);
assert!(file.trk.iter().all(|t| t.id != trk_id));
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_selected_segments() {
let (mut fx, id) = loaded();
let trk = &fx.files[&id].trk[0];
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: HashSet::from([seg_id]),
};
Delete.apply(&mut fx.state()).unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before - 1);
assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
assert!(
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&trk_id))
);
}
fn with_waypoints(fx: &mut Fixture, id: FileId, n: usize) -> Vec<crate::WaypointId> {
let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect();
let ids = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: wpts,
..Default::default()
})];
fx.files.insert(id, Rc::new(file));
ids
}
#[test]
fn test_delete_selected_waypoints() {
let (mut fx, id) = loaded();
let ids = with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[1]]),
};
let rev = fx.files[&id].wpt_rev_id;
Delete.apply(&mut fx.state()).unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let left: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
assert_eq!(left, vec![ids[0], ids[2]]);
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_all_waypoints_of_file() {
let (mut fx, id) = loaded();
with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoints { file_id: id };
Delete.apply(&mut fx.state()).unwrap();
assert!(fx.files[&id].wpt.is_empty());
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
Delete.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_unknown_ids_is_nothing_to_do() {
let (mut fx, id) = loaded();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([Default::default()]),
};
assert_eq!(
Delete.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -0,0 +1,38 @@
use crate::{Apply, CommandError, Selection, State};
#[derive(Debug)]
pub struct DeleteAll;
impl Apply for DeleteAll {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
if state.files.is_empty() {
return Err(CommandError::NothingToDo);
}
state.files.clear();
state.order.0.clear();
*state.selection = Selection::Empty;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_delete_all() {
let mut fx = Fixture::default();
assert_eq!(
DeleteAll.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
crate::New { name: "a" }.apply(&mut fx.state()).unwrap();
crate::New { name: "b" }.apply(&mut fx.state()).unwrap();
assert!(DeleteAll.apply(&mut fx.state()).is_ok());
assert!(fx.files.is_empty());
assert!(fx.order.0.is_empty());
assert!(matches!(fx.selection, Selection::Empty));
}
}
@@ -0,0 +1,366 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint,
WaypointChunk,
};
#[derive(Debug)]
pub struct Duplicate;
impl Apply for Duplicate {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection {
Selection::File { file_ids } => {
let mut copies = HashSet::new();
let mut order = Vec::new();
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let Some(file) = state.files.get(id) else {
continue;
};
let copy = copy_file(file);
order.push(copy.id);
copies.insert(copy.id);
state.files.insert(copy.id, Rc::new(copy));
}
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
let at = state
.order
.0
.iter()
.rposition(|id| file_ids.contains(id))
.map_or(state.order.0.len(), |i| i + 1);
state.order.0.splice(at..at, order);
Selection::File { file_ids: copies }
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let mut copies = HashSet::new();
append_copies(
&mut file.trk,
|trk| trk_ids.contains(&trk.id),
|trk| {
let copy = copy_track(trk);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: copies,
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let mut copies = HashSet::new();
append_copies(
&mut trk.trkseg,
|seg| trkseg_ids.contains(&seg.id),
|seg| {
let copy = copy_segment(seg);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: copies,
}
}
Selection::Waypoints { file_id } => {
duplicate_waypoints(state.files, *file_id, |_| true)?
}
Selection::Waypoint { file_id, wpt_ids } => {
duplicate_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
fn duplicate_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let last_chunk = file
.wpt
.iter()
.rposition(|chunk| chunk.wpt.iter().any(&filter))
.ok_or(CommandError::NothingToDo)?;
let mut copies = HashSet::new();
let mut inserted = Vec::new();
let mut chunk = WaypointChunk::default();
for wpt in file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| filter(wpt))
{
let mut copy = wpt.clone();
copy.id = Default::default();
copies.insert(copy.id);
chunk.wpt.push(copy);
if chunk.is_full() {
inserted.push(Rc::new(std::mem::take(&mut chunk)));
}
}
if !chunk.wpt.is_empty() {
inserted.push(Rc::new(chunk));
}
// The copies go right after the last selected waypoint: only the chunk holding it is cut
// (when waypoints follow it), all the other chunks are kept as they are.
let cut = &file.wpt[last_chunk];
let split = cut.wpt.iter().rposition(&filter).unwrap() + 1;
let replacement = if split == cut.wpt.len() {
let mut chunks = vec![cut.clone()];
chunks.extend(inserted);
chunks
} else {
let part = |wpt: &[Waypoint]| {
Rc::new(WaypointChunk {
wpt: wpt.to_vec(),
..Default::default()
})
};
let mut chunks = vec![part(&cut.wpt[..split])];
chunks.extend(inserted);
chunks.push(part(&cut.wpt[split..]));
chunks
};
file.wpt.splice(last_chunk..=last_chunk, replacement);
file.wpt_rev_id = Default::default();
files.insert(file_id, Rc::new(file));
Ok(Selection::Waypoint {
file_id,
wpt_ids: copies,
})
}
/// Inserts a copy of each item matching `filter`, as a block after the last matching item.
fn append_copies<T>(items: &mut Vec<T>, filter: impl Fn(&T) -> bool, copy: impl FnMut(&T) -> T) {
let Some(last) = items.iter().rposition(&filter) else {
return;
};
let copies: Vec<T> = items.iter().filter(|item| filter(item)).map(copy).collect();
items.splice(last + 1..last + 1, copies);
}
// Track points are shared chunks, so copies are cheap.
fn copy_segment(segment: &TrackSegment) -> TrackSegment {
let mut copy = segment.clone();
copy.id = Default::default();
copy
}
fn copy_track(track: &Track) -> Track {
Track {
id: Default::default(),
trkseg: track.trkseg.iter().map(copy_segment).collect(),
..track.clone()
}
}
fn copy_file(file: &File) -> File {
File {
id: Default::default(),
trk: file.trk.iter().map(copy_track).collect(),
..file.clone()
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use crate::{FileId, Load};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_duplicate_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Duplicate.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_duplicate_file() {
let (mut fx, id) = loaded();
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files.len(), 2);
let copy_id = fx.order.0[1];
assert_eq!(fx.selected_files(), [copy_id].into());
let (orig, copy) = (&fx.files[&id], &fx.files[&copy_id]);
assert_ne!(copy_id, id);
assert_eq!(copy.info.name, orig.info.name);
assert_eq!(copy.trk.len(), orig.trk.len());
for (a, b) in orig.trk.iter().zip(&copy.trk) {
assert_ne!(a.id, b.id);
assert_eq!(a.info, b.info);
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_ne!(a.id, b.id);
assert_eq!(a.len(), b.len());
}
}
}
#[test]
fn test_duplicate_files_go_after_last_selected() {
let mut fx = Fixture::default();
for name in ["a", "b", "c"] {
crate::New { name }.apply(&mut fx.state()).unwrap();
}
let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]];
fx.selection = Selection::File {
file_ids: [a, b].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0.len(), 5);
assert_eq!(fx.order.0[..2], [a, b]);
assert_eq!(fx.order.0[4], c);
assert_eq!(
fx.selected_files(),
fx.order.0[2..4].iter().copied().collect()
);
}
#[test]
fn test_duplicate_track_appends_copy() {
let (mut fx, id) = loaded();
let before = fx.files[&id].trk.len();
let (trk_id, second_id) = (fx.files[&id].trk[0].id, fx.files[&id].trk[1].id);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [trk_id].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before + 1);
assert_eq!(file.trk[0].id, trk_id);
assert_ne!(file.trk[1].id, trk_id);
// copies go right after the selected track, not at the end
assert_eq!(file.trk[2].id, second_id);
assert!(
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&file.trk[1].id))
);
}
#[test]
fn test_duplicate_selected_waypoints() {
let (mut fx, id) = loaded();
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: wpts,
..Default::default()
})];
fx.files.insert(id, Rc::new(file));
let original_chunk = fx.files[&id].wpt[0].clone();
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[0]]),
};
let rev = fx.files[&id].wpt_rev_id;
Duplicate.apply(&mut fx.state()).unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let all: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
assert_eq!(all.len(), 4);
assert!(
matches!(&fx.selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids == &HashSet::from([all[1]]))
);
// the chunk is cut after the selected waypoint: [w0] [copy] [w1 w2]
assert_eq!(all[..], [ids[0], all[1], ids[1], ids[2]]);
assert_eq!(fx.files[&id].wpt.len(), 3);
assert_ne!(fx.files[&id].wpt[0].id, original_chunk.id);
// selecting the last waypoint of a chunk does not cut it
let last = fx.files[&id].wpt.last().unwrap().wpt.last().unwrap().id;
let chunks = fx.files[&id].wpt.clone();
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([last]),
};
Duplicate.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.wpt.len(), 4);
assert!(
file.wpt[..3]
.iter()
.zip(&chunks)
.all(|(a, b)| Rc::ptr_eq(a, b))
);
fx.selection = Selection::Waypoints { file_id: id };
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(
fx.files[&id].wpt.iter().map(|c| c.wpt.len()).sum::<usize>(),
10
);
}
#[test]
fn test_duplicate_segment_appends_copy() {
let (mut fx, id) = loaded();
let trk = &fx.files[&id].trk[0];
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: [seg_id].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before + 1);
assert_eq!(trk.trkseg[0].id, seg_id);
assert_ne!(trk.trkseg[1].id, seg_id);
assert_eq!(trk.trkseg[0].len(), trk.trkseg[1].len());
}
}
@@ -0,0 +1,48 @@
use crate::{Apply, CommandError, State, parse, produce};
#[derive(Debug)]
pub struct Load<'a> {
pub data: &'a [u8],
}
impl Apply for Load<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let file = parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?;
produce(state, |_| vec![file]);
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_load() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap();
assert!(Load { data: &data }.apply(&mut fx.state()).is_ok());
assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap();
assert!(!file.trk.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
}
#[test]
fn test_load_invalid_file_changes_nothing() {
let mut fx = Fixture::default();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let selected = fx.selected_files();
let result = Load {
data: b"<gpx><trk></gpx>",
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected);
}
}
@@ -0,0 +1,11 @@
mod delete;
mod delete_all;
mod duplicate;
mod load;
mod new;
pub use delete::*;
pub use delete_all::*;
pub use duplicate::*;
pub use load::*;
pub use new::*;
@@ -0,0 +1,36 @@
use crate::{Apply, CommandError, File, State, produce};
#[derive(Debug)]
pub struct New<'a> {
pub name: &'a str,
}
impl Apply for New<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
produce(state, |_| {
let mut file = File::default();
file.info.name = self.name.to_owned();
vec![file]
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_new() {
let mut fx = Fixture::default();
assert!(New { name: "new" }.apply(&mut fx.state()).is_ok());
assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "new");
assert!(file.trk.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
assert_eq!(fx.order.0, vec![file.id]);
}
}
@@ -0,0 +1,30 @@
//! Shared helpers for the tests of the commands.
use std::collections::HashSet;
use crate::{FileId, FileOrder, Selection, StackEntry, State};
#[derive(Default)]
pub struct Fixture {
pub files: StackEntry,
pub selection: Selection,
pub order: FileOrder,
}
impl Fixture {
pub fn state(&mut self) -> State<'_> {
State {
files: &mut self.files,
selection: &mut self.selection,
order: &mut self.order,
}
}
/// Ids of the selected files, empty unless the selection is a file selection.
pub fn selected_files(&self) -> HashSet<FileId> {
match &self.selection {
Selection::File { file_ids } => file_ids.clone(),
_ => HashSet::new(),
}
}
}
+15
View File
@@ -0,0 +1,15 @@
mod apply;
mod command;
mod edit;
mod file;
#[cfg(test)]
pub(crate) mod fixture;
mod pattern;
mod tools;
pub use apply::*;
pub use command::*;
pub use edit::*;
pub use file::*;
pub use pattern::*;
pub use tools::*;
@@ -0,0 +1,40 @@
use std::rc::Rc;
use crate::{File, Waypoint, WaypointChunk};
/// Edits the waypoints of the chunks containing a waypoint accepted by `filter`.
///
/// `f` receives a copy of the chunk's waypoints and returns whether it changed them. A
/// changed chunk is replaced by a new one (with a new id, so that derived data is recomputed)
/// and chunks left empty are dropped. Returns whether anything changed.
pub fn edit_waypoint_chunks(
file: &mut File,
filter: impl Fn(&Waypoint) -> bool,
mut f: impl FnMut(&mut Vec<Waypoint>) -> bool,
) -> bool {
let mut changed = false;
let mut chunks = Vec::with_capacity(file.wpt.len());
for chunk in file.wpt.drain(..) {
if !chunk.wpt.iter().any(&filter) {
chunks.push(chunk);
continue;
}
let mut wpt = chunk.wpt.clone();
if !f(&mut wpt) {
chunks.push(chunk);
continue;
}
changed = true;
if !wpt.is_empty() {
chunks.push(Rc::new(WaypointChunk {
wpt,
..Default::default()
}));
}
}
file.wpt = chunks;
if changed {
file.wpt_rev_id = Default::default();
}
changed
}
@@ -0,0 +1,7 @@
mod edit_waypoint_chunks;
mod produce;
mod update_selected;
pub use edit_waypoint_chunks::*;
pub use produce::*;
pub use update_selected::*;
@@ -0,0 +1,81 @@
use std::rc::Rc;
use crate::{File, Selection, State};
/// Pure producer: adds the files returned by `f` to the state and selects the first one.
pub fn produce<F>(state: &mut State, f: F)
where
F: FnOnce(&State) -> Vec<File>,
{
*state.selection = Selection::Empty;
for file in f(state) {
let file_id = file.id;
state.files.insert(file_id, Rc::new(file));
state.order.0.push(file_id);
if matches!(*state.selection, Selection::Empty) {
*state.selection = Selection::File {
file_ids: [file_id].into(),
};
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use super::*;
fn named(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_string();
file
}
#[test]
fn test_produce_adds_orders_and_selects_first() {
let mut fx = Fixture::default();
let (a, b) = (named("a"), named("b"));
let (id_a, id_b) = (a.id, b.id);
produce(&mut fx.state(), |_| vec![a, b]);
assert_eq!(fx.files.len(), 2);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_a]));
}
#[test]
fn test_produce_replaces_selection_and_keeps_order() {
let mut fx = Fixture::default();
let a = named("a");
let id_a = a.id;
produce(&mut fx.state(), |_| vec![a]);
let b = named("b");
let id_b = b.id;
produce(&mut fx.state(), |_| vec![b]);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_b]));
}
#[test]
fn test_produce_nothing_clears_selection() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |_| vec![]);
assert!(matches!(fx.selection, Selection::Empty));
assert_eq!(fx.files.len(), 1);
}
#[test]
fn test_produce_sees_current_state() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |state| {
assert_eq!(state.files.len(), 1);
vec![]
});
}
}
@@ -0,0 +1,340 @@
use std::rc::Rc;
use crate::{
File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint, edit_waypoint_chunks,
};
/// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Edit {
Unchanged,
Changed,
}
impl Edit {
fn merge(self, other: Edit) -> Edit {
if self == Edit::Changed || other == Edit::Changed {
Edit::Changed
} else {
Edit::Unchanged
}
}
}
/// Independent edit of each selected element, without needing the rest of the state.
///
/// A selected file, track or segment is passed to the hook of its own level. By default a
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment).
/// Waypoints are only edited when selected, never through their file.
pub trait Editor {
fn file(&mut self, file: &mut File) -> Edit {
edit_each(&mut file.trk, |trk| self.track(trk))
}
fn track(&mut self, track: &mut Track) -> Edit {
edit_each(&mut track.trkseg, |seg| edit_segment(self, seg))
}
fn segment(&mut self, _segment: &mut TrackSegment) -> Edit {
Edit::Unchanged
}
fn waypoint(&mut self, _waypoint: &mut Waypoint) -> Edit {
Edit::Unchanged
}
}
/// Calls the segment hook and gives the segment a new revision id if it changed, so that
/// derived data (statistics) is recomputed.
fn edit_segment<E: Editor + ?Sized>(editor: &mut E, segment: &mut TrackSegment) -> Edit {
let edit = editor.segment(segment);
if edit == Edit::Changed {
segment.rev_id = Default::default();
}
edit
}
fn edit_each<T>(items: &mut [T], f: impl FnMut(&mut T) -> Edit) -> Edit {
edit_where(items, |_| true, f)
}
fn edit_where<T>(
items: &mut [T],
filter: impl Fn(&T) -> bool,
mut f: impl FnMut(&mut T) -> Edit,
) -> Edit {
items
.iter_mut()
.filter(|item| filter(item))
.fold(Edit::Unchanged, |acc, item| acc.merge(f(item)))
}
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
/// new snapshot only if something changed.
fn update_file(files: &mut StackEntry, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
let Some(current) = files.get(&id) else {
return;
};
let mut file = (**current).clone();
if f(&mut file) == Edit::Changed {
files.insert(id, Rc::new(file));
}
}
fn edit_waypoints<E: Editor + ?Sized>(
file: &mut File,
filter: impl Fn(&Waypoint) -> bool,
editor: &mut E,
) -> Edit {
let changed = edit_waypoint_chunks(file, &filter, |wpts| {
edit_where(wpts, &filter, |wpt| editor.waypoint(wpt)) == Edit::Changed
});
if changed {
Edit::Changed
} else {
Edit::Unchanged
}
}
/// Applies the editor to every selected file, track or segment.
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
match &*state.selection {
Selection::File { file_ids } => {
for id in file_ids {
update_file(state.files, *id, |file| editor.file(file));
}
}
Selection::Track { file_id, trk_ids } => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.trk,
|trk| trk_ids.contains(&trk.id),
|trk| editor.track(trk),
)
});
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.trk,
|trk| trk.id == *trk_id,
|trk| {
edit_where(
&mut trk.trkseg,
|seg| trkseg_ids.contains(&seg.id),
|seg| edit_segment(editor, seg),
)
},
)
});
}
Selection::Waypoints { file_id } => {
update_file(state.files, *file_id, |file| {
edit_waypoints(file, |_| true, editor)
});
}
Selection::Waypoint { file_id, wpt_ids } => {
update_file(state.files, *file_id, |file| {
edit_waypoints(file, |wpt| wpt_ids.contains(&wpt.id), editor)
});
}
Selection::Empty => {}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use super::*;
struct Rename;
impl Editor for Rename {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name.push('!');
Edit::Changed
}
fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some("track".to_string());
Edit::Changed
}
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
Edit::Changed
}
}
struct Nothing;
impl Editor for Nothing {}
fn fixture_with_tracks() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
let file = Rc::new(crate::parse(&data).unwrap());
let id = file.id;
fx.files.insert(id, file);
(fx, id)
}
#[test]
fn test_only_selected_files_are_updated() {
let mut fx = Fixture::default();
let (a, b, c) = (File::default(), File::default(), File::default());
let ids = [a.id, b.id, c.id];
for file in [a, b, c] {
fx.files.insert(file.id, Rc::new(file));
}
let untouched = fx.files[&ids[2]].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([ids[0], ids[1]]),
};
update_selected(&mut fx.state(), &mut Rename);
assert_eq!(fx.files[&ids[0]].info.name, "!");
assert_eq!(fx.files[&ids[1]].info.name, "!");
assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched));
}
#[test]
fn test_unchanged_file_keeps_its_snapshot() {
let (mut fx, id) = fixture_with_tracks();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
let before = fx.files[&id].clone();
update_selected(&mut fx.state(), &mut Nothing);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_track_selection_uses_track_hook() {
let (mut fx, id) = fixture_with_tracks();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
update_selected(&mut fx.state(), &mut Rename);
let file = &fx.files[&id];
assert_eq!(file.info.name, "with_tracks_and_segments");
assert_eq!(file.trk[0].info.name.as_deref(), Some("track"));
assert_ne!(file.trk[1].info.name.as_deref(), Some("track"));
}
#[test]
fn test_segment_selection_bumps_revision_id() {
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
let trk = &before.trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
};
update_selected(&mut fx.state(), &mut Rename);
let after = &fx.files[&id];
assert_ne!(after.trk[0].trkseg[0].rev_id, trk.trkseg[0].rev_id);
assert_eq!(after.trk[0].trkseg[1].rev_id, trk.trkseg[1].rev_id);
assert_eq!(
after.trk[1].trkseg[0].rev_id,
before.trk[1].trkseg[0].rev_id
);
}
#[test]
fn test_file_selection_cascades_to_segments() {
struct SegmentsOnly;
impl Editor for SegmentsOnly {
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
update_selected(&mut fx.state(), &mut SegmentsOnly);
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
for (b, a) in b.trkseg.iter().zip(a.trkseg.iter()) {
assert_ne!(b.rev_id, a.rev_id);
}
}
}
#[test]
fn test_waypoint_selection_edits_only_selected_waypoints() {
struct Name;
impl Editor for Name {
fn waypoint(&mut self, wpt: &mut Waypoint) -> Edit {
wpt.name = Some("x".into());
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: wpts,
..Default::default()
})];
fx.files.insert(id, Rc::new(file));
let chunk_id = fx.files[&id].wpt[0].id;
// A file selection never touches waypoints.
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
update_selected(&mut fx.state(), &mut Name);
assert!(fx.files[&id].wpt[0].wpt.iter().all(|w| w.name.is_none()));
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[1]]),
};
let rev = fx.files[&id].wpt_rev_id;
update_selected(&mut fx.state(), &mut Name);
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let chunk = &fx.files[&id].wpt[0];
assert_ne!(chunk.id, chunk_id);
let named: Vec<_> = chunk.wpt.iter().map(|w| w.name.is_some()).collect();
assert_eq!(named, [false, true, false]);
fx.selection = Selection::Waypoints { file_id: id };
update_selected(&mut fx.state(), &mut Name);
assert!(fx.files[&id].wpt[0].wpt.iter().all(|w| w.name.is_some()));
}
#[test]
fn test_missing_files_and_other_selections_are_ignored() {
let mut fx = Fixture::default();
let file = File::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
let before = fx.files[&id].clone();
for selection in [
Selection::File {
file_ids: HashSet::from([FileId::default()]),
},
Selection::Empty,
] {
fx.selection = selection;
update_selected(&mut fx.state(), &mut Rename);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
}
@@ -0,0 +1,22 @@
use crate::{Apply, CommandError, LngLatBounds, State};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleanType {
Inside,
Outside,
}
#[derive(Debug)]
pub struct Clean {
pub bounds: LngLatBounds,
pub type_: CleanType,
pub trkpt: bool,
pub wpt: bool,
}
impl Apply for Clean {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("clean"))
}
}
@@ -0,0 +1,14 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Crop {
pub start: u32,
pub end: u32,
}
impl Apply for Crop {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("crop"))
}
}
@@ -0,0 +1,13 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Elevation<'a> {
pub ele: &'a [f64],
}
impl Apply for Elevation<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("elevation"))
}
}
@@ -0,0 +1,11 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Extract;
impl Apply for Extract {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("extract"))
}
}
@@ -0,0 +1,19 @@
use crate::{Apply, CommandError, State};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeType {
Connect,
Group,
}
#[derive(Debug)]
pub struct Merge {
pub type_: MergeType,
}
impl Apply for Merge {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("merge"))
}
}
@@ -0,0 +1,23 @@
mod clean;
mod crop;
mod elevation;
mod extract;
mod merge;
mod move_waypoint;
mod new_waypoint;
mod reverse;
mod splice_trackpoints;
mod split;
mod time;
pub use clean::*;
pub use crop::*;
pub use elevation::*;
pub use extract::*;
pub use merge::*;
pub use move_waypoint::*;
pub use new_waypoint::*;
pub use reverse::*;
pub use splice_trackpoints::*;
pub use split::*;
pub use time::*;
@@ -0,0 +1,15 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct MoveWaypoint {
pub lng: f64,
pub lat: f64,
pub ele: f64,
}
impl Apply for MoveWaypoint {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("move waypoint"))
}
}
@@ -0,0 +1,19 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct NewWaypoint<'a> {
pub lng: f64,
pub lat: f64,
pub ele: f64,
pub name: &'a str,
pub desc: &'a str,
pub icon: &'a str,
pub link: &'a str,
}
impl Apply for NewWaypoint<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("new waypoint"))
}
}
@@ -0,0 +1,11 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Reverse;
impl Apply for Reverse {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("reverse"))
}
}
@@ -0,0 +1,305 @@
use std::rc::Rc;
use crate::{Apply, CommandError, File, FileId, LngLat, Selection, State, Trackpoint};
/// Replaces the trackpoints `start..end` of the last track segment of the selection by the
/// given ones (a pure insertion when `start == end`, a pure removal when there are no new
/// points).
///
/// The selection can be made of several files, tracks or track segments: the indices are
/// meant for its very last segment, in file order, then track order, then segment order.
#[derive(Debug)]
pub struct SpliceTrackpoints<'a> {
pub start: u32,
pub end: u32,
pub lng: &'a [f64],
pub lat: &'a [f64],
pub ele: &'a [f64],
}
impl Apply for SpliceTrackpoints<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize);
if start > end || self.lng.len() != self.lat.len() || self.lat.len() != self.ele.len() {
return Err(CommandError::InvalidData(
"invalid trackpoints range".into(),
));
}
let (file_id, trk, seg) = last_segment(state).ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&file_id]).clone();
let segment = &mut file.trk[trk].trkseg[seg];
if end > segment.len() {
return Err(CommandError::InvalidData(
"trackpoints range out of bounds".into(),
));
}
if start == end && self.lng.is_empty() {
return Err(CommandError::NothingToDo);
}
let points = (0..self.lng.len())
.map(|i| Trackpoint {
coordinates: LngLat {
lng: self.lng[i],
lat: self.lat[i],
},
ele: self.ele[i],
..Default::default()
})
.collect();
segment.splice(start, end, points);
segment.rev_id = Default::default();
state.files.insert(file_id, Rc::new(file));
Ok(())
}
}
/// Position (file, track index, segment index) of the last segment of the selection.
fn last_segment(state: &State) -> Option<(FileId, usize, usize)> {
let last_of_track = |file: &File, trk: usize| file.trk[trk].trkseg.len().checked_sub(1);
let last_of_file = |file: &File, filter: &dyn Fn(usize) -> bool| {
(0..file.trk.len())
.rev()
.filter(|&trk| filter(trk))
.find_map(|trk| last_of_track(file, trk).map(|seg| (trk, seg)))
};
match &*state.selection {
Selection::File { file_ids } => state
.order
.0
.iter()
.rev()
.filter(|id| file_ids.contains(id))
.find_map(|id| {
let (trk, seg) = last_of_file(state.files.get(id)?, &|_| true)?;
Some((*id, trk, seg))
}),
Selection::Track { file_id, trk_ids } => {
let file = state.files.get(file_id)?;
let (trk, seg) = last_of_file(file, &|trk| trk_ids.contains(&file.trk[trk].id))?;
Some((*file_id, trk, seg))
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state.files.get(file_id)?;
let trk = file.trk.iter().position(|trk| trk.id == *trk_id)?;
let seg = file.trk[trk]
.trkseg
.iter()
.rposition(|seg| trkseg_ids.contains(&seg.id))?;
Some((*file_id, trk, seg))
}
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
let trk = &fx.files[&id].trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
};
(fx, id)
}
fn splice<'a>(
start: u32,
end: u32,
lng: &'a [f64],
lat: &'a [f64],
ele: &'a [f64],
) -> SpliceTrackpoints<'a> {
SpliceTrackpoints {
start,
end,
lng,
lat,
ele,
}
}
#[test]
fn test_splice_replaces_points_of_selected_segment_only() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let len = before.trk[0].trkseg[0].len();
assert!(len >= 3);
splice(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0])
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]);
assert_eq!(new.len(), len + 1);
assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng);
assert_eq!(new[1].coordinates.lng, 1.0);
assert_eq!(new[3].coordinates.lat, 6.0);
assert_eq!(new[3].ele, 9.0);
assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng);
assert_ne!(new.rev_id, old.rev_id);
assert_eq!(new.id, old.id);
// other segments and tracks keep their revision
for (b, a) in before.trk.iter().zip(&after.trk) {
for (b, a) in b.trkseg.iter().zip(&a.trkseg) {
if a.id != new.id {
assert_eq!(a.rev_id, b.rev_id);
}
}
}
}
#[test]
fn test_splice_append_and_remove() {
let (mut fx, id) = loaded();
let len = fx.files[&id].trk[0].trkseg[0].len() as u32;
splice(len, len, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let seg = &fx.files[&id].trk[0].trkseg[0];
assert_eq!(seg.len(), len as usize + 1);
assert_eq!(seg[len as usize].ele, 3.0);
splice(0, len + 1, &[], &[], &[])
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.files[&id].trk[0].trkseg[0].len(), 0);
}
#[test]
fn test_splice_invalid_arguments_change_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let len = before.trk[0].trkseg[0].len() as u32;
let results = [
splice(2, 1, &[], &[], &[]).apply(&mut fx.state()),
splice(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
splice(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
];
assert!(
results
.iter()
.all(|r| matches!(r, Err(CommandError::InvalidData(_))))
);
assert_eq!(
splice(0, 0, &[], &[], &[]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_splice_needs_a_segment_selection() {
let (mut fx, _) = loaded();
fx.selection = Selection::Empty;
assert_eq!(
splice(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_splice_targets_last_segment_of_the_selection() {
let (mut fx, id) = loaded();
let file = fx.files[&id].clone();
assert!(file.trk.len() >= 2);
let last_trk = file.trk.len() - 1;
let last_seg = file.trk[last_trk].trkseg.len() - 1;
// whole file selected: last segment of its last track
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
for (t, (b, a)) in file.trk.iter().zip(&after.trk).enumerate() {
for (s, (b, a)) in b.trkseg.iter().zip(&a.trkseg).enumerate() {
let target = (t, s) == (last_trk, last_seg);
assert_eq!(a.len(), b.len() + usize::from(target));
assert_eq!(a.rev_id != b.rev_id, target);
}
}
// several tracks selected: last selected one (in file order, whatever the set order)
let ids: Vec<_> = file.trk.iter().map(|t| t.id).collect();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([ids[1], ids[0]]),
};
let len = |fx: &Fixture, t: usize| {
let trk = &fx.files[&id].trk[t];
trk.trkseg.last().unwrap().len()
};
let (l0, l1) = (len(&fx, 0), len(&fx, 1));
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(len(&fx, 0), l0);
assert_eq!(len(&fx, 1), l1 + 1);
// several segments selected
let trk = &fx.files[&id].trk[0];
let seg_ids: Vec<_> = trk.trkseg.iter().map(|s| s.id).collect();
if seg_ids.len() >= 2 {
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: seg_ids.iter().copied().collect(),
};
let lens: Vec<_> = trk.trkseg.iter().map(|s| s.len()).collect();
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let after: Vec<_> = fx.files[&id].trk[0]
.trkseg
.iter()
.map(|s| s.len())
.collect();
let n = lens.len();
assert_eq!(after[..n - 1], lens[..n - 1]);
assert_eq!(after[n - 1], lens[n - 1] + 1);
}
}
#[test]
fn test_splice_on_multiple_files_uses_last_in_file_order() {
let (mut fx, first) = loaded();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let second = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([second, first]),
};
let total = |fx: &Fixture, id: FileId| -> usize {
fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.len())
.sum()
};
let (a, b) = (total(&fx, first), total(&fx, second));
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(total(&fx, first), a);
assert_eq!(total(&fx, second), b + 1);
}
}
@@ -0,0 +1,13 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Split {
pub at: u32,
}
impl Apply for Split {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("split"))
}
}
@@ -0,0 +1,11 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Time;
impl Apply for Time {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("time"))
}
}
@@ -0,0 +1,152 @@
use std::collections::{HashMap, HashSet};
use crate::{
FileId, FileWaypointsRevisionId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId,
};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
/// of its file) changes, so editing a segment does not touch the buffers of the others.
#[derive(Debug, Default)]
pub struct CoordinatesCache {
segments: HashMap<TrackSegmentId, (TrackSegmentRevisionId, Vec<f64>)>,
waypoints: HashMap<FileId, (FileWaypointsRevisionId, Vec<f64>)>,
}
impl CoordinatesCache {
pub fn update(&mut self, files: Option<&Files>) {
let mut segments = HashSet::new();
let mut waypoints = HashSet::new();
for file in files.into_iter().flat_map(|files| files.values()) {
waypoints.insert(file.id);
if self
.waypoints
.get(&file.id)
.is_none_or(|(r, _)| *r != file.wpt_rev_id)
{
let mut coordinates = Vec::new();
for wpt in file.wpt.iter().flat_map(|chunk| &chunk.wpt) {
coordinates.extend([wpt.coordinates.lng, wpt.coordinates.lat]);
}
self.waypoints
.insert(file.id, (file.wpt_rev_id, coordinates));
}
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
segments.insert(seg.id);
if self
.segments
.get(&seg.id)
.is_none_or(|(r, _)| *r != seg.rev_id)
{
let mut coordinates = Vec::with_capacity(seg.len() * 2);
for trkpt in seg.iter() {
coordinates.extend([trkpt.coordinates.lng, trkpt.coordinates.lat]);
}
self.segments.insert(seg.id, (seg.rev_id, coordinates));
}
}
}
self.segments.retain(|id, _| segments.contains(id));
self.waypoints.retain(|id, _| waypoints.contains(id));
}
/// Coordinates of the trackpoints of a segment, empty if it does not exist.
pub fn segment(&self, id: &TrackSegmentId) -> &[f64] {
self.segments.get(id).map_or(&[], |(_, c)| c)
}
/// Coordinates of the waypoints of a file, empty if it does not exist.
pub fn waypoints(&self, id: &FileId) -> &[f64] {
self.waypoints.get(id).map_or(&[], |(_, c)| c)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, LngLat, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_segment_buffers_match_trackpoints() {
let (fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
for seg in fx.files[&id].trk.iter().flat_map(|t| &t.trkseg) {
let buffer = cache.segment(&seg.id);
assert_eq!(buffer.len(), seg.len() * 2);
for (i, p) in seg.iter().enumerate() {
assert_eq!(buffer[2 * i], p.coordinates.lng);
assert_eq!(buffer[2 * i + 1], p.coordinates.lat);
}
}
assert!(cache.segment(&TrackSegmentId::default()).is_empty());
}
#[test]
fn test_only_changed_buffers_are_rebuilt_and_stale_ones_dropped() {
let (mut fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
let ids: Vec<_> = fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.id)
.collect();
assert!(ids.len() >= 2);
let ptr = |c: &CoordinatesCache, i: usize| c.segment(&ids[i]).as_ptr();
let (p0, p1) = (ptr(&cache, 0), ptr(&cache, 1));
let mut file = (*fx.files[&id]).clone();
let seg = &mut file.trk[0].trkseg[0];
seg.splice(0, 0, vec![Default::default()]);
seg.rev_id = Default::default();
let len = seg.len();
fx.files.insert(id, Rc::new(file));
cache.update(Some(&fx.files));
assert_eq!(cache.segment(&ids[0]).len(), len * 2);
assert_ne!(ptr(&cache, 0), p0);
assert_eq!(ptr(&cache, 1), p1);
cache.update(None);
assert!(cache.segment(&ids[0]).is_empty());
assert!(cache.waypoints(&id).is_empty());
}
#[test]
fn test_waypoint_buffer() {
let (mut fx, id) = loaded();
let wpt = |lng, lat| Waypoint {
coordinates: LngLat { lng, lat },
..Default::default()
};
let mut file = (*fx.files[&id]).clone();
file.wpt_rev_id = Default::default();
file.wpt = vec![
Rc::new(WaypointChunk {
wpt: vec![wpt(1.0, 2.0)],
..Default::default()
}),
Rc::new(WaypointChunk {
wpt: vec![wpt(3.0, 4.0)],
..Default::default()
}),
];
fx.files.insert(id, Rc::new(file));
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
assert_eq!(cache.waypoints(&id), [1.0, 2.0, 3.0, 4.0]);
}
}
@@ -0,0 +1,133 @@
use crate::{
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId,
WaypointId,
};
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints,
/// with the ids that reference every element. Coordinates are not part of it, they are read
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
/// a segment).
#[derive(Debug, Clone, PartialEq)]
pub struct FileStructure {
pub id: FileId,
pub name: String,
pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change.
pub wpt_rev_id: FileWaypointsRevisionId,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub name: Option<String>,
pub segments: Vec<SegmentNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentNode {
pub id: TrackSegmentId,
/// Changes whenever the trackpoints of the segment (hence their buffer) change.
pub rev_id: TrackSegmentRevisionId,
pub len: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WaypointNode {
pub id: WaypointId,
pub name: Option<String>,
}
impl FileStructure {
pub fn new(file: &File) -> Self {
Self {
id: file.id,
name: file.info.name.clone(),
tracks: file
.trk
.iter()
.map(|trk| TrackNode {
id: trk.id,
name: trk.info.name.clone(),
segments: trk
.trkseg
.iter()
.map(|seg| SegmentNode {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
})
.collect(),
})
.collect(),
waypoints: file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| WaypointNode {
id: wpt.id,
name: wpt.name.clone(),
})
.collect(),
wpt_rev_id: file.wpt_rev_id,
}
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_structure_of_a_file() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let file = &fx.files[&fx.order.0[0]];
let node = FileStructure::new(file);
assert_eq!((node.id, &node.name), (file.id, &file.info.name));
assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name), (t.id, &t.info.name));
assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
}
}
}
#[test]
fn test_waypoints_in_buffer_order_and_revision() {
let mut file = crate::File::default();
let wpt = |n: &str| Waypoint {
name: Some(n.to_string()),
..Default::default()
};
let before = file.wpt_rev_id;
file.wpt_rev_id = Default::default();
file.wpt = vec![
Rc::new(WaypointChunk {
wpt: vec![wpt("a"), wpt("b")],
..Default::default()
}),
Rc::new(WaypointChunk {
wpt: vec![wpt("c")],
..Default::default()
}),
];
let node = FileStructure::new(&file);
let names: Vec<_> = node
.waypoints
.iter()
.map(|w| w.name.clone().unwrap())
.collect();
assert_eq!(names, ["a", "b", "c"]);
assert_ne!(node.wpt_rev_id, before);
assert_eq!(node.wpt_rev_id, file.wpt_rev_id);
}
}
@@ -0,0 +1,82 @@
use std::collections::HashMap;
use crate::{Diff, FileId, FileStructure, StackEntry as Files};
/// The structure of each file, only recomputed for the files that changed according to the
/// [`Diff`] of the stack.
#[derive(Debug, Default)]
pub struct FileStructureCache {
map: HashMap<FileId, FileStructure>,
}
impl FileStructureCache {
/// Brings the cache in line with `files`, given what changed since the last update.
pub fn update(&mut self, files: Option<&Files>, diff: Option<&Diff>) {
if let Some(diff) = diff {
for id in diff.removed.iter() {
self.map.remove(id);
}
for id in diff.added.iter().chain(&diff.modified) {
if let Some(file) = files.and_then(|files| files.get(id)) {
self.map.insert(*id, FileStructure::new(file));
}
}
}
}
pub fn get(&self, id: &FileId) -> Option<&FileStructure> {
self.map.get(id)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, Load, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_only_files_of_the_diff_are_recomputed() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let mut cache = FileStructureCache::default();
cache.update(
Some(&fx.files),
Some(&Diff {
added: vec![a, b],
..Default::default()
}),
);
let before = cache.get(&b).unwrap() as *const FileStructure;
let mut file = (*fx.files[&a]).clone();
file.info.name = "renamed".into();
fx.files.insert(a, Rc::new(file));
cache.update(
Some(&fx.files),
Some(&Diff {
modified: vec![a],
..Default::default()
}),
);
assert_eq!(cache.get(&a).unwrap().name, "renamed");
assert!(std::ptr::eq(cache.get(&b).unwrap(), before));
fx.files.remove(&b);
cache.update(
Some(&fx.files),
Some(&Diff {
removed: vec![b],
..Default::default()
}),
);
assert!(cache.get(&b).is_none());
assert!(cache.get(&a).is_some());
}
}
+11
View File
@@ -0,0 +1,11 @@
mod coordinates_cache;
mod file_structure;
mod file_structure_cache;
mod statistics_buffer;
mod statistics_cache;
pub use coordinates_cache::*;
pub use file_structure::*;
pub use file_structure_cache::*;
pub use statistics_buffer::*;
pub use statistics_cache::*;
@@ -0,0 +1,149 @@
use crate::{GlobalStatistics, Statistics, sum_options};
#[derive(Debug, Default)]
pub struct StatisticsBuffer {
pub total_distance: Vec<f64>,
pub moving_distance: Vec<f64>,
pub total_time: Vec<i32>,
pub moving_time: Vec<i32>,
pub speed: Vec<f64>,
pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>,
pub slope_segment_slope: Vec<f64>,
pub slope_segment_distance: Vec<f64>,
}
impl StatisticsBuffer {
pub fn update(&mut self, stats: &[&Statistics]) {
self.total_distance.clear();
self.moving_distance.clear();
self.total_time.clear();
self.moving_time.clear();
self.speed.clear();
self.elevation_gain.clear();
self.elevation_loss.clear();
self.slope.clear();
self.slope_segment_slope.clear();
self.slope_segment_distance.clear();
let mut cumul_stats = GlobalStatistics::default();
for stats in stats {
for trkpt_stats in stats.local.iter() {
self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
self.moving_distance.push(
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance)
.unwrap_or_default(),
);
self.total_time.push(
sum_options(cumul_stats.total_time, trkpt_stats.total_time).unwrap_or_default(),
);
self.moving_time.push(
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time)
.unwrap_or_default(),
);
self.speed.push(trkpt_stats.speed.unwrap_or_default());
self.elevation_gain
.push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain);
self.elevation_loss
.push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss);
self.slope.push(trkpt_stats.slope);
self.slope_segment_slope
.push(trkpt_stats.slope_segment.slope);
self.slope_segment_distance
.push(trkpt_stats.slope_segment.distance);
}
cumul_stats.merge(&stats.global);
}
}
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn stats(path: &str) -> Statistics {
let data = std::fs::read(path).unwrap();
let file = parse(&data).unwrap();
Statistics::compute(&file.trk[0].trkseg[0])
}
#[test]
fn test_empty() {
let mut buffer = StatisticsBuffer::default();
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
assert!(buffer.speed.is_empty());
}
#[test]
fn test_single_segment_matches_local_stats() {
let s = stats("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s]);
let n = s.local.len();
for len in [
buffer.total_distance.len(),
buffer.moving_distance.len(),
buffer.total_time.len(),
buffer.moving_time.len(),
buffer.speed.len(),
buffer.elevation_gain.len(),
buffer.elevation_loss.len(),
buffer.slope.len(),
buffer.slope_segment_slope.len(),
buffer.slope_segment_distance.len(),
] {
assert_eq!(len, n);
}
for (i, local) in s.local.iter().enumerate() {
assert_eq!(buffer.total_distance[i], local.total_distance);
assert_eq!(buffer.slope[i], local.slope);
}
}
#[test]
fn test_distances_accumulate_over_segments() {
let s = stats("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s, &s]);
assert_eq!(buffer.total_distance.len(), 2 * n);
// the second segment starts where the first one ended
assert_eq!(buffer.total_distance[n], s.global.total_distance);
assert!((buffer.total_distance[2 * n - 1] - 2.0 * s.global.total_distance).abs() < 1e-9);
assert!(buffer.total_distance.windows(2).all(|w| w[0] <= w[1]));
assert!((buffer.elevation_gain[2 * n - 1] - 2.0 * s.global.elevation_gain).abs() < 1e-9);
}
#[test]
fn test_total_time_accumulates_over_segments() {
let s = stats("data/with_time.gpx");
let n = s.local.len();
let duration = s.global.total_time.unwrap();
assert!(duration > 0);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s, &s]);
assert_eq!(buffer.total_time[n - 1], duration);
assert_eq!(buffer.total_time[n], duration);
assert_eq!(buffer.total_time[2 * n - 1], 2 * duration);
assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1]));
}
#[test]
fn test_update_replaces_previous_content() {
let s = stats("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s]);
buffer.update(&[&s]);
assert_eq!(buffer.total_distance.len(), s.local.len());
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
}
}
@@ -0,0 +1,233 @@
use std::{
collections::{HashMap, HashSet, hash_map::Entry},
rc::Rc,
};
use crate::{File, Selection, StackEntry, Statistics, Track, TrackSegment, TrackSegmentRevisionId};
#[derive(Debug, Default)]
pub struct StatisticsCache {
map: HashMap<TrackSegmentRevisionId, Statistics>,
}
impl StatisticsCache {
pub fn update(&mut self, state: Option<&StackEntry>) {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
for trk in file.trk.iter() {
for trkseg in trk.trkseg.iter() {
match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id);
}
Entry::Vacant(e) => {
e.insert(Statistics::compute(trkseg));
}
}
}
}
}
}
for id in stale.iter() {
self.map.remove(id);
}
}
pub fn get<'a>(
&'a self,
state: Option<&StackEntry>,
selection: &Selection,
) -> Vec<&'a Statistics> {
let mut stats = vec![];
if let Some(state) = state {
match selection {
Selection::Empty => (),
Selection::File { file_ids } => {
for id in file_ids.iter() {
if let Some(file) = state.get(id) {
self.add_file_stats(file, &mut stats);
}
}
}
Selection::Track { file_id, trk_ids } => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if trk_ids.contains(&trk.id) {
self.add_track_stats(trk, &mut stats);
}
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if *trk_id == trk.id {
for trkseg in trk.trkseg.iter() {
if trkseg_ids.contains(&trkseg.id) {
self.add_segment_stats(trkseg, &mut stats);
}
}
break;
}
}
}
}
Selection::Waypoints { file_id } => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut stats);
}
}
Selection::Waypoint {
file_id,
wpt_ids: _,
} => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut stats);
}
}
}
}
stats
}
fn add_file_stats<'a>(&'a self, file: &Rc<File>, stats: &mut Vec<&'a Statistics>) {
for trk in file.trk.iter() {
self.add_track_stats(trk, stats);
}
}
fn add_track_stats<'a>(&'a self, trk: &Track, stats: &mut Vec<&'a Statistics>) {
for trkseg in trk.trkseg.iter() {
self.add_segment_stats(trkseg, stats);
}
}
fn add_segment_stats<'a>(&'a self, trkseg: &TrackSegment, stats: &mut Vec<&'a Statistics>) {
if let Some(s) = self.map.get(&trkseg.rev_id) {
stats.push(s);
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, parse};
use super::*;
fn state(path: &str) -> (StackEntry, Rc<File>) {
let data = std::fs::read(path).unwrap();
let file = Rc::new(parse(&data).unwrap());
let mut state = StackEntry::default();
state.insert(file.id, file.clone());
(state, file)
}
fn file_selection(id: FileId) -> Selection {
Selection::File {
file_ids: HashSet::from([id]),
}
}
#[test]
fn test_update_and_select_file() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let nb_segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
assert_eq!(cache.map.len(), nb_segments);
assert_eq!(
cache.get(Some(&state), &file_selection(file.id)).len(),
nb_segments
);
assert!(cache.get(Some(&state), &Selection::Empty).is_empty());
assert!(cache.get(None, &file_selection(file.id)).is_empty());
// unknown file
assert!(
cache
.get(Some(&state), &file_selection(FileId::default()))
.is_empty()
);
}
#[test]
fn test_select_track_and_segment() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let trk = &file.trk[0];
let selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([trk.id]),
};
assert_eq!(cache.get(Some(&state), &selection).len(), trk.trkseg.len());
let trkseg = &trk.trkseg[1];
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trkseg.id]),
};
let stats = cache.get(Some(&state), &selection);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].local.len(), trkseg.len());
// a segment id under the wrong track selects nothing
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[1].id,
trkseg_ids: HashSet::from([trkseg.id]),
};
assert!(cache.get(Some(&state), &selection).is_empty());
}
#[test]
fn test_unchanged_segments_are_not_recomputed() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
// a new snapshot of the file that shares its segments (same revision ids)
let mut next = StackEntry::default();
let renamed = {
let mut f = (*file).clone();
f.info.name = "other".to_string();
Rc::new(f)
};
next.insert(renamed.id, renamed);
cache.update(Some(&next));
let after: *const Statistics = cache.get(Some(&next), &file_selection(file.id))[0];
assert!(std::ptr::eq(before, after));
}
#[test]
fn test_modified_segment_is_recomputed_and_stale_entries_dropped() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let old_rev = file.trk[0].trkseg[0].rev_id;
assert!(cache.map.contains_key(&old_rev));
let mut f = (*file).clone();
f.trk[0].trkseg[0].rev_id = Default::default();
let new_rev = f.trk[0].trkseg[0].rev_id;
let mut next = StackEntry::default();
next.insert(f.id, Rc::new(f));
cache.update(Some(&next));
assert!(cache.map.contains_key(&new_rev));
assert!(!cache.map.contains_key(&old_rev));
cache.update(None);
assert!(cache.map.is_empty());
}
}
+449
View File
@@ -0,0 +1,449 @@
// TODO license in every file
use crate::{
Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure,
FileStructureCache, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId,
};
#[derive(Debug, Default)]
pub struct Engine {
stack: Stack,
selection: Selection,
order: FileOrder,
statistics_cache: StatisticsCache,
coordinates_cache: CoordinatesCache,
structure_cache: FileStructureCache,
diff: Option<Diff>,
order_changed: bool,
selection_changed: bool,
statistics_buffer: StatisticsBuffer,
}
impl Engine {
pub fn statistics(&self) -> &StatisticsBuffer {
&self.statistics_buffer
}
/// Files in display order.
pub fn order(&self) -> &[FileId] {
&self.order.0
}
/// Name, tracks, segments and waypoints (with their ids) of a file.
pub fn file_structure(&self, id: &FileId) -> Option<&FileStructure> {
self.structure_cache.get(id)
}
/// Which files the last action added, removed or modified.
pub fn last_diff(&self) -> Option<&Diff> {
self.diff.as_ref()
}
/// Whether the last action changed the order of the files.
pub fn order_changed(&self) -> bool {
self.order_changed
}
/// Currently selected elements.
pub fn selection(&self) -> &Selection {
&self.selection
}
/// Whether the last action changed the selection.
pub fn selection_changed(&self) -> bool {
self.selection_changed
}
/// Coordinates (`[lng, lat, ...]`) of the trackpoints of a segment.
pub fn segment_coordinates(&self, id: &TrackSegmentId) -> &[f64] {
self.coordinates_cache.segment(id)
}
/// Coordinates (`[lng, lat, ...]`) of the waypoints of a file.
pub fn waypoint_coordinates(&self, id: &FileId) -> &[f64] {
self.coordinates_cache.waypoints(id)
}
/// Single entry point for every action coming from the frontend. Returns whether anything
/// changed.
pub fn execute(&mut self, action: Action) -> bool {
let selection_before = self.selection.clone();
let order_before = self.order.0.clone();
// the files change iff there is a diff, but selection and order actions have none
self.diff = match action {
Action::Edit(command) => self.edit(command),
Action::Undo => self.stack.undo(),
Action::Redo => self.stack.redo(),
Action::Select { file_ids } => {
self.selection = Selection::File {
file_ids: file_ids.into_iter().collect(),
};
None
}
Action::AddSelect { file_ids } => {
match &mut self.selection {
Selection::File { file_ids: ids } => ids.extend(file_ids),
selection => {
*selection = Selection::File {
file_ids: file_ids.into_iter().collect(),
}
}
}
None
}
Action::Reorder { file_ids, index } => {
self.order.move_files(&file_ids, index);
None
}
Action::SelectAll => {
self.selection = Selection::File {
file_ids: self.order.0.iter().copied().collect(),
};
None
}
};
let changed = self.diff.is_some()
|| self.selection != selection_before
|| self.order.0 != order_before;
if changed {
self.refresh();
}
// after the refresh, which also syncs the order and selection with the files (undo, redo)
self.order_changed = self.order.0 != order_before;
self.selection_changed = self.selection != selection_before;
changed
}
fn edit(&mut self, command: Command) -> Option<Diff> {
self.stack.create_and_push_next(|files| {
let mut state = State {
files,
selection: &mut self.selection,
order: &mut self.order,
};
command.apply(&mut state).map_err(|err| err.to_string())
})
}
/// Brings everything derived from the files back in line with the current stack entry.
fn refresh(&mut self) {
let current = self.stack.current();
match current {
Some(files) => {
// files coming back after an undo/redo are added at the end of the order
self.order.sync(files);
self.selection.retain_existing(files);
}
None => {
self.order.0.clear();
self.selection = Selection::Empty;
}
}
self.statistics_cache.update(current);
self.coordinates_cache.update(current);
self.structure_cache.update(current, self.diff.as_ref());
self.statistics_buffer
.update(&self.statistics_cache.get(current, &self.selection));
}
}
#[cfg(test)]
mod tests {
use crate::{Load, Metadata, New, NewTrack, Style};
use super::*;
fn edit(engine: &mut Engine, command: Command) -> bool {
engine.execute(Action::Edit(command))
}
fn new(engine: &mut Engine, name: &str) -> bool {
edit(engine, Command::New(New { name }))
}
fn load(engine: &mut Engine, path: &str) -> bool {
let data = std::fs::read(path).unwrap();
edit(engine, Command::Load(Load { data: &data }))
}
fn selected(engine: &Engine) -> Vec<FileId> {
match &engine.selection {
Selection::File { file_ids } => file_ids.iter().copied().collect(),
_ => vec![],
}
}
#[test]
fn test_load_file() {
let mut engine = Engine::default();
assert!(load(&mut engine, "data/simple.gpx"));
assert_eq!(engine.statistics_buffer.total_distance.len(), 80);
}
#[test]
fn test_new_file_has_no_statistics() {
let mut engine = Engine::default();
assert!(new(&mut engine, "empty"));
assert_eq!(engine.stack.current().unwrap().len(), 1);
assert_eq!(engine.order.0.len(), 1);
assert!(engine.statistics().total_distance.is_empty());
}
#[test]
fn test_statistics_follow_selection() {
let mut engine = Engine::default();
assert!(load(&mut engine, "data/simple.gpx"));
let n = engine.statistics().total_distance.len();
assert!(n > 0);
// a newly created (empty) file becomes the selection
assert!(new(&mut engine, "empty"));
assert!(engine.statistics().total_distance.is_empty());
assert_eq!(engine.stack.current().unwrap().len(), 2);
// loading a file selects it again
assert!(load(&mut engine, "data/simple.gpx"));
assert_eq!(engine.statistics().total_distance.len(), n);
}
#[test]
fn test_invalid_load_is_rejected() {
let mut engine = Engine::default();
assert!(!edit(
&mut engine,
Command::Load(Load {
data: b"<gpx><trk></gpx>"
})
));
assert!(engine.stack.current().is_none());
assert!(!engine.stack.can_undo());
assert!(engine.order.0.is_empty());
}
#[test]
fn test_not_implemented_command_is_rejected() {
let mut engine = Engine::default();
new(&mut engine, "file");
assert!(!edit(&mut engine, Command::Reverse(crate::Reverse)));
assert!(!engine.stack.can_redo());
assert_eq!(engine.stack.current().unwrap().len(), 1);
}
#[test]
fn test_metadata_renames_selected_file() {
let mut engine = Engine::default();
new(&mut engine, "before");
assert!(edit(
&mut engine,
Command::Metadata(Metadata {
name: "after",
desc: "about",
})
));
let file = engine.stack.current().unwrap().values().next().unwrap();
assert_eq!(file.info.name, "after");
assert_eq!(file.info.desc.as_deref(), Some("about"));
}
#[test]
fn test_metadata_without_selection_does_not_modify_files() {
let mut engine = Engine::default();
new(&mut engine, "file");
engine.selection = Selection::Empty;
edit(
&mut engine,
Command::Metadata(Metadata {
name: "renamed",
desc: "",
}),
);
let file = engine.stack.current().unwrap().values().next().unwrap();
assert_eq!(file.info.name, "file");
}
#[test]
fn test_edit_changes_statistics_when_segments_change() {
let mut engine = Engine::default();
load(&mut engine, "data/simple.gpx");
let before = engine
.statistics_cache
.get(engine.stack.current(), &engine.selection)[0] as *const _;
edit(&mut engine, Command::NewTrack(NewTrack));
edit(
&mut engine,
Command::Style(Style {
color: Some("ff0000"),
opacity: None,
width: None,
}),
);
// styling does not touch the segments: their statistics are reused
let after = engine
.statistics_cache
.get(engine.stack.current(), &engine.selection)[0] as *const _;
assert!(std::ptr::eq(before, after));
}
#[test]
fn test_undo_redo_keep_selection_and_order_consistent() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = selected(&engine)[0];
new(&mut engine, "b");
let b = selected(&engine)[0];
assert_eq!(engine.order.0, vec![a, b]);
assert!(engine.execute(Action::Undo));
assert_eq!(engine.order.0, vec![a]);
assert!(selected(&engine).is_empty());
assert!(engine.execute(Action::Redo));
assert_eq!(engine.order.0, vec![a, b]);
assert!(engine.execute(Action::Undo));
assert!(engine.execute(Action::Undo));
assert!(engine.order.0.is_empty());
assert!(!engine.execute(Action::Undo));
assert!(engine.stack.current().is_none());
}
#[test]
fn test_reorder_is_not_undoable_and_returning_files_go_last() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = engine.order.0[0];
new(&mut engine, "b");
let b = engine.order.0[1];
new(&mut engine, "c");
let c = engine.order.0[2];
assert!(engine.execute(Action::Reorder {
file_ids: vec![c],
index: 0
}));
assert_eq!(engine.order.0, vec![c, a, b]);
assert!(!engine.execute(Action::Reorder {
file_ids: vec![c],
index: 0
}));
// undoing the creation of c does not undo the reorder
assert!(engine.execute(Action::Undo));
assert_eq!(engine.order.0, vec![a, b]);
// c comes back at the end
assert!(engine.execute(Action::Redo));
assert_eq!(engine.order.0, vec![a, b, c]);
}
#[test]
fn test_structures_and_coordinates_follow_actions() {
let mut engine = Engine::default();
assert!(engine.order().is_empty());
load(&mut engine, "data/simple.gpx");
let loaded_id = engine.order()[0];
let diff = engine.last_diff().unwrap();
assert_eq!(diff.added, vec![loaded_id]);
assert!(engine.order_changed());
new(&mut engine, "empty");
let empty_id = engine.order()[1];
// only the new file is reported, the other one is not recomputed
let diff = engine.last_diff().unwrap();
assert_eq!(diff.added, vec![empty_id]);
assert!(diff.modified.is_empty() && diff.removed.is_empty());
assert_eq!(engine.file_structure(&empty_id).unwrap().name, "empty");
let structure = engine.file_structure(&loaded_id).unwrap();
let seg = &structure.tracks[0].segments[0];
let (seg_id, len) = (seg.id, seg.len);
assert_eq!(engine.segment_coordinates(&seg_id).len(), len * 2);
assert!(engine.waypoint_coordinates(&loaded_id).is_empty());
// reordering only changes the order
assert!(engine.execute(Action::Reorder {
file_ids: vec![loaded_id],
index: 1
}));
assert_eq!(engine.order(), [empty_id, loaded_id]);
assert!(engine.last_diff().is_none());
assert!(engine.order_changed());
// an action that changes nothing reports nothing
assert!(!engine.execute(Action::Reorder {
file_ids: vec![loaded_id],
index: 1
}));
assert!(engine.last_diff().is_none());
assert!(!engine.order_changed());
// undo removes the empty file, redoing the load removes the other one too
assert!(engine.execute(Action::Undo));
let diff = engine.last_diff().unwrap();
assert_eq!(diff.removed, vec![empty_id]);
assert!(engine.order_changed());
assert!(engine.file_structure(&empty_id).is_none());
assert!(engine.execute(Action::Undo));
assert!(engine.order().is_empty());
assert!(engine.file_structure(&loaded_id).is_none());
assert!(engine.segment_coordinates(&seg_id).is_empty());
}
#[test]
fn test_selection_changed_flag() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = engine.order()[0];
// a new file is selected by the edit
assert!(engine.selection_changed());
assert!(!engine.execute(Action::Select { file_ids: vec![a] }));
assert!(!engine.selection_changed());
assert!(engine.execute(Action::Select { file_ids: vec![] }));
assert!(engine.selection_changed());
// undoing drops the selected file from the selection
engine.execute(Action::Select { file_ids: vec![a] });
assert!(engine.execute(Action::Undo));
assert!(engine.selection_changed());
assert_eq!(engine.selection(), &Selection::Empty);
}
#[test]
fn test_undo_updates_statistics() {
let mut engine = Engine::default();
load(&mut engine, "data/simple.gpx");
assert!(!engine.statistics().total_distance.is_empty());
engine.execute(Action::Undo);
assert!(engine.statistics().total_distance.is_empty());
engine.execute(Action::Redo);
engine.execute(Action::SelectAll);
assert_eq!(engine.statistics().total_distance.len(), 80);
}
#[test]
fn test_selection_actions() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = selected(&engine)[0];
new(&mut engine, "b");
let b = selected(&engine)[0];
assert_eq!(selected(&engine), vec![b]);
assert!(engine.execute(Action::Select { file_ids: vec![a] }));
assert_eq!(selected(&engine), vec![a]);
assert!(engine.execute(Action::AddSelect { file_ids: vec![b] }));
assert_eq!(selected(&engine).len(), 2);
engine.execute(Action::Select { file_ids: vec![] });
assert!(engine.execute(Action::SelectAll));
assert_eq!(selected(&engine).len(), 2);
}
#[test]
fn test_selection_does_not_create_undo_steps() {
let mut engine = Engine::default();
new(&mut engine, "a");
engine.execute(Action::SelectAll);
engine.execute(Action::Undo);
assert!(engine.stack.current().is_none());
}
}
+11
View File
@@ -0,0 +1,11 @@
mod action;
mod command;
mod derived;
mod engine;
mod state;
pub use action::*;
pub use command::*;
pub use derived::*;
pub use engine::*;
pub use state::*;
+13
View File
@@ -0,0 +1,13 @@
mod order;
mod selection;
mod stack;
pub use order::*;
pub use selection::*;
pub use stack::*;
pub struct State<'a> {
pub files: &'a mut StackEntry,
pub selection: &'a mut Selection,
pub order: &'a mut FileOrder,
}
+97
View File
@@ -0,0 +1,97 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry as Files};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileOrder(pub Vec<FileId>);
impl FileOrder {
/// Moves the given files, as a block and in the given order, so that they start at `index`
/// among the other files (clamped to the end). Unknown ids are ignored. Returns whether the
/// order changed.
pub fn move_files(&mut self, file_ids: &[FileId], index: usize) -> bool {
let mut seen = HashSet::new();
let moved: Vec<FileId> = file_ids
.iter()
.copied()
.filter(|id| self.0.contains(id) && seen.insert(*id))
.collect();
let mut order: Vec<FileId> = self
.0
.iter()
.copied()
.filter(|id| !seen.contains(id))
.collect();
let index = index.min(order.len());
order.splice(index..index, moved);
let changed = order != self.0;
self.0 = order;
changed
}
/// Drops the files that do not exist anymore and appends the new ones at the end.
pub fn sync(&mut self, files: &Files) {
self.0.retain(|id| files.contains_key(id));
let known: HashSet<FileId> = self.0.iter().copied().collect();
self.0
.extend(files.keys().filter(|id| !known.contains(id)).copied());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> [FileId; 3] {
[FileId::default(), FileId::default(), FileId::default()]
}
#[test]
fn test_move_files_as_block_in_given_order() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c, a], 1));
// index counts among the files that are not moved
assert_eq!(order.0, vec![b, c, a]);
}
#[test]
fn test_move_files_start_and_clamped_end() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c], 0));
assert_eq!(order.0, vec![c, a, b]);
assert!(order.move_files(&[c], 100));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_move_files_ignores_unknown_and_duplicate_ids() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[FileId::default(), b, b], 0));
assert_eq!(order.0, vec![b, a, c]);
}
#[test]
fn test_move_files_unchanged() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(!order.move_files(&[], 0));
assert!(!order.move_files(&[a, b], 0));
assert!(!order.move_files(&[FileId::default()], 0));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_sync_drops_missing_and_appends_new_at_the_end() {
let [a, b, c] = ids();
let mut files = Files::default();
for id in [b, c] {
files.insert(id, Default::default());
}
let mut order = FileOrder(vec![a, b]);
order.sync(&files);
assert_eq!(order.0, vec![b, c]);
}
}
@@ -0,0 +1,89 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Selection {
#[default]
Empty,
File {
file_ids: HashSet<FileId>,
},
Track {
file_id: FileId,
trk_ids: HashSet<TrackId>,
},
TrackSegment {
file_id: FileId,
trk_id: TrackId,
trkseg_ids: HashSet<TrackSegmentId>,
},
Waypoints {
file_id: FileId,
},
Waypoint {
file_id: FileId,
wpt_ids: HashSet<WaypointId>,
},
}
impl Selection {
/// Drops the files that do not exist anymore (e.g. after an undo).
pub fn retain_existing(&mut self, files: &StackEntry) {
match self {
Selection::Empty => {}
Selection::File { file_ids } => {
file_ids.retain(|id| files.contains_key(id));
if file_ids.is_empty() {
*self = Selection::Empty;
}
}
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => {
if !files.contains_key(file_id) {
*self = Selection::Empty;
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::File;
use super::*;
#[test]
fn test_retain_existing() {
let file = File::default();
let (kept, gone) = (file.id, FileId::default());
let mut files = StackEntry::default();
files.insert(kept, Rc::new(file));
let mut selection = Selection::File {
file_ids: [kept, gone].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::File { file_ids } if file_ids.len() == 1 && file_ids.contains(&kept))
);
let mut selection = Selection::File {
file_ids: [gone].into(),
};
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
let mut selection = Selection::Waypoints { file_id: kept };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Waypoints { .. }));
let mut selection = Selection::Waypoints { file_id: gone };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
}
}
@@ -3,7 +3,7 @@ use std::{
rc::Rc,
};
use crate::gpx::{File, FileId};
use crate::{File, FileId};
#[derive(Debug, Default)]
pub struct Stack {
@@ -18,11 +18,11 @@ impl Stack {
pub fn create_and_push_next<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut StackEntry) -> bool,
F: FnOnce(&mut StackEntry) -> Result<(), String>,
{
self.record_diff(|stack| {
let mut next = stack.current().map_or_default(|c| c.clone());
if f(&mut next) {
if f(&mut next).is_ok() {
stack.push(next);
}
})
@@ -67,18 +67,12 @@ impl Stack {
}
fn get(&self, index: usize) -> Option<&StackEntry> {
if index < self.entries.len() {
Some(&self.entries[index])
} else {
None
}
self.entries.get(index)
}
fn push(&mut self, entry: StackEntry) {
if let Some(i) = self.index {
if i + 1 < self.entries.len() {
self.entries.truncate(i + 1);
}
self.entries.truncate(i + 1);
}
self.entries.push(entry);
@@ -103,7 +97,7 @@ impl Stack {
for id in prev_ids.intersection(&cur_ids) {
let before = prev.map_or_default(|e| e.get(id));
let after = cur.map_or_default(|e| e.get(id));
if before != after {
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) && before != after {
modified.push(*id);
}
}
@@ -117,7 +111,8 @@ impl Stack {
pub type StackEntry = HashMap<FileId, Rc<File>>;
#[derive(Debug, Default)]
/// Which files were added, removed or modified by a change of the current stack entry.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Diff {
pub added: Vec<FileId>,
pub removed: Vec<FileId>,
@@ -139,7 +134,7 @@ mod tests {
let diff = stack.create_and_push_next(|e| {
let file = Rc::new(File::default());
e.insert(file.id, file);
true
Ok(())
});
assert!(stack.can_undo());
@@ -154,5 +149,104 @@ mod tests {
assert!(diff.modified.is_empty());
}
// TODO more tests
fn add_file(stack: &mut Stack) -> FileId {
let file = Rc::new(File::default());
let id = file.id;
stack
.create_and_push_next(|e| {
e.insert(id, file);
Ok(())
})
.unwrap();
id
}
#[test]
fn test_failed_command_pushes_nothing() {
let mut stack = Stack::default();
let diff = stack.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default()));
Err("nope".to_string())
});
assert!(diff.is_none());
assert!(!stack.can_undo());
assert!(stack.current().is_none());
}
#[test]
fn test_undo_redo() {
let mut stack = Stack::default();
assert!(stack.undo().is_none());
assert!(stack.redo().is_none());
let id = add_file(&mut stack);
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty());
assert!(stack.current().is_none());
assert!(!stack.can_undo());
assert!(stack.can_redo());
assert!(stack.undo().is_none());
let diff = stack.redo().unwrap();
assert_eq!(diff.added, vec![id]);
assert!(stack.current().unwrap().contains_key(&id));
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
}
#[test]
fn test_modified_and_removed() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
let diff = stack
.create_and_push_next(|e| {
let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty());
// an unchanged file is not reported as modified
let diff = stack.create_and_push_next(|_| Ok(())).unwrap();
assert!(diff.modified.is_empty());
let diff = stack
.create_and_push_next(|e| {
e.remove(&id);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(stack.current().unwrap().is_empty());
// going back restores the renamed file
stack.undo();
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_push_after_undo_drops_redo_branch() {
let mut stack = Stack::default();
let first = add_file(&mut stack);
let second = add_file(&mut stack);
stack.undo();
assert!(stack.can_redo());
assert!(!stack.current().unwrap().contains_key(&second));
let third = add_file(&mut stack);
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
let current = stack.current().unwrap();
assert!(current.contains_key(&first));
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
}
+5
View File
@@ -0,0 +1,5 @@
mod core;
mod engine;
pub use core::*;
pub use engine::*;
-5
View File
@@ -1,5 +0,0 @@
use crate::state::StackEntry;
pub trait Action {
fn apply(entry: &mut StackEntry) -> Result<(), &'static str>;
}
-1
View File
@@ -1 +0,0 @@
use crate::action::Action;
-5
View File
@@ -1,5 +0,0 @@
mod action;
mod file;
pub use action::*;
pub use file::*;
-69
View File
@@ -1,69 +0,0 @@
use crate::{
gpx::{TrackSegment, TrackSegmentIndex},
utils::crossarc,
};
pub fn ramer_douglas_peucker<F>(
trkseg: &TrackSegment,
mapping: &F,
epsilon: f64,
) -> Vec<TrackSegmentIndex>
where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
match trkseg.len() {
0 => vec![],
1 => vec![trkseg.first_index().unwrap()],
2 => vec![trkseg.last_index().unwrap()],
_ => {
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
let mut indices = vec![first];
ramer_douglas_peucker_helper(trkseg, first, last, mapping, epsilon, &mut indices);
indices.push(last);
indices
}
}
}
fn ramer_douglas_peucker_helper<F>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
mapping: &F,
epsilon: f64,
indices: &mut Vec<TrackSegmentIndex>,
) where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
let mut max_idx = None;
let mut max_dist = 0.0;
let start_pt = mapping(start);
let end_pt = mapping(end);
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let pt = mapping(idx);
let dist = crossarc(start_pt.0, start_pt.1, end_pt.0, end_pt.1, pt.0, pt.1);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx {
if max_dist > epsilon {
ramer_douglas_peucker_helper(trkseg, start, idx, mapping, epsilon, indices);
indices.push(idx);
ramer_douglas_peucker_helper(trkseg, idx, end, mapping, epsilon, indices);
}
}
}
-54
View File
@@ -1,54 +0,0 @@
#[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);
}
}};
}
-4
View File
@@ -1,4 +0,0 @@
pub struct CoordinatesBuffer {
pub lng: Vec<f64>,
pub lat: Vec<f64>,
}
-5
View File
@@ -1,5 +0,0 @@
mod coordinates;
mod statistics;
pub use coordinates::*;
pub use statistics::*;
-61
View File
@@ -1,61 +0,0 @@
use crate::statistics::{GlobalStatistics, Statistics, sum_options};
#[derive(Debug, Default)]
pub struct StatisticsBuffer {
pub total_distance: Vec<f64>,
pub moving_distance: Vec<f64>,
pub total_time: Vec<i32>,
pub moving_time: Vec<i32>,
pub speed: Vec<f64>,
pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>,
pub slope_segment_slope: Vec<f64>,
pub slope_segment_distance: Vec<f64>,
}
impl StatisticsBuffer {
pub fn update(&mut self, stats: &[&Statistics]) {
self.total_distance.clear();
self.moving_distance.clear();
self.total_time.clear();
self.moving_time.clear();
self.speed.clear();
self.elevation_gain.clear();
self.elevation_loss.clear();
self.slope.clear();
self.slope_segment_slope.clear();
self.slope_segment_distance.clear();
let mut cumul_stats = GlobalStatistics::default();
for stats in stats {
for trkpt_stats in stats.local.iter() {
self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
self.moving_distance.push(
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance)
.unwrap_or_default(),
);
self.total_time.push(
sum_options(cumul_stats.total_time(), trkpt_stats.total_time)
.unwrap_or_default(),
);
self.moving_time.push(
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time)
.unwrap_or_default(),
);
self.speed.push(trkpt_stats.speed.unwrap_or_default());
self.elevation_gain
.push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain);
self.elevation_loss
.push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss);
self.slope.push(trkpt_stats.slope);
self.slope_segment_slope
.push(trkpt_stats.slope_segment.slope);
self.slope_segment_distance
.push(trkpt_stats.slope_segment.distance);
}
cumul_stats.merge(&stats.global);
}
}
}
-152
View File
@@ -1,152 +0,0 @@
// TODO license in every file
use std::rc::Rc;
use js_sys::{Float64Array, Function, Int32Array};
use uuid::Uuid;
use wasm_bindgen::prelude::*;
use crate::{
buffer::StatisticsBuffer,
gpx::{File, FileId},
io::parse,
selection::Selection,
state::{Stack, StackEntry},
statistics::StatisticsCache,
};
extern crate console_error_panic_hook;
#[derive(Debug, Default)]
#[wasm_bindgen]
pub struct Engine {
stack: Stack,
selection: Selection,
statistics_cache: StatisticsCache,
statistics_buffer: StatisticsBuffer,
callback: Option<Function>,
}
#[wasm_bindgen]
impl Engine {
#[wasm_bindgen(constructor)]
pub fn new(callback: Function) -> Self {
console_error_panic_hook::set_once();
Self {
stack: Default::default(),
selection: Default::default(),
statistics_cache: Default::default(),
statistics_buffer: Default::default(),
callback: Some(callback),
}
}
pub fn total_distance(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.total_distance) }
}
pub fn moving_distance(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.moving_distance) }
}
pub fn total_time(&self) -> Int32Array {
unsafe { Int32Array::view(&self.statistics_buffer.total_time) }
}
pub fn moving_time(&self) -> Int32Array {
unsafe { Int32Array::view(&self.statistics_buffer.moving_time) }
}
pub fn speed(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.speed) }
}
pub fn elevation_gain(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.elevation_gain) }
}
pub fn elevation_loss(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.elevation_loss) }
}
pub fn slope(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.slope) }
}
pub fn slope_segment_slope(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.slope_segment_slope) }
}
pub fn slope_segment_distance(&self) -> Float64Array {
unsafe { Float64Array::view(&self.statistics_buffer.slope_segment_distance) }
}
pub fn create_file(&mut self, name: &str) {
self.event_loop(|entry| {
let mut file = File::default();
file.info.name = name.to_string();
entry.insert(file.id, Rc::new(file));
true
});
}
pub fn load_file(&mut self, data: &[u8]) {
self.event_loop(|entry| {
if let Ok(file) = parse(data) {
entry.insert(file.id, Rc::new(file));
true
} else {
false
}
});
}
pub fn delete_file(&mut self, id: &[u8]) {
self.event_loop(|entry| {
if let Ok(id) = Uuid::from_slice(id) {
let id = FileId(id);
entry.remove(&id).is_some()
} else {
false
}
});
}
fn event_loop<F>(&mut self, f: F)
where
F: FnOnce(&mut StackEntry) -> bool,
{
if let Some(diff) = self.stack.create_and_push_next(f) {
self.selection.select(diff.added[0]);
self.statistics_cache.update(self.stack.current());
self.statistics_buffer.update(
&self
.statistics_cache
.get(self.stack.current(), &self.selection),
);
if let Some(c) = &self.callback {
let _ = c.call0(&JsValue::NULL);
}
}
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::Read};
use super::*;
#[test]
fn test_load_file() {
let mut engine = Engine::default();
let mut f = File::open("data/simple.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
engine.load_file(data.as_bytes());
assert_eq!(engine.statistics_buffer.total_distance.len(), 80);
}
}
-61
View File
@@ -1,61 +0,0 @@
use uuid::Uuid;
use crate::gpx::{Trackpoint, Waypoint};
static MAX_TRKPT_CHUNK_SIZE: usize = 4096;
#[derive(Debug, PartialEq, Eq)]
pub struct TrackpointChunkId(Uuid);
impl Default for TrackpointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default)]
pub struct TrackpointChunk {
pub id: TrackpointChunkId,
pub trkpt: Vec<Trackpoint>,
}
impl PartialEq for TrackpointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl TrackpointChunk {
pub fn is_full(&self) -> bool {
self.trkpt.len() == MAX_TRKPT_CHUNK_SIZE
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct WaypointChunkId(Uuid);
impl Default for WaypointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
static MAX_WPT_CHUNK_SIZE: usize = 128;
#[derive(Debug, Default)]
pub struct WaypointChunk {
pub id: WaypointChunkId,
pub wpt: Vec<Waypoint>,
}
impl PartialEq for WaypointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl WaypointChunk {
pub fn is_full(&self) -> bool {
self.wpt.len() == MAX_WPT_CHUNK_SIZE
}
}
-48
View File
@@ -1,48 +0,0 @@
#[derive(Debug, Default, 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)]
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.min(coordinates.lng);
self.ne.lat = self.ne.lat.min(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.min(other.ne.lng);
self.ne.lat = self.ne.lat.min(other.ne.lat);
}
}
-273
View File
@@ -1,273 +0,0 @@
use std::{ops::Index, rc::Rc};
use uuid::Uuid;
use crate::gpx::{Trackpoint, TrackpointChunk};
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct TrackSegmentId(Uuid);
impl Default for TrackSegmentId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackSegmentRevisionId(Uuid);
impl Default for TrackSegmentRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, PartialEq)]
pub struct TrackSegment {
pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId,
chunks: Vec<Rc<TrackpointChunk>>,
cumul_length: Vec<usize>,
}
impl TrackSegment {
pub fn push(&mut self, chunk: TrackpointChunk) {
if chunk.trkpt.is_empty() {
return;
}
self.cumul_length
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.trkpt.len());
self.chunks.push(Rc::new(chunk));
}
pub fn len(&self) -> usize {
self.cumul_length.last().copied().unwrap_or_default()
}
pub fn iter(&self) -> TrackSegmentIterator<'_> {
TrackSegmentIterator::new(self)
}
pub fn first_index(&self) -> Option<TrackSegmentIndex> {
self.next_index(None)
}
pub fn last_index(&self) -> Option<TrackSegmentIndex> {
self.prev_index(None)
}
pub fn next_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut next = cur.map_or_default(|idx| TrackSegmentIndex {
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].trkpt.len() {
next.chunk += 1;
next.pos = 0;
} else {
return Some(next);
}
}
}
pub fn prev_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut prev = cur.unwrap_or(TrackSegmentIndex {
chunk: self.chunks.len(),
pos: 0,
flat: self.cumul_length.last().copied().unwrap_or_default(),
});
loop {
if prev.pos == 0 {
if prev.chunk == 0 {
return None;
}
prev.chunk -= 1;
prev.pos = self.chunks[prev.chunk].trkpt.len();
prev.flat -= 1;
} else {
prev.pos -= 1;
prev.flat -= 1;
return Some(prev);
}
}
}
fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> {
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].trkpt.len() {
None
} else {
Some(TrackSegmentIndex {
chunk,
pos,
flat: idx,
})
}
}
}
impl Index<TrackSegmentIndex> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: TrackSegmentIndex) -> &Self::Output {
&self.chunks[idx.chunk].trkpt[idx.pos]
}
}
impl Index<usize> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: usize) -> &Self::Output {
&self[self.locate(idx).unwrap()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct TrackSegmentIndex {
pub chunk: usize,
pub pos: usize,
pub flat: usize,
}
#[derive(Debug, Clone)]
pub struct TrackSegmentIterator<'a> {
trkseg: &'a TrackSegment,
idx: Option<TrackSegmentIndex>,
}
impl<'a> TrackSegmentIterator<'a> {
pub fn new(trkseg: &'a TrackSegment) -> Self {
Self {
trkseg,
idx: Default::default(),
}
}
}
impl<'a> Iterator for TrackSegmentIterator<'a> {
type Item = &'a Trackpoint;
fn next(&mut self) -> Option<Self::Item> {
self.idx = self.trkseg.next_index(self.idx);
self.idx.map(|idx| &self.trkseg[idx])
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let idx = self.idx.map_or_default(|idx| idx.flat) + n;
self.idx = self.trkseg.locate(idx);
self.idx.map(|idx| &self.trkseg[idx])
}
}
#[cfg(test)]
mod tests {
use crate::gpx::TrackpointChunk;
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 {
let mut trkpt = Trackpoint::default();
trkpt.ele = count as f64;
chunk.trkpt.push(trkpt);
count += 1;
}
trkseg.push(chunk);
}
trkseg
}
#[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);
trkseg[trkseg.len()].ele;
}
#[test]
#[should_panic]
fn test_index_out_of_bounds_2() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
trkseg[TrackSegmentIndex {
chunk: trkseg.chunks.len(),
pos: 0,
flat: 0,
}]
.ele;
}
#[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));
}
}
}
-12
View File
@@ -1,12 +0,0 @@
mod action;
mod algorithm;
mod buffer;
mod engine;
mod gpx;
mod io;
mod selection;
mod state;
mod statistics;
mod utils;
pub use engine::*;
-51
View File
@@ -1,51 +0,0 @@
use std::collections::HashSet;
use crate::gpx::{FileId, TrackId, TrackSegmentId, WaypointId};
#[derive(Debug)]
pub struct FileSelection {
pub files: HashSet<FileId>,
}
#[derive(Debug)]
pub struct TrackSelection {
pub file: FileId,
pub trk: HashSet<TrackId>,
}
#[derive(Debug)]
pub struct TrackSegmentSelection {
pub file: FileId,
pub trk: TrackId,
pub trkseg: HashSet<TrackSegmentId>,
}
#[derive(Debug)]
pub struct WaypointsSelection {
pub file: FileId,
}
#[derive(Debug)]
pub struct WaypointSelection {
pub file: FileId,
pub wpt: HashSet<WaypointId>,
}
#[derive(Debug, Default)]
pub enum Selection {
#[default]
Empty,
File(FileSelection),
Track(TrackSelection),
TrackSegment(TrackSegmentSelection),
Waypoints(WaypointsSelection),
Waypoint(WaypointSelection),
}
impl Selection {
pub fn select(&mut self, id: FileId) {
let mut files = HashSet::default();
files.insert(id);
*self = Selection::File(FileSelection { files });
}
}
-3
View File
@@ -1,3 +0,0 @@
mod stack;
pub use stack::*;
-113
View File
@@ -1,113 +0,0 @@
use std::{
collections::{HashMap, HashSet, hash_map::Entry},
rc::Rc,
};
use crate::{
gpx::{File, Track, TrackSegment, TrackSegmentRevisionId},
selection::Selection,
state::StackEntry,
statistics::Statistics,
};
#[derive(Debug, Default)]
pub struct StatisticsCache {
map: HashMap<TrackSegmentRevisionId, Statistics>,
}
impl StatisticsCache {
pub fn update(&mut self, state: Option<&StackEntry>) {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
for trk in file.trk.iter() {
for trkseg in trk.trkseg.iter() {
match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id);
}
Entry::Vacant(e) => {
e.insert(Statistics::compute(trkseg));
}
}
}
}
}
}
for id in stale.iter() {
self.map.remove(id);
}
}
pub fn get<'a>(
&'a self,
state: Option<&StackEntry>,
selection: &Selection,
) -> Vec<&'a Statistics> {
let mut stats = vec![];
if let Some(state) = state {
match selection {
Selection::Empty => (),
Selection::File(s) => {
for id in s.files.iter() {
if let Some(file) = state.get(id) {
self.add_file_stats(file, &mut stats);
}
}
}
Selection::Track(s) => {
if let Some(file) = state.get(&s.file) {
for trk in file.trk.iter() {
if s.trk.contains(&trk.id) {
self.add_track_stats(trk, &mut stats);
}
}
}
}
Selection::TrackSegment(s) => {
if let Some(file) = state.get(&s.file) {
for trk in file.trk.iter() {
if s.trk == trk.id {
for trkseg in trk.trkseg.iter() {
if s.trkseg.contains(&trkseg.id) {
self.add_segment_stats(trkseg, &mut stats);
}
}
break;
}
}
}
}
Selection::Waypoints(s) => {
if let Some(file) = state.get(&s.file) {
self.add_file_stats(file, &mut stats);
}
}
Selection::Waypoint(s) => {
if let Some(file) = state.get(&s.file) {
self.add_file_stats(file, &mut stats);
}
}
}
}
stats
}
fn add_file_stats<'a>(&'a self, file: &Rc<File>, stats: &mut Vec<&'a Statistics>) {
for trk in file.trk.iter() {
self.add_track_stats(trk, stats);
}
}
fn add_track_stats<'a>(&'a self, trk: &Track, stats: &mut Vec<&'a Statistics>) {
for trkseg in trk.trkseg.iter() {
self.add_segment_stats(trkseg, stats);
}
}
fn add_segment_stats<'a>(&'a self, trkseg: &TrackSegment, stats: &mut Vec<&'a Statistics>) {
if let Some(s) = self.map.get(&trkseg.rev_id) {
stats.push(s);
}
}
}
-13
View File
@@ -1,13 +0,0 @@
use std::ops::Add;
pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Add<Output = T>,
{
match (a, b) {
(Some(a), Some(b)) => Some(a + b),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
-121
View File
@@ -1,121 +0,0 @@
use std::f64::consts::PI;
use crate::gpx::LngLat;
static TO_RADIANS: f64 = PI / 180.0;
static 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<i32> {
match (a, b) {
(Some(t1), Some(t2)) => Some((t1 - t2) as i32),
_ => None,
}
}
/// Computes the speed for a given distance in kilometers and a time in milliseconds
pub fn speed(distance: f64, time: i32) -> f64 {
distance / (time as f64 / 3600_000.0)
}
pub fn slope(ele: f64, distance: f64) -> f64 {
if distance == 0.0 {
100.0
} else {
0.1 * ele / distance
}
}
static METERS_PER_LATITUDE_DEGREE: f64 = 111320.0;
fn get_meters_per_longitude_degree(latitude: f64) -> f64 {
((latitude * PI) / 180.0).cos() * METERS_PER_LATITUDE_DEGREE
}
// 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).
fn projected(p1: LngLat, p2: LngLat, p3: LngLat) -> LngLat {
// Convert to meters using approximate scaling
let meters_per_longitude_degree = get_meters_per_longitude_degree(p1.lat);
let x1 = p1.lng * meters_per_longitude_degree;
let y1 = p1.lat * METERS_PER_LATITUDE_DEGREE;
let x2 = p2.lng * meters_per_longitude_degree;
let y2 = p2.lat * METERS_PER_LATITUDE_DEGREE;
let x3 = p3.lng * meters_per_longitude_degree;
let y3 = p3.lat * METERS_PER_LATITUDE_DEGREE;
let dx = x2 - x1;
let dy = y2 - y1;
let segment_length_squared = dx * dx + dy * dy;
if segment_length_squared == 0.0 {
// p1 and p2 are the same point
p1
} else {
// Project p3 onto the line defined by p1-p2
let t =
0.0_f64.max(1.0_f64.min(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared));
// Find the closest point on the segment
let proj_x = x1 + t * dx;
let proj_y = y1 + t * dy;
// Convert back to degrees
LngLat {
lng: proj_x / meters_per_longitude_degree,
lat: proj_y / METERS_PER_LATITUDE_DEGREE,
}
}
}
/// 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).
fn crossarc_lnglat(p1: LngLat, p2: LngLat, p3: LngLat) -> f64 {
// Convert to meters using approximate scaling
let meters_per_longitude_degree = get_meters_per_longitude_degree(p1.lat);
crossarc(
p1.lng * meters_per_longitude_degree,
p1.lat * METERS_PER_LATITUDE_DEGREE,
p2.lng * meters_per_longitude_degree,
p2.lat * METERS_PER_LATITUDE_DEGREE,
p3.lng * meters_per_longitude_degree,
p3.lat * METERS_PER_LATITUDE_DEGREE,
)
}
pub fn crossarc(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 {
// p1 and p2 are the same point
((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1)).sqrt()
} else {
// Project p3 onto the line defined by p1 - p2
let t =
0.0_f64.max(1.0_f64.min(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared));
// Find the closest point on the segment
let proj_x = x1 + t * dx;
let proj_y = y1 + t * dy;
// Return distance from p3 to the projected point
((x3 - proj_x) * (x3 - proj_x) + (y3 - proj_y) * (y3 - proj_y)).sqrt()
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "gpx-rs"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1.7"
gpx-engine = { path = "../engine" }
js-sys = "0.3.106"
uuid = { version = "1.26.1", features = ["js"] }
wasm-bindgen = "0.2"
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more