mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
Compare commits
86
Commits
ef9a5247f0
..
wasm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc36de2a48 | ||
|
|
da9abbae5e | ||
|
|
3bb553d6b5 | ||
|
|
83b76bb838 | ||
|
|
42a55e6759 | ||
|
|
b085a1d476 | ||
|
|
840e943626 | ||
|
|
16e2e0f679 | ||
|
|
0f9557ea82 | ||
|
|
67a4c090bd | ||
|
|
606a0e974a | ||
|
|
e8c4eb92f9 | ||
|
|
f0851086c0 | ||
|
|
bec1ce4c9b | ||
|
|
b29c4cdb08 | ||
|
|
63ba8596a7 | ||
|
|
82d2f79da0 | ||
|
|
f642d60f42 | ||
|
|
24b877c4ee | ||
|
|
e07ec97e12 | ||
|
|
290f4d8c44 | ||
|
|
a48608840f | ||
|
|
df07892e76 | ||
|
|
594dd8979a | ||
|
|
29850c1c8f | ||
|
|
adbe4a2693 | ||
|
|
b843a18441 | ||
|
|
da8a9e3d84 | ||
|
|
c6bd5b06e8 | ||
|
|
c04367dd86 | ||
|
|
5e2f506120 | ||
|
|
c536e6247b | ||
|
|
594d714b7f | ||
|
|
543fecadb8 | ||
|
|
017d8521d6 | ||
|
|
78e9d45072 | ||
|
|
7651d859c4 | ||
|
|
ec448b72e2 | ||
|
|
bf946f179a | ||
|
|
9ff9b5475a | ||
|
|
55f1d275b7 | ||
|
|
1d534881f8 | ||
|
|
03cbacfbb4 | ||
|
|
9d0905959b | ||
|
|
b7ca462f58 | ||
|
|
9015da4876 | ||
|
|
f458a1104f | ||
|
|
caba9388af | ||
|
|
763a7406ce | ||
|
|
68c8933c35 | ||
|
|
84b1558b2e | ||
|
|
cd870f0186 | ||
|
|
9ac2a2b69c | ||
|
|
86aba8f4b1 | ||
|
|
74b674e8e8 | ||
|
|
f1904bc12f | ||
|
|
9afba53e5d | ||
|
|
fd76e0fa46 | ||
|
|
438a87f06f | ||
|
|
a994e16f51 | ||
|
|
639e7491eb | ||
|
|
891a08a3cc | ||
|
|
0c07164dec | ||
|
|
f133d55889 | ||
|
|
fbb636cb06 | ||
|
|
bddceb28aa | ||
|
|
bff84fa24c | ||
|
|
64914f9aa0 | ||
|
|
2abf46c747 | ||
|
|
888f41e979 | ||
|
|
17533586ef | ||
|
|
c6ad088947 | ||
|
|
87d6d5f30e | ||
|
|
dbe894b3a3 | ||
|
|
a921d4b9cd | ||
|
|
b7db73cf77 | ||
|
|
5868595a6c | ||
|
|
eb8e09b835 | ||
|
|
41dc853f65 | ||
|
|
27c0a57cc1 | ||
|
|
497ac120ec | ||
|
|
a486016d96 | ||
|
|
cc5f5653ef | ||
|
|
20222c450d | ||
|
|
69049e1345 | ||
|
|
ed6264444f |
No files matched your search
@@ -27,17 +27,16 @@ Any help is greatly appreciated!
|
||||
|
||||
The code is split into two parts:
|
||||
|
||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
||||
- `gpx-rs`: a Rust library, compiled to WebAssembly, for parsing and manipulating GPX files,
|
||||
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
||||
|
||||
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
||||
You will need [Node.js](https://nodejs.org/), [Rust](https://rustup.rs/) and [wasm-pack](https://rustwasm.github.io/wasm-pack/) to build and run these two parts.
|
||||
|
||||
### Building the `gpx` library
|
||||
### Building the `gpx-rs` library
|
||||
|
||||
```bash
|
||||
cd gpx
|
||||
npm install
|
||||
npm run build
|
||||
cd gpx-rs
|
||||
wasm-pack build wasm --target bundler --out-dir ../pkg
|
||||
```
|
||||
|
||||
### Running the website
|
||||
@@ -64,9 +63,6 @@ This project has been made possible thanks to the following open source projects
|
||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||
- Logic:
|
||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
||||
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
||||
- Mapping:
|
||||
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
pkg/
|
||||
Generated
+794
@@ -0,0 +1,794 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-polyfill"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpx-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"postcard",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpx-rs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"gpx-engine",
|
||||
"idb",
|
||||
"js-sys",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.7.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
|
||||
dependencies = [
|
||||
"atomic-polyfill",
|
||||
"hash32",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"spin",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idb"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"embedded-io 0.4.0",
|
||||
"embedded-io 0.6.1",
|
||||
"heapless",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,6 @@
|
||||
[workspace]
|
||||
members = ["engine", "wasm"]
|
||||
resolver = "3"
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
@@ -0,0 +1,14 @@
|
||||
# gpx-rs
|
||||
|
||||
- `engine`: the GPX engine, plain Rust. Tested natively: `cargo test`.
|
||||
- `wasm`: the interface to the web page, compiled to WebAssembly.
|
||||
|
||||
```
|
||||
wasm-pack build wasm --target bundler --out-dir ../pkg
|
||||
```
|
||||
|
||||
The interface is tested in Node (it needs `wasm-pack`, which fetches the test runner the first time):
|
||||
|
||||
```
|
||||
wasm-pack test --node wasm
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "gpx-engine"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.45"
|
||||
postcard = { version = "1.1.3", features = ["use-std"] }
|
||||
quick-xml = "0.42.0"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
uuid = { version = "1.26.1", features = ["serde", "v4"] }
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="gpx.studio" xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xmlns:ext="https://example.com/ext/0.1">
|
||||
<metadata>
|
||||
<name>self closing points</name>
|
||||
<desc>Points without children are self-closing elements.</desc>
|
||||
<extensions>
|
||||
<ext:version>2.0</ext:version>
|
||||
<ext:paths/>
|
||||
</extensions>
|
||||
</metadata>
|
||||
<wpt lat="50.0" lon="4.0"/>
|
||||
<wpt lat="50.1" lon="4.1"><name>with children</name></wpt>
|
||||
<wpt lat="50.2" lon="4.2"/>
|
||||
<trk>
|
||||
<name>track</name>
|
||||
<trkseg>
|
||||
<trkpt lat="50.00" lon="4.00"/>
|
||||
<trkpt lat="50.01" lon="4.01"><ele>12.5</ele></trkpt>
|
||||
<trkpt lat="50.02" lon="4.02"/>
|
||||
</trkseg>
|
||||
<trkseg>
|
||||
<trkpt lat="51.00" lon="5.00"/>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
@@ -1,18 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd
|
||||
http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd
|
||||
http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd
|
||||
http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
|
||||
creator="https://gpx.studio">
|
||||
<metadata>
|
||||
<name>simple</name>
|
||||
<desc>description</desc>
|
||||
<author>
|
||||
<name>gpx.studio</name>
|
||||
<link href="https://gpx.studio"></link>
|
||||
<link href="https://gpx.studio">
|
||||
<text>link text</text>
|
||||
</link>
|
||||
</author>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>simple</name>
|
||||
<name>track name</name>
|
||||
<cmt>track comment</cmt>
|
||||
<desc>track description</desc>
|
||||
<src>track source</src>
|
||||
<link href="https://gpx.studio">
|
||||
<text>track link text</text>
|
||||
</link>
|
||||
<type>Cycling</type>
|
||||
<trkseg>
|
||||
<trkpt lat="50.790867" lon="4.404968">
|
||||
File renamed without changes.
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" version="1.1" creator="https://gpx.studio">
|
||||
<metadata>
|
||||
<name>with_highway</name>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>with_highway</name>
|
||||
<trkseg>
|
||||
<trkpt lat="50.790867" lon="4.404968">
|
||||
<ele>109.0</ele>
|
||||
<extensions>
|
||||
<gpxtpx:TrackPointExtension>
|
||||
<gpxtpx:Extensions>
|
||||
<surface>asphalt</surface>
|
||||
<highway>residential</highway>
|
||||
</gpxtpx:Extensions>
|
||||
</gpxtpx:TrackPointExtension>
|
||||
</extensions>
|
||||
</trkpt>
|
||||
<trkpt lat="50.791000" lon="4.405100">
|
||||
<ele>110.0</ele>
|
||||
<extensions>
|
||||
<gpxtpx:TrackPointExtension>
|
||||
<gpxtpx:Extensions>
|
||||
<surface>asphalt</surface>
|
||||
<highway>residential</highway>
|
||||
</gpxtpx:Extensions>
|
||||
</gpxtpx:TrackPointExtension>
|
||||
</extensions>
|
||||
</trkpt>
|
||||
<trkpt lat="50.791200" lon="4.405300">
|
||||
<ele>111.0</ele>
|
||||
</trkpt>
|
||||
<trkpt lat="50.791400" lon="4.405500">
|
||||
<ele>112.0</ele>
|
||||
<extensions>
|
||||
<gpxtpx:TrackPointExtension>
|
||||
<gpxtpx:Extensions>
|
||||
<surface>gravel</surface>
|
||||
<highway>track</highway>
|
||||
<sac_scale>mountain_hiking</sac_scale>
|
||||
<mtb_scale>1</mtb_scale>
|
||||
</gpxtpx:Extensions>
|
||||
</gpxtpx:TrackPointExtension>
|
||||
</extensions>
|
||||
</trkpt>
|
||||
<trkpt lat="50.791600" lon="4.405700">
|
||||
<ele>113.0</ele>
|
||||
<extensions>
|
||||
<gpxtpx:TrackPointExtension>
|
||||
<gpxtpx:Extensions>
|
||||
<surface>gravel</surface>
|
||||
<sac_scale>hiking</sac_scale>
|
||||
<mtb_scale>1</mtb_scale>
|
||||
</gpxtpx:Extensions>
|
||||
</gpxtpx:TrackPointExtension>
|
||||
</extensions>
|
||||
</trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
File renamed without changes.
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="test">
|
||||
<metadata>
|
||||
<name>with links</name>
|
||||
<author>
|
||||
<name>someone</name>
|
||||
<email id="someone" domain="example.com"/>
|
||||
<link href="https://example.com/author"><text>author</text></link>
|
||||
</author>
|
||||
<link href="https://example.com/file-1"><text>first</text></link>
|
||||
<link href="https://example.com/file-2"/>
|
||||
</metadata>
|
||||
<wpt lat="50" lon="4">
|
||||
<name>summit</name>
|
||||
<link href="https://example.com/wpt-1"><text>one</text></link>
|
||||
<link href="https://example.com/wpt-2"/>
|
||||
<link href="https://example.com/wpt-3"><text>three</text></link>
|
||||
</wpt>
|
||||
<trk>
|
||||
<name>track</name>
|
||||
<link href="https://example.com/trk-1"/>
|
||||
<link href="https://example.com/trk-2"><text>two</text></link>
|
||||
<trkseg>
|
||||
<trkpt lat="50" lon="4"/>
|
||||
<trkpt lat="50.1" lon="4.1"/>
|
||||
</trkseg>
|
||||
</trk>
|
||||
<rte>
|
||||
<name>route</name>
|
||||
<link href="https://example.com/rte-1"/>
|
||||
<link href="https://example.com/rte-2"/>
|
||||
<rtept lat="51" lon="5"/>
|
||||
<rtept lat="51.1" lon="5.1"/>
|
||||
</rte>
|
||||
<wpt lat="51" lon="5">
|
||||
<name>email variants</name>
|
||||
</wpt>
|
||||
</gpx>
|
||||
File renamed without changes.
@@ -1,9 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd
|
||||
http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd
|
||||
http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd
|
||||
http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
||||
xmlns:gpxpx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
|
||||
creator="https://gpx.studio">
|
||||
<metadata>
|
||||
<name>with_power</name>
|
||||
<author>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
|
||||
<rte>
|
||||
<name>with extensions</name>
|
||||
<rtept lat="1" lon="1">
|
||||
<ele>10</ele>
|
||||
<extensions>
|
||||
<gpxx:RoutePointExtension>
|
||||
<gpxx:rpt lat="1.1" lon="1.1"/>
|
||||
<gpxx:rpt lat="1.2" lon="1.2"></gpxx:rpt>
|
||||
</gpxx:RoutePointExtension>
|
||||
</extensions>
|
||||
</rtept>
|
||||
<rtept lat="2" lon="2">
|
||||
<ele>20</ele>
|
||||
<time>2024-01-01T00:00:00Z</time>
|
||||
</rtept>
|
||||
<rtept lat="3" lon="3"/>
|
||||
</rte>
|
||||
<rte>
|
||||
<name>empty</name>
|
||||
</rte>
|
||||
</gpx>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
|
||||
<rte>
|
||||
<name>Lausanne</name>
|
||||
<cmt>comment</cmt>
|
||||
<desc>description</desc>
|
||||
<type>Cycling</type>
|
||||
<rtept lat="46.544628096744418" lon="6.658079791814089">
|
||||
<ele>500</ele>
|
||||
<extensions>
|
||||
<gpxx:RoutePointExtension>
|
||||
<gpxx:rpt lat="46.544483928009868" lon="6.658018603920937"/>
|
||||
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||
</gpxx:RoutePointExtension>
|
||||
</extensions>
|
||||
</rtept>
|
||||
<rtept lat="46.544100000000000" lon="6.659000000000000">
|
||||
<ele>510</ele>
|
||||
<extensions>
|
||||
<gpxx:RoutePointExtension>
|
||||
<gpxx:rpt lat="46.543900000000000" lon="6.659500000000000"/>
|
||||
</gpxx:RoutePointExtension>
|
||||
</extensions>
|
||||
</rtept>
|
||||
<rtept lat="46.543000000000000" lon="6.660000000000000">
|
||||
<ele>505</ele>
|
||||
</rtept>
|
||||
</rte>
|
||||
</gpx>
|
||||
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.
@@ -1,9 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd
|
||||
http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd
|
||||
http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd
|
||||
http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
|
||||
creator="https://gpx.studio">
|
||||
<metadata>
|
||||
<name>with_waypoint</name>
|
||||
<author>
|
||||
@@ -13,10 +18,14 @@
|
||||
</metadata>
|
||||
<wpt lat="50.7836710064975" lon="4.410764082658738">
|
||||
<ele>122.0</ele>
|
||||
<name>Waypoint</name>
|
||||
<cmt>Comment</cmt>
|
||||
<desc>Description</desc>
|
||||
<name>waypoint name</name>
|
||||
<cmt>waypoint comment</cmt>
|
||||
<desc>waypoint description</desc>
|
||||
<link href="https://gpx.studio">
|
||||
<text>waypoint link text</text>
|
||||
</link>
|
||||
<sym>Bike Trail</sym>
|
||||
<type>Bike Trail</type>
|
||||
</wpt>
|
||||
<trk>
|
||||
<name>with_waypoint</name>
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::{
|
||||
TrackSegment, TrackSegmentIndex, core::utils::crossarc_lnglat, ramer_douglas_peucker_by,
|
||||
};
|
||||
|
||||
/// Highest zoom level of the map at which an anchor can appear.
|
||||
pub const MAX_ANCHOR_ZOOM: u8 = 22;
|
||||
|
||||
/// Details smaller than this (in meters) do not get an anchor.
|
||||
const ANCHOR_EPSILON: f64 = 1.0;
|
||||
|
||||
const EARTH_RADIUS_METERS: f64 = 6371008.8;
|
||||
|
||||
/// Lowest map zoom level at which an anchor selected at `distance` meters from the line of its
|
||||
/// neighbours is worth showing: the smaller the detail, the higher the zoom. The ends of a
|
||||
/// segment (no distance) are always shown.
|
||||
pub fn anchor_zoom(lat: f64, distance: Option<f64>) -> u8 {
|
||||
let Some(distance) = distance else {
|
||||
return 0;
|
||||
};
|
||||
let width = EARTH_RADIUS_METERS * (lat * PI / 180.0).cos() / distance;
|
||||
width.log2().round().clamp(0.0, MAX_ANCHOR_ZOOM as f64) as u8
|
||||
}
|
||||
|
||||
/// The anchors of a segment, as `(index, zoom)` sorted by index: the trackpoints kept by the
|
||||
/// Ramer-Douglas-Peucker simplification of its path, the ones for smaller details being shown at
|
||||
/// higher zoom levels only.
|
||||
pub fn compute_anchors(trkseg: &TrackSegment) -> Vec<(usize, u8)> {
|
||||
let measure = |start: TrackSegmentIndex, end: TrackSegmentIndex, idx: TrackSegmentIndex| {
|
||||
crossarc_lnglat(
|
||||
trkseg[start].coordinates,
|
||||
trkseg[end].coordinates,
|
||||
trkseg[idx].coordinates,
|
||||
)
|
||||
};
|
||||
ramer_douglas_peucker_by(trkseg, &measure, ANCHOR_EPSILON)
|
||||
.into_iter()
|
||||
.map(|(idx, distance)| (idx.flat, anchor_zoom(trkseg[idx].coordinates.lat, distance)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{LngLat, Trackpoint, TrackpointChunk};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn segment(points: &[(f64, f64)]) -> TrackSegment {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
for (lng, lat) in points {
|
||||
chunk.trkpt.push(Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: *lng,
|
||||
lat: *lat,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
trkseg.push(chunk);
|
||||
trkseg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zoom_depends_on_the_size_of_the_detail() {
|
||||
assert_eq!(anchor_zoom(45.0, None), 0);
|
||||
// 2^13 meters is close to the radius of the earth divided by 1 km
|
||||
assert_eq!(anchor_zoom(0.0, Some(1_000.0)), 13);
|
||||
assert!(anchor_zoom(0.0, Some(10.0)) > anchor_zoom(0.0, Some(1_000.0)));
|
||||
assert_eq!(anchor_zoom(0.0, Some(1e-9)), MAX_ANCHOR_ZOOM);
|
||||
assert_eq!(anchor_zoom(0.0, Some(1e9)), 0);
|
||||
// the poles do not give NaN or a panic
|
||||
assert_eq!(anchor_zoom(90.0, Some(10.0)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchors_of_a_straight_line_are_its_ends() {
|
||||
let line: Vec<_> = (0..10).map(|i| (i as f64 * 0.001, 0.0)).collect();
|
||||
assert_eq!(compute_anchors(&segment(&line)), vec![(0, 0), (9, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchors_keep_the_corners() {
|
||||
// a big corner and a small bump
|
||||
let points = [
|
||||
(0.0, 0.0),
|
||||
(0.01, 0.0),
|
||||
(0.02, 0.0),
|
||||
(0.02, 0.01),
|
||||
(0.02, 0.02),
|
||||
(0.02, 0.020005),
|
||||
(0.02, 0.03),
|
||||
];
|
||||
let anchors = compute_anchors(&segment(&points));
|
||||
let indices: Vec<_> = anchors.iter().map(|(i, _)| *i).collect();
|
||||
assert_eq!(indices, vec![0, 2, 6]);
|
||||
assert_eq!(anchors[0].1, 0);
|
||||
assert_eq!(anchors[2].1, 0);
|
||||
assert!(anchors[1].1 > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchors_of_short_segments() {
|
||||
assert!(compute_anchors(&segment(&[])).is_empty());
|
||||
assert_eq!(compute_anchors(&segment(&[(1.0, 2.0)])), vec![(0, 0)]);
|
||||
assert_eq!(
|
||||
compute_anchors(&segment(&[(1.0, 2.0), (1.0, 2.1)])),
|
||||
vec![(0, 0), (1, 0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod anchors;
|
||||
mod reverse;
|
||||
mod simplify;
|
||||
mod smooth;
|
||||
mod timestamps;
|
||||
|
||||
pub use anchors::*;
|
||||
pub use reverse::*;
|
||||
pub use simplify::*;
|
||||
pub use timestamps::*;
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Reversing a path: the order of its trackpoints, and of the segments and tracks that hold them.
|
||||
//!
|
||||
//! The timestamps are mirrored, so that the reversed path goes through the same period of time:
|
||||
//! a trackpoint at time `t` of a part that spans `start..end` ends up at `start + end - t`. When
|
||||
//! the part is made of several ones (the segments of a track, the tracks of a file), the gaps
|
||||
//! between them are kept: every part comes after the previous one in the new order, as late as the
|
||||
//! original one came after the part that followed it.
|
||||
|
||||
use crate::{Track, TrackSegment, Trackpoint};
|
||||
|
||||
/// The time of the first trackpoint of a segment.
|
||||
pub fn start_time(segment: &TrackSegment) -> Option<i64> {
|
||||
segment.iter().next().and_then(|trkpt| trkpt.time)
|
||||
}
|
||||
|
||||
/// The time of the last trackpoint of a segment.
|
||||
pub fn end_time(segment: &TrackSegment) -> Option<i64> {
|
||||
segment.last_index().and_then(|idx| segment[idx].time)
|
||||
}
|
||||
|
||||
/// The time of the first trackpoint of a track.
|
||||
pub fn track_start_time(track: &Track) -> Option<i64> {
|
||||
track.trkseg.first().and_then(start_time)
|
||||
}
|
||||
|
||||
/// The time of the last trackpoint of a track.
|
||||
pub fn track_end_time(track: &Track) -> Option<i64> {
|
||||
track.trkseg.last().and_then(end_time)
|
||||
}
|
||||
|
||||
/// Reverses the trackpoints of a segment.
|
||||
///
|
||||
/// `original_next` is the time at which what came after the segment started, and `new_previous`
|
||||
/// the time at which what comes before it now ends: the segment is mirrored to start there. Both
|
||||
/// are those of the segment itself if the segment is reversed on its own, which are the ones
|
||||
/// used when they are missing (`new_previous` is the start of the segment, and `original_next`
|
||||
/// follows from the duration of the segment). Without timestamps, the trackpoints are only put
|
||||
/// in the opposite order.
|
||||
pub fn reverse_segment(
|
||||
segment: &mut TrackSegment,
|
||||
original_next: Option<i64>,
|
||||
new_previous: Option<i64>,
|
||||
) {
|
||||
let (original_start, original_end) = (start_time(segment), end_time(segment));
|
||||
let new_previous = new_previous.or(original_start);
|
||||
let original_next = original_next.or_else(|| {
|
||||
// the segment lasts as long as before
|
||||
Some(new_previous? + original_end? - original_start?)
|
||||
});
|
||||
|
||||
let len = segment.len();
|
||||
let mut points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
if let (Some(next), Some(previous), Some(end)) = (original_next, new_previous, original_end) {
|
||||
let new_start = previous + next - end;
|
||||
for point in points.iter_mut() {
|
||||
point.time = point.time.map(|time| new_start + (end - time));
|
||||
}
|
||||
}
|
||||
points.reverse();
|
||||
segment.splice(0, len, points);
|
||||
segment.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Makes a segment go back where it started: a reversed copy of it is added after its last
|
||||
/// trackpoint, without the first trackpoint of the copy, which is the last one of the segment.
|
||||
///
|
||||
/// The timestamps of the copy go on from the end of the segment: it would start at the time of the
|
||||
/// last trackpoint, and it lasts as long as the segment. Only the end of the segment changes, so
|
||||
/// its other chunks stay shared.
|
||||
pub fn round_trip(segment: &mut TrackSegment) {
|
||||
let len = segment.len();
|
||||
let end = end_time(segment);
|
||||
let mut back = segment.clone();
|
||||
reverse_segment(&mut back, end, end);
|
||||
segment.splice(len, len, back.iter().skip(1).cloned().collect());
|
||||
segment.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Reverses the segments of a track, and their trackpoints: see [`reverse_segment`] for the
|
||||
/// meaning of the times, which are the ones of the track as a whole.
|
||||
pub fn reverse_segments(
|
||||
segments: &mut [TrackSegment],
|
||||
original_next: Option<i64>,
|
||||
new_previous: Option<i64>,
|
||||
) {
|
||||
segments.reverse();
|
||||
let (mut next, mut previous) = (original_next, new_previous);
|
||||
for segment in segments.iter_mut() {
|
||||
// the next one is the one that was before, which started here
|
||||
let original_start = start_time(segment);
|
||||
reverse_segment(segment, next, previous);
|
||||
next = original_start;
|
||||
previous = end_time(segment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverses the tracks of a file, and their segments and trackpoints.
|
||||
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
|
||||
tracks.reverse();
|
||||
let (mut next, mut previous) = (original_next, new_previous);
|
||||
for track in tracks.iter_mut() {
|
||||
let original_start = track_start_time(track);
|
||||
reverse_segments(&mut track.trkseg, next, previous);
|
||||
next = original_start;
|
||||
previous = track_end_time(track);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::LngLat;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn segment(points: &[(f64, Option<i64>)]) -> TrackSegment {
|
||||
let mut segment = TrackSegment::default();
|
||||
let points = points
|
||||
.iter()
|
||||
.map(|(lng, time)| Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: *lng,
|
||||
lat: 0.0,
|
||||
},
|
||||
time: *time,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
segment.splice(0, 0, points);
|
||||
segment
|
||||
}
|
||||
|
||||
fn lngs(segment: &TrackSegment) -> Vec<f64> {
|
||||
segment.iter().map(|p| p.coordinates.lng).collect()
|
||||
}
|
||||
|
||||
fn times(segment: &TrackSegment) -> Vec<Option<i64>> {
|
||||
segment.iter().map(|p| p.time).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_segment_is_mirrored_in_its_own_time_span() {
|
||||
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
|
||||
let (start, end) = (start_time(&s), end_time(&s));
|
||||
reverse_segment(&mut s, end, start);
|
||||
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
|
||||
// same span, the durations between points follow the points
|
||||
assert_eq!(times(&s), [Some(0), Some(30), Some(40)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_segment_defaults() {
|
||||
// without the times around, the segment is mirrored where it is
|
||||
let mut s = segment(&[(0.0, Some(100)), (1.0, Some(130))]);
|
||||
reverse_segment(&mut s, None, None);
|
||||
assert_eq!(times(&s), [Some(100), Some(130)]);
|
||||
assert_eq!(lngs(&s), [1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_segment_without_times_is_only_reversed() {
|
||||
let mut s = segment(&[(0.0, None), (1.0, None), (2.0, None)]);
|
||||
reverse_segment(&mut s, None, None);
|
||||
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
|
||||
assert_eq!(times(&s), [None, None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_goes_back_in_time_from_the_end() {
|
||||
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
|
||||
let first = s.rev_id;
|
||||
round_trip(&mut s);
|
||||
assert_ne!(s.rev_id, first);
|
||||
// the last trackpoint is the turning point, it is not repeated
|
||||
assert_eq!(lngs(&s), [0.0, 1.0, 2.0, 1.0, 0.0]);
|
||||
// the way back lasts as long as the way there, and starts when it ended
|
||||
assert_eq!(times(&s), [Some(0), Some(10), Some(40), Some(70), Some(80)]);
|
||||
// and it is a segment whose ends are anchors
|
||||
assert_eq!(s[0].anchor, Some(0));
|
||||
assert_eq!(s[4].anchor, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_without_times() {
|
||||
let mut s = segment(&[(0.0, None), (1.0, None)]);
|
||||
round_trip(&mut s);
|
||||
assert_eq!(lngs(&s), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(times(&s), [None; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_segments_keep_the_gaps_between_them() {
|
||||
// 0..20, then 100..120: a gap of 80
|
||||
let mut segments = vec![
|
||||
segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(20))]),
|
||||
segment(&[(3.0, Some(100)), (4.0, Some(110)), (5.0, Some(120))]),
|
||||
];
|
||||
let (end, start) = (end_time(&segments[1]), start_time(&segments[0]));
|
||||
reverse_segments(&mut segments, end, start);
|
||||
assert_eq!(lngs(&segments[0]), [5.0, 4.0, 3.0]);
|
||||
assert_eq!(lngs(&segments[1]), [2.0, 1.0, 0.0]);
|
||||
assert_eq!(times(&segments[0]), [Some(0), Some(10), Some(20)]);
|
||||
// the gap is still 80
|
||||
assert_eq!(times(&segments[1]), [Some(100), Some(110), Some(120)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tracks_are_reversed_like_segments() {
|
||||
let track = |segments: Vec<TrackSegment>| Track {
|
||||
trkseg: segments,
|
||||
..Default::default()
|
||||
};
|
||||
let mut tracks = vec![
|
||||
track(vec![
|
||||
segment(&[(0.0, Some(0)), (1.0, Some(10))]),
|
||||
segment(&[(2.0, Some(50)), (3.0, Some(60))]),
|
||||
]),
|
||||
track(vec![segment(&[(4.0, Some(200)), (5.0, Some(230))])]),
|
||||
];
|
||||
let (end, start) = (track_end_time(&tracks[1]), track_start_time(&tracks[0]));
|
||||
reverse_tracks(&mut tracks, end, start);
|
||||
let all: Vec<f64> = tracks
|
||||
.iter()
|
||||
.flat_map(|t| t.trkseg.iter().flat_map(lngs))
|
||||
.collect();
|
||||
assert_eq!(all, [5.0, 4.0, 3.0, 2.0, 1.0, 0.0]);
|
||||
// 3 segments: durations 10, 10, 30 and gaps 40 and 140 are reversed to 30, 10, 10 and
|
||||
// gaps 140 and 40 (the first part of the time is the longest)
|
||||
assert_eq!(times(&tracks[0].trkseg[0]), [Some(0), Some(30)]);
|
||||
assert_eq!(times(&tracks[1].trkseg[0]), [Some(170), Some(180)]);
|
||||
assert_eq!(times(&tracks[1].trkseg[1]), [Some(220), Some(230)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use crate::{
|
||||
core::gpx::{TrackSegment, TrackSegmentIndex},
|
||||
core::utils::{crossarc, crossarc_lnglat},
|
||||
};
|
||||
|
||||
pub fn ramer_douglas_peucker<F>(
|
||||
trkseg: &TrackSegment,
|
||||
mapping: &F,
|
||||
epsilon: f64,
|
||||
) -> Vec<TrackSegmentIndex>
|
||||
where
|
||||
F: Fn(TrackSegmentIndex) -> (f64, f64),
|
||||
{
|
||||
let measure = |start, end, idx| {
|
||||
let (start, end, pt) = (mapping(start), mapping(end), mapping(idx));
|
||||
crossarc(start.0, start.1, end.0, end.1, pt.0, pt.1)
|
||||
};
|
||||
ramer_douglas_peucker_by(trkseg, &measure, epsilon)
|
||||
.into_iter()
|
||||
.map(|(idx, _)| idx)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ramer-Douglas-Peucker with any distance from a point (the last argument) to the line made of
|
||||
/// two others (the first ones). Gives the kept points, with the distance at which each of them
|
||||
/// was selected (`None` for the ends of the segment, which are always kept).
|
||||
pub fn ramer_douglas_peucker_by<M>(
|
||||
trkseg: &TrackSegment,
|
||||
measure: &M,
|
||||
epsilon: f64,
|
||||
) -> Vec<(TrackSegmentIndex, Option<f64>)>
|
||||
where
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
match trkseg.len() {
|
||||
0 => vec![],
|
||||
1 => vec![(trkseg.first_index().unwrap(), None)],
|
||||
2 => vec![
|
||||
(trkseg.first_index().unwrap(), None),
|
||||
(trkseg.last_index().unwrap(), None),
|
||||
],
|
||||
_ => {
|
||||
let first = trkseg.first_index().unwrap();
|
||||
let last = trkseg.last_index().unwrap();
|
||||
|
||||
let mut kept = vec![(first, None)];
|
||||
ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept);
|
||||
kept.push((last, None));
|
||||
kept
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ramer_douglas_peucker_helper<M>(
|
||||
trkseg: &TrackSegment,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
measure: &M,
|
||||
epsilon: f64,
|
||||
kept: &mut Vec<(TrackSegmentIndex, Option<f64>)>,
|
||||
) where
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
let mut max_idx = None;
|
||||
let mut max_dist = 0.0;
|
||||
|
||||
let mut cur = trkseg.next_index(Some(start));
|
||||
while let Some(idx) = cur {
|
||||
if idx == end {
|
||||
break;
|
||||
}
|
||||
|
||||
let dist = measure(start, end, idx);
|
||||
if dist > max_dist {
|
||||
max_idx = Some(idx);
|
||||
max_dist = dist;
|
||||
}
|
||||
|
||||
cur = trkseg.next_index(cur);
|
||||
}
|
||||
|
||||
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
|
||||
ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
|
||||
kept.push((idx, Some(max_dist)));
|
||||
ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`].
|
||||
pub const MIN_REDUCE_TOLERANCE: f64 = 0.1;
|
||||
|
||||
/// The distance (in meters) from a trackpoint to the line between two others of a segment.
|
||||
fn lnglat_measure(
|
||||
trkseg: &TrackSegment,
|
||||
) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ {
|
||||
move |start, end, idx| {
|
||||
crossarc_lnglat(
|
||||
trkseg[start].coordinates,
|
||||
trkseg[end].coordinates,
|
||||
trkseg[idx].coordinates,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance`
|
||||
/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept.
|
||||
pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec<usize> {
|
||||
ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance)
|
||||
.into_iter()
|
||||
.map(|(idx, _)| idx.flat)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by
|
||||
/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that
|
||||
/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its
|
||||
/// value is greater than it.
|
||||
///
|
||||
/// A trackpoint is only selected if the ones that split the path before it are, so its value is
|
||||
/// the smallest distance at which it or one of them was selected.
|
||||
pub fn reduction_distances(trkseg: &TrackSegment) -> Vec<f64> {
|
||||
let mut distances = vec![0.0; trkseg.len()];
|
||||
let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else {
|
||||
return distances;
|
||||
};
|
||||
distances[first.flat] = f64::INFINITY;
|
||||
distances[last.flat] = f64::INFINITY;
|
||||
if trkseg.len() > 2 {
|
||||
let measure = lnglat_measure(trkseg);
|
||||
reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances);
|
||||
}
|
||||
distances
|
||||
}
|
||||
|
||||
fn reduction_distances_helper<M>(
|
||||
trkseg: &TrackSegment,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
measure: &M,
|
||||
bound: f64,
|
||||
distances: &mut [f64],
|
||||
) where
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
let mut max_idx = None;
|
||||
let mut max_dist = 0.0;
|
||||
let mut cur = trkseg.next_index(Some(start));
|
||||
while let Some(idx) = cur {
|
||||
if idx == end {
|
||||
break;
|
||||
}
|
||||
let dist = measure(start, end, idx);
|
||||
if dist > max_dist {
|
||||
max_idx = Some(idx);
|
||||
max_dist = dist;
|
||||
}
|
||||
cur = trkseg.next_index(cur);
|
||||
}
|
||||
|
||||
if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) {
|
||||
let distance = max_dist.min(bound);
|
||||
distances[idx.flat] = distance;
|
||||
reduction_distances_helper(trkseg, start, idx, measure, distance, distances);
|
||||
reduction_distances_helper(trkseg, idx, end, measure, distance, distances);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Trackpoint, TrackpointChunk};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn segment(eles: &[f64]) -> TrackSegment {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
// several small chunks, to also exercise the chunk boundaries
|
||||
for part in eles.chunks(3) {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
for ele in part {
|
||||
chunk.trkpt.push(Trackpoint {
|
||||
ele: *ele,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
trkseg.push(chunk);
|
||||
}
|
||||
trkseg
|
||||
}
|
||||
|
||||
fn simplify(eles: &[f64], epsilon: f64) -> Vec<usize> {
|
||||
let trkseg = segment(eles);
|
||||
ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon)
|
||||
.iter()
|
||||
.map(|idx| idx.flat)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_and_single_point() {
|
||||
assert!(simplify(&[], 1.0).is_empty());
|
||||
assert_eq!(simplify(&[5.0], 1.0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collinear_points_are_removed() {
|
||||
let eles: Vec<f64> = (0..20).map(|i| i as f64 * 2.0).collect();
|
||||
assert_eq!(simplify(&eles, 0.1), vec![0, 19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peak_is_kept_when_above_epsilon() {
|
||||
let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0];
|
||||
assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peak_is_dropped_when_below_epsilon() {
|
||||
let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0];
|
||||
assert_eq!(simplify(&eles, 1.0), vec![0, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_indices_are_sorted_and_keep_extremities() {
|
||||
let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0];
|
||||
let kept = simplify(&eles, 0.5);
|
||||
assert_eq!(kept.first(), Some(&0));
|
||||
assert_eq!(kept.last(), Some(&9));
|
||||
assert!(kept.windows(2).all(|w| w[0] < w[1]));
|
||||
// a larger epsilon never keeps more points
|
||||
assert!(simplify(&eles, 5.0).len() <= kept.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#[macro_export]
|
||||
macro_rules! for_each_window {
|
||||
(
|
||||
$trkseg:expr,
|
||||
$left:expr,
|
||||
$right:expr,
|
||||
$window:expr,
|
||||
|$a:ident, $b:ident| $distance:expr,
|
||||
|$i:ident, $l:ident, $r:ident| $body:block,
|
||||
) => {{
|
||||
let mut window_start = $left.unwrap_or_default();
|
||||
let mut window_center = $left;
|
||||
let mut window_end = $left.unwrap_or_default();
|
||||
while let Some(idx) = window_center {
|
||||
// advance left if needed
|
||||
while window_start != idx && {
|
||||
let $a = window_start;
|
||||
let $b = idx;
|
||||
$distance
|
||||
} > $window {
|
||||
if let Some(next) = $trkseg.next_index(Some(window_start)) {
|
||||
if next == idx {
|
||||
break;
|
||||
}
|
||||
window_start = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// advance right if needed
|
||||
window_end = window_end.max(idx);
|
||||
while let Some(next) = $trkseg.next_index(Some(window_end)) {
|
||||
if window_end != idx && {
|
||||
let $a = idx;
|
||||
let $b = next;
|
||||
$distance
|
||||
} > $window {
|
||||
break;
|
||||
}
|
||||
window_end = next;
|
||||
}
|
||||
// apply body
|
||||
let $i = idx;
|
||||
let $l = window_start;
|
||||
let $r = window_end;
|
||||
$body
|
||||
// go next
|
||||
if window_center == $right {
|
||||
break;
|
||||
}
|
||||
window_center = $trkseg.next_index(window_center);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{TrackSegment, Trackpoint, TrackpointChunk};
|
||||
|
||||
fn segment(eles: &[f64]) -> TrackSegment {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
// several small chunks, to also exercise the chunk boundaries
|
||||
for part in eles.chunks(3) {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
for ele in part {
|
||||
chunk.trkpt.push(Trackpoint {
|
||||
ele: *ele,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
trkseg.push(chunk);
|
||||
}
|
||||
trkseg
|
||||
}
|
||||
|
||||
/// Returns (center, left, right) flat indices for each visited center.
|
||||
fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> {
|
||||
let trkseg = segment(&vec![0.0; len]);
|
||||
let mut visited = vec![];
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
trkseg.first_index(),
|
||||
trkseg.last_index(),
|
||||
window,
|
||||
|a, b| (b.flat - a.flat) as f64,
|
||||
|i, l, r| {
|
||||
visited.push((i.flat, l.flat, r.flat));
|
||||
},
|
||||
);
|
||||
visited
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_bounds() {
|
||||
let visited = windows(10, 2.0);
|
||||
assert_eq!(visited.len(), 10);
|
||||
for (i, l, r) in visited {
|
||||
assert_eq!(l, i.saturating_sub(2), "left of {i}");
|
||||
assert_eq!(r, (i + 2).min(9), "right of {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_always_includes_neighbours() {
|
||||
for (i, l, r) in windows(6, 0.0) {
|
||||
assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_larger_than_segment() {
|
||||
for (_, l, r) in windows(5, 100.0) {
|
||||
assert_eq!((l, r), (0, 4));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_sub_range() {
|
||||
let trkseg = segment(&[0.0; 10]);
|
||||
let mut centers = vec![];
|
||||
let mut first = trkseg.first_index();
|
||||
for _ in 0..3 {
|
||||
first = trkseg.next_index(first);
|
||||
}
|
||||
let mut last = first;
|
||||
for _ in 0..2 {
|
||||
last = trkseg.next_index(last);
|
||||
}
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
first,
|
||||
last,
|
||||
1.0,
|
||||
|a, b| (b.flat - a.flat) as f64,
|
||||
|i, _l, _r| {
|
||||
centers.push(i.flat);
|
||||
},
|
||||
);
|
||||
assert_eq!(centers, vec![3, 4, 5]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
use crate::{Statistics, TrackSegment, Trackpoint, distance};
|
||||
|
||||
/// The new timestamps of the trackpoints of `segments`, one after the other: each segment goes on
|
||||
/// from the end of the previous one, the first one starts at `start_time` (in milliseconds).
|
||||
///
|
||||
/// A segment that has timestamps keeps its durations multiplied by `ratio`; one that has none
|
||||
/// (judged by its first trackpoint) follows the previous trackpoint at `speed` (km/h). See
|
||||
/// [`shifted_and_compressed`] and [`with_timestamps`].
|
||||
pub fn changed_segment_times(
|
||||
segments: &[&TrackSegment],
|
||||
start_time: i64,
|
||||
speed: f64,
|
||||
ratio: f64,
|
||||
) -> Vec<Vec<Option<i64>>> {
|
||||
let mut last: Option<Trackpoint> = None;
|
||||
segments
|
||||
.iter()
|
||||
.map(|segment| {
|
||||
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
let Some(first) = points.first() else {
|
||||
return vec![];
|
||||
};
|
||||
let start = last.clone().unwrap_or_else(|| {
|
||||
let mut start = first.clone();
|
||||
start.time = Some(start_time);
|
||||
start
|
||||
});
|
||||
let points = if first.time.is_none() {
|
||||
with_timestamps(points, Some(speed), Some(&start), Some(start_time))
|
||||
} else {
|
||||
shifted_and_compressed(points, Some(speed), ratio, &start)
|
||||
};
|
||||
last = points.last().cloned();
|
||||
points.iter().map(|point| point.time).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Makes up timestamps for the trackpoints of `segments`, one after the other: they start at
|
||||
/// `start_time` (in milliseconds) and last `total_time` seconds together, the longer and the
|
||||
/// steeper an interval the more of it. Each segment goes on from the end of the previous one.
|
||||
pub fn artificial_segment_times(
|
||||
segments: &[&TrackSegment],
|
||||
start_time: i64,
|
||||
total_time: f64,
|
||||
) -> Vec<Vec<Option<i64>>> {
|
||||
let mut weights: Vec<Vec<f64>> = segments
|
||||
.iter()
|
||||
.map(|segment| {
|
||||
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
let slopes: Vec<f64> = Statistics::compute(segment)
|
||||
.local
|
||||
.iter()
|
||||
.map(|l| l.slope)
|
||||
.collect();
|
||||
artificial_weights(&points, &slopes)
|
||||
})
|
||||
.collect();
|
||||
let mut total: f64 = weights.iter().flatten().sum();
|
||||
if total.is_nan() || total <= 0.0 {
|
||||
// no distance to share the time by: every interval takes as long
|
||||
for weights in &mut weights {
|
||||
weights.iter_mut().for_each(|w| *w = 1.0);
|
||||
}
|
||||
total = weights.iter().map(Vec::len).sum::<usize>() as f64;
|
||||
}
|
||||
let ms_per_weight = if total > 0.0 {
|
||||
total_time * 1000.0 / total
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let mut start = start_time;
|
||||
segments
|
||||
.iter()
|
||||
.zip(&weights)
|
||||
.map(|(segment, weights)| {
|
||||
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
if points.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let points = with_artificial_timestamps(points, weights, ms_per_weight, start);
|
||||
// the next segment goes on from the end of this one
|
||||
start = points.last().and_then(|point| point.time).unwrap_or(start);
|
||||
points.iter().map(|point| point.time).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Replaces the trackpoints `start..end` of `trkpt` by `points`, keeping the timestamps of the
|
||||
/// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library.
|
||||
///
|
||||
/// The timestamps only matter if the path has some, or if `speed` (in km/h) is given: then the
|
||||
/// new points that have none get some (a trackpoint after the previous one at `speed`, or one
|
||||
/// second after it without a speed), and so do the points before or after the range if they
|
||||
/// had none. `start_time` is the time of the first trackpoint when nothing else gives it.
|
||||
/// New points that have timestamps are shifted if they are too early to follow the previous
|
||||
/// trackpoint, and so are the points after the range. With `remove_gaps`, the new points are
|
||||
/// also brought closer to the previous trackpoint if they come long after it.
|
||||
///
|
||||
/// Panics if `start..end` is not inside `trkpt`.
|
||||
pub fn replace_trackpoints(
|
||||
trkpt: &mut Vec<Trackpoint>,
|
||||
start: usize,
|
||||
end: usize,
|
||||
mut points: Vec<Trackpoint>,
|
||||
speed: Option<f64>,
|
||||
start_time: Option<i64>,
|
||||
remove_gaps: bool,
|
||||
) {
|
||||
assert!(start <= end && end <= trkpt.len());
|
||||
let has_times = trkpt.first().is_some_and(|first| first.time.is_some());
|
||||
|
||||
if speed.is_some() || has_times {
|
||||
if start > 0 && trkpt[0].time.is_none() {
|
||||
// the points before the range have no timestamps
|
||||
let before: Vec<_> = trkpt.drain(..start).collect();
|
||||
trkpt.splice(0..0, with_timestamps(before, speed, None, start_time));
|
||||
}
|
||||
|
||||
if !points.is_empty() {
|
||||
let last = start.checked_sub(1).map(|i| &trkpt[i]);
|
||||
let missing =
|
||||
points[0].time.is_none() || points.get(1).is_some_and(|p| p.time.is_none());
|
||||
if missing {
|
||||
points = with_timestamps(points, speed, last, start_time);
|
||||
} else if let Some(last) = last {
|
||||
if is_before(points[0].time, last.time) {
|
||||
// too early
|
||||
points = shifted_and_compressed(points, speed, 1.0, last);
|
||||
} else if remove_gaps {
|
||||
if same_place(&points[0], last) {
|
||||
// the same place: the new points start at the previous trackpoint, which
|
||||
// is the new first one
|
||||
if is_before(last.time, points[0].time) {
|
||||
points = shifted_and_compressed(points, speed, 1.0, last);
|
||||
points.remove(0);
|
||||
}
|
||||
} else if matches!((points[0].time, last.time), (Some(new), Some(old)) if new - old > 1000)
|
||||
{
|
||||
// another place: the new points start one second after the previous one
|
||||
let mut artificial_last = points[0].clone();
|
||||
artificial_last.time = last.time.map(|time| time + 1000);
|
||||
points = shifted_and_compressed(points, speed, 1.0, &artificial_last);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if end < trkpt.len() {
|
||||
// the points after the range
|
||||
let last = points
|
||||
.last()
|
||||
.or_else(|| start.checked_sub(1).map(|i| &trkpt[i]))
|
||||
.cloned();
|
||||
if trkpt[end].time.is_none() {
|
||||
let after: Vec<_> = trkpt.drain(end..).collect();
|
||||
let after = with_timestamps(after, speed, last.as_ref(), start_time);
|
||||
trkpt.extend(after);
|
||||
} else if let Some(last) = last
|
||||
&& is_before(trkpt[end].time, last.time)
|
||||
{
|
||||
let after: Vec<_> = trkpt.drain(end..).collect();
|
||||
trkpt.extend(shifted_and_compressed(after, speed, 1.0, &last));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trkpt.splice(start..end, points);
|
||||
}
|
||||
|
||||
/// How long each interval between two consecutive trackpoints of a segment should last, relative
|
||||
/// to the others, for a made-up pace: the longer the interval, and the steeper it goes up, the
|
||||
/// longer. `slopes` has the slope (in %) at each trackpoint.
|
||||
pub fn artificial_weights(points: &[Trackpoint], slopes: &[f64]) -> Vec<f64> {
|
||||
points
|
||||
.windows(2)
|
||||
.zip(slopes)
|
||||
.map(|(pair, slope)| {
|
||||
let km = distance(pair[0].coordinates, pair[1].coordinates);
|
||||
km * (0.5 + 1.0 / (1.0 + (-0.2 * slope).exp()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `points` with made-up timestamps: the first one is at `start`, and each next one follows the
|
||||
/// previous one after `weights[i]` times `ms_per_weight` milliseconds (see [`artificial_weights`]).
|
||||
pub fn with_artificial_timestamps(
|
||||
mut points: Vec<Trackpoint>,
|
||||
weights: &[f64],
|
||||
ms_per_weight: f64,
|
||||
start: i64,
|
||||
) -> Vec<Trackpoint> {
|
||||
let mut elapsed = 0.0;
|
||||
for (i, point) in points.iter_mut().enumerate() {
|
||||
if i > 0 {
|
||||
elapsed += weights[i - 1] * ms_per_weight;
|
||||
}
|
||||
point.time = Some(start + elapsed as i64);
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool {
|
||||
a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat
|
||||
}
|
||||
|
||||
/// Whether both times are known and `a` is before `b`.
|
||||
fn is_before(a: Option<i64>, b: Option<i64>) -> bool {
|
||||
matches!((a, b), (Some(a), Some(b)) if a < b)
|
||||
}
|
||||
|
||||
/// The time at which `b` is reached after `a`, at `speed` km/h: one second later without a
|
||||
/// speed, unknown if `a` has no time.
|
||||
///
|
||||
/// The old code gave an invalid date for a null or negative speed: it is as if there was none.
|
||||
fn timestamp_after(a: &Trackpoint, b: &Trackpoint, speed: Option<f64>) -> Option<i64> {
|
||||
let time = a.time?;
|
||||
match speed.filter(|speed| speed.is_finite() && *speed > 0.0) {
|
||||
None => Some(time + 1000),
|
||||
Some(speed) => {
|
||||
let km = distance(a.coordinates, b.coordinates);
|
||||
Some(time + (3_600_000.0 * km / speed) as i64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `points` with the timestamps that follow `last` (the trackpoint before them), one after the
|
||||
/// other. Without `last`, the first one is at `start_time`.
|
||||
///
|
||||
/// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy
|
||||
/// used here.
|
||||
pub fn with_timestamps(
|
||||
points: Vec<Trackpoint>,
|
||||
speed: Option<f64>,
|
||||
last: Option<&Trackpoint>,
|
||||
start_time: Option<i64>,
|
||||
) -> Vec<Trackpoint> {
|
||||
let Some(first) = points.first() else {
|
||||
return points;
|
||||
};
|
||||
let mut last = match last {
|
||||
Some(last) if last.time.is_some() => last.clone(),
|
||||
other => {
|
||||
let mut last = other.unwrap_or(first).clone();
|
||||
last.time = start_time;
|
||||
last
|
||||
}
|
||||
};
|
||||
points
|
||||
.into_iter()
|
||||
.map(|mut point| {
|
||||
point.time = timestamp_after(&last, &point, speed);
|
||||
last = point.clone();
|
||||
point
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `points` with their timestamps moved so that the first one follows `last`, and their durations
|
||||
/// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in
|
||||
/// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known.
|
||||
pub fn shifted_and_compressed(
|
||||
points: Vec<Trackpoint>,
|
||||
speed: Option<f64>,
|
||||
ratio: f64,
|
||||
last: &Trackpoint,
|
||||
) -> Vec<Trackpoint> {
|
||||
let Some(first) = points.first() else {
|
||||
return points;
|
||||
};
|
||||
let start = timestamp_after(last, first, speed);
|
||||
let first_time = first.time;
|
||||
let mut previous = first.clone();
|
||||
points
|
||||
.into_iter()
|
||||
.map(|point| {
|
||||
let mut shifted = point.clone();
|
||||
shifted.time = match point.time {
|
||||
None => timestamp_after(&previous, &point, speed),
|
||||
Some(time) => start
|
||||
.zip(first_time)
|
||||
.map(|(start, first)| start + (ratio * (time - first) as f64) as i64),
|
||||
};
|
||||
previous = shifted.clone();
|
||||
shifted
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::LngLat;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A point every 1 km along the equator... about, at 0.009 degrees of longitude.
|
||||
fn point(i: usize, time: Option<i64>) -> Trackpoint {
|
||||
Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: i as f64 * 0.009,
|
||||
lat: 0.0,
|
||||
},
|
||||
time,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn timed(range: std::ops::Range<usize>, start: i64, step: i64) -> Vec<Trackpoint> {
|
||||
range
|
||||
.map(|i| point(i, Some(start + step * i as i64)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn untimed(range: std::ops::Range<usize>) -> Vec<Trackpoint> {
|
||||
range.map(|i| point(i, None)).collect()
|
||||
}
|
||||
|
||||
fn times(trkpt: &[Trackpoint]) -> Vec<Option<i64>> {
|
||||
trkpt.iter().map(|p| p.time).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_without_times_nor_speed_it_is_a_splice() {
|
||||
let mut trkpt = untimed(0..5);
|
||||
replace_trackpoints(&mut trkpt, 1, 3, untimed(10..13), None, None, false);
|
||||
assert_eq!(trkpt.len(), 6);
|
||||
assert!(trkpt.iter().all(|p| p.time.is_none()));
|
||||
assert_eq!(trkpt[1].coordinates.lng, point(10, None).coordinates.lng);
|
||||
assert_eq!(trkpt[4].coordinates.lng, point(3, None).coordinates.lng);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_points_follow_the_previous_one_at_the_speed() {
|
||||
// 1 km in 10 s is 360 km/h
|
||||
let mut trkpt = timed(0..4, 0, 10_000);
|
||||
let km = distance(point(0, None).coordinates, point(1, None).coordinates);
|
||||
let speed = 36.0 * km / 0.01; // the time of 1 point to the next is 100 s
|
||||
replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), Some(speed), None, false);
|
||||
let expected = 10_000 + (3_600_000.0 * km / speed) as i64;
|
||||
assert_eq!(trkpt[2].time, Some(expected));
|
||||
assert!(trkpt[2].time > trkpt[1].time);
|
||||
// the points after follow the new ones, they were not too early
|
||||
assert_eq!(trkpt.len(), 5);
|
||||
assert_eq!(trkpt[4].time, Some(30_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_without_a_speed_new_points_are_one_second_apart() {
|
||||
let mut trkpt = timed(0..3, 5_000, 60_000);
|
||||
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..13), None, None, false);
|
||||
assert_eq!(
|
||||
times(&trkpt[..4]),
|
||||
[Some(5_000), Some(6_000), Some(7_000), Some(8_000)]
|
||||
);
|
||||
// what comes after was after, it is kept
|
||||
assert_eq!(trkpt[4].time, Some(125_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_points_after_are_shifted_if_they_are_too_early() {
|
||||
let mut trkpt = timed(0..4, 0, 10_000);
|
||||
// 30 new points, one second apart: the last one is at 30 s, but the next original
|
||||
// trackpoint is at 20 s
|
||||
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..40), None, None, false);
|
||||
assert_eq!(trkpt.len(), 33);
|
||||
assert_eq!(trkpt[1].time, Some(1_000));
|
||||
assert_eq!(trkpt[30].time, Some(30_000));
|
||||
// the following points keep the duration between them, after the new ones
|
||||
assert_eq!(trkpt[31].time, Some(31_000));
|
||||
assert_eq!(trkpt[32].time, Some(41_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_path_without_times_gets_them_from_the_speed_and_start_time() {
|
||||
let mut trkpt = untimed(0..4);
|
||||
replace_trackpoints(
|
||||
&mut trkpt,
|
||||
3,
|
||||
4,
|
||||
untimed(3..6),
|
||||
Some(10.0),
|
||||
Some(1_000),
|
||||
false,
|
||||
);
|
||||
let times: Vec<_> = times(&trkpt);
|
||||
assert_eq!(times[0], Some(1_000));
|
||||
assert!(times.iter().all(Option::is_some));
|
||||
assert!(times.windows(2).all(|w| w[0].unwrap() < w[1].unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_unusable_speed_is_as_if_there_was_none() {
|
||||
for speed in [0.0, -5.0, f64::NAN, f64::INFINITY] {
|
||||
let mut trkpt = timed(0..2, 0, 10_000);
|
||||
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(speed), None, false);
|
||||
assert_eq!(
|
||||
times(&trkpt),
|
||||
[Some(0), Some(10_000), Some(11_000), Some(12_000)],
|
||||
"{speed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_points_with_times_that_are_too_early_are_shifted() {
|
||||
let mut trkpt = timed(0..3, 100_000, 10_000);
|
||||
// they are at 0, 5 s: they are moved to follow the trackpoint at 110 s
|
||||
let new = vec![point(5, Some(0)), point(6, Some(5_000))];
|
||||
replace_trackpoints(&mut trkpt, 2, 3, new, None, None, false);
|
||||
// they follow the previous trackpoint after one second, keeping their own durations
|
||||
assert_eq!(trkpt[2].time, Some(111_000));
|
||||
assert_eq!(trkpt[3].time, Some(116_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_gaps_at_the_same_place_and_elsewhere() {
|
||||
// same place: the first new point is the previous trackpoint
|
||||
let mut trkpt = timed(0..3, 0, 10_000);
|
||||
let new = vec![point(2, Some(500_000)), point(3, Some(510_000))];
|
||||
replace_trackpoints(&mut trkpt, 3, 3, new.clone(), None, None, true);
|
||||
assert_eq!(trkpt.len(), 4);
|
||||
// without a speed, it is one second after the previous trackpoint, not at its time
|
||||
assert_eq!(trkpt[3].time, Some(31_000));
|
||||
// with a speed, the distance is zero
|
||||
let mut trkpt = timed(0..3, 0, 10_000);
|
||||
replace_trackpoints(&mut trkpt, 3, 3, new, Some(10.0), None, true);
|
||||
assert_eq!(trkpt[3].time, Some(30_000));
|
||||
|
||||
// another place: one second after the previous one
|
||||
let mut trkpt = timed(0..3, 0, 10_000);
|
||||
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
|
||||
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, true);
|
||||
assert_eq!(trkpt.len(), 5);
|
||||
// the old code adds the second twice with no speed, see `timestamp_after`
|
||||
assert_eq!(trkpt[3].time, Some(22_000));
|
||||
assert_eq!(trkpt[4].time, Some(32_000));
|
||||
|
||||
// without removing the gaps they stay
|
||||
let mut trkpt = timed(0..3, 0, 10_000);
|
||||
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
|
||||
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, false);
|
||||
assert_eq!(trkpt[3].time, Some(500_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_removing_points_adapts_nothing_if_the_next_one_is_after() {
|
||||
let mut trkpt = timed(0..5, 0, 10_000);
|
||||
replace_trackpoints(&mut trkpt, 1, 3, vec![], None, None, false);
|
||||
assert_eq!(times(&trkpt), [Some(0), Some(30_000), Some(40_000)]);
|
||||
// from the beginning
|
||||
replace_trackpoints(&mut trkpt, 0, 2, vec![], None, None, false);
|
||||
assert_eq!(times(&trkpt), [Some(40_000)]);
|
||||
replace_trackpoints(&mut trkpt, 0, 1, vec![], None, None, false);
|
||||
assert!(trkpt.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_points_without_a_known_previous_time_get_none() {
|
||||
// nothing gives a time to start with
|
||||
let mut trkpt = untimed(0..2);
|
||||
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(10.0), None, false);
|
||||
assert!(trkpt.iter().all(|p| p.time.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes,
|
||||
/// so that the trackpoints only hold a `u8` instead of a string.
|
||||
///
|
||||
/// The codes are the positions of the names in the order of first appearance. The table only
|
||||
/// grows: a code never changes meaning, so it stays valid in every state of the history.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Categories(Vec<String>);
|
||||
|
||||
impl Categories {
|
||||
/// A table with these names, in the order of their codes (at most [`Self::CAPACITY`] of them
|
||||
/// are kept).
|
||||
pub fn from_names(mut names: Vec<String>) -> Self {
|
||||
names.truncate(Self::CAPACITY);
|
||||
Self(names)
|
||||
}
|
||||
|
||||
/// Maximum number of distinct values.
|
||||
pub const CAPACITY: usize = u8::MAX as usize;
|
||||
|
||||
/// The code of `name`, which is added to the table if it is new. `None` if the table is full.
|
||||
pub fn code(&mut self, name: &str) -> Option<u8> {
|
||||
let index = match self.0.iter().position(|known| known == name) {
|
||||
Some(index) => index,
|
||||
None if self.0.len() < Self::CAPACITY => {
|
||||
self.0.push(name.to_owned());
|
||||
self.0.len() - 1
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
u8::try_from(index).ok()
|
||||
}
|
||||
|
||||
/// The name of a code, `None` if it is unknown.
|
||||
pub fn name(&self, code: u8) -> Option<&str> {
|
||||
self.0.get(usize::from(code)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// All the names, in the order of their codes.
|
||||
pub fn names(&self) -> &[String] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
|
||||
/// its files.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct TrackpointCategories {
|
||||
pub surface: Categories,
|
||||
pub highway: Categories,
|
||||
pub sac_scale: Categories,
|
||||
pub mtb_scale: Categories,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_codes_follow_the_order_of_appearance() {
|
||||
let mut categories = Categories::default();
|
||||
assert_eq!(categories.code("asphalt"), Some(0));
|
||||
assert_eq!(categories.code("gravel"), Some(1));
|
||||
// a known name keeps its code
|
||||
assert_eq!(categories.code("asphalt"), Some(0));
|
||||
assert_eq!(categories.names(), ["asphalt", "gravel"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_names_of_codes() {
|
||||
let mut categories = Categories::default();
|
||||
let code = categories.code("asphalt").unwrap();
|
||||
assert_eq!(categories.name(code), Some("asphalt"));
|
||||
assert_eq!(categories.name(code + 1), None);
|
||||
assert_eq!(Categories::default().name(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_full_table_gives_no_code_to_new_names() {
|
||||
let mut categories = Categories::default();
|
||||
for i in 0..Categories::CAPACITY {
|
||||
assert_eq!(categories.code(&i.to_string()), Some(i as u8));
|
||||
}
|
||||
assert_eq!(categories.code("one too many"), None);
|
||||
assert_eq!(categories.names().len(), Categories::CAPACITY);
|
||||
// the known ones still have their code, the last one included
|
||||
assert_eq!(categories.code("0"), Some(0));
|
||||
assert_eq!(categories.code("254"), Some(254));
|
||||
assert_eq!(categories.name(254), Some("254"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_categories_are_independent() {
|
||||
let mut categories = TrackpointCategories::default();
|
||||
categories.surface.code("asphalt");
|
||||
assert_eq!(categories.highway.code("path"), Some(0));
|
||||
assert_eq!(categories.sac_scale.code("hiking"), Some(0));
|
||||
assert_eq!(categories.mtb_scale.code("1"), Some(0));
|
||||
assert_eq!(categories.surface.names(), ["asphalt"]);
|
||||
assert_eq!(categories.highway.names(), ["path"]);
|
||||
assert_eq!(categories.sac_scale.names(), ["hiking"]);
|
||||
assert_eq!(categories.mtb_scale.names(), ["1"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::{Trackpoint, Waypoint};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
/// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk
|
||||
/// that is not touched by an edit is not copied.
|
||||
///
|
||||
/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new
|
||||
/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is.
|
||||
pub trait Chunk {
|
||||
type Item: Clone;
|
||||
|
||||
/// Maximum number of items of a chunk.
|
||||
const MAX_SIZE: usize;
|
||||
|
||||
/// A chunk with a new identity, holding `items`.
|
||||
fn new(items: Vec<Self::Item>) -> Self;
|
||||
|
||||
/// Gives the chunk a new identity, after its items were changed in place.
|
||||
fn renew(&mut self);
|
||||
|
||||
fn items(&self) -> &Vec<Self::Item>;
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<Self::Item>;
|
||||
|
||||
fn is_full(&self) -> bool {
|
||||
self.items().len() >= Self::MAX_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
|
||||
|
||||
uuid_id!(TrackpointChunkId);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TrackpointChunk {
|
||||
pub id: TrackpointChunkId,
|
||||
pub trkpt: Vec<Trackpoint>,
|
||||
}
|
||||
|
||||
impl Chunk for TrackpointChunk {
|
||||
type Item = Trackpoint;
|
||||
const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE;
|
||||
|
||||
fn new(trkpt: Vec<Trackpoint>) -> Self {
|
||||
Self {
|
||||
trkpt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn renew(&mut self) {
|
||||
self.id = Default::default();
|
||||
}
|
||||
|
||||
fn items(&self) -> &Vec<Trackpoint> {
|
||||
&self.trkpt
|
||||
}
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<Trackpoint> {
|
||||
&mut self.trkpt
|
||||
}
|
||||
}
|
||||
|
||||
uuid_id!(WaypointChunkId);
|
||||
|
||||
const MAX_WPT_CHUNK_SIZE: usize = 128;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WaypointChunk {
|
||||
pub id: WaypointChunkId,
|
||||
pub wpt: Vec<Waypoint>,
|
||||
}
|
||||
|
||||
impl Chunk for WaypointChunk {
|
||||
type Item = Waypoint;
|
||||
const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
|
||||
|
||||
fn new(wpt: Vec<Waypoint>) -> Self {
|
||||
Self {
|
||||
wpt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn renew(&mut self) {
|
||||
self.id = Default::default();
|
||||
}
|
||||
|
||||
fn items(&self) -> &Vec<Waypoint> {
|
||||
&self.wpt
|
||||
}
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<Waypoint> {
|
||||
&mut self.wpt
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trackpoint_chunk_full() {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
assert!(!chunk.is_full());
|
||||
for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 {
|
||||
chunk.trkpt.push(Trackpoint::default());
|
||||
}
|
||||
assert!(!chunk.is_full());
|
||||
chunk.trkpt.push(Trackpoint::default());
|
||||
assert!(chunk.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_waypoint_chunk_full() {
|
||||
let mut chunk = WaypointChunk::default();
|
||||
assert!(!chunk.is_full());
|
||||
for _ in 0..MAX_WPT_CHUNK_SIZE {
|
||||
chunk.wpt.push(Waypoint::default());
|
||||
}
|
||||
assert!(chunk.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_chunks_have_their_own_identity() {
|
||||
let a = TrackpointChunk::new(vec![Trackpoint::default()]);
|
||||
let b = TrackpointChunk::new(vec![Trackpoint::default()]);
|
||||
assert_ne!(a.id, b.id);
|
||||
assert_eq!(a.items().len(), 1);
|
||||
assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
use std::{ops::Index, rc::Rc};
|
||||
|
||||
use crate::Chunk;
|
||||
|
||||
/// A list of items, stored in [`Chunk`]s that are shared between its versions: editing it only
|
||||
/// copies the chunks around the change, so that a version costs little more than the change.
|
||||
///
|
||||
/// There are no empty chunks.
|
||||
#[derive(Debug)]
|
||||
pub struct Chunked<C: Chunk> {
|
||||
chunks: Vec<Rc<C>>,
|
||||
cumul_length: Vec<usize>,
|
||||
}
|
||||
|
||||
impl<C: Chunk> Clone for Chunked<C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
chunks: self.chunks.clone(),
|
||||
cumul_length: self.cumul_length.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Chunk> Default for Chunked<C> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chunks: vec![],
|
||||
cumul_length: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of an item: in which chunk, where in it, and among all the items.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
|
||||
pub struct ChunkIndex {
|
||||
pub chunk: usize,
|
||||
pub pos: usize,
|
||||
pub flat: usize,
|
||||
}
|
||||
|
||||
impl<C: Chunk> Chunked<C> {
|
||||
/// Adds a chunk at the end, which is dropped if it is empty.
|
||||
pub fn push(&mut self, chunk: C) {
|
||||
self.push_shared(Rc::new(chunk));
|
||||
}
|
||||
|
||||
pub fn push_shared(&mut self, chunk: Rc<C>) {
|
||||
if chunk.items().is_empty() {
|
||||
return;
|
||||
}
|
||||
self.cumul_length
|
||||
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.items().len());
|
||||
self.chunks.push(chunk);
|
||||
}
|
||||
|
||||
pub fn chunks(&self) -> &[Rc<C>] {
|
||||
&self.chunks
|
||||
}
|
||||
|
||||
/// Replaces the items in `start..end` by `items`. Panics if the range is out of bounds.
|
||||
///
|
||||
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the
|
||||
/// range are copied and refilled.
|
||||
pub fn splice(&mut self, start: usize, end: usize, items: Vec<C::Item>) {
|
||||
assert!(
|
||||
start <= end && end <= self.len(),
|
||||
"splice range out of bounds"
|
||||
);
|
||||
let old = std::mem::take(&mut self.chunks);
|
||||
self.cumul_length.clear();
|
||||
|
||||
let mut items = Some(items);
|
||||
let mut pending = vec![];
|
||||
let mut offset = 0;
|
||||
for chunk in old {
|
||||
let (lo, hi) = (offset, offset + chunk.items().len());
|
||||
offset = hi;
|
||||
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
|
||||
if hi < start || (hi == start && chunk.is_full()) {
|
||||
self.push_shared(chunk);
|
||||
continue;
|
||||
}
|
||||
if let Some(items) = items.take() {
|
||||
self.fill(&mut pending, chunk.items()[..start - lo].iter().cloned());
|
||||
self.fill(&mut pending, items);
|
||||
}
|
||||
if hi <= end {
|
||||
continue;
|
||||
}
|
||||
if lo >= end {
|
||||
self.flush(&mut pending);
|
||||
self.push_shared(chunk);
|
||||
} else {
|
||||
self.fill(&mut pending, chunk.items()[end - lo..].iter().cloned());
|
||||
}
|
||||
}
|
||||
if let Some(items) = items {
|
||||
self.fill(&mut pending, items);
|
||||
}
|
||||
self.flush(&mut pending);
|
||||
}
|
||||
|
||||
fn fill(&mut self, pending: &mut Vec<C::Item>, items: impl IntoIterator<Item = C::Item>) {
|
||||
for item in items {
|
||||
pending.push(item);
|
||||
if pending.len() >= C::MAX_SIZE {
|
||||
self.flush(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self, pending: &mut Vec<C::Item>) {
|
||||
self.push(C::new(std::mem::take(pending)));
|
||||
}
|
||||
|
||||
/// Edits the items of the chunks that contain an item accepted by `filter`.
|
||||
///
|
||||
/// `f` receives a copy of the items of such a chunk and returns whether it changed them. A
|
||||
/// changed chunk is replaced by a new one (so that what is derived from it is computed
|
||||
/// again) and the chunks left empty are dropped. The other chunks are kept. Returns whether
|
||||
/// anything changed.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
filter: impl Fn(&C::Item) -> bool,
|
||||
mut f: impl FnMut(&mut Vec<C::Item>) -> bool,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let old = std::mem::take(&mut self.chunks);
|
||||
self.cumul_length.clear();
|
||||
for chunk in old {
|
||||
if !chunk.items().iter().any(&filter) {
|
||||
self.push_shared(chunk);
|
||||
continue;
|
||||
}
|
||||
let mut items = chunk.items().clone();
|
||||
if !f(&mut items) {
|
||||
self.push_shared(chunk);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
self.push(C::new(items));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Changes what is in the chunk `chunk`, but not how many items there are. A chunk that is
|
||||
/// shared is copied first, so that the other versions do not change.
|
||||
fn replace_chunk(&mut self, chunk: usize, f: impl FnOnce(&mut Vec<C::Item>)) {
|
||||
let len = self.chunks[chunk].items().len();
|
||||
match Rc::get_mut(&mut self.chunks[chunk]) {
|
||||
Some(unique) => {
|
||||
f(unique.items_mut());
|
||||
unique.renew();
|
||||
}
|
||||
None => {
|
||||
let mut items = self.chunks[chunk].items().clone();
|
||||
f(&mut items);
|
||||
self.chunks[chunk] = Rc::new(C::new(items));
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(self.chunks[chunk].items().len(), len);
|
||||
}
|
||||
|
||||
/// Applies `f` to the item at `index`, which is copied in a new chunk. Panics if there is no
|
||||
/// such item.
|
||||
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut C::Item)) {
|
||||
let ChunkIndex { chunk, pos, .. } = self.locate(index).unwrap();
|
||||
self.replace_chunk(chunk, |items| f(&mut items[pos]));
|
||||
}
|
||||
|
||||
/// Applies `f` to every item, with its index, in new chunks.
|
||||
pub fn update_all(&mut self, mut f: impl FnMut(usize, &mut C::Item)) {
|
||||
let mut offset = 0;
|
||||
for chunk in 0..self.chunks.len() {
|
||||
let len = self.chunks[chunk].items().len();
|
||||
self.replace_chunk(chunk, |items| {
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
f(offset + i, item);
|
||||
}
|
||||
});
|
||||
offset += len;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.cumul_length.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.chunks.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> ChunkedIter<'_, C> {
|
||||
ChunkedIter::new(self)
|
||||
}
|
||||
|
||||
pub fn first_index(&self) -> Option<ChunkIndex> {
|
||||
self.next_index(None)
|
||||
}
|
||||
|
||||
pub fn last_index(&self) -> Option<ChunkIndex> {
|
||||
self.prev_index(None)
|
||||
}
|
||||
|
||||
pub fn next_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
|
||||
let mut next = cur.map_or_default(|idx| ChunkIndex {
|
||||
chunk: idx.chunk,
|
||||
pos: idx.pos + 1,
|
||||
flat: idx.flat + 1,
|
||||
});
|
||||
loop {
|
||||
if next.chunk >= self.chunks.len() {
|
||||
return None;
|
||||
}
|
||||
if next.pos == self.chunks[next.chunk].items().len() {
|
||||
next.chunk += 1;
|
||||
next.pos = 0;
|
||||
} else {
|
||||
return Some(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
|
||||
let mut prev = cur.unwrap_or(ChunkIndex {
|
||||
chunk: self.chunks.len(),
|
||||
pos: 0,
|
||||
flat: self.cumul_length.last().copied().unwrap_or_default(),
|
||||
});
|
||||
if prev.pos == 0 {
|
||||
while prev.chunk > 0 {
|
||||
prev.chunk -= 1;
|
||||
if !self.chunks[prev.chunk].items().is_empty() {
|
||||
prev.pos = self.chunks[prev.chunk].items().len() - 1;
|
||||
prev.flat -= 1;
|
||||
return Some(prev);
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
prev.pos -= 1;
|
||||
prev.flat -= 1;
|
||||
Some(prev)
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of the item `idx` among all the items.
|
||||
pub fn locate(&self, idx: usize) -> Option<ChunkIndex> {
|
||||
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
|
||||
if chunk >= self.chunks.len() {
|
||||
return None;
|
||||
}
|
||||
let pos = if chunk > 0 {
|
||||
idx - self.cumul_length[chunk - 1]
|
||||
} else {
|
||||
idx
|
||||
};
|
||||
if pos >= self.chunks[chunk].items().len() {
|
||||
None
|
||||
} else {
|
||||
Some(ChunkIndex {
|
||||
chunk,
|
||||
pos,
|
||||
flat: idx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Chunk> Index<ChunkIndex> for Chunked<C> {
|
||||
type Output = C::Item;
|
||||
|
||||
fn index(&self, idx: ChunkIndex) -> &Self::Output {
|
||||
&self.chunks[idx.chunk].items()[idx.pos]
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Chunk> Index<usize> for Chunked<C> {
|
||||
type Output = C::Item;
|
||||
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
&self[self.locate(idx).unwrap()]
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, C: Chunk> IntoIterator for &'a Chunked<C> {
|
||||
type Item = &'a C::Item;
|
||||
type IntoIter = ChunkedIter<'a, C>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChunkedIter<'a, C: Chunk> {
|
||||
chunked: &'a Chunked<C>,
|
||||
/// The item returned last.
|
||||
idx: Option<ChunkIndex>,
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
impl<C: Chunk> Clone for ChunkedIter<'_, C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
chunked: self.chunked,
|
||||
idx: self.idx,
|
||||
exhausted: self.exhausted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, C: Chunk> ChunkedIter<'a, C> {
|
||||
pub fn new(chunked: &'a Chunked<C>) -> Self {
|
||||
Self {
|
||||
chunked,
|
||||
idx: None,
|
||||
exhausted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn move_to(&mut self, idx: Option<ChunkIndex>) -> Option<&'a C::Item> {
|
||||
self.exhausted = idx.is_none();
|
||||
self.idx = idx.or(self.idx);
|
||||
idx.map(|idx| &self.chunked[idx])
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, C: Chunk> Iterator for ChunkedIter<'a, C> {
|
||||
type Item = &'a C::Item;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.exhausted {
|
||||
return None;
|
||||
}
|
||||
let next = self.chunked.next_index(self.idx);
|
||||
self.move_to(next)
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
if self.exhausted {
|
||||
return None;
|
||||
}
|
||||
let target = self.idx.map_or(0, |idx| idx.flat + 1).saturating_add(n);
|
||||
let next = self.chunked.locate(target);
|
||||
self.move_to(next)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Chunks of at most 3 numbers, with an identity like the real ones.
|
||||
#[derive(Debug)]
|
||||
struct Numbers {
|
||||
id: usize,
|
||||
items: Vec<u32>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static NEXT_ID: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn next_id() -> usize {
|
||||
NEXT_ID.with(|id| id.replace(id.get() + 1))
|
||||
}
|
||||
|
||||
impl Chunk for Numbers {
|
||||
type Item = u32;
|
||||
const MAX_SIZE: usize = 3;
|
||||
|
||||
fn new(items: Vec<u32>) -> Self {
|
||||
Self {
|
||||
id: next_id(),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
fn renew(&mut self) {
|
||||
self.id = next_id();
|
||||
}
|
||||
|
||||
fn items(&self) -> &Vec<u32> {
|
||||
&self.items
|
||||
}
|
||||
|
||||
fn items_mut(&mut self) -> &mut Vec<u32> {
|
||||
&mut self.items
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbers `0..n` in chunks of 3.
|
||||
fn numbers(n: u32) -> Chunked<Numbers> {
|
||||
let mut chunked = Chunked::default();
|
||||
for start in (0..n).step_by(3) {
|
||||
chunked.push(Numbers::new((start..(start + 3).min(n)).collect()));
|
||||
}
|
||||
chunked
|
||||
}
|
||||
|
||||
fn values(chunked: &Chunked<Numbers>) -> Vec<u32> {
|
||||
chunked.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn ids(chunked: &Chunked<Numbers>) -> Vec<usize> {
|
||||
chunked.chunks().iter().map(|chunk| chunk.id).collect()
|
||||
}
|
||||
|
||||
fn assert_consistent(chunked: &Chunked<Numbers>) {
|
||||
let sizes: Vec<usize> = chunked.chunks().iter().map(|c| c.items.len()).collect();
|
||||
assert!(
|
||||
sizes
|
||||
.iter()
|
||||
.all(|&size| 0 < size && size <= Numbers::MAX_SIZE)
|
||||
);
|
||||
assert_eq!(chunked.len(), sizes.iter().sum::<usize>());
|
||||
for index in 0..chunked.len() {
|
||||
assert_eq!(chunked.locate(index).unwrap().flat, index);
|
||||
}
|
||||
assert!(chunked.locate(chunked.len()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_drops_empty_chunks() {
|
||||
let mut chunked = numbers(4);
|
||||
chunked.push(Numbers::new(vec![]));
|
||||
assert_eq!(chunked.chunks().len(), 2);
|
||||
assert_eq!(chunked.len(), 4);
|
||||
assert!(Chunked::<Numbers>::default().is_empty());
|
||||
assert!(!chunked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locate_index_and_iterate() {
|
||||
let chunked = numbers(7);
|
||||
assert_eq!(
|
||||
chunked.locate(4),
|
||||
Some(ChunkIndex {
|
||||
chunk: 1,
|
||||
pos: 1,
|
||||
flat: 4
|
||||
})
|
||||
);
|
||||
assert_eq!(chunked[4], 4);
|
||||
assert_eq!(chunked[chunked.locate(6).unwrap()], 6);
|
||||
assert!(chunked.locate(7).is_none());
|
||||
assert_eq!(values(&chunked), (0..7).collect::<Vec<_>>());
|
||||
assert_eq!(chunked.iter().count(), 7);
|
||||
assert_eq!((&chunked).into_iter().last(), Some(&6));
|
||||
assert_eq!(values(&Chunked::default()), Vec::<u32>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_last_next_and_previous_index() {
|
||||
let chunked = numbers(7);
|
||||
let first = chunked.first_index().unwrap();
|
||||
let last = chunked.last_index().unwrap();
|
||||
assert_eq!((first.flat, last.flat), (0, 6));
|
||||
assert_eq!((last.chunk, last.pos), (2, 0));
|
||||
|
||||
let mut forward = vec![];
|
||||
let mut cur = None;
|
||||
while let Some(next) = chunked.next_index(cur) {
|
||||
forward.push(chunked[next]);
|
||||
cur = Some(next);
|
||||
}
|
||||
assert_eq!(forward, (0..7).collect::<Vec<_>>());
|
||||
|
||||
let mut backward = vec![];
|
||||
let mut cur = None;
|
||||
while let Some(prev) = chunked.prev_index(cur) {
|
||||
backward.push(chunked[prev]);
|
||||
cur = Some(prev);
|
||||
}
|
||||
assert_eq!(backward, (0..7).rev().collect::<Vec<_>>());
|
||||
|
||||
let empty = Chunked::<Numbers>::default();
|
||||
assert!(empty.first_index().is_none() && empty.last_index().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::iter_nth_zero)]
|
||||
fn test_iterator_nth_goes_on_from_the_current_item() {
|
||||
let chunked = numbers(10);
|
||||
let mut iter = chunked.iter();
|
||||
assert_eq!(iter.nth(1), Some(&1));
|
||||
assert_eq!(iter.next(), Some(&2));
|
||||
// skips 3 and 4
|
||||
assert_eq!(iter.nth(2), Some(&5));
|
||||
assert_eq!(iter.nth(0), Some(&6));
|
||||
assert_eq!(iter.nth(2), Some(&9));
|
||||
assert_eq!(iter.nth(0), None);
|
||||
assert_eq!(iter.next(), None, "an exhausted iterator stays exhausted");
|
||||
|
||||
assert_eq!(values(&numbers(10)).iter().skip(4).count(), 6);
|
||||
assert_eq!(
|
||||
chunked.iter().skip(4).copied().collect::<Vec<_>>(),
|
||||
(4..10).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
chunked.iter().step_by(4).copied().collect::<Vec<_>>(),
|
||||
vec![0, 4, 8]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_replaces_inserts_deletes_and_appends() {
|
||||
let mut chunked = numbers(10);
|
||||
chunked.splice(2, 5, vec![100, 101]);
|
||||
assert_eq!(values(&chunked), [0, 1, 100, 101, 5, 6, 7, 8, 9]);
|
||||
assert_consistent(&chunked);
|
||||
|
||||
chunked.splice(0, 0, vec![50]);
|
||||
assert_eq!(values(&chunked)[..3], [50, 0, 1]);
|
||||
chunked.splice(chunked.len(), chunked.len(), vec![7, 7, 7, 7]);
|
||||
assert_eq!(values(&chunked)[chunked.len() - 5..], [9, 7, 7, 7, 7]);
|
||||
assert_consistent(&chunked);
|
||||
|
||||
let len = chunked.len();
|
||||
chunked.splice(0, len, vec![]);
|
||||
assert!(chunked.is_empty());
|
||||
assert_eq!(chunked.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "splice range out of bounds")]
|
||||
fn test_splice_out_of_bounds_panics() {
|
||||
numbers(4).splice(2, 5, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_keeps_the_chunks_it_does_not_touch() {
|
||||
let mut chunked = numbers(12);
|
||||
let before = ids(&chunked);
|
||||
// inside the second chunk
|
||||
chunked.splice(4, 5, vec![40]);
|
||||
let after = ids(&chunked);
|
||||
assert_eq!(after.len(), before.len());
|
||||
assert_eq!(
|
||||
(after[0], after[2], after[3]),
|
||||
(before[0], before[2], before[3])
|
||||
);
|
||||
assert_ne!(after[1], before[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_replaces_only_the_changed_chunks() {
|
||||
let mut chunked = numbers(9);
|
||||
let before = ids(&chunked);
|
||||
|
||||
// nothing is changed: nothing is replaced
|
||||
assert!(!chunked.edit(|n| *n == 4, |_| false));
|
||||
assert_eq!(ids(&chunked), before);
|
||||
|
||||
// the filter picks the chunk, the hook changes it
|
||||
assert!(chunked.edit(
|
||||
|n| *n == 4,
|
||||
|items| {
|
||||
items[1] = 400;
|
||||
true
|
||||
}
|
||||
));
|
||||
let after = ids(&chunked);
|
||||
assert_eq!((after[0], after[2]), (before[0], before[2]));
|
||||
assert_ne!(after[1], before[1]);
|
||||
assert_eq!(values(&chunked), [0, 1, 2, 3, 400, 5, 6, 7, 8]);
|
||||
|
||||
// a chunk left empty is dropped
|
||||
assert!(chunked.edit(
|
||||
|n| *n == 0,
|
||||
|items| {
|
||||
items.clear();
|
||||
true
|
||||
}
|
||||
));
|
||||
assert_eq!(chunked.chunks().len(), 2);
|
||||
assert_consistent(&chunked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_copies_the_chunk_so_that_other_versions_do_not_change() {
|
||||
let mut chunked = numbers(6);
|
||||
let version = chunked.clone();
|
||||
let before = ids(&chunked);
|
||||
chunked.update(4, |n| *n = 40);
|
||||
assert_eq!(values(&chunked), [0, 1, 2, 3, 40, 5]);
|
||||
assert_eq!(values(&version), (0..6).collect::<Vec<_>>());
|
||||
assert_eq!(ids(&version), before);
|
||||
assert_eq!(ids(&chunked)[0], before[0]);
|
||||
assert_ne!(ids(&chunked)[1], before[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_updated_chunks_get_a_new_identity_even_when_nothing_shares_them() {
|
||||
// what is derived from or kept of a chunk is keyed by its identity
|
||||
let mut chunked = numbers(6);
|
||||
let before = ids(&chunked);
|
||||
chunked.update(1, |n| *n += 1);
|
||||
assert_ne!(ids(&chunked)[0], before[0]);
|
||||
assert_eq!(ids(&chunked)[1], before[1]);
|
||||
|
||||
let before = ids(&chunked);
|
||||
chunked.update_all(|_, n| *n += 1);
|
||||
let after = ids(&chunked);
|
||||
assert!(before.iter().zip(&after).all(|(a, b)| a != b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_all_gives_the_index_of_each_item() {
|
||||
let mut chunked = numbers(8);
|
||||
let mut seen = vec![];
|
||||
chunked.update_all(|index, n| {
|
||||
seen.push(index);
|
||||
*n = 10 * index as u32;
|
||||
});
|
||||
assert_eq!(seen, (0..8).collect::<Vec<_>>());
|
||||
assert_eq!(values(&chunked), [0, 10, 20, 30, 40, 50, 60, 70]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/// Defines an identifier made of a UUID, which is a new one by default.
|
||||
macro_rules! uuid_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct $name(pub uuid::Uuid);
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use uuid_id;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Link {
|
||||
pub href: String,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LngLat {
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LngLatBounds {
|
||||
pub sw: LngLat,
|
||||
pub ne: LngLat,
|
||||
}
|
||||
|
||||
impl Default for LngLatBounds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sw: LngLat {
|
||||
lng: 180.0,
|
||||
lat: 90.0,
|
||||
},
|
||||
ne: LngLat {
|
||||
lng: -180.0,
|
||||
lat: -90.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LngLatBounds {
|
||||
pub fn extend(&mut self, coordinates: LngLat) {
|
||||
self.sw.lng = self.sw.lng.min(coordinates.lng);
|
||||
self.sw.lat = self.sw.lat.min(coordinates.lat);
|
||||
self.ne.lng = self.ne.lng.max(coordinates.lng);
|
||||
self.ne.lat = self.ne.lat.max(coordinates.lat);
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &LngLatBounds) {
|
||||
self.sw.lng = self.sw.lng.min(other.sw.lng);
|
||||
self.sw.lat = self.sw.lat.min(other.sw.lat);
|
||||
self.ne.lng = self.ne.lng.max(other.ne.lng);
|
||||
self.ne.lat = self.ne.lat.max(other.ne.lat);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bounds_extend() {
|
||||
let mut bounds = LngLatBounds::default();
|
||||
bounds.extend(LngLat {
|
||||
lng: 4.0,
|
||||
lat: 50.0,
|
||||
});
|
||||
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0));
|
||||
assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0));
|
||||
|
||||
bounds.extend(LngLat {
|
||||
lng: 6.0,
|
||||
lat: 45.0,
|
||||
});
|
||||
bounds.extend(LngLat {
|
||||
lng: 5.0,
|
||||
lat: 48.0,
|
||||
});
|
||||
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0));
|
||||
assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bounds_merge() {
|
||||
let mut a = LngLatBounds::default();
|
||||
a.extend(LngLat { lng: 0.0, lat: 0.0 });
|
||||
a.extend(LngLat { lng: 1.0, lat: 1.0 });
|
||||
let mut b = LngLatBounds::default();
|
||||
b.extend(LngLat {
|
||||
lng: -2.0,
|
||||
lat: 0.5,
|
||||
});
|
||||
b.extend(LngLat { lng: 0.5, lat: 3.0 });
|
||||
|
||||
a.merge(&b);
|
||||
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
|
||||
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
|
||||
|
||||
// merging an empty bounds changes nothing
|
||||
a.merge(&LngLatBounds::default());
|
||||
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
|
||||
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::{Link, Track, Waypoints};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(FileId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct File {
|
||||
pub id: FileId,
|
||||
pub info: FileInfo,
|
||||
pub trk: Vec<Track>,
|
||||
pub wpt: Waypoints,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct FileInfo {
|
||||
pub name: String,
|
||||
pub desc: Option<String>,
|
||||
pub author: Option<Author>,
|
||||
pub links: Vec<Link>,
|
||||
pub time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Author {
|
||||
pub name: Option<String>,
|
||||
/// `id@domain`, which the file gives as the two attributes of an `email` element.
|
||||
pub email: Option<String>,
|
||||
/// The author has a single link, unlike the other elements.
|
||||
pub link: Option<Link>,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
mod categories;
|
||||
mod chunk;
|
||||
mod chunked;
|
||||
mod common;
|
||||
mod file;
|
||||
mod segment;
|
||||
mod track;
|
||||
mod trackpoint;
|
||||
mod waypoint;
|
||||
mod waypoints;
|
||||
|
||||
pub use categories::*;
|
||||
pub use chunk::*;
|
||||
pub use chunked::*;
|
||||
pub use common::*;
|
||||
pub use file::*;
|
||||
pub use segment::*;
|
||||
pub use track::*;
|
||||
pub use trackpoint::*;
|
||||
pub use waypoint::*;
|
||||
pub use waypoints::*;
|
||||
@@ -0,0 +1,411 @@
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(TrackSegmentId);
|
||||
|
||||
uuid_id!(TrackSegmentRevisionId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TrackSegment {
|
||||
pub id: TrackSegmentId,
|
||||
pub rev_id: TrackSegmentRevisionId,
|
||||
points: Chunked<TrackpointChunk>,
|
||||
}
|
||||
|
||||
/// The position of a trackpoint in a segment.
|
||||
pub type TrackSegmentIndex = ChunkIndex;
|
||||
|
||||
pub type TrackSegmentIterator<'a> = ChunkedIter<'a, TrackpointChunk>;
|
||||
|
||||
impl Deref for TrackSegment {
|
||||
type Target = Chunked<TrackpointChunk>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.points
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackSegment {
|
||||
/// Adds a chunk after the last trackpoint, which is dropped if it is empty.
|
||||
pub fn push(&mut self, chunk: TrackpointChunk) {
|
||||
self.points.push(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Adds a chunk that is shared with something else after the last trackpoint.
|
||||
pub fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
|
||||
self.points.push_shared(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no
|
||||
/// such trackpoint.
|
||||
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) {
|
||||
self.points.update(index, f);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Applies `f` to every trackpoint, with its index, see [`Chunked::update_all`].
|
||||
pub fn update_all(&mut self, f: impl FnMut(usize, &mut Trackpoint)) {
|
||||
self.points.update_all(f);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Replaces the points in `start..end` by `points`, see [`Chunked::splice`]. The first and
|
||||
/// last trackpoints are anchors afterwards.
|
||||
pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
|
||||
self.points.splice(start, end, points);
|
||||
self.rev_id = Default::default();
|
||||
self.ensure_end_anchors();
|
||||
}
|
||||
|
||||
/// The first and the last trackpoints are always anchors, shown at every zoom level.
|
||||
///
|
||||
/// It is done by [`TrackSegment::splice`]; commands that build segments in another way need to
|
||||
/// call it.
|
||||
pub fn ensure_end_anchors(&mut self) {
|
||||
let Some(last) = self.len().checked_sub(1) else {
|
||||
return;
|
||||
};
|
||||
for index in [0, last] {
|
||||
if self[index].anchor != Some(0) {
|
||||
self.set_anchor(index, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if
|
||||
/// there is no such trackpoint.
|
||||
pub fn set_anchor(&mut self, index: usize, zoom: u8) {
|
||||
self.update(index, |trkpt| trkpt.anchor = Some(zoom));
|
||||
}
|
||||
|
||||
/// Sets the anchors of the trackpoints from the details of the path of the segment (see
|
||||
/// [`compute_anchors`]), forgetting the previous ones.
|
||||
pub fn compute_anchors(&mut self) {
|
||||
let anchors = compute_anchors(self);
|
||||
let mut anchors = anchors.into_iter().peekable();
|
||||
self.update_all(|index, trkpt| {
|
||||
trkpt.anchor = match anchors.peek() {
|
||||
Some(&(anchor, zoom)) if anchor == index => {
|
||||
anchors.next();
|
||||
Some(zoom)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::Chunk;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_track_segment(nb_chunks: usize) -> TrackSegment {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
let mut count = 0;
|
||||
for n in 1..=nb_chunks {
|
||||
let mut chunk = TrackpointChunk::default();
|
||||
for _ in 0..n {
|
||||
chunk.trkpt.push(Trackpoint {
|
||||
ele: count as f64,
|
||||
..Default::default()
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
trkseg.push(chunk);
|
||||
}
|
||||
trkseg
|
||||
}
|
||||
|
||||
fn eles(trkseg: &TrackSegment) -> Vec<f64> {
|
||||
trkseg.iter().map(|p| p.ele).collect()
|
||||
}
|
||||
|
||||
fn points(eles: &[f64]) -> Vec<Trackpoint> {
|
||||
eles.iter()
|
||||
.map(|&ele| Trackpoint {
|
||||
ele,
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_replace_insert_delete_append() {
|
||||
// chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14]
|
||||
let mut trkseg = create_track_segment(5);
|
||||
trkseg.splice(4, 8, points(&[-1.0, -2.0]));
|
||||
let mut expected: Vec<f64> = (0..4).map(f64::from).collect();
|
||||
expected.extend([-1.0, -2.0]);
|
||||
expected.extend((8..15).map(f64::from));
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
assert_eq!(trkseg.len(), expected.len());
|
||||
for i in 0..trkseg.len() {
|
||||
assert_eq!(trkseg[i].ele, expected[i]);
|
||||
}
|
||||
|
||||
trkseg.splice(2, 2, points(&[100.0]));
|
||||
expected.insert(2, 100.0);
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
|
||||
trkseg.splice(0, 3, vec![]);
|
||||
expected.drain(..3);
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
|
||||
let len = trkseg.len();
|
||||
trkseg.splice(len, len, points(&[7.0, 8.0]));
|
||||
expected.extend([7.0, 8.0]);
|
||||
assert_eq!(eles(&trkseg), expected);
|
||||
|
||||
let len = trkseg.len();
|
||||
trkseg.splice(0, len, vec![]);
|
||||
assert_eq!(trkseg.len(), 0);
|
||||
assert!(trkseg.first_index().is_none());
|
||||
}
|
||||
|
||||
fn anchors(trkseg: &TrackSegment) -> Vec<Option<u8>> {
|
||||
trkseg.iter().map(|p| p.anchor).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_keeps_the_ends_anchors() {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
assert_eq!(anchors(&trkseg), vec![]);
|
||||
trkseg.splice(0, 0, points(&[0.0, 1.0, 2.0]));
|
||||
assert_eq!(anchors(&trkseg), [Some(0), None, Some(0)]);
|
||||
|
||||
// new ends: the previous ones stay anchors
|
||||
trkseg.splice(3, 3, points(&[3.0]));
|
||||
trkseg.splice(0, 0, points(&[-1.0]));
|
||||
assert_eq!(anchors(&trkseg), [Some(0), Some(0), None, Some(0), Some(0)]);
|
||||
|
||||
// removing the ends: the new ones become anchors
|
||||
trkseg.splice(4, 5, vec![]);
|
||||
trkseg.splice(0, 2, vec![]);
|
||||
assert_eq!(anchors(&trkseg), [Some(0), Some(0)]);
|
||||
|
||||
// a single point is both ends
|
||||
trkseg.splice(1, 2, vec![]);
|
||||
assert_eq!(anchors(&trkseg), [Some(0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_anchors_replaces_the_previous_ones() {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
let line: Vec<_> = (0..10)
|
||||
.map(|i| Trackpoint {
|
||||
coordinates: crate::LngLat {
|
||||
lng: i as f64 * 0.001,
|
||||
lat: 0.0,
|
||||
},
|
||||
anchor: Some(5),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
trkseg.splice(0, 0, line);
|
||||
trkseg.compute_anchors();
|
||||
let mut expected = vec![None; 10];
|
||||
expected[0] = Some(0);
|
||||
expected[9] = Some(0);
|
||||
assert_eq!(anchors(&trkseg), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_keeps_untouched_chunks_shared() {
|
||||
let mut trkseg = create_track_segment(5);
|
||||
// the ends are anchors already, or their chunks would be copied
|
||||
trkseg.ensure_end_anchors();
|
||||
let before = trkseg.chunks().to_vec();
|
||||
// inside the third chunk only
|
||||
trkseg.splice(4, 5, points(&[-1.0]));
|
||||
assert!(Rc::ptr_eq(&trkseg.chunks()[0], &before[0]));
|
||||
assert!(Rc::ptr_eq(&trkseg.chunks()[1], &before[1]));
|
||||
assert!(Rc::ptr_eq(trkseg.chunks().last().unwrap(), &before[4]));
|
||||
assert!(Rc::ptr_eq(
|
||||
&trkseg.chunks()[trkseg.chunks().len() - 2],
|
||||
&before[3]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_append_extends_last_chunk_and_respects_max_size() {
|
||||
let mut trkseg = TrackSegment::default();
|
||||
for i in 0..10_000 {
|
||||
let len = trkseg.len();
|
||||
trkseg.splice(len, len, points(&[i as f64]));
|
||||
}
|
||||
assert_eq!(trkseg.len(), 10_000);
|
||||
assert!(trkseg.chunks().len() <= 3);
|
||||
assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096));
|
||||
assert_eq!(trkseg[9_999].ele, 9_999.0);
|
||||
assert_eq!(trkseg.len(), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_len() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
assert_eq!(trkseg.len(), nb_chunks * (nb_chunks + 1) / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locate() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for i in 0..trkseg.len() {
|
||||
let idx = trkseg.locate(i);
|
||||
assert!(idx.is_some());
|
||||
assert_eq!(trkseg[idx.unwrap()].ele, i as f64);
|
||||
}
|
||||
|
||||
assert!(trkseg.locate(trkseg.len()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for i in 0..trkseg.len() {
|
||||
assert_eq!(trkseg[i].ele, i as f64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_index_out_of_bounds_1() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
let _ = trkseg[trkseg.len()];
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_index_out_of_bounds_2() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
let _ = trkseg[TrackSegmentIndex {
|
||||
chunk: trkseg.chunks().len(),
|
||||
pos: 0,
|
||||
flat: 0,
|
||||
}];
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for (i, trkpt) in trkseg.iter().enumerate() {
|
||||
assert!(std::ptr::eq(&trkseg[i], trkpt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter_nth() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
let tenth = trkseg.iter().nth(10);
|
||||
assert!(tenth.is_some());
|
||||
assert!(std::ptr::eq(&trkseg[10], tenth.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter_skip() {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
for (i, trkpt) in trkseg.iter().enumerate().skip(5) {
|
||||
assert!(std::ptr::eq(&trkseg[i], trkpt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_last_index() {
|
||||
assert!(TrackSegment::default().first_index().is_none());
|
||||
assert!(TrackSegment::default().last_index().is_none());
|
||||
|
||||
let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5
|
||||
let first = trkseg.first_index().unwrap();
|
||||
let last = trkseg.last_index().unwrap();
|
||||
assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0));
|
||||
assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14));
|
||||
assert_eq!(trkseg[last].ele, 14.0);
|
||||
assert_eq!(trkseg.locate(14), Some(last));
|
||||
assert!(trkseg.next_index(Some(last)).is_none());
|
||||
assert!(trkseg.prev_index(Some(first)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prev_next_are_inverse() {
|
||||
let trkseg = create_track_segment(6);
|
||||
let mut idx = trkseg.last_index();
|
||||
let mut visited = vec![];
|
||||
while let Some(i) = idx {
|
||||
assert_eq!(trkseg.locate(i.flat), Some(i));
|
||||
visited.push(i.flat);
|
||||
idx = trkseg.prev_index(Some(i));
|
||||
}
|
||||
assert_eq!(visited, (0..trkseg.len()).rev().collect::<Vec<_>>());
|
||||
|
||||
let mut idx = trkseg.first_index();
|
||||
while let Some(i) = idx {
|
||||
let next = trkseg.next_index(Some(i));
|
||||
if let Some(n) = next {
|
||||
assert_eq!(trkseg.prev_index(Some(n)), Some(i));
|
||||
}
|
||||
idx = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn points_of(n: usize) -> Vec<Trackpoint> {
|
||||
(0..n).map(|_| Trackpoint::default()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_change_gives_a_new_revision() {
|
||||
type Change = (&'static str, fn(&mut TrackSegment));
|
||||
let changes: [Change; 7] = [
|
||||
("push", |s| s.push(TrackpointChunk::new(points_of(1)))),
|
||||
("push_shared", |s| {
|
||||
s.push_shared(Rc::new(TrackpointChunk::new(points_of(1))))
|
||||
}),
|
||||
("update", |s| s.update(0, |p| p.ele += 1.0)),
|
||||
("update_all", |s| s.update_all(|_, p| p.ele += 1.0)),
|
||||
("splice", |s| s.splice(1, 2, points_of(2))),
|
||||
("set_anchor", |s| s.set_anchor(1, 5)),
|
||||
("compute_anchors", |s| s.compute_anchors()),
|
||||
];
|
||||
for (name, change) in changes {
|
||||
let mut trkseg = create_track_segment(3);
|
||||
let before = trkseg.rev_id;
|
||||
change(&mut trkseg);
|
||||
assert_ne!(trkseg.rev_id, before, "{name}");
|
||||
}
|
||||
// reading does not
|
||||
let trkseg = create_track_segment(3);
|
||||
let before = trkseg.rev_id;
|
||||
let _ = trkseg.iter().count();
|
||||
assert_eq!(trkseg.rev_id, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ends_are_anchors_after_a_splice() {
|
||||
let mut trkseg = create_track_segment(3);
|
||||
trkseg.splice(0, 1, points_of(2));
|
||||
let last = trkseg.len() - 1;
|
||||
assert_eq!(trkseg[0].anchor, Some(0));
|
||||
assert_eq!(trkseg[last].anchor, Some(0));
|
||||
|
||||
// nothing to anchor in an empty segment
|
||||
let mut empty = TrackSegment::default();
|
||||
empty.ensure_end_anchors();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::{Link, TrackSegment};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(TrackId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Track {
|
||||
pub id: TrackId,
|
||||
pub info: TrackInfo,
|
||||
pub trkseg: Vec<TrackSegment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct TrackInfo {
|
||||
pub name: Option<String>,
|
||||
pub cmt: Option<String>,
|
||||
pub desc: Option<String>,
|
||||
pub src: Option<String>,
|
||||
pub links: Vec<Link>,
|
||||
pub type_: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub opacity: Option<f64>,
|
||||
pub width: Option<f64>,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::LngLat;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Trackpoint {
|
||||
pub coordinates: LngLat,
|
||||
pub ele: f64,
|
||||
pub time: Option<i64>,
|
||||
pub atemp: Option<i16>,
|
||||
pub hr: Option<u16>,
|
||||
pub cad: Option<u16>,
|
||||
pub power: Option<u16>,
|
||||
/// Code of the surface, see [`crate::TrackpointCategories`].
|
||||
pub surface: Option<u8>,
|
||||
/// Code of the highway, see [`crate::TrackpointCategories`].
|
||||
pub highway: Option<u8>,
|
||||
/// Code of the SAC hiking scale, see [`crate::TrackpointCategories`].
|
||||
pub sac_scale: Option<u8>,
|
||||
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
|
||||
pub mtb_scale: Option<u8>,
|
||||
/// Whether the trackpoint is an anchor of the routing tool (a point the user can drag to
|
||||
/// reroute the segment): the lowest map zoom level at which it is shown. The first and last
|
||||
/// trackpoints of a segment are always anchors that are always shown (zoom 0).
|
||||
pub anchor: Option<u8>,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::{Link, LngLat};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(WaypointId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Waypoint {
|
||||
pub id: WaypointId,
|
||||
pub coordinates: LngLat,
|
||||
pub ele: f64,
|
||||
pub time: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub cmt: Option<String>,
|
||||
pub desc: Option<String>,
|
||||
pub links: Vec<Link>,
|
||||
pub sym: Option<String>,
|
||||
pub type_: Option<String>,
|
||||
}
|
||||
|
||||
impl Waypoint {
|
||||
/// Whether the waypoints are the same, whatever their ids.
|
||||
pub fn same_content(&self, other: &Waypoint) -> bool {
|
||||
self.coordinates.lng == other.coordinates.lng
|
||||
&& self.coordinates.lat == other.coordinates.lat
|
||||
&& self.ele == other.ele
|
||||
&& self.time == other.time
|
||||
&& self.name == other.name
|
||||
&& self.cmt == other.cmt
|
||||
&& self.desc == other.desc
|
||||
&& self.links == other.links
|
||||
&& self.sym == other.sym
|
||||
&& self.type_ == other.type_
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use crate::{Chunked, Waypoint, WaypointChunk, WaypointId};
|
||||
|
||||
use super::common::uuid_id;
|
||||
|
||||
uuid_id!(FileWaypointsRevisionId);
|
||||
|
||||
/// The waypoints of a file, in chunks that are shared between the versions of the file (see
|
||||
/// [`Chunked`]).
|
||||
///
|
||||
/// Everything that changes the waypoints changes the revision too, which is why there is no
|
||||
/// mutable access to the list itself: the revision tells what is derived from the waypoints
|
||||
/// (coordinates for the map...) that it is out of date.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Waypoints {
|
||||
chunks: Chunked<WaypointChunk>,
|
||||
/// Changes when the waypoints do.
|
||||
pub rev_id: FileWaypointsRevisionId,
|
||||
}
|
||||
|
||||
impl Deref for Waypoints {
|
||||
type Target = Chunked<WaypointChunk>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.chunks
|
||||
}
|
||||
}
|
||||
|
||||
impl Waypoints {
|
||||
/// Waypoints made of the given chunks, which are used as they are.
|
||||
pub fn new(chunks: impl IntoIterator<Item = WaypointChunk>) -> Self {
|
||||
let mut waypoints = Self::default();
|
||||
for chunk in chunks {
|
||||
waypoints.push(chunk);
|
||||
}
|
||||
waypoints
|
||||
}
|
||||
|
||||
/// Adds a chunk after the last waypoint.
|
||||
pub fn push(&mut self, chunk: WaypointChunk) {
|
||||
self.chunks.push(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Adds a chunk that is shared with something else after the last waypoint.
|
||||
pub fn push_shared(&mut self, chunk: std::rc::Rc<WaypointChunk>) {
|
||||
self.chunks.push_shared(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`].
|
||||
pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec<Waypoint>) {
|
||||
self.chunks.splice(start, end, waypoints);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Inserts waypoints, so that the first one is at `index` among the waypoints (at the end if
|
||||
/// the index is past it). Only the chunks around the insertion are copied.
|
||||
pub fn insert_at(&mut self, index: usize, waypoints: Vec<Waypoint>) {
|
||||
if waypoints.is_empty() {
|
||||
return;
|
||||
}
|
||||
let index = index.min(self.len());
|
||||
self.splice(index, index, waypoints);
|
||||
}
|
||||
|
||||
/// Inserts waypoints right after the waypoint `after`, or at the end if there is none (or if
|
||||
/// it is not there). See [`Waypoints::insert_at`].
|
||||
pub fn insert_after(&mut self, after: Option<WaypointId>, waypoints: Vec<Waypoint>) {
|
||||
let index = after
|
||||
.and_then(|after| self.position(after))
|
||||
.map_or(usize::MAX, |i| i + 1);
|
||||
self.insert_at(index, waypoints);
|
||||
}
|
||||
|
||||
/// The position of the waypoint among the waypoints.
|
||||
pub fn position(&self, id: WaypointId) -> Option<usize> {
|
||||
self.iter().position(|wpt| wpt.id == id)
|
||||
}
|
||||
|
||||
/// Edits the waypoints of the chunks that contain one accepted by `filter`, see
|
||||
/// [`Chunked::edit`]. Returns whether anything changed.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
f: impl FnMut(&mut Vec<Waypoint>) -> bool,
|
||||
) -> bool {
|
||||
let changed = self.chunks.edit(filter, f);
|
||||
if changed {
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn waypoint() -> Waypoint {
|
||||
Waypoint::default()
|
||||
}
|
||||
|
||||
fn waypoints_with(chunks: &[usize]) -> (Waypoints, Vec<WaypointId>) {
|
||||
let mut waypoints = Waypoints::default();
|
||||
let mut ids = vec![];
|
||||
for n in chunks {
|
||||
let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect();
|
||||
ids.extend(wpt.iter().map(|w| w.id));
|
||||
waypoints.push(WaypointChunk {
|
||||
wpt,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
(waypoints, ids)
|
||||
}
|
||||
|
||||
fn ids(waypoints: &Waypoints) -> Vec<WaypointId> {
|
||||
waypoints.iter().map(|w| w.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_and_push() {
|
||||
let (waypoints, ids_) = waypoints_with(&[2, 1]);
|
||||
assert_eq!(waypoints.len(), 3);
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert_eq!(ids(&waypoints), ids_);
|
||||
assert_eq!(waypoints.position(ids_[2]), Some(2));
|
||||
assert_eq!(waypoints.position(WaypointId::default()), None);
|
||||
// empty chunks are dropped
|
||||
let waypoints = Waypoints::new([WaypointChunk::default()]);
|
||||
assert!(waypoints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_at_the_end() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||
let first = waypoints.chunks()[0].clone();
|
||||
let rev = waypoints.rev_id;
|
||||
let new = vec![waypoint(), waypoint()];
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
waypoints.insert_after(None, new);
|
||||
assert_eq!(ids(&waypoints), [before.clone(), new_ids].concat());
|
||||
// the chunk that is not touched is kept, the last one is extended
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
|
||||
assert_ne!(waypoints.rev_id, rev);
|
||||
// an unknown waypoint is an insertion at the end too
|
||||
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_after(Some(WaypointId::default()), vec![one]);
|
||||
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_next_ones() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 2]);
|
||||
let last = waypoints.chunks()[1].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_after(Some(before[1]), vec![one]);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..2], &[one_id], &before[2..]].concat()
|
||||
);
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_in_the_middle_of_a_chunk_cuts_it() {
|
||||
let (mut waypoints, before) = waypoints_with(&[3, 1]);
|
||||
let last = waypoints.chunks()[1].clone();
|
||||
let new = vec![waypoint(), waypoint()];
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
waypoints.insert_after(Some(before[0]), new);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..1], &new_ids[..], &before[1..]].concat()
|
||||
);
|
||||
// the cut chunk is refilled, the other one is kept
|
||||
assert_eq!(waypoints.chunks().len(), 2);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_nothing() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||
let rev = waypoints.rev_id;
|
||||
waypoints.insert_after(Some(before[0]), vec![]);
|
||||
assert_eq!(ids(&waypoints), before);
|
||||
assert_eq!(waypoints.rev_id, rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_many_fills_chunks() {
|
||||
let mut waypoints = Waypoints::default();
|
||||
let new: Vec<_> = (0..300).map(|_| waypoint()).collect();
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
waypoints.insert_after(None, new);
|
||||
assert_eq!(ids(&waypoints), new_ids);
|
||||
assert_eq!(waypoints.chunks().len(), 3);
|
||||
assert!(waypoints.chunks()[..2].iter().all(|chunk| {
|
||||
use crate::Chunk;
|
||||
chunk.is_full()
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_at_an_index() {
|
||||
// at the start
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||
let first = waypoints.chunks()[0].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(0, vec![one]);
|
||||
assert_eq!(ids(&waypoints), [vec![one_id], before.clone()].concat());
|
||||
// the existing chunks are kept
|
||||
assert_eq!(waypoints.chunks().len(), 3);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &first));
|
||||
|
||||
// between two chunks
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||
let last = waypoints.chunks()[1].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(2, vec![one]);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..2], &[one_id], &before[2..]].concat()
|
||||
);
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||
|
||||
// inside a chunk
|
||||
let (mut waypoints, before) = waypoints_with(&[3]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(1, vec![one]);
|
||||
assert_eq!(
|
||||
ids(&waypoints),
|
||||
[&before[..1], &[one_id], &before[1..]].concat()
|
||||
);
|
||||
|
||||
// at the end, or past it
|
||||
for index in [3, 100, usize::MAX] {
|
||||
let (mut waypoints, before) = waypoints_with(&[3]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(index, vec![one]);
|
||||
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
|
||||
}
|
||||
|
||||
// in a file without waypoints
|
||||
let mut waypoints = Waypoints::default();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
waypoints.insert_at(0, vec![one]);
|
||||
assert_eq!(ids(&waypoints), vec![one_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_replaces_waypoints() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 2]);
|
||||
let rev = waypoints.rev_id;
|
||||
waypoints.splice(1, 3, vec![]);
|
||||
assert_eq!(ids(&waypoints), [before[0], before[3]]);
|
||||
assert_ne!(waypoints.rev_id, rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_changes_the_chunks_with_a_match_only() {
|
||||
let (mut waypoints, before) = waypoints_with(&[2, 2, 1]);
|
||||
let (first, last) = (waypoints.chunks()[0].clone(), waypoints.chunks()[2].clone());
|
||||
let rev = waypoints.rev_id;
|
||||
let target = before[2];
|
||||
let changed = waypoints.edit(
|
||||
|wpt| wpt.id == target,
|
||||
|wpts| {
|
||||
wpts.retain(|wpt| wpt.id != target);
|
||||
true
|
||||
},
|
||||
);
|
||||
assert!(changed);
|
||||
assert_ne!(waypoints.rev_id, rev);
|
||||
assert_eq!(ids(&waypoints), [&before[..2], &before[3..]].concat());
|
||||
// the chunks without a match are the same ones, the changed one is new
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[2], &last));
|
||||
assert_eq!(waypoints.chunks().len(), 3);
|
||||
|
||||
// a chunk left empty is dropped
|
||||
let (mut waypoints, before) = waypoints_with(&[1, 2]);
|
||||
waypoints.edit(
|
||||
|wpt| wpt.id == before[0],
|
||||
|wpts| {
|
||||
wpts.clear();
|
||||
true
|
||||
},
|
||||
);
|
||||
assert_eq!(waypoints.chunks().len(), 1);
|
||||
assert_eq!(ids(&waypoints), before[1..]);
|
||||
|
||||
// a chunk that is not changed is kept, and so is the revision
|
||||
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||
let (chunk, rev) = (waypoints.chunks()[0].clone(), waypoints.rev_id);
|
||||
assert!(!waypoints.edit(|wpt| wpt.id == before[0], |_| false));
|
||||
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &chunk));
|
||||
assert_eq!(waypoints.rev_id, rev);
|
||||
assert!(!waypoints.edit(|_| false, |_| true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod parse;
|
||||
mod write;
|
||||
|
||||
pub use parse::*;
|
||||
pub use write::*;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,766 @@
|
||||
use std::io;
|
||||
|
||||
use chrono::DateTime;
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{BytesDecl, BytesText, Event};
|
||||
|
||||
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
|
||||
|
||||
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
||||
/// The same type tells which data a file has, see [`File::exportable_data`].
|
||||
///
|
||||
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
|
||||
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExportOptions {
|
||||
pub time: bool,
|
||||
pub hr: bool,
|
||||
pub cad: bool,
|
||||
pub atemp: bool,
|
||||
pub power: bool,
|
||||
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
||||
pub osm: bool,
|
||||
/// Writes every segment as a route (`rte`) instead of a track.
|
||||
pub as_route: bool,
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
pub const ALL: Self = Self::of(true);
|
||||
pub const NONE: Self = Self::of(false);
|
||||
|
||||
const fn of(value: bool) -> Self {
|
||||
Self {
|
||||
time: value,
|
||||
hr: value,
|
||||
cad: value,
|
||||
atemp: value,
|
||||
power: value,
|
||||
osm: value,
|
||||
as_route: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The options that are on in `self` or in `other`.
|
||||
pub fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
time: self.time || other.time,
|
||||
hr: self.hr || other.hr,
|
||||
cad: self.cad || other.cad,
|
||||
atemp: self.atemp || other.atemp,
|
||||
power: self.power || other.power,
|
||||
osm: self.osm || other.osm,
|
||||
as_route: self.as_route || other.as_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl File {
|
||||
/// The data that the trackpoints of the file have (and waypoints for the time), which the
|
||||
/// options of [`write`] can leave out.
|
||||
pub fn exportable_data(&self) -> ExportOptions {
|
||||
let mut data = ExportOptions::NONE;
|
||||
for point in self
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|trk| &trk.trkseg)
|
||||
.flat_map(|seg| seg.iter())
|
||||
{
|
||||
data.time |= point.time.is_some();
|
||||
data.hr |= point.hr.is_some();
|
||||
data.cad |= point.cad.is_some();
|
||||
data.atemp |= point.atemp.is_some();
|
||||
data.power |= point.power.is_some();
|
||||
data.osm |= point.surface.is_some()
|
||||
|| point.highway.is_some()
|
||||
|| point.sac_scale.is_some()
|
||||
|| point.mtb_scale.is_some();
|
||||
if data == ExportOptions::ALL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
|
||||
|
||||
type XmlWriter = Writer<Vec<u8>>;
|
||||
|
||||
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
||||
/// the names of their codes in `categories`.
|
||||
///
|
||||
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a
|
||||
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints
|
||||
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a
|
||||
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`.
|
||||
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
||||
// writing to memory does not fail
|
||||
write_file(&mut writer, file, categories, options).expect("writing to memory");
|
||||
writer.into_inner()
|
||||
}
|
||||
|
||||
fn write_file(
|
||||
w: &mut XmlWriter,
|
||||
file: &File,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
|
||||
w.create_element("gpx")
|
||||
.with_attributes([
|
||||
("creator", "https://gpx.studio"),
|
||||
("version", "1.1"),
|
||||
("xmlns", "http://www.topografix.com/GPX/1/1"),
|
||||
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
|
||||
("xsi:schemaLocation", SCHEMA_LOCATION),
|
||||
(
|
||||
"xmlns:gpxtpx",
|
||||
"http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
|
||||
),
|
||||
(
|
||||
"xmlns:gpxx",
|
||||
"http://www.garmin.com/xmlschemas/GpxExtensions/v3",
|
||||
),
|
||||
(
|
||||
"xmlns:gpxpx",
|
||||
"http://www.garmin.com/xmlschemas/PowerExtension/v1",
|
||||
),
|
||||
(
|
||||
"xmlns:gpx_style",
|
||||
"http://www.topografix.com/GPX/gpx_style/0/2",
|
||||
),
|
||||
])
|
||||
.write_inner_content(|w| {
|
||||
write_metadata(w, file, options)?;
|
||||
for wpt in file.wpt.iter() {
|
||||
write_waypoint(w, wpt, options)?;
|
||||
}
|
||||
for trk in &file.trk {
|
||||
// a single unnamed track is named like the file
|
||||
let name = match (&trk.info.name, file.trk.len()) {
|
||||
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
|
||||
(_, 1) => Some(file.info.name.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
if options.as_route {
|
||||
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) {
|
||||
write_route(w, trk, segment, name, categories, options)?;
|
||||
}
|
||||
} else {
|
||||
write_track(w, trk, name, categories, options)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> {
|
||||
if !value.is_empty() {
|
||||
w.create_element(name)
|
||||
.write_text_content(BytesText::new(value))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn optional_text(w: &mut XmlWriter, name: &str, value: &Option<String>) -> io::Result<()> {
|
||||
text(w, name, value.as_deref().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> {
|
||||
text(w, name, &value.to_string())
|
||||
}
|
||||
|
||||
fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
|
||||
if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) {
|
||||
text(
|
||||
w,
|
||||
"time",
|
||||
&time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
|
||||
if !link.href.is_empty() {
|
||||
w.create_element("link")
|
||||
.with_attribute(("href", link.href.as_str()))
|
||||
.write_inner_content(|w| optional_text(w, "text", &link.text))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> {
|
||||
links.iter().try_for_each(|l| link(w, l))
|
||||
}
|
||||
|
||||
fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
|
||||
let info = &file.info;
|
||||
let time = options.time.then_some(info.time).flatten();
|
||||
if info.name.is_empty()
|
||||
&& info.desc.is_none()
|
||||
&& info.author.is_none()
|
||||
&& info.links.is_empty()
|
||||
&& time.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
w.create_element("metadata").write_inner_content(|w| {
|
||||
text(w, "name", &info.name)?;
|
||||
optional_text(w, "desc", &info.desc)?;
|
||||
if let Some(author) = &info.author {
|
||||
w.create_element("author").write_inner_content(|w| {
|
||||
optional_text(w, "name", &author.name)?;
|
||||
if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@'))
|
||||
{
|
||||
w.create_element("email")
|
||||
.with_attributes([("id", id), ("domain", domain)])
|
||||
.write_empty()?;
|
||||
}
|
||||
author.link.iter().try_for_each(|l| link(w, l))
|
||||
})?;
|
||||
}
|
||||
links(w, &info.links)?;
|
||||
self::time(w, time)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_coordinates<'a>(
|
||||
w: &'a mut XmlWriter,
|
||||
name: &'a str,
|
||||
coordinates: crate::LngLat,
|
||||
) -> quick_xml::writer::ElementWriter<'a, Vec<u8>> {
|
||||
w.create_element(name).with_attributes([
|
||||
("lat", coordinates.lat.to_string().as_str()),
|
||||
("lon", coordinates.lng.to_string().as_str()),
|
||||
])
|
||||
}
|
||||
|
||||
fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> {
|
||||
write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| {
|
||||
number(w, "ele", wpt.ele)?;
|
||||
if options.time {
|
||||
time(w, wpt.time)?;
|
||||
}
|
||||
optional_text(w, "name", &wpt.name)?;
|
||||
optional_text(w, "cmt", &wpt.cmt)?;
|
||||
optional_text(w, "desc", &wpt.desc)?;
|
||||
links(w, &wpt.links)?;
|
||||
optional_text(w, "sym", &wpt.sym)?;
|
||||
optional_text(w, "type", &wpt.type_)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The information of a track that its routes have too: name, comment, description, source,
|
||||
/// links, type and style.
|
||||
fn write_track_info(
|
||||
w: &mut XmlWriter,
|
||||
name: Option<&str>,
|
||||
info: &crate::TrackInfo,
|
||||
) -> io::Result<()> {
|
||||
text(w, "name", name.unwrap_or_default())?;
|
||||
optional_text(w, "cmt", &info.cmt)?;
|
||||
optional_text(w, "desc", &info.desc)?;
|
||||
optional_text(w, "src", &info.src)?;
|
||||
links(w, &info.links)?;
|
||||
optional_text(w, "type", &info.type_)?;
|
||||
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
|
||||
w.create_element("extensions").write_inner_content(|w| {
|
||||
w.create_element("gpx_style:line")
|
||||
.write_inner_content(|w| {
|
||||
optional_text(w, "gpx_style:color", &info.color)?;
|
||||
if let Some(opacity) = info.opacity {
|
||||
number(w, "gpx_style:opacity", opacity)?;
|
||||
}
|
||||
if let Some(width) = info.width {
|
||||
number(w, "gpx_style:width", width)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_track(
|
||||
w: &mut XmlWriter,
|
||||
trk: &Track,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.create_element("trk").write_inner_content(|w| {
|
||||
write_track_info(w, name, &trk.info)?;
|
||||
for segment in &trk.trkseg {
|
||||
w.create_element("trkseg").write_inner_content(|w| {
|
||||
for point in segment.iter() {
|
||||
write_point(w, "trkpt", point, &[], categories, options)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_route(
|
||||
w: &mut XmlWriter,
|
||||
trk: &Track,
|
||||
segment: &crate::TrackSegment,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
w.create_element("rte").write_inner_content(|w| {
|
||||
write_track_info(w, name, &trk.info)?;
|
||||
let points: Vec<&Trackpoint> = segment.iter().collect();
|
||||
let last = points.len() - 1;
|
||||
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some();
|
||||
let mut i = 0;
|
||||
while i < points.len() {
|
||||
// the trackpoints up to the next anchor
|
||||
let mut next = i + 1;
|
||||
while next < points.len() && !is_anchor(next) {
|
||||
next += 1;
|
||||
}
|
||||
write_point(
|
||||
w,
|
||||
"rtept",
|
||||
points[i],
|
||||
&points[i + 1..next],
|
||||
categories,
|
||||
options,
|
||||
)?;
|
||||
i = next;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one,
|
||||
/// of which only the positions are written.
|
||||
fn write_point(
|
||||
w: &mut XmlWriter,
|
||||
tag: &str,
|
||||
point: &Trackpoint,
|
||||
path: &[&Trackpoint],
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
let atemp = point.atemp.filter(|_| options.atemp);
|
||||
let hr = point.hr.filter(|_| options.hr);
|
||||
let cad = point.cad.filter(|_| options.cad);
|
||||
let power = point.power.filter(|_| options.power);
|
||||
let osm = [
|
||||
("surface", point.surface, &categories.surface),
|
||||
("highway", point.highway, &categories.highway),
|
||||
("sac_scale", point.sac_scale, &categories.sac_scale),
|
||||
("mtb_scale", point.mtb_scale, &categories.mtb_scale),
|
||||
]
|
||||
.map(|(name, code, categories)| {
|
||||
let value = code
|
||||
.filter(|_| options.osm)
|
||||
.and_then(|code| categories.name(code));
|
||||
(name, value)
|
||||
});
|
||||
let has_osm = osm.iter().any(|(_, value)| value.is_some());
|
||||
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
|
||||
|
||||
write_coordinates(w, tag, point.coordinates).write_inner_content(|w| {
|
||||
number(w, "ele", point.ele)?;
|
||||
if options.time {
|
||||
time(w, point.time)?;
|
||||
}
|
||||
if !has_track_point_extension && power.is_none() && path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
w.create_element("extensions").write_inner_content(|w| {
|
||||
if has_track_point_extension {
|
||||
w.create_element("gpxtpx:TrackPointExtension")
|
||||
.write_inner_content(|w| {
|
||||
if let Some(atemp) = atemp {
|
||||
number(w, "gpxtpx:atemp", atemp)?;
|
||||
}
|
||||
if let Some(hr) = hr {
|
||||
number(w, "gpxtpx:hr", hr)?;
|
||||
}
|
||||
if let Some(cad) = cad {
|
||||
number(w, "gpxtpx:cad", cad)?;
|
||||
}
|
||||
if has_osm {
|
||||
w.create_element("gpxtpx:Extensions")
|
||||
.write_inner_content(|w| {
|
||||
for (name, value) in osm {
|
||||
text(w, name, value.unwrap_or_default())?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if let Some(power) = power {
|
||||
w.create_element("gpxpx:PowerExtension")
|
||||
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
|
||||
}
|
||||
if !path.is_empty() {
|
||||
w.create_element("gpxx:RoutePointExtension")
|
||||
.write_inner_content(|w| {
|
||||
for point in path {
|
||||
write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read(name: &str) -> (File, TrackpointCategories) {
|
||||
let mut categories = TrackpointCategories::default();
|
||||
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
|
||||
(parse(&data, &mut categories).unwrap(), categories)
|
||||
}
|
||||
|
||||
fn written_text(
|
||||
file: &File,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> String {
|
||||
String::from_utf8(write(file, categories, options)).unwrap()
|
||||
}
|
||||
|
||||
/// Writes a file and reads it again.
|
||||
fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) {
|
||||
let (file, mut categories) = read(name);
|
||||
let written = write(&file, &categories, options);
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
(file, again, categories)
|
||||
}
|
||||
|
||||
fn points(file: &File) -> Vec<Trackpoint> {
|
||||
file.trk
|
||||
.iter()
|
||||
.flat_map(|trk| &trk.trkseg)
|
||||
.flat_map(|seg| seg.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_points(a: &[Trackpoint], b: &[Trackpoint]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||
assert_eq!(a.ele, b.ele);
|
||||
assert_eq!(a.time, b.time);
|
||||
assert_eq!(a.atemp, b.atemp);
|
||||
assert_eq!(a.hr, b.hr);
|
||||
assert_eq!(a.cad, b.cad);
|
||||
assert_eq!(a.power, b.power);
|
||||
assert_eq!(a.surface, b.surface);
|
||||
assert_eq!(a.highway, b.highway);
|
||||
assert_eq!(a.sac_scale, b.sac_scale);
|
||||
assert_eq!(a.mtb_scale, b.mtb_scale);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_links_and_the_email_are_written() {
|
||||
let data = std::fs::read("data/with_links.gpx").unwrap();
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let again = parse(
|
||||
&write(&file, &Default::default(), ExportOptions::ALL),
|
||||
&mut Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(again.info.links, file.info.links);
|
||||
assert_eq!(again.info.links.len(), 2);
|
||||
assert_eq!(again.info.author, file.info.author);
|
||||
assert_eq!(
|
||||
again.info.author.as_ref().unwrap().email.as_deref(),
|
||||
Some("someone@example.com")
|
||||
);
|
||||
let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(waypoints(&again), waypoints(&file));
|
||||
assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3);
|
||||
for (a, b) in again.trk.iter().zip(&file.trk) {
|
||||
assert_eq!(a.info.links, b.info.links);
|
||||
assert_eq!(a.info.links.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_survive_a_round_trip() {
|
||||
for name in [
|
||||
"simple",
|
||||
"with_links",
|
||||
"with_time",
|
||||
"with_hr",
|
||||
"with_cad",
|
||||
"with_temp",
|
||||
"with_power_1",
|
||||
"with_power_2",
|
||||
"with_surface",
|
||||
"with_highway",
|
||||
"with_style",
|
||||
"with_waypoint",
|
||||
"with_tracks_and_segments",
|
||||
"self_closing_points",
|
||||
] {
|
||||
let (file, again, _) = round_trip(name, ExportOptions::ALL);
|
||||
assert_eq!(file.info.name, again.info.name, "{name}");
|
||||
assert_eq!(file.info.desc, again.info.desc, "{name}");
|
||||
assert_eq!(file.info.author, again.info.author, "{name}");
|
||||
assert_eq!(file.trk.len(), again.trk.len(), "{name}");
|
||||
for (a, b) in file.trk.iter().zip(&again.trk) {
|
||||
assert_eq!(a.info, b.info, "{name}");
|
||||
assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}");
|
||||
}
|
||||
same_points(&points(&file), &points(&again));
|
||||
let waypoints: Vec<_> = file.wpt.iter().collect();
|
||||
let again_waypoints: Vec<_> = again.wpt.iter().collect();
|
||||
assert_eq!(waypoints.len(), again_waypoints.len(), "{name}");
|
||||
for (a, b) in waypoints.iter().zip(&again_waypoints) {
|
||||
assert!(a.same_content(b), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_options_leave_data_out() {
|
||||
let (file, categories) = read("with_hr");
|
||||
assert!(points(&file).iter().any(|p| p.hr.is_some()));
|
||||
let options = ExportOptions {
|
||||
hr: false,
|
||||
..ExportOptions::ALL
|
||||
};
|
||||
let written = written_text(&file, &categories, options);
|
||||
assert!(!written.contains("hr>"));
|
||||
|
||||
let (file, categories) = read("with_time");
|
||||
let options = ExportOptions {
|
||||
time: false,
|
||||
..ExportOptions::ALL
|
||||
};
|
||||
assert!(!written_text(&file, &categories, options).contains("<time>"));
|
||||
assert!(
|
||||
written_text(&file, &categories, ExportOptions::ALL)
|
||||
.contains("<time>2023-12-31T23:00:00.000Z</time>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_is_escaped_and_the_single_track_gets_the_name_of_the_file() {
|
||||
let (mut file, categories) = read("simple");
|
||||
file.info.name = "a <b> & c".into();
|
||||
file.trk.truncate(1);
|
||||
file.trk[0].info.name = None;
|
||||
let written = write(&file, &categories, ExportOptions::ALL);
|
||||
let text = String::from_utf8(written.clone()).unwrap();
|
||||
assert!(text.contains("a <b> & c"));
|
||||
// the track has the name of the file
|
||||
assert_eq!(text.matches("a <b> & c").count(), 2);
|
||||
// and it is read back as it was
|
||||
let mut categories = categories;
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
assert_eq!(again.info.name, "a <b> & c");
|
||||
assert_eq!(again.trk[0].info.name.as_deref(), Some("a <b> & c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exportable_data() {
|
||||
let (file, _) = read("with_time");
|
||||
let data = file.exportable_data();
|
||||
assert!(data.time && !data.hr && !data.osm);
|
||||
let (file, _) = read("with_surface");
|
||||
assert!(file.exportable_data().osm);
|
||||
let (file, _) = read("with_power_2");
|
||||
assert!(file.exportable_data().power);
|
||||
}
|
||||
|
||||
fn as_route() -> ExportOptions {
|
||||
ExportOptions {
|
||||
as_route: true,
|
||||
..ExportOptions::ALL
|
||||
}
|
||||
}
|
||||
|
||||
fn count(text: &str, pattern: &str) -> usize {
|
||||
text.matches(pattern).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_as_route_writes_routes_instead_of_tracks() {
|
||||
let (file, categories) = read("with_route_points");
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
|
||||
assert_eq!(count(&text, "<rte>"), 1);
|
||||
assert_eq!(count(&text, "<trk>"), 0);
|
||||
assert_eq!(count(&text, "<trkseg>"), 0);
|
||||
assert_eq!(count(&text, "<trkpt"), 0);
|
||||
// the anchors are the route points, the trackpoints between them the points of their path
|
||||
assert_eq!(count(&text, "<rtept"), 3);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), 4);
|
||||
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
|
||||
// a route has the information of its track
|
||||
assert!(text.contains("<name>Lausanne</name>"));
|
||||
assert!(text.contains("<cmt>comment</cmt>"));
|
||||
assert!(text.contains("<desc>description</desc>"));
|
||||
assert!(text.contains("<type>Cycling</type>"));
|
||||
// and its points only have a position in the path, and an elevation as route points
|
||||
let first_rtept = &text[text.find("<rtept").unwrap()..];
|
||||
let (lat, lng) = (46.54462809674442_f64, 6.658079791814089_f64);
|
||||
assert!(first_rtept.starts_with(&format!("<rtept lat=\"{lat}\" lon=\"{lng}\">")));
|
||||
assert!(first_rtept.contains("<ele>500</ele>"));
|
||||
let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64);
|
||||
assert!(text.contains(&format!("<gpxx:rpt lat=\"{lat}\" lon=\"{lng}\"/>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_route_survives_being_exported_as_a_route() {
|
||||
let (file, mut categories) = read("with_route_points");
|
||||
let written = write(&file, &categories, as_route());
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
|
||||
assert_eq!(again.trk.len(), 1);
|
||||
assert_eq!(again.trk[0].info, file.trk[0].info);
|
||||
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||
assert_eq!(a.anchor, b.anchor);
|
||||
}
|
||||
// the elevation of the route points is kept, the other ones are worked out again
|
||||
let anchors = |s: &crate::TrackSegment| {
|
||||
s.iter()
|
||||
.filter(|p| p.anchor.is_some())
|
||||
.map(|p| p.ele)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(anchors(a), anchors(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
|
||||
let (file, mut categories) = read("with_tracks_and_segments");
|
||||
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
||||
assert!(segments > 2);
|
||||
let written = write(&file, &categories, as_route());
|
||||
let text = String::from_utf8(written.clone()).unwrap();
|
||||
assert_eq!(count(&text, "<rte>"), segments);
|
||||
assert_eq!(count(&text, "<trk>"), 0);
|
||||
// every route starts and ends with a route point
|
||||
assert_eq!(count(&text, "</rte>"), segments);
|
||||
|
||||
let again = parse(&written, &mut categories).unwrap();
|
||||
assert_eq!(again.trk.len(), segments);
|
||||
let mut routes = again.trk.iter();
|
||||
for track in &file.trk {
|
||||
for segment in &track.trkseg {
|
||||
let route = routes.next().unwrap();
|
||||
// the information of the track, repeated for each of its segments
|
||||
assert_eq!(route.info.name, track.info.name);
|
||||
assert_eq!(route.info.type_, track.info.type_);
|
||||
let (a, b) = (segment, &route.trkseg[0]);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(
|
||||
(a.coordinates.lat, a.coordinates.lng),
|
||||
(b.coordinates.lat, b.coordinates.lng)
|
||||
);
|
||||
}
|
||||
// the anchors of the segment are the ones of its route
|
||||
let anchors = |s: &crate::TrackSegment| {
|
||||
s.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.anchor.is_some())
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(anchors(a), anchors(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
|
||||
let (mut file, categories) = read("simple");
|
||||
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None);
|
||||
let len = file.trk[0].trkseg[0].len();
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert_eq!(count(&text, "<rtept"), 2);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
|
||||
// and a single trackpoint is a route of one point
|
||||
file.trk[0].trkseg[0].splice(1, len, vec![]);
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert_eq!(count(&text, "<rtept"), 1);
|
||||
assert_eq!(count(&text, "<gpxx:rpt"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_segments_are_not_routes() {
|
||||
let (mut file, categories) = read("simple");
|
||||
file.trk[0].trkseg.push(crate::TrackSegment::default());
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert_eq!(count(&text, "<rte>"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_have_the_style_of_their_track() {
|
||||
let (file, categories) = read("with_style");
|
||||
let text = written_text(&file, &categories, as_route());
|
||||
assert!(count(&text, "<gpx_style:line>") >= 1);
|
||||
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
|
||||
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||
assert_eq!(again.trk[0].info.color, file.trk[0].info.color);
|
||||
assert_eq!(again.trk[0].info.width, file.trk[0].info.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
|
||||
let (file, categories) = read("with_hr");
|
||||
let with_all = written_text(&file, &categories, as_route());
|
||||
assert!(with_all.contains("<gpxtpx:hr>"));
|
||||
let without = written_text(
|
||||
&file,
|
||||
&categories,
|
||||
ExportOptions {
|
||||
hr: false,
|
||||
time: false,
|
||||
as_route: true,
|
||||
..ExportOptions::ALL
|
||||
},
|
||||
);
|
||||
assert!(!without.contains("<gpxtpx:hr>"));
|
||||
assert!(!without.contains("<time>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_route_option_is_not_data() {
|
||||
let (file, _) = read("with_route_points");
|
||||
assert!(!file.exportable_data().as_route);
|
||||
// it is a way to write the data, never part of them
|
||||
let all = std::hint::black_box(ExportOptions::ALL);
|
||||
assert!(!all.as_route && !ExportOptions::NONE.as_route);
|
||||
assert!(as_route().union(ExportOptions::NONE).as_route);
|
||||
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod algorithm;
|
||||
pub(crate) mod gpx;
|
||||
mod io;
|
||||
pub(crate) mod statistics;
|
||||
mod utils;
|
||||
|
||||
pub use algorithm::*;
|
||||
pub use gpx::*;
|
||||
pub use io::*;
|
||||
pub use statistics::*;
|
||||
pub use utils::*;
|
||||
@@ -0,0 +1,527 @@
|
||||
use crate::{
|
||||
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
|
||||
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
|
||||
};
|
||||
|
||||
/// Time between two trackpoints, to size the windows of a smoothing. It is unknown when one of
|
||||
/// them has no time, which is longer than any known time span: the window does not go there.
|
||||
#[derive(Clone, Copy, PartialEq, PartialOrd)]
|
||||
enum TimeSpan {
|
||||
Known(i64),
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TimeSpan {
|
||||
fn between(end: Option<i64>, start: Option<i64>) -> Self {
|
||||
time_diff(end, start).map_or(Self::Unknown, Self::Known)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Statistics {
|
||||
pub global: GlobalStatistics,
|
||||
pub local: Vec<TrackpointStatistics>,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
pub fn total_speed(&self) -> Option<f64> {
|
||||
self.global.total_speed()
|
||||
}
|
||||
|
||||
pub fn moving_speed(&self) -> Option<f64> {
|
||||
self.global.moving_speed()
|
||||
}
|
||||
|
||||
pub fn compute(trkseg: &TrackSegment) -> Self {
|
||||
let mut stats = Self::default();
|
||||
if trkseg.is_empty() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
let mut prev = &trkseg[0];
|
||||
for cur in trkseg.iter() {
|
||||
stats.accumulate(prev, cur);
|
||||
prev = cur;
|
||||
}
|
||||
|
||||
stats.compute_smoothed_speed(trkseg);
|
||||
stats.compute_smoothed_elevation_segments(trkseg);
|
||||
stats.compute_smoothed_slope(trkseg);
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
|
||||
self.accumulate_distance_and_time(prev, cur);
|
||||
self.update_time_bounds(cur.time);
|
||||
self.update_bounds(cur.coordinates);
|
||||
self.local
|
||||
.push(TrackpointStatistics::from_partial_stats(self));
|
||||
}
|
||||
|
||||
fn accumulate_distance_and_time(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
|
||||
let dist = distance(prev.coordinates, cur.coordinates);
|
||||
let time = time_diff(cur.time, prev.time);
|
||||
|
||||
self.global.total_distance += dist;
|
||||
|
||||
let moving = time
|
||||
.zip(time.and_then(|time| speed(dist, time)))
|
||||
.filter(|(_, speed)| (0.5..=1500.0).contains(speed));
|
||||
if let Some((time, _)) = moving {
|
||||
self.global.moving_distance = self
|
||||
.global
|
||||
.moving_distance
|
||||
.map_or(Some(dist), |d| Some(d + dist));
|
||||
self.global.moving_time = self
|
||||
.global
|
||||
.moving_time
|
||||
.map_or(Some(time), |t| Some(t + time));
|
||||
}
|
||||
}
|
||||
|
||||
fn update_time_bounds(&mut self, time: Option<i64>) {
|
||||
if let Some(time) = time {
|
||||
if self.global.start_time.is_none() {
|
||||
self.global.start_time = Some(time);
|
||||
}
|
||||
self.global.end_time = Some(time);
|
||||
self.global.total_time = time_diff(self.global.end_time, self.global.start_time);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_bounds(&mut self, coordinates: LngLat) {
|
||||
self.global.bounds.extend(coordinates);
|
||||
}
|
||||
|
||||
/// Speed over a window of 10 seconds on each side of the trackpoints that have a time (the
|
||||
/// others have no speed). The windows do not extend to the trackpoints without time: if the
|
||||
/// previous or next trackpoint has none, the window ends at the trackpoint itself on that side.
|
||||
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
trkseg.first_index(),
|
||||
trkseg.last_index(),
|
||||
TimeSpan::Known(10_000),
|
||||
|i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|
||||
|i, left, right| {
|
||||
if trkseg[i].time.is_some() {
|
||||
let timed = |index: TrackSegmentIndex| {
|
||||
if trkseg[index].time.is_some() {
|
||||
index
|
||||
} else {
|
||||
i
|
||||
}
|
||||
};
|
||||
let (left, right) = (timed(left), timed(right));
|
||||
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
|
||||
.and_then(|time| {
|
||||
speed(
|
||||
self.local[right.flat].total_distance
|
||||
- self.local[left.flat].total_distance,
|
||||
time,
|
||||
)
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_smoothed_elevation_segments(&mut self, trkseg: &TrackSegment) {
|
||||
let simplified = self.get_elevation_extremas(trkseg);
|
||||
|
||||
for i in 0..(simplified.len() - 1) {
|
||||
let start = simplified[i];
|
||||
let end = simplified[i + 1];
|
||||
let last = i + 1 == simplified.len() - 1;
|
||||
|
||||
self.compute_smoothed_elevation_gain(trkseg, start, end, last);
|
||||
|
||||
let segment_dist =
|
||||
self.local[end.flat].total_distance - self.local[start.flat].total_distance;
|
||||
let segment_ele = trkseg[end].ele - trkseg[start].ele;
|
||||
let segment_slope = slope(segment_ele, segment_dist);
|
||||
for k in start.flat..(end.flat + last as usize) {
|
||||
self.local[k].slope_segment = SlopeSegment {
|
||||
slope: segment_slope,
|
||||
distance: segment_dist,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_smoothed_elevation_gain(
|
||||
&mut self,
|
||||
trkseg: &TrackSegment,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
last: bool,
|
||||
) {
|
||||
let mut cumul_ele = 0.0;
|
||||
let mut current_left = start;
|
||||
let mut current_right = Some(start);
|
||||
let mut prev_smoothed_ele = trkseg[start].ele;
|
||||
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
Some(start),
|
||||
Some(end),
|
||||
0.1,
|
||||
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|
||||
|i, left, right| {
|
||||
while current_left != left {
|
||||
cumul_ele -= trkseg[current_left].ele;
|
||||
current_left = trkseg.next_index(Some(current_left)).unwrap();
|
||||
}
|
||||
while let Some(current) = current_right {
|
||||
if current > right {
|
||||
break;
|
||||
}
|
||||
cumul_ele += trkseg[current].ele;
|
||||
current_right = trkseg.next_index(current_right);
|
||||
}
|
||||
|
||||
let smoothed_ele = if i == start || i == end {
|
||||
trkseg[i].ele
|
||||
} else {
|
||||
cumul_ele / (right.flat - left.flat + 1) as f64
|
||||
};
|
||||
|
||||
let delta = smoothed_ele - prev_smoothed_ele;
|
||||
if delta > 0.0 {
|
||||
self.global.elevation_gain += delta;
|
||||
} else if delta < 0.0 {
|
||||
self.global.elevation_loss -= delta;
|
||||
}
|
||||
|
||||
if i < end || last {
|
||||
self.local[i.flat].elevation_gain = self.global.elevation_gain;
|
||||
self.local[i.flat].elevation_loss = self.global.elevation_loss;
|
||||
}
|
||||
|
||||
prev_smoothed_ele = smoothed_ele;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_smoothed_slope(&mut self, trkseg: &TrackSegment) {
|
||||
for_each_window!(
|
||||
trkseg,
|
||||
trkseg.first_index(),
|
||||
trkseg.last_index(),
|
||||
0.05,
|
||||
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|
||||
|i, left, right| {
|
||||
let dist =
|
||||
self.local[right.flat].total_distance - self.local[left.flat].total_distance;
|
||||
let ele = trkseg[right].ele - trkseg[left].ele;
|
||||
self.local[i.flat].slope = slope(ele, dist);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn get_elevation_extremas(&self, trkseg: &TrackSegment) -> Vec<TrackSegmentIndex> {
|
||||
ramer_douglas_peucker(
|
||||
trkseg,
|
||||
&|idx| {
|
||||
(
|
||||
self.local[idx.flat].total_distance * 1000.0,
|
||||
trkseg[idx].ele,
|
||||
)
|
||||
},
|
||||
20.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct GlobalStatistics {
|
||||
pub total_distance: f64,
|
||||
pub moving_distance: Option<f64>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
pub start_time: Option<i64>,
|
||||
pub end_time: Option<i64>,
|
||||
pub bounds: LngLatBounds,
|
||||
}
|
||||
|
||||
impl GlobalStatistics {
|
||||
/// Average speed over the total time, unknown if that is not a positive duration (for
|
||||
/// example a range with a single timestamp, or timestamps going backwards).
|
||||
pub fn total_speed(&self) -> Option<f64> {
|
||||
self.total_time.and_then(|t| speed(self.total_distance, t))
|
||||
}
|
||||
|
||||
/// Average speed over the moving time, unknown if there is no moving time.
|
||||
pub fn moving_speed(&self) -> Option<f64> {
|
||||
self.moving_distance
|
||||
.zip(self.moving_time)
|
||||
.and_then(|(d, t)| speed(d, t))
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &GlobalStatistics) {
|
||||
self.total_distance += other.total_distance;
|
||||
self.moving_distance = sum_options(self.moving_distance, other.moving_distance);
|
||||
self.total_time = sum_options(self.total_time, other.total_time);
|
||||
self.moving_time = sum_options(self.moving_time, other.moving_time);
|
||||
self.elevation_gain += other.elevation_gain;
|
||||
self.elevation_loss += other.elevation_loss;
|
||||
self.start_time = min_options(self.start_time, other.start_time);
|
||||
self.end_time = max_options(self.end_time, other.end_time);
|
||||
self.bounds.merge(&other.bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct TrackpointStatistics {
|
||||
pub total_distance: f64,
|
||||
pub moving_distance: Option<f64>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub speed: Option<f64>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
pub slope: f64,
|
||||
pub slope_segment: SlopeSegment,
|
||||
}
|
||||
|
||||
impl TrackpointStatistics {
|
||||
fn from_partial_stats(stats: &Statistics) -> Self {
|
||||
Self {
|
||||
total_distance: stats.global.total_distance,
|
||||
moving_distance: stats.global.moving_distance,
|
||||
total_time: stats.global.total_time,
|
||||
moving_time: stats.global.moving_time,
|
||||
// stats below are computed later
|
||||
speed: None,
|
||||
elevation_gain: Default::default(),
|
||||
elevation_loss: Default::default(),
|
||||
slope: Default::default(),
|
||||
slope_segment: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct SlopeSegment {
|
||||
pub slope: f64,
|
||||
pub distance: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::Read};
|
||||
|
||||
use crate::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_compute_smoothed_speed() {
|
||||
let mut f = File::open("data/with_time.gpx").unwrap();
|
||||
let mut data = String::new();
|
||||
let _ = f.read_to_string(&mut data);
|
||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||
|
||||
let trkseg = &gpx.trk[0].trkseg[0];
|
||||
let stats = Statistics::compute(trkseg);
|
||||
assert_eq!(stats.local.len(), trkseg.len());
|
||||
for trkpt_stats in stats.local.iter() {
|
||||
assert!(trkpt_stats.speed.is_some());
|
||||
let speed = trkpt_stats.speed.unwrap();
|
||||
assert_ne!(speed, f64::NAN);
|
||||
assert!((speed - 20.0).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The speeds of the segment of `data/with_time.gpx` (80 trackpoints at 20 km/h) once the
|
||||
/// trackpoints at `holes` have lost their time.
|
||||
fn speeds_with_holes(holes: &[usize]) -> Vec<Option<f64>> {
|
||||
let gpx = load("data/with_time.gpx");
|
||||
let mut segment = gpx.trk[0].trkseg[0].clone();
|
||||
let points: Vec<_> = segment
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, trkpt)| {
|
||||
let mut trkpt = trkpt.clone();
|
||||
if holes.contains(&i) {
|
||||
trkpt.time = None;
|
||||
}
|
||||
trkpt
|
||||
})
|
||||
.collect();
|
||||
segment.splice(0, points.len(), points);
|
||||
Statistics::compute(&segment)
|
||||
.local
|
||||
.iter()
|
||||
.map(|local| local.speed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Without time, there is no speed. With one, the speed is the one of the trackpoints around.
|
||||
fn assert_speeds_with_holes(holes: &[usize]) {
|
||||
let speeds = speeds_with_holes(holes);
|
||||
assert_eq!(speeds.len(), 80);
|
||||
for (i, speed) in speeds.iter().enumerate() {
|
||||
if holes.contains(&i) {
|
||||
assert_eq!(*speed, None, "trackpoint {i}");
|
||||
} else {
|
||||
let speed = speed.unwrap_or_else(|| panic!("no speed for trackpoint {i}"));
|
||||
assert!((speed - 20.0).abs() < 0.1, "trackpoint {i}: {speed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_without_holes() {
|
||||
assert_speeds_with_holes(&[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_at_the_beginning() {
|
||||
assert_speeds_with_holes(&[0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_at_the_end() {
|
||||
assert_speeds_with_holes(&[75, 76, 77, 78, 79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_at_both_ends() {
|
||||
assert_speeds_with_holes(&[0, 1, 2, 77, 78, 79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_holes_in_the_middle() {
|
||||
assert_speeds_with_holes(&[40]);
|
||||
assert_speeds_with_holes(&[30, 31, 32, 33, 34]);
|
||||
assert_speeds_with_holes(&(20..60).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_with_scattered_holes() {
|
||||
let holes: Vec<_> = (0..80).step_by(7).collect();
|
||||
assert_speeds_with_holes(&holes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_without_any_time() {
|
||||
let holes: Vec<_> = (0..80).collect();
|
||||
assert!(speeds_with_holes(&holes).iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
fn load(path: &str) -> crate::File {
|
||||
let mut f = File::open(path).unwrap();
|
||||
let mut data = String::new();
|
||||
let _ = f.read_to_string(&mut data);
|
||||
parse(data.as_bytes(), &mut Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_empty_segment() {
|
||||
let stats = Statistics::compute(&TrackSegment::default());
|
||||
assert!(stats.local.is_empty());
|
||||
assert_eq!(stats.global.total_distance, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_distance_and_bounds() {
|
||||
let gpx = load("data/simple.gpx");
|
||||
let trkseg = &gpx.trk[0].trkseg[0];
|
||||
let stats = Statistics::compute(trkseg);
|
||||
|
||||
assert_eq!(stats.local.len(), trkseg.len());
|
||||
assert_eq!(stats.local[0].total_distance, 0.0);
|
||||
assert!(stats.global.total_distance > 0.0);
|
||||
// cumulative distance never decreases and ends at the global distance
|
||||
assert!(
|
||||
stats
|
||||
.local
|
||||
.windows(2)
|
||||
.all(|w| w[0].total_distance <= w[1].total_distance)
|
||||
);
|
||||
let last = stats.local.last().unwrap();
|
||||
assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9);
|
||||
assert!(stats.global.elevation_gain >= 0.0);
|
||||
assert!(stats.global.elevation_loss >= 0.0);
|
||||
|
||||
// every point lies inside the bounds
|
||||
let b = &stats.global.bounds;
|
||||
for trkpt in trkseg.iter() {
|
||||
assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng);
|
||||
assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_time() {
|
||||
let gpx = load("data/with_time.gpx");
|
||||
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
|
||||
let total_time = stats.global.total_time.unwrap();
|
||||
assert!(total_time > 0);
|
||||
let speed = stats.global.total_speed().unwrap();
|
||||
assert!((speed - 20.0).abs() < 0.5, "{speed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_time_without_timestamps() {
|
||||
let gpx = load("data/simple.gpx");
|
||||
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
|
||||
assert!(stats.global.total_time.is_none());
|
||||
assert!(stats.global.total_speed().is_none());
|
||||
assert!(stats.local.iter().all(|s| s.speed.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_merge() {
|
||||
let mut a = GlobalStatistics {
|
||||
total_distance: 1.0,
|
||||
elevation_gain: 10.0,
|
||||
moving_time: Some(5),
|
||||
..Default::default()
|
||||
};
|
||||
a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 });
|
||||
let mut b = GlobalStatistics {
|
||||
total_distance: 2.0,
|
||||
elevation_loss: 4.0,
|
||||
moving_distance: Some(1.5),
|
||||
moving_time: Some(7),
|
||||
..Default::default()
|
||||
};
|
||||
b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 });
|
||||
|
||||
a.merge(&b);
|
||||
assert_eq!(a.total_distance, 3.0);
|
||||
assert_eq!(a.elevation_gain, 10.0);
|
||||
assert_eq!(a.elevation_loss, 4.0);
|
||||
assert_eq!(a.moving_distance, Some(1.5));
|
||||
assert_eq!(a.moving_time, Some(12));
|
||||
assert_eq!(a.total_time, None);
|
||||
|
||||
let mut c = GlobalStatistics {
|
||||
start_time: Some(1_000),
|
||||
end_time: Some(4_000),
|
||||
total_time: Some(3_000),
|
||||
..Default::default()
|
||||
};
|
||||
let d = GlobalStatistics {
|
||||
start_time: Some(10_000),
|
||||
end_time: Some(12_000),
|
||||
total_time: Some(2_000),
|
||||
..Default::default()
|
||||
};
|
||||
c.merge(&d);
|
||||
// 3 s + 2 s, the gap between the two is ignored
|
||||
assert_eq!(c.total_time, Some(5_000));
|
||||
let mut cumul = GlobalStatistics::default();
|
||||
cumul.merge(&c);
|
||||
cumul.merge(&d);
|
||||
assert_eq!(cumul.start_time, Some(1_000));
|
||||
assert_eq!(cumul.end_time, Some(12_000));
|
||||
assert_eq!(cumul.total_time, Some(7_000));
|
||||
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod compute;
|
||||
mod utils;
|
||||
|
||||
pub use compute::*;
|
||||
pub use utils::*;
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::ops::Add;
|
||||
|
||||
pub fn combine_options<T>(a: Option<T>, b: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(f(a, b)),
|
||||
(Some(v), None) | (None, Some(v)) => Some(v),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||
where
|
||||
T: Add<Output = T>,
|
||||
{
|
||||
combine_options(a, b, |a, b| a + b)
|
||||
}
|
||||
|
||||
pub fn min_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||
where
|
||||
T: Ord,
|
||||
{
|
||||
combine_options(a, b, |a, b| a.min(b))
|
||||
}
|
||||
|
||||
pub fn max_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||
where
|
||||
T: Ord,
|
||||
{
|
||||
combine_options(a, b, |a, b| a.max(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sum_options() {
|
||||
assert_eq!(sum_options(Some(1), Some(2)), Some(3));
|
||||
assert_eq!(sum_options(Some(1), None), Some(1));
|
||||
assert_eq!(sum_options(None, Some(2)), Some(2));
|
||||
assert_eq!(sum_options::<i32>(None, None), None);
|
||||
assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::LngLat;
|
||||
|
||||
const TO_RADIANS: f64 = PI / 180.0;
|
||||
const EARTH_RADIUS: f64 = 6371.0088;
|
||||
|
||||
/// Computes the distance in kilometers between two coordinates using the Haversine formula
|
||||
pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
|
||||
let lat1 = p1.lat * TO_RADIANS;
|
||||
let lat2 = p2.lat * TO_RADIANS;
|
||||
let delta_lat = lat2 - lat1;
|
||||
let delta_lng = (p2.lng - p1.lng) * TO_RADIANS;
|
||||
|
||||
let a =
|
||||
(delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lng / 2.0).sin().powi(2);
|
||||
let c = 2.0 * a.min(1.0).sqrt().asin();
|
||||
EARTH_RADIUS * c
|
||||
}
|
||||
|
||||
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||
Some(a? - b?)
|
||||
}
|
||||
|
||||
/// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
|
||||
/// Unknown if the time is not a positive duration.
|
||||
pub fn speed(distance: f64, time: i64) -> Option<f64> {
|
||||
(time > 0).then(|| distance / (time as f64 / 3_600_000.0))
|
||||
}
|
||||
|
||||
pub fn slope(ele: f64, distance: f64) -> f64 {
|
||||
if distance == 0.0 {
|
||||
100.0
|
||||
} else {
|
||||
0.1 * ele / distance
|
||||
}
|
||||
}
|
||||
|
||||
const METERS_PER_LATITUDE_DEGREE: f64 = 111320.0;
|
||||
|
||||
/// Approximate planar projection, in meters, around the latitude of `origin` (ignores the
|
||||
/// curvature of the earth).
|
||||
struct Planar {
|
||||
meters_per_longitude_degree: f64,
|
||||
}
|
||||
|
||||
impl Planar {
|
||||
fn around(origin: LngLat) -> Self {
|
||||
Self {
|
||||
meters_per_longitude_degree: (origin.lat * TO_RADIANS).cos()
|
||||
* METERS_PER_LATITUDE_DEGREE,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_meters(&self, p: LngLat) -> (f64, f64) {
|
||||
(
|
||||
p.lng * self.meters_per_longitude_degree,
|
||||
p.lat * METERS_PER_LATITUDE_DEGREE,
|
||||
)
|
||||
}
|
||||
|
||||
fn to_degrees(&self, (x, y): (f64, f64)) -> LngLat {
|
||||
LngLat {
|
||||
lng: x / self.meters_per_longitude_degree,
|
||||
lat: y / METERS_PER_LATITUDE_DEGREE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Position, between 0 (at `(x1, y1)`) and 1 (at `(x2, y2)`), of the point of the segment that is
|
||||
/// the closest to `(x3, y3)`. A degenerate segment (same ends) is a point.
|
||||
fn closest_on_segment(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
|
||||
let dx = x2 - x1;
|
||||
let dy = y2 - y1;
|
||||
let segment_length_squared = dx * dx + dy * dy;
|
||||
if segment_length_squared == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the point on the line segment defined by p1 and p2 that is closest to the third
|
||||
/// point, p3. Uses simple planar geometry (ignores earth curvature).
|
||||
pub fn projected(p1: LngLat, p2: LngLat, p3: LngLat) -> LngLat {
|
||||
let planar = Planar::around(p1);
|
||||
let (x1, y1) = planar.to_meters(p1);
|
||||
let (x2, y2) = planar.to_meters(p2);
|
||||
let (x3, y3) = planar.to_meters(p3);
|
||||
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
|
||||
planar.to_degrees((x1 + t * (x2 - x1), y1 + t * (y2 - y1)))
|
||||
}
|
||||
|
||||
/// Calculates the perpendicular distance in meters between a line segment (defined by p1 and p2)
|
||||
/// and a third point, p3. Uses simple planar geometry (ignores earth curvature).
|
||||
pub fn crossarc_lnglat(p1: LngLat, p2: LngLat, p3: LngLat) -> f64 {
|
||||
let planar = Planar::around(p1);
|
||||
let (x1, y1) = planar.to_meters(p1);
|
||||
let (x2, y2) = planar.to_meters(p2);
|
||||
let (x3, y3) = planar.to_meters(p3);
|
||||
crossarc(x1, y1, x2, y2, x3, y3)
|
||||
}
|
||||
|
||||
/// Distance from the point `(x3, y3)` to the segment `(x1, y1)`-`(x2, y2)`, in the units of the
|
||||
/// coordinates (planar geometry).
|
||||
pub fn crossarc(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
|
||||
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
|
||||
(x3 - (x1 + t * (x2 - x1))).hypot(y3 - (y1 + t * (y2 - y1)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(lng: f64, lat: f64) -> LngLat {
|
||||
LngLat { lng, lat }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_diff_and_speed_with_long_durations() {
|
||||
// 40 days in milliseconds, more than an i32 can hold
|
||||
let forty_days = 40 * 24 * 3_600_000_i64;
|
||||
assert!(forty_days > i64::from(i32::MAX));
|
||||
assert_eq!(time_diff(Some(forty_days), Some(0)), Some(forty_days));
|
||||
assert_eq!(time_diff(Some(0), Some(forty_days)), Some(-forty_days));
|
||||
assert_eq!(time_diff(None, Some(1)), None);
|
||||
assert_eq!(time_diff(Some(1), None), None);
|
||||
// 1 km in one hour
|
||||
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distance() {
|
||||
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
|
||||
// one degree of latitude
|
||||
let d = distance(p(0.0, 0.0), p(0.0, 1.0));
|
||||
assert!((d - 111.195).abs() < 0.01, "{d}");
|
||||
// symmetric
|
||||
let a = p(4.40, 50.79);
|
||||
let b = p(6.13, 45.90);
|
||||
assert!((distance(a, b) - distance(b, a)).abs() < 1e-9);
|
||||
// half the Earth's circumference, without NaN from rounding
|
||||
let antipodal = distance(p(0.0, 0.0), p(180.0, 0.0));
|
||||
assert!((antipodal - PI * EARTH_RADIUS).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_diff() {
|
||||
assert_eq!(time_diff(Some(5000), Some(2000)), Some(3000));
|
||||
assert_eq!(time_diff(Some(2000), Some(5000)), Some(-3000));
|
||||
assert_eq!(time_diff(None, Some(1)), None);
|
||||
assert_eq!(time_diff(Some(1), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed() {
|
||||
// 1 km in 1 h
|
||||
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||
// 10 km in 30 min
|
||||
assert!((speed(10.0, 1_800_000).unwrap() - 20.0).abs() < 1e-12);
|
||||
// no duration, no speed
|
||||
assert_eq!(speed(1.0, 0), None);
|
||||
assert_eq!(speed(0.0, 0), None);
|
||||
assert_eq!(speed(1.0, -1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slope() {
|
||||
// 10 m of elevation over 100 m (distance is expressed in km, hence the 0.1 factor)
|
||||
assert!((slope(10.0, 0.1) - 10.0).abs() < 1e-12);
|
||||
assert!((slope(-5.0, 0.1) + 5.0).abs() < 1e-12);
|
||||
assert_eq!(slope(0.0, 1.0), 0.0);
|
||||
assert_eq!(slope(10.0, 0.0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crossarc() {
|
||||
// perpendicular distance to the segment (0,0)-(10,0)
|
||||
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 5.0, 3.0) - 3.0).abs() < 1e-12);
|
||||
// beyond the ends: distance to the closest endpoint
|
||||
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 13.0, 4.0) - 5.0).abs() < 1e-12);
|
||||
assert!((crossarc(0.0, 0.0, 10.0, 0.0, -3.0, 4.0) - 5.0).abs() < 1e-12);
|
||||
// point on the segment
|
||||
assert_eq!(crossarc(0.0, 0.0, 10.0, 0.0, 4.0, 0.0), 0.0);
|
||||
// degenerate segment
|
||||
assert!((crossarc(1.0, 1.0, 1.0, 1.0, 4.0, 5.0) - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_projected() {
|
||||
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
|
||||
assert!((proj.lng - 0.5).abs() < 1e-9);
|
||||
assert!(proj.lat.abs() < 1e-9);
|
||||
// clamped to the segment
|
||||
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(2.0, 1.0));
|
||||
assert!((proj.lng - 1.0).abs() < 1e-9);
|
||||
// degenerate segment
|
||||
let proj = projected(p(3.0, 4.0), p(3.0, 4.0), p(5.0, 6.0));
|
||||
assert_eq!((proj.lng, proj.lat), (3.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crossarc_lnglat() {
|
||||
// about 1 degree of latitude away from an east-west segment on the equator
|
||||
let d = crossarc_lnglat(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
|
||||
assert!((d - METERS_PER_LATITUDE_DEGREE).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use crate::{Command, FileId, SelectMode, Selection};
|
||||
|
||||
/// 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,
|
||||
/// Selects files, tracks, segments or waypoints (or the waypoints node of one file),
|
||||
/// ignoring what does not exist: a selection left with nothing is empty, which deselects
|
||||
/// everything. See [`SelectMode`] for how it combines with the current selection.
|
||||
Select {
|
||||
selection: Selection,
|
||||
mode: SelectMode,
|
||||
},
|
||||
/// Selects all the elements of the same kind as the selected ones, in the same place (see
|
||||
/// [`Selection::all_at_level`]). Does nothing when there is nothing more to select.
|
||||
SelectAll,
|
||||
/// Moves the selection to the next (`down`) or previous element of the same kind, like the
|
||||
/// arrow keys do in the file list (see [`Selection::neighbour`]). With `add`, the element is
|
||||
/// added to the selection (shift + arrow), otherwise it replaces it.
|
||||
ArrowSelect {
|
||||
down: bool,
|
||||
add: bool,
|
||||
},
|
||||
/// Puts the selected elements in the clipboard, to be pasted by [`crate::Paste`]. Does
|
||||
/// nothing when nothing is selected.
|
||||
Copy,
|
||||
/// Like `Copy`, but the elements are moved instead of copied when they are pasted.
|
||||
Cut,
|
||||
/// Moves the files to `index` among the other files, in the given order. Not undoable.
|
||||
Reorder {
|
||||
file_ids: Vec<FileId>,
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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 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::NothingToDo => write!(f, "nothing to do"),
|
||||
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommandError {}
|
||||
@@ -0,0 +1,161 @@
|
||||
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());
|
||||
// a single track is named like the file
|
||||
if let [track] = file.trk.as_mut_slice() {
|
||||
track.info.name = Some(self.name.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, NewTrack, Selection, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_metadata_applies_to_selected_file() {
|
||||
let mut fx = Fixture::default();
|
||||
New {
|
||||
name: "first",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
New {
|
||||
name: "second",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap(); // now selected
|
||||
let selected = *fx.selected_files().iter().next().unwrap();
|
||||
|
||||
Metadata {
|
||||
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,
|
||||
name: "file",
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/// A new file with `nb_tracks` tracks (without name), selected.
|
||||
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
|
||||
New {
|
||||
name: "file",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
for _ in 0..nb_tracks {
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn rename(fx: &mut Fixture) {
|
||||
Metadata {
|
||||
name: "renamed",
|
||||
desc: "",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_track_is_renamed_like_the_file() {
|
||||
let mut fx = Fixture::default();
|
||||
file_with_tracks(&mut fx, 1);
|
||||
rename(&mut fx);
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert_eq!(file.info.name, "renamed");
|
||||
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_track_with_a_name_is_renamed_too() {
|
||||
let mut fx = Fixture::default();
|
||||
file_with_tracks(&mut fx, 1);
|
||||
let id = *fx.files.keys().next().unwrap();
|
||||
std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()).trk[0]
|
||||
.info
|
||||
.name = Some("track".to_owned());
|
||||
rename(&mut fx);
|
||||
let file = &fx.files[&id];
|
||||
assert_eq!(file.info.name, "renamed");
|
||||
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_several_tracks_are_not_renamed() {
|
||||
let mut fx = Fixture::default();
|
||||
file_with_tracks(&mut fx, 2);
|
||||
rename(&mut fx);
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert_eq!(file.info.name, "renamed");
|
||||
assert!(file.trk.iter().all(|trk| trk.info.name.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -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,75 @@
|
||||
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",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
let file = fx.files.values().next().unwrap();
|
||||
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",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
|
||||
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,41 @@
|
||||
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",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrackSegment.apply(&mut fx.state()).unwrap();
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
name: "file",
|
||||
}
|
||||
.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,288 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint};
|
||||
|
||||
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
|
||||
/// are deleted instead, even if only a track or a waypoint is selected.
|
||||
#[derive(Debug)]
|
||||
pub struct Delete {
|
||||
pub whole_files: bool,
|
||||
}
|
||||
|
||||
impl Apply for Delete {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let files = state.selection.to_files();
|
||||
let selection = if self.whole_files {
|
||||
&files
|
||||
} else {
|
||||
&*state.selection
|
||||
};
|
||||
let next = match 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(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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 = file.wpt.edit(&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,
|
||||
name: "file",
|
||||
}
|
||||
.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 { whole_files: false }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_selected_files() {
|
||||
let (mut fx, id) = loaded();
|
||||
crate::New {
|
||||
name: "keep",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let keep = fx.order.0[1];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
Delete { whole_files: false }
|
||||
.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 { whole_files: false }
|
||||
.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 { whole_files: false }
|
||||
.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 = crate::Waypoints::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 { whole_files: false }
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_ne!(fx.files[&id].wpt.rev_id, rev);
|
||||
let left: Vec<_> = fx.files[&id].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 { whole_files: false }
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert!(fx.files[&id].wpt.is_empty());
|
||||
fx.selection = Selection::Waypoints { file_id: id };
|
||||
assert_eq!(
|
||||
Delete { whole_files: false }.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 { whole_files: false }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_whole_files_of_the_selected_elements() {
|
||||
for selection in [
|
||||
|id: FileId, fx: &Fixture| Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: HashSet::from([fx.files[&id].trk[0].id]),
|
||||
},
|
||||
|id: FileId, fx: &Fixture| Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: fx.files[&id].trk[0].id,
|
||||
trkseg_ids: HashSet::from([fx.files[&id].trk[0].trkseg[0].id]),
|
||||
},
|
||||
|id: FileId, _: &Fixture| Selection::Waypoints { file_id: id },
|
||||
|id: FileId, _: &Fixture| Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
},
|
||||
] {
|
||||
let (mut fx, id) = loaded();
|
||||
crate::New {
|
||||
name: "keep",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let keep = fx.order.0[1];
|
||||
fx.selection = selection(id, &fx);
|
||||
Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
|
||||
assert_eq!(fx.order.0, vec![keep]);
|
||||
assert!(!fx.files.contains_key(&id));
|
||||
assert!(matches!(fx.selection, Selection::Empty));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_whole_files_nothing_selected() {
|
||||
let mut fx = Fixture::default();
|
||||
assert_eq!(
|
||||
Delete { whole_files: true }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
crate::New {
|
||||
name: "b",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert!(DeleteAll.apply(&mut fx.state()).is_ok());
|
||||
assert!(fx.files.is_empty());
|
||||
assert!(fx.order.0.is_empty());
|
||||
assert!(matches!(fx.selection, Selection::Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use crate::{Apply, CommandError, FileId, Selection, State, WaypointId, delete_waypoints};
|
||||
|
||||
/// Deletes a waypoint of a file, whatever is selected (what the delete button of its popup does).
|
||||
/// If it was the only selected element, its file gets selected.
|
||||
#[derive(Debug)]
|
||||
pub struct DeleteWaypoint {
|
||||
pub file_id: FileId,
|
||||
pub waypoint_id: WaypointId,
|
||||
}
|
||||
|
||||
impl Apply for DeleteWaypoint {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
delete_waypoints(state.files, self.file_id, |wpt| wpt.id == self.waypoint_id)?;
|
||||
if let Selection::Waypoint { file_id, wpt_ids } = &*state.selection
|
||||
&& *file_id == self.file_id
|
||||
&& wpt_ids.iter().all(|id| *id == self.waypoint_id)
|
||||
{
|
||||
*state.selection = Selection::File {
|
||||
file_ids: [self.file_id].into(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deletes_one_waypoint_without_touching_the_selection() {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: (0..3)
|
||||
.map(|i| Waypoint {
|
||||
name: Some(format!("w{i}")),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
});
|
||||
let id = file.id;
|
||||
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.order.0.push(id);
|
||||
fx.selection = Selection::File {
|
||||
file_ids: [id].into(),
|
||||
};
|
||||
|
||||
DeleteWaypoint {
|
||||
file_id: id,
|
||||
waypoint_id: ids[1],
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let names: Vec<_> = fx.files[&id]
|
||||
.wpt
|
||||
.iter()
|
||||
.map(|wpt| wpt.name.clone().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, ["w0", "w2"]);
|
||||
assert_eq!(
|
||||
fx.selection,
|
||||
Selection::File {
|
||||
file_ids: [id].into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deleting_the_selected_waypoint_selects_its_file() {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![Waypoint::default(), Waypoint::default()],
|
||||
..Default::default()
|
||||
});
|
||||
let id = file.id;
|
||||
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.selection = Selection::Waypoint {
|
||||
file_id: id,
|
||||
wpt_ids: [ids[0]].into(),
|
||||
};
|
||||
DeleteWaypoint {
|
||||
file_id: id,
|
||||
waypoint_id: ids[0],
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fx.selection,
|
||||
Selection::File {
|
||||
file_ids: [id].into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_waypoint_is_nothing_to_do() {
|
||||
let mut fx = Fixture::default();
|
||||
let file = File::default();
|
||||
let id = file.id;
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
let result = DeleteWaypoint {
|
||||
file_id: id,
|
||||
waypoint_id: WaypointId::default(),
|
||||
}
|
||||
.apply(&mut fx.state());
|
||||
assert!(matches!(result, Err(CommandError::NothingToDo)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
|
||||
copy_track, copy_waypoint,
|
||||
};
|
||||
|
||||
#[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 selected: Vec<&Waypoint> = file.wpt.iter().filter(|wpt| filter(wpt)).collect();
|
||||
let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
|
||||
let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
|
||||
let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
|
||||
|
||||
// The copies go right after the last selected waypoint.
|
||||
let mut file = (**file).clone();
|
||||
file.wpt.insert_after(Some(last), copies);
|
||||
files.insert(file_id, Rc::new(file));
|
||||
Ok(Selection::Waypoint {
|
||||
file_id,
|
||||
wpt_ids: copy_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
#[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,
|
||||
name: "file",
|
||||
}
|
||||
.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[©_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(©.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,
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
}
|
||||
let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]];
|
||||
fx.selection = Selection::File {
|
||||
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 = crate::Waypoints::new([crate::WaypointChunk {
|
||||
wpt: wpts,
|
||||
..Default::default()
|
||||
}]);
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
let original_chunk = fx.files[&id].wpt.chunks()[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().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 copy comes right after the selected waypoint, in a new chunk
|
||||
assert_eq!(all[..], [ids[0], all[1], ids[1], ids[2]]);
|
||||
assert_eq!(fx.files[&id].wpt.chunks().len(), 1);
|
||||
assert_ne!(fx.files[&id].wpt.chunks()[0].id, original_chunk.id);
|
||||
|
||||
// selecting the last waypoint: the copy goes at the end
|
||||
let last = fx.files[&id].wpt.iter().last().unwrap().id;
|
||||
fx.selection = Selection::Waypoint {
|
||||
file_id: id,
|
||||
wpt_ids: HashSet::from([last]),
|
||||
};
|
||||
Duplicate.apply(&mut fx.state()).unwrap();
|
||||
let all: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
|
||||
assert_eq!(all.len(), 5);
|
||||
assert_eq!(all[..4], [ids[0], all[1], ids[1], ids[2]]);
|
||||
|
||||
fx.selection = Selection::Waypoints { file_id: id };
|
||||
Duplicate.apply(&mut fx.state()).unwrap();
|
||||
assert_eq!(fx.files[&id].wpt.len(), 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,233 @@
|
||||
use crate::{Apply, CommandError, File, State, TrackpointCategories, parse, produce};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Load<'a> {
|
||||
pub data: &'a [u8],
|
||||
/// Name of the file when the data has none (typically the name of the file on disk, without
|
||||
/// its extension).
|
||||
pub name: &'a str,
|
||||
}
|
||||
|
||||
impl Load<'_> {
|
||||
fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
|
||||
let mut file = parse(self.data, categories)
|
||||
.map_err(|err| CommandError::InvalidData(err.to_string()))?;
|
||||
if file.info.name.trim().is_empty() {
|
||||
file.info.name = self.name.to_owned();
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
}
|
||||
|
||||
impl Apply for Load<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
LoadFiles { files: vec![self] }.apply(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads several files at once, as a single command (one undo step): the files are added at the
|
||||
/// end of the list, in the given order, and the first one is selected.
|
||||
///
|
||||
/// The files that cannot be read are skipped, unless none of them can: then nothing is loaded
|
||||
/// and the error is the one of the first file.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadFiles<'a> {
|
||||
pub files: Vec<Load<'a>>,
|
||||
}
|
||||
|
||||
impl Apply for LoadFiles<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let mut files = Vec::with_capacity(self.files.len());
|
||||
let mut first_error = None;
|
||||
for load in self.files {
|
||||
match load.parse(state.categories) {
|
||||
Ok(file) => files.push(file),
|
||||
Err(error) => {
|
||||
first_error.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Err(first_error.unwrap_or(CommandError::NothingToDo));
|
||||
}
|
||||
produce(state, |_| files);
|
||||
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,
|
||||
name: "file",
|
||||
}
|
||||
.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",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let selected = fx.selected_files();
|
||||
|
||||
let result = Load {
|
||||
data: b"<gpx><trk></gpx>",
|
||||
name: "file",
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
fn load_with_name(data: &[u8]) -> String {
|
||||
let mut fx = Fixture::default();
|
||||
Load {
|
||||
data,
|
||||
name: "from disk",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
fx.files.values().next().unwrap().info.name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_keeps_the_name_of_the_file() {
|
||||
let data = br#"<gpx version="1.1"><metadata><name>in the file</name></metadata></gpx>"#;
|
||||
assert_eq!(load_with_name(data), "in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_without_name_uses_the_given_name() {
|
||||
let without = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
|
||||
assert_eq!(load_with_name(without), "from disk");
|
||||
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
|
||||
assert_eq!(load_with_name(empty), "from disk");
|
||||
}
|
||||
|
||||
fn gpx(name: &str) -> Vec<u8> {
|
||||
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
|
||||
}
|
||||
|
||||
fn load<'a>(data: &'a [u8], name: &'a str) -> Load<'a> {
|
||||
Load { data, name }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_several_files() {
|
||||
let mut fx = Fixture::default();
|
||||
crate::New {
|
||||
name: "before",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let before = fx.order.0[0];
|
||||
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
|
||||
|
||||
LoadFiles {
|
||||
files: vec![load(&a, "x"), load(&b, "x"), load(&c, "x")],
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
// added after the others, in the order they were given
|
||||
let names: Vec<_> = fx
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.map(|id| fx.files[id].info.name.clone())
|
||||
.collect();
|
||||
assert_eq!(names, ["before", "a", "b", "c"]);
|
||||
assert_eq!(fx.order.0[0], before);
|
||||
// the first one is selected
|
||||
assert_eq!(fx.selected_files(), [fx.order.0[1]].into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_several_files_skips_the_invalid_ones() {
|
||||
let mut fx = Fixture::default();
|
||||
let (a, c) = (gpx("a"), gpx("c"));
|
||||
LoadFiles {
|
||||
files: vec![load(&a, "x"), load(b"<gpx><trk></gpx>", "x"), load(&c, "x")],
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let names: Vec<_> = fx
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.map(|id| fx.files[id].info.name.clone())
|
||||
.collect();
|
||||
assert_eq!(names, ["a", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_several_invalid_files_changes_nothing() {
|
||||
let mut fx = Fixture::default();
|
||||
crate::New {
|
||||
name: "keep",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let selected = fx.selected_files();
|
||||
|
||||
let result = LoadFiles {
|
||||
files: vec![
|
||||
load(b"<gpx><trk></gpx>", "x"),
|
||||
load(b"<gpx><wpt></gpx>", "y"),
|
||||
],
|
||||
}
|
||||
.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);
|
||||
|
||||
// nothing to load
|
||||
assert_eq!(
|
||||
LoadFiles { files: vec![] }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_each_file_without_name_gets_its_own_name() {
|
||||
let mut fx = Fixture::default();
|
||||
let unnamed = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
|
||||
LoadFiles {
|
||||
files: vec![load(unnamed, "first"), load(unnamed, "second")],
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let names: Vec<_> = fx
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.map(|id| fx.files[id].info.name.clone())
|
||||
.collect();
|
||||
assert_eq!(names, ["first", "second"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
mod delete;
|
||||
mod delete_all;
|
||||
mod delete_waypoint;
|
||||
mod duplicate;
|
||||
mod load;
|
||||
mod move_elements;
|
||||
mod new;
|
||||
mod paste;
|
||||
mod transfer;
|
||||
|
||||
pub use delete::*;
|
||||
pub use delete_all::*;
|
||||
pub use delete_waypoint::*;
|
||||
pub use duplicate::*;
|
||||
pub use load::*;
|
||||
pub use move_elements::*;
|
||||
pub use new::*;
|
||||
pub use paste::*;
|
||||
pub use transfer::*;
|
||||
@@ -0,0 +1,447 @@
|
||||
use crate::{
|
||||
Apply, Clipboard, CommandError, Destination, FileId, Place, Selection, State, TrackId, transfer,
|
||||
};
|
||||
|
||||
/// Where moved elements go: a list of the file tree, and the position in it. The position is
|
||||
/// counted among the elements of the list that are not moved, like the position of a dragged
|
||||
/// element in the list it is dropped in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MoveTarget {
|
||||
/// The list of files.
|
||||
Files { index: usize },
|
||||
/// The tracks of a file.
|
||||
Tracks { file_id: FileId, index: usize },
|
||||
/// The segments of a track.
|
||||
Segments {
|
||||
file_id: FileId,
|
||||
trk_id: TrackId,
|
||||
index: usize,
|
||||
},
|
||||
/// The waypoints of a file.
|
||||
Waypoints { file_id: FileId, index: usize },
|
||||
}
|
||||
|
||||
/// Moves elements to a place of the file tree (what drag and drop does), with their ids. The
|
||||
/// moved elements are selected.
|
||||
///
|
||||
/// What can go where:
|
||||
/// - files: among the files;
|
||||
/// - tracks: among the files (each track becomes a file), or among the tracks of a file;
|
||||
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each
|
||||
/// segment becomes a track), or among the segments of a track;
|
||||
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
|
||||
/// file.
|
||||
#[derive(Debug)]
|
||||
pub struct Move {
|
||||
pub what: Selection,
|
||||
pub to: MoveTarget,
|
||||
}
|
||||
|
||||
impl From<MoveTarget> for Destination {
|
||||
fn from(target: MoveTarget) -> Self {
|
||||
match target {
|
||||
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
|
||||
MoveTarget::Tracks { file_id, index } => {
|
||||
Destination::Tracks(file_id, Place::Index(index))
|
||||
}
|
||||
MoveTarget::Segments {
|
||||
file_id,
|
||||
trk_id,
|
||||
index,
|
||||
} => Destination::Segments(file_id, trk_id, Place::Index(index)),
|
||||
MoveTarget::Waypoints { file_id, index } => {
|
||||
Destination::Waypoints(file_id, Place::Index(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Apply for Move {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let clipboard = Clipboard::new(&self.what, state.files, &state.order.0, true)
|
||||
.ok_or(CommandError::NothingToDo)?;
|
||||
*state.selection = transfer(state, &clipboard.content, true, self.to.into())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId,
|
||||
Waypoints, engine::command::fixture::Fixture, new_file, waypoint_ids,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A file with the given tracks (name, number of segments) and number of waypoints.
|
||||
fn file(name: &str, tracks: &[(&str, usize)], waypoints: usize) -> File {
|
||||
let mut file = new_file(
|
||||
name,
|
||||
tracks
|
||||
.iter()
|
||||
.map(|(name, segments)| Track {
|
||||
info: TrackInfo {
|
||||
name: Some((*name).to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if waypoints > 0 {
|
||||
file.wpt = Waypoints::new([WaypointChunk {
|
||||
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
|
||||
..Default::default()
|
||||
}]);
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
fn add(fx: &mut Fixture, file: File) -> FileId {
|
||||
let id = file.id;
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.order.0.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
|
||||
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
|
||||
}
|
||||
|
||||
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
|
||||
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
|
||||
}
|
||||
|
||||
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
|
||||
waypoint_ids(&fx.files[&id]).collect()
|
||||
}
|
||||
|
||||
fn do_move(fx: &mut Fixture, what: Selection, to: MoveTarget) -> Result<(), CommandError> {
|
||||
Move { what, to }.apply(&mut fx.state())
|
||||
}
|
||||
|
||||
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
|
||||
Selection::Track {
|
||||
file_id,
|
||||
trk_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
|
||||
Selection::Waypoint {
|
||||
file_id,
|
||||
wpt_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn files(ids: &[FileId]) -> Selection {
|
||||
Selection::File {
|
||||
file_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reorder_tracks_in_a_file() {
|
||||
let build = || {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(
|
||||
&mut fx,
|
||||
file("a", &[("0", 1), ("1", 1), ("2", 1), ("3", 1)], 0),
|
||||
);
|
||||
let t = track_ids(&fx, a);
|
||||
(fx, a, t)
|
||||
};
|
||||
let at = |a, index| MoveTarget::Tracks { file_id: a, index };
|
||||
|
||||
// the position counts the tracks that stay
|
||||
let (mut fx, a, t) = build();
|
||||
do_move(&mut fx, tracks(a, &[t[0]]), at(a, 1)).unwrap();
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1], t[0], t[2], t[3]]);
|
||||
assert_eq!(fx.selection, tracks(a, &[t[0]]));
|
||||
|
||||
let (mut fx, a, t) = build();
|
||||
do_move(&mut fx, tracks(a, &[t[3]]), at(a, 0)).unwrap();
|
||||
assert_eq!(track_ids(&fx, a), vec![t[3], t[0], t[1], t[2]]);
|
||||
|
||||
// several at once, as a block, in their order; past the end is the end
|
||||
let (mut fx, a, t) = build();
|
||||
do_move(&mut fx, tracks(a, &[t[1], t[0]]), at(a, 99)).unwrap();
|
||||
assert_eq!(track_ids(&fx, a), vec![t[2], t[3], t[0], t[1]]);
|
||||
assert_eq!(fx.selection, tracks(a, &[t[0], t[1]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_tracks_to_another_file_or_to_new_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[("t0", 1), ("t1", 1)], 0));
|
||||
let b = add(&mut fx, file("b", &[("b0", 1), ("b1", 1)], 0));
|
||||
let c = add(&mut fx, file("c", &[], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
let b_tracks = track_ids(&fx, b);
|
||||
|
||||
do_move(
|
||||
&mut fx,
|
||||
tracks(a, &[t[0]]),
|
||||
MoveTarget::Tracks {
|
||||
file_id: b,
|
||||
index: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||
assert_eq!(track_ids(&fx, b), vec![b_tracks[0], t[0], b_tracks[1]]);
|
||||
assert_eq!(fx.selection, tracks(b, &[t[0]]));
|
||||
|
||||
// as a new file, at a position of the list of files
|
||||
do_move(&mut fx, tracks(a, &[t[1]]), MoveTarget::Files { index: 1 }).unwrap();
|
||||
assert!(track_ids(&fx, a).is_empty());
|
||||
assert_eq!(fx.order.0.len(), 4);
|
||||
let new = fx.order.0[1];
|
||||
assert_eq!(fx.order.0[0], a);
|
||||
assert_eq!(fx.order.0[2..], [b, c]);
|
||||
assert_eq!(track_ids(&fx, new), vec![t[1]]);
|
||||
assert_eq!(fx.files[&new].info.name, "t1");
|
||||
assert_eq!(fx.selection, files(&[new]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_segments() {
|
||||
let build = || {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[("t0", 3), ("t1", 1)], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
(fx, a, t)
|
||||
};
|
||||
|
||||
// within a track
|
||||
let (mut fx, a, t) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
do_move(
|
||||
&mut fx,
|
||||
segments(a, t[0], &[s[2]]),
|
||||
MoveTarget::Segments {
|
||||
file_id: a,
|
||||
trk_id: t[0],
|
||||
index: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(segment_ids(&fx, a, 0), vec![s[2], s[0], s[1]]);
|
||||
assert_eq!(fx.selection, segments(a, t[0], &[s[2]]));
|
||||
|
||||
// to another track
|
||||
let (mut fx, a, t) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
let other = segment_ids(&fx, a, 1);
|
||||
do_move(
|
||||
&mut fx,
|
||||
segments(a, t[0], &[s[0], s[1]]),
|
||||
MoveTarget::Segments {
|
||||
file_id: a,
|
||||
trk_id: t[1],
|
||||
index: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
|
||||
assert_eq!(segment_ids(&fx, a, 1), vec![s[0], s[1], other[0]]);
|
||||
|
||||
// as new tracks of a file
|
||||
let (mut fx, a, t) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
do_move(
|
||||
&mut fx,
|
||||
segments(a, t[0], &[s[1]]),
|
||||
MoveTarget::Tracks {
|
||||
file_id: a,
|
||||
index: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fx.files[&a].trk.len(), 3);
|
||||
assert_eq!(segment_ids(&fx, a, 1), vec![s[1]]);
|
||||
assert_eq!(segment_ids(&fx, a, 0), vec![s[0], s[2]]);
|
||||
assert_eq!(fx.files[&a].trk[0].id, t[0]);
|
||||
assert_eq!(fx.files[&a].trk[2].id, t[1]);
|
||||
|
||||
// as new files
|
||||
let (mut fx, a, t) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
do_move(
|
||||
&mut fx,
|
||||
segments(a, t[0], &[s[0], s[1]]),
|
||||
MoveTarget::Files { index: 0 },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fx.order.0.len(), 3);
|
||||
assert_eq!(fx.order.0[2], a);
|
||||
assert_eq!(fx.files[&fx.order.0[0]].info.name, "t0");
|
||||
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_waypoints() {
|
||||
let build = || {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[], 4));
|
||||
let b = add(&mut fx, file("b", &[], 2));
|
||||
(fx, a, b)
|
||||
};
|
||||
let at = |file_id, index| MoveTarget::Waypoints { file_id, index };
|
||||
|
||||
// within a file
|
||||
let (mut fx, a, _) = build();
|
||||
let w = wpt_ids(&fx, a);
|
||||
do_move(&mut fx, waypoints(a, &[w[3]]), at(a, 1)).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, a), vec![w[0], w[3], w[1], w[2]]);
|
||||
assert_eq!(fx.selection, waypoints(a, &[w[3]]));
|
||||
|
||||
// to the start of another file
|
||||
let (mut fx, a, b) = build();
|
||||
let w = wpt_ids(&fx, a);
|
||||
let before = wpt_ids(&fx, b);
|
||||
do_move(&mut fx, waypoints(a, &[w[0], w[1]]), at(b, 0)).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, a), w[2..].to_vec());
|
||||
assert_eq!(wpt_ids(&fx, b), [&w[..2], &before[..]].concat());
|
||||
|
||||
// all the waypoints of a file
|
||||
let (mut fx, a, b) = build();
|
||||
let w = wpt_ids(&fx, a);
|
||||
let before = wpt_ids(&fx, b);
|
||||
do_move(&mut fx, Selection::Waypoints { file_id: a }, at(b, 1)).unwrap();
|
||||
assert!(wpt_ids(&fx, a).is_empty());
|
||||
assert_eq!(
|
||||
wpt_ids(&fx, b),
|
||||
[&before[..1], &w[..], &before[1..]].concat()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[], 0));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let c = add(&mut fx, file("c", &[], 0));
|
||||
do_move(&mut fx, files(&[c]), MoveTarget::Files { index: 0 }).unwrap();
|
||||
assert_eq!(fx.order.0, vec![c, a, b]);
|
||||
do_move(&mut fx, files(&[c, a]), MoveTarget::Files { index: 1 }).unwrap();
|
||||
assert_eq!(fx.order.0, vec![b, c, a]);
|
||||
assert_eq!(fx.selection, files(&[c, a]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_what_cannot_be_moved() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[("t", 1)], 1));
|
||||
let t = track_ids(&fx, a);
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
let w = wpt_ids(&fx, a);
|
||||
let before = fx.files[&a].clone();
|
||||
let selection = fx.selection.clone();
|
||||
|
||||
let invalid = [
|
||||
// the wrong kind of list
|
||||
(
|
||||
files(&[a]),
|
||||
MoveTarget::Tracks {
|
||||
file_id: a,
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
tracks(a, &t),
|
||||
MoveTarget::Waypoints {
|
||||
file_id: a,
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
tracks(a, &t),
|
||||
MoveTarget::Segments {
|
||||
file_id: a,
|
||||
trk_id: t[0],
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
segments(a, t[0], &s),
|
||||
MoveTarget::Waypoints {
|
||||
file_id: a,
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
waypoints(a, &w),
|
||||
MoveTarget::Tracks {
|
||||
file_id: a,
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(waypoints(a, &w), MoveTarget::Files { index: 0 }),
|
||||
(
|
||||
Selection::Waypoints { file_id: a },
|
||||
MoveTarget::Files { index: 0 },
|
||||
),
|
||||
(Selection::Empty, MoveTarget::Files { index: 0 }),
|
||||
// elements that do not exist
|
||||
(
|
||||
tracks(a, &[TrackId::default()]),
|
||||
MoveTarget::Files { index: 0 },
|
||||
),
|
||||
(
|
||||
waypoints(a, &[WaypointId::default()]),
|
||||
MoveTarget::Waypoints {
|
||||
file_id: a,
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(files(&[FileId::default()]), MoveTarget::Files { index: 0 }),
|
||||
// a place that does not exist
|
||||
(
|
||||
tracks(a, &t),
|
||||
MoveTarget::Tracks {
|
||||
file_id: FileId::default(),
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
segments(a, t[0], &s),
|
||||
MoveTarget::Segments {
|
||||
file_id: a,
|
||||
trk_id: TrackId::default(),
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
waypoints(a, &w),
|
||||
MoveTarget::Waypoints {
|
||||
file_id: FileId::default(),
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
];
|
||||
for (what, to) in invalid {
|
||||
assert_eq!(
|
||||
do_move(&mut fx, what.clone(), to),
|
||||
Err(CommandError::NothingToDo),
|
||||
"{what:?} to {to:?}"
|
||||
);
|
||||
// nothing changed
|
||||
assert!(Rc::ptr_eq(&fx.files[&a], &before));
|
||||
assert_eq!(fx.order.0, vec![a]);
|
||||
assert_eq!(fx.selection, selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce};
|
||||
|
||||
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a
|
||||
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single
|
||||
/// undo step.
|
||||
#[derive(Debug)]
|
||||
pub struct New<'a> {
|
||||
pub name: &'a str,
|
||||
pub trackpoint: Option<NewTrackpoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct NewTrackpoint {
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
pub ele: f64,
|
||||
}
|
||||
|
||||
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();
|
||||
if let Some(point) = self.trackpoint {
|
||||
let mut segment = TrackSegment::default();
|
||||
// the ends of a segment are anchors of the routing tool
|
||||
segment.splice(
|
||||
0,
|
||||
0,
|
||||
vec![Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: point.lng,
|
||||
lat: point.lat,
|
||||
},
|
||||
ele: point.ele,
|
||||
..Default::default()
|
||||
}],
|
||||
);
|
||||
file.trk.push(Track {
|
||||
trkseg: vec![segment],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
vec![file]
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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",
|
||||
trackpoint: None
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(fx.files.len(), 1);
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert_eq!(file.info.name, "new");
|
||||
assert!(file.trk.is_empty());
|
||||
assert_eq!(fx.selected_files(), [file.id].into());
|
||||
assert_eq!(fx.order.0, vec![file.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_with_a_trackpoint() {
|
||||
let mut fx = Fixture::default();
|
||||
let point = NewTrackpoint {
|
||||
lng: 1.0,
|
||||
lat: 2.0,
|
||||
ele: 3.0,
|
||||
};
|
||||
New {
|
||||
name: "new",
|
||||
trackpoint: Some(point),
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert_eq!(file.trk.len(), 1);
|
||||
assert_eq!(file.trk[0].trkseg.len(), 1);
|
||||
let segment = &file.trk[0].trkseg[0];
|
||||
assert_eq!(segment.len(), 1);
|
||||
assert_eq!(segment[0].coordinates.lng, 1.0);
|
||||
assert_eq!(segment[0].coordinates.lat, 2.0);
|
||||
assert_eq!(segment[0].ele, 3.0);
|
||||
// the only trackpoint is an anchor of the routing tool
|
||||
assert_eq!(segment[0].anchor, Some(0));
|
||||
assert_eq!(fx.selected_files(), [file.id].into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
use std::{collections::HashSet, hash::Hash};
|
||||
|
||||
use crate::{
|
||||
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId,
|
||||
TrackSegmentId, WaypointId, transfer, waypoint_ids,
|
||||
};
|
||||
|
||||
/// Pastes what was copied or cut (see [`crate::Clipboard`]).
|
||||
///
|
||||
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
|
||||
/// - nothing selected: the files are copied, and tracks and segments become new files (named
|
||||
/// like their track, or like the file they come from);
|
||||
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are
|
||||
/// added to its own;
|
||||
/// - a track: the segments are added to it, tracks go after it in its file;
|
||||
/// - a segment: the segments go after it in its track;
|
||||
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
|
||||
///
|
||||
/// Copies are made, with new ids, and the pasted elements are selected. What was cut is moved
|
||||
/// instead, with its ids, the first time: what is still where it was is removed.
|
||||
///
|
||||
/// What is pasted is always the elements as they were when they were copied or cut, even if they
|
||||
/// were changed or deleted since. The clipboard is kept so that it can be pasted again: after
|
||||
/// what was cut was moved it is not cut anymore, and the next pastes make copies.
|
||||
#[derive(Debug)]
|
||||
pub struct Paste;
|
||||
|
||||
impl Apply for Paste {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let clipboard = state
|
||||
.clipboard
|
||||
.clone()
|
||||
.filter(|clipboard| clipboard.can_paste(state.selection))
|
||||
.ok_or(CommandError::NothingToDo)?;
|
||||
let cut = clipboard.cut;
|
||||
// always the elements as they were when they were copied or cut
|
||||
let to = destination(state, &clipboard.content)?;
|
||||
let next = transfer(state, &clipboard.content, cut, to)?;
|
||||
|
||||
if cut && let Some(clipboard) = state.clipboard.as_mut() {
|
||||
// moved: the next pastes make copies
|
||||
clipboard.cut = false;
|
||||
}
|
||||
*state.selection = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
|
||||
state
|
||||
.files
|
||||
.get(&file_id)
|
||||
.map(|file| file.trk.iter().map(|trk| trk.id).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn segment_ids_of(state: &State, file_id: FileId, trk_id: TrackId) -> Vec<TrackSegmentId> {
|
||||
state
|
||||
.files
|
||||
.get(&file_id)
|
||||
.and_then(|file| file.trk.iter().find(|trk| trk.id == trk_id))
|
||||
.map(|trk| trk.trkseg.iter().map(|seg| seg.id).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn waypoint_ids_of(state: &State, file_id: FileId) -> Vec<WaypointId> {
|
||||
state
|
||||
.files
|
||||
.get(&file_id)
|
||||
.map(|file| waypoint_ids(file).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where the content goes, depending on what is selected.
|
||||
fn destination(state: &State, content: &ClipboardContent) -> Result<Destination, CommandError> {
|
||||
let nothing = CommandError::NothingToDo;
|
||||
let last_file = |file_ids: &HashSet<FileId>| {
|
||||
last_selected(state.order.0.iter().copied(), file_ids).ok_or(CommandError::NothingToDo)
|
||||
};
|
||||
Ok(match (content, &*state.selection) {
|
||||
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
|
||||
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => {
|
||||
Destination::Files(Place::End)
|
||||
}
|
||||
(
|
||||
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
|
||||
Selection::File { file_ids },
|
||||
) => Destination::Tracks(last_file(file_ids)?, Place::End),
|
||||
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => {
|
||||
let last =
|
||||
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
|
||||
Destination::Tracks(*file_id, Place::After(last))
|
||||
}
|
||||
(ClipboardContent::Segments(_), Selection::Track { file_id, trk_ids }) => {
|
||||
let last =
|
||||
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
|
||||
Destination::Segments(*file_id, last, Place::End)
|
||||
}
|
||||
(
|
||||
ClipboardContent::Segments(_),
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
},
|
||||
) => {
|
||||
let last = last_selected(
|
||||
segment_ids_of(state, *file_id, *trk_id).into_iter(),
|
||||
trkseg_ids,
|
||||
)
|
||||
.ok_or(nothing)?;
|
||||
Destination::Segments(*file_id, *trk_id, Place::After(last))
|
||||
}
|
||||
(ClipboardContent::Waypoints(_), Selection::File { file_ids }) => {
|
||||
Destination::Waypoints(last_file(file_ids)?, Place::End)
|
||||
}
|
||||
(ClipboardContent::Waypoints(_), Selection::Waypoints { file_id }) => {
|
||||
Destination::Waypoints(*file_id, Place::End)
|
||||
}
|
||||
(ClipboardContent::Waypoints(_), Selection::Waypoint { file_id, wpt_ids }) => {
|
||||
let last = last_selected(waypoint_ids_of(state, *file_id).into_iter(), wpt_ids)
|
||||
.ok_or(nothing)?;
|
||||
Destination::Waypoints(*file_id, Place::After(last))
|
||||
}
|
||||
_ => return Err(nothing),
|
||||
})
|
||||
}
|
||||
|
||||
/// The last of the `selected` ids in the order of `all`: the element a paste goes after.
|
||||
fn last_selected<T: Copy + Eq + Hash>(
|
||||
all: impl Iterator<Item = T>,
|
||||
selected: &HashSet<T>,
|
||||
) -> Option<T> {
|
||||
all.filter(|id| selected.contains(id)).last()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment,
|
||||
TrackSegmentId, Waypoint, WaypointId, Waypoints, engine::command::fixture::Fixture,
|
||||
new_file,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A file with the given tracks (name, number of segments) and number of waypoints.
|
||||
fn file(name: &str, tracks: &[(Option<&str>, usize)], waypoints: usize) -> File {
|
||||
let mut file = new_file(
|
||||
name,
|
||||
tracks
|
||||
.iter()
|
||||
.map(|(name, segments)| Track {
|
||||
info: TrackInfo {
|
||||
name: name.map(str::to_owned),
|
||||
..Default::default()
|
||||
},
|
||||
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if waypoints > 0 {
|
||||
file.wpt = Waypoints::new([crate::WaypointChunk {
|
||||
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
|
||||
..Default::default()
|
||||
}]);
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
fn add(fx: &mut Fixture, file: File) -> FileId {
|
||||
let id = file.id;
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.order.0.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
|
||||
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
|
||||
}
|
||||
|
||||
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
|
||||
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
|
||||
}
|
||||
|
||||
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
|
||||
waypoint_ids(&fx.files[&id]).collect()
|
||||
}
|
||||
|
||||
fn names(fx: &Fixture) -> Vec<String> {
|
||||
fx.order
|
||||
.0
|
||||
.iter()
|
||||
.map(|id| fx.files[id].info.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn copy(fx: &mut Fixture, selection: Selection, cut: bool) {
|
||||
fx.clipboard = Clipboard::new(&selection, &fx.files, &fx.order.0, cut);
|
||||
}
|
||||
|
||||
/// The ids of what is in the clipboard, and whether it is cut.
|
||||
fn clipboard_state(fx: &Fixture) -> Option<(ClipboardIds, bool)> {
|
||||
fx.clipboard.as_ref().map(|c| (c.content.ids(), c.cut))
|
||||
}
|
||||
|
||||
fn paste(fx: &mut Fixture) -> Result<(), CommandError> {
|
||||
Paste.apply(&mut fx.state())
|
||||
}
|
||||
|
||||
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
|
||||
Selection::Track {
|
||||
file_id,
|
||||
trk_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
|
||||
Selection::Waypoint {
|
||||
file_id,
|
||||
wpt_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn files(ids: &[FileId]) -> Selection {
|
||||
Selection::File {
|
||||
file_ids: ids.iter().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_copied_tracks_into_a_file() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(
|
||||
&mut fx,
|
||||
file("a", &[(Some("t0"), 1), (Some("t1"), 2), (None, 1)], 0),
|
||||
);
|
||||
let b = add(&mut fx, file("b", &[(Some("b0"), 1)], 0));
|
||||
let source = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[source[0], source[1]]), false);
|
||||
fx.selection = files(&[b]);
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
|
||||
// the source is untouched, copies with new ids are added at the end
|
||||
assert_eq!(track_ids(&fx, a), source);
|
||||
let in_b = track_ids(&fx, b);
|
||||
assert_eq!(in_b.len(), 3);
|
||||
assert!(!source.contains(&in_b[1]) && !source.contains(&in_b[2]));
|
||||
let names: Vec<_> = fx.files[&b]
|
||||
.trk
|
||||
.iter()
|
||||
.map(|t| t.info.name.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[Some("b0".into()), Some("t0".into()), Some("t1".into())]
|
||||
);
|
||||
// the segments are copies too
|
||||
assert_eq!(fx.files[&b].trk[2].trkseg.len(), 2);
|
||||
assert_ne!(segment_ids(&fx, b, 2), segment_ids(&fx, a, 1));
|
||||
// the pasted tracks are selected, and the clipboard is still there
|
||||
assert_eq!(fx.selection, tracks(b, &in_b[1..]));
|
||||
assert_eq!(
|
||||
clipboard_state(&fx),
|
||||
Some((ClipboardIds::Tracks(vec![source[0], source[1]]), false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_cut_tracks_moves_them() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let source = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[source[0]]), true);
|
||||
fx.selection = files(&[b]);
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
|
||||
assert_eq!(track_ids(&fx, a), vec![source[1]]);
|
||||
assert_eq!(track_ids(&fx, b), vec![source[0]]);
|
||||
assert_eq!(fx.selection, tracks(b, &[source[0]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_tracks_after_the_selected_track() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(
|
||||
&mut fx,
|
||||
file(
|
||||
"a",
|
||||
&[
|
||||
(Some("0"), 1),
|
||||
(Some("1"), 1),
|
||||
(Some("2"), 1),
|
||||
(Some("3"), 1),
|
||||
],
|
||||
0,
|
||||
),
|
||||
);
|
||||
let t = track_ids(&fx, a);
|
||||
// copy: the block goes after the selected track
|
||||
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
|
||||
fx.selection = tracks(a, &[t[2]]);
|
||||
paste(&mut fx).unwrap();
|
||||
let names: Vec<_> = fx.files[&a]
|
||||
.trk
|
||||
.iter()
|
||||
.map(|t| t.info.name.clone().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, ["0", "1", "2", "0", "1", "3"]);
|
||||
|
||||
// cut: moved after the selected track
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(
|
||||
&mut fx,
|
||||
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
|
||||
);
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||
fx.selection = tracks(a, &[t[2]]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
|
||||
|
||||
// the selected track is cut itself: the tracks go at the end
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(
|
||||
&mut fx,
|
||||
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
|
||||
);
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||
fx.selection = tracks(a, &[t[0]]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_tracks_as_new_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("source", &[(Some("named"), 1), (None, 1)], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
|
||||
fx.selection = Selection::Empty;
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
|
||||
// named like the track, or like the file it comes from
|
||||
assert_eq!(names(&fx), ["source", "named", "source"]);
|
||||
assert_eq!(fx.files.len(), 3);
|
||||
let new = &fx.order.0[1..];
|
||||
assert!(
|
||||
matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2 && new.iter().all(|id| file_ids.contains(id)))
|
||||
);
|
||||
assert_eq!(fx.files[&new[0]].trk.len(), 1);
|
||||
// the source keeps its tracks
|
||||
assert_eq!(track_ids(&fx, a), t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_cut_tracks_as_new_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(
|
||||
&mut fx,
|
||||
file("source", &[(Some("one"), 1), (Some("two"), 1)], 0),
|
||||
);
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||
fx.selection = Selection::Empty;
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||
let new = fx.order.0[1];
|
||||
assert_eq!(track_ids(&fx, new), vec![t[0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_segments() {
|
||||
let build = || {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t0"), 3), (Some("t1"), 1)], 0));
|
||||
(fx, a)
|
||||
};
|
||||
|
||||
// at the end of the selected track
|
||||
let (mut fx, a) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, segments(a, t[0], &[s[0]]), false);
|
||||
fx.selection = tracks(a, &[t[1]]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(segment_ids(&fx, a, 0), s);
|
||||
let in_t1 = segment_ids(&fx, a, 1);
|
||||
assert_eq!(in_t1.len(), 2);
|
||||
assert_ne!(in_t1[1], s[0]);
|
||||
assert_eq!(fx.selection, segments(a, t[1], &in_t1[1..]));
|
||||
|
||||
// after the selected segment, moved
|
||||
let (mut fx, a) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, segments(a, t[0], &[s[0]]), true);
|
||||
fx.selection = segments(a, t[0], &[s[1]]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(segment_ids(&fx, a, 0), vec![s[1], s[0], s[2]]);
|
||||
|
||||
// each segment becomes a track of the selected file
|
||||
let (mut fx, a) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
|
||||
fx.selection = files(&[a]);
|
||||
paste(&mut fx).unwrap();
|
||||
let tracks_now = track_ids(&fx, a);
|
||||
assert_eq!(tracks_now.len(), 4);
|
||||
assert!(
|
||||
fx.files[&a].trk[2..]
|
||||
.iter()
|
||||
.all(|trk| trk.trkseg.len() == 1)
|
||||
);
|
||||
assert_eq!(fx.selection, tracks(a, &tracks_now[2..]));
|
||||
|
||||
// each segment becomes a file, named like its track
|
||||
let (mut fx, a) = build();
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
|
||||
fx.selection = Selection::Empty;
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(names(&fx), ["a", "t0", "t0"]);
|
||||
assert!(
|
||||
fx.order.0[1..]
|
||||
.iter()
|
||||
.all(|id| fx.files[id].trk[0].trkseg.len() == 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_waypoints() {
|
||||
let build = || {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[], 4));
|
||||
let b = add(&mut fx, file("b", &[], 2));
|
||||
(fx, a, b)
|
||||
};
|
||||
|
||||
// to the end of another file
|
||||
let (mut fx, a, b) = build();
|
||||
let w = wpt_ids(&fx, a);
|
||||
let before = wpt_ids(&fx, b);
|
||||
copy(&mut fx, waypoints(a, &[w[1], w[2]]), false);
|
||||
fx.selection = files(&[b]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, a), w);
|
||||
let now = wpt_ids(&fx, b);
|
||||
assert_eq!(now.len(), 4);
|
||||
assert_eq!(&now[..2], &before[..]);
|
||||
assert!(!w.contains(&now[2]));
|
||||
assert_eq!(fx.selection, waypoints(b, &now[2..]));
|
||||
|
||||
// after the selected waypoint
|
||||
let (mut fx, a, b) = build();
|
||||
let w = wpt_ids(&fx, a);
|
||||
let before = wpt_ids(&fx, b);
|
||||
copy(&mut fx, waypoints(a, &[w[0]]), true);
|
||||
fx.selection = waypoints(b, &[before[0]]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, a), w[1..].to_vec());
|
||||
assert_eq!(wpt_ids(&fx, b), vec![before[0], w[0], before[1]]);
|
||||
|
||||
// within the file, after a waypoint
|
||||
let (mut fx, a, _) = build();
|
||||
let w = wpt_ids(&fx, a);
|
||||
copy(&mut fx, waypoints(a, &[w[0], w[1]]), true);
|
||||
fx.selection = waypoints(a, &[w[2]]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, a), vec![w[2], w[0], w[1], w[3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_all_the_waypoints_of_a_file() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[], 3));
|
||||
let b = add(&mut fx, file("b", &[], 1));
|
||||
let w = wpt_ids(&fx, a);
|
||||
copy(&mut fx, Selection::Waypoints { file_id: a }, false);
|
||||
fx.selection = Selection::Waypoints { file_id: b };
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, a), w);
|
||||
assert_eq!(wpt_ids(&fx, b).len(), 4);
|
||||
|
||||
// moved: the source has none left
|
||||
copy(&mut fx, Selection::Waypoints { file_id: a }, true);
|
||||
fx.selection = files(&[b]);
|
||||
paste(&mut fx).unwrap();
|
||||
assert!(wpt_ids(&fx, a).is_empty());
|
||||
assert_eq!(&wpt_ids(&fx, b)[4..], &w[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let c = add(&mut fx, file("c", &[], 0));
|
||||
copy(&mut fx, files(&[b, a]), false);
|
||||
fx.selection = files(&[c]);
|
||||
paste(&mut fx).unwrap();
|
||||
// copies, in the order of the files, at the end
|
||||
assert_eq!(names(&fx), ["a", "b", "c", "a", "b"]);
|
||||
assert_eq!(fx.files.len(), 5);
|
||||
assert_eq!(fx.files[&fx.order.0[3]].trk.len(), 1);
|
||||
assert_ne!(track_ids(&fx, fx.order.0[3]), track_ids(&fx, a));
|
||||
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2));
|
||||
|
||||
// cut: moved to the end, they keep their ids
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[], 0));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let c = add(&mut fx, file("c", &[], 0));
|
||||
copy(&mut fx, files(&[a]), true);
|
||||
fx.selection = Selection::Empty;
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(fx.order.0, vec![b, c, a]);
|
||||
assert_eq!(fx.selection, files(&[a]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_paste() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
|
||||
let t = track_ids(&fx, a);
|
||||
let w = wpt_ids(&fx, a);
|
||||
fx.selection = files(&[a]);
|
||||
|
||||
// an empty clipboard
|
||||
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||
|
||||
// a target that does not accept it (tracks cannot go to waypoints), the clipboard stays
|
||||
copy(&mut fx, tracks(a, &t), false);
|
||||
fx.selection = waypoints(a, &w);
|
||||
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||
assert!(fx.clipboard.is_some());
|
||||
assert_eq!(fx.selection, waypoints(a, &w));
|
||||
|
||||
// nothing to copy: what does not exist is not put in the clipboard
|
||||
copy(&mut fx, tracks(a, &[TrackId::default()]), false);
|
||||
fx.selection = files(&[a]);
|
||||
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||
copy(&mut fx, waypoints(a, &[WaypointId::default()]), false);
|
||||
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||
copy(&mut fx, files(&[FileId::default()]), false);
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||
assert_eq!(fx.files.len(), 1);
|
||||
assert_eq!(fx.order.0, vec![a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_a_copy_several_times() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 1));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), false);
|
||||
fx.selection = files(&[b]);
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
paste(&mut fx).unwrap();
|
||||
paste(&mut fx).unwrap();
|
||||
|
||||
// each paste made a copy with new ids, and the source is untouched
|
||||
let in_b = track_ids(&fx, b);
|
||||
assert_eq!(in_b.len(), 3);
|
||||
assert_eq!(in_b.iter().collect::<HashSet<_>>().len(), 3);
|
||||
assert!(in_b.iter().all(|id| !t.contains(id)));
|
||||
assert_eq!(track_ids(&fx, a), t);
|
||||
assert!(fx.clipboard.is_some());
|
||||
|
||||
// the selection is the last copy: the next paste goes after it
|
||||
assert_eq!(fx.selection, tracks(b, &[in_b[2]]));
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(track_ids(&fx, b).len(), 4);
|
||||
|
||||
// the same with other elements
|
||||
let w = wpt_ids(&fx, a);
|
||||
copy(&mut fx, waypoints(a, &w), false);
|
||||
fx.selection = files(&[b]);
|
||||
paste(&mut fx).unwrap();
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, b).len(), 2);
|
||||
assert_eq!(wpt_ids(&fx, a), w);
|
||||
|
||||
copy(&mut fx, files(&[b]), false);
|
||||
fx.selection = Selection::Empty;
|
||||
paste(&mut fx).unwrap();
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(fx.files.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_a_cut_moves_the_first_time_then_copies() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let c = add(&mut fx, file("c", &[], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||
fx.selection = files(&[b]);
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
// moved with its id, and what is in the clipboard now is a copy of it
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||
assert_eq!(
|
||||
clipboard_state(&fx),
|
||||
Some((ClipboardIds::Tracks(vec![t[0]]), false))
|
||||
);
|
||||
|
||||
fx.selection = files(&[c]);
|
||||
paste(&mut fx).unwrap();
|
||||
paste(&mut fx).unwrap();
|
||||
// copies: the moved track stays where it was moved to
|
||||
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||
let in_c = track_ids(&fx, c);
|
||||
assert_eq!(in_c.len(), 2);
|
||||
assert!(!in_c.contains(&t[0]));
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_cut_files_several_times() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[], 0));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
copy(&mut fx, files(&[a]), true);
|
||||
fx.selection = Selection::Empty;
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(fx.order.0, vec![b, a]);
|
||||
// the next pastes copy it
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(fx.files.len(), 3);
|
||||
assert_eq!(fx.order.0[..2], [b, a]);
|
||||
}
|
||||
|
||||
/// Renames the first track of the file, as an edit would.
|
||||
fn rename_track(fx: &mut Fixture, id: FileId, name: &str) {
|
||||
let file = std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap());
|
||||
file.trk[0].info.name = Some(name.to_owned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_the_elements_as_they_were_when_copied() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("original"), 1)], 0));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), false);
|
||||
rename_track(&mut fx, a, "edited");
|
||||
fx.selection = files(&[b]);
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
let pasted = &fx.files[&b].trk[0];
|
||||
assert_eq!(pasted.info.name.as_deref(), Some("original"));
|
||||
// the source keeps what was done to it
|
||||
assert_eq!(fx.files[&a].trk[0].info.name.as_deref(), Some("edited"));
|
||||
|
||||
// and each time
|
||||
paste(&mut fx).unwrap();
|
||||
assert!(
|
||||
fx.files[&b]
|
||||
.trk
|
||||
.iter()
|
||||
.all(|trk| trk.info.name.as_deref() == Some("original"))
|
||||
);
|
||||
assert_eq!(fx.files[&b].trk.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_a_cut_as_it_was_when_cut() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(
|
||||
&mut fx,
|
||||
file("a", &[(Some("original"), 1), (Some("other"), 1)], 0),
|
||||
);
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||
rename_track(&mut fx, a, "edited");
|
||||
fx.selection = files(&[b]);
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
// what is in the clipboard is moved, the edited version is not what is pasted
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||
assert_eq!(fx.files[&b].trk[0].info.name.as_deref(), Some("original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_after_the_elements_were_deleted() {
|
||||
let delete_file = |fx: &mut Fixture, id: FileId| {
|
||||
fx.files.remove(&id);
|
||||
fx.order.0.retain(|other| *other != id);
|
||||
};
|
||||
|
||||
// copied tracks, waypoints, segments and files are still there to be pasted
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
|
||||
copy(&mut fx, tracks(a, &t), false);
|
||||
fx.selection = files(&[b]);
|
||||
delete_file(&mut fx, a);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(fx.files[&b].trk.len(), 1);
|
||||
assert_eq!(fx.files[&b].trk[0].trkseg.len(), 2);
|
||||
// the copy of a track comes from a file that is gone: named like the track
|
||||
fx.selection = Selection::Empty;
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(names(&fx), ["b", "t"]);
|
||||
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let (t, s) = (track_ids(&fx, a), segment_ids(&fx, a, 0));
|
||||
copy(&mut fx, segments(a, t[0], &s), false);
|
||||
fx.selection = files(&[b]);
|
||||
delete_file(&mut fx, a);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(fx.files[&b].trk.len(), 2);
|
||||
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let w = wpt_ids(&fx, a);
|
||||
copy(&mut fx, waypoints(a, &w), false);
|
||||
fx.selection = files(&[b]);
|
||||
delete_file(&mut fx, a);
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(wpt_ids(&fx, b).len(), 2);
|
||||
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 0));
|
||||
copy(&mut fx, files(&[a]), false);
|
||||
fx.selection = Selection::Empty;
|
||||
delete_file(&mut fx, a);
|
||||
assert!(fx.files.is_empty());
|
||||
paste(&mut fx).unwrap();
|
||||
paste(&mut fx).unwrap();
|
||||
assert_eq!(fx.files.len(), 2);
|
||||
assert_eq!(names(&fx), ["a", "a"]);
|
||||
let _ = (s, w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paste_a_cut_after_the_elements_were_deleted() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
|
||||
let b = add(&mut fx, file("b", &[], 0));
|
||||
let t = track_ids(&fx, a);
|
||||
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||
// deleted before it is pasted
|
||||
std::rc::Rc::make_mut(fx.files.get_mut(&a).unwrap())
|
||||
.trk
|
||||
.retain(|trk| trk.id != t[0]);
|
||||
fx.selection = files(&[b]);
|
||||
|
||||
paste(&mut fx).unwrap();
|
||||
// nothing to remove, the track is where it was pasted, with its id
|
||||
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||
assert_eq!(
|
||||
clipboard_state(&fx),
|
||||
Some((ClipboardIds::Tracks(vec![t[0]]), false))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Copying or moving elements to a place of the file tree: what paste and move are made of.
|
||||
//! The elements come as the content of a clipboard, and a destination says where they go among
|
||||
//! the elements already there.
|
||||
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection,
|
||||
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file,
|
||||
copy_segment, copy_track, copy_waypoint,
|
||||
};
|
||||
|
||||
/// Where elements are put, among the others of the same list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Place<T> {
|
||||
/// After all of them.
|
||||
End,
|
||||
/// Right after the one with this id, or at the end if it is not there (anymore).
|
||||
After(T),
|
||||
/// At this position (at the end if it is past it).
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
impl<T: Copy + PartialEq> Place<T> {
|
||||
/// The position in the list of `ids`.
|
||||
pub fn position(&self, ids: &[T]) -> usize {
|
||||
match self {
|
||||
Place::End => ids.len(),
|
||||
Place::After(id) => ids
|
||||
.iter()
|
||||
.position(|other| other == id)
|
||||
.map_or(ids.len(), |i| i + 1),
|
||||
Place::Index(index) => (*index).min(ids.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where elements go. What can go where:
|
||||
/// - files: among the files;
|
||||
/// - tracks: among the files (each track becomes a file, named like the track, or like the file
|
||||
/// it comes from), or among the tracks of a file;
|
||||
/// - segments: among the files (each segment becomes a file with a track, named like its track,
|
||||
/// or like its file), among the tracks of a file (each segment becomes a track), or among the
|
||||
/// segments of a track;
|
||||
/// - waypoints: among the waypoints of a file.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Destination {
|
||||
Files(Place<FileId>),
|
||||
Tracks(FileId, Place<TrackId>),
|
||||
Segments(FileId, TrackId, Place<TrackSegmentId>),
|
||||
Waypoints(FileId, Place<WaypointId>),
|
||||
}
|
||||
|
||||
pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
|
||||
file.wpt.iter().map(|wpt| wpt.id)
|
||||
}
|
||||
|
||||
fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> {
|
||||
state
|
||||
.files
|
||||
.get_mut(&id)
|
||||
.map(Rc::make_mut)
|
||||
.ok_or(CommandError::NothingToDo)
|
||||
}
|
||||
|
||||
pub fn new_file(name: &str, trk: Vec<Track>) -> File {
|
||||
let mut file = File {
|
||||
trk,
|
||||
..Default::default()
|
||||
};
|
||||
file.info.name = name.to_owned();
|
||||
file
|
||||
}
|
||||
|
||||
/// Adds the files to the state, at `place` in the order of the files.
|
||||
fn add_files(state: &mut State, files: Vec<Rc<File>>, place: Place<FileId>) -> Selection {
|
||||
let at = place.position(&state.order.0);
|
||||
let ids: Vec<FileId> = files.iter().map(|file| file.id).collect();
|
||||
for file in files {
|
||||
state.files.insert(file.id, file);
|
||||
}
|
||||
state.order.0.splice(at..at, ids.iter().copied());
|
||||
Selection::File {
|
||||
file_ids: ids.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the elements of the content from the files, wherever they are.
|
||||
pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
|
||||
match content {
|
||||
ClipboardContent::Files(files) => {
|
||||
let ids: HashSet<FileId> = files.iter().map(|file| file.id).collect();
|
||||
state.files.retain(|id, _| !ids.contains(id));
|
||||
state.order.0.retain(|id| !ids.contains(id));
|
||||
}
|
||||
ClipboardContent::Tracks(tracks) => {
|
||||
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
|
||||
for file in state.files.values_mut() {
|
||||
if file.trk.iter().any(|trk| ids.contains(&trk.id)) {
|
||||
Rc::make_mut(file).trk.retain(|trk| !ids.contains(&trk.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
ClipboardContent::Segments(segments) => {
|
||||
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.segment.id).collect();
|
||||
for file in state.files.values_mut() {
|
||||
let contains = |trk: &Track| trk.trkseg.iter().any(|seg| ids.contains(&seg.id));
|
||||
if file.trk.iter().any(contains) {
|
||||
for trk in Rc::make_mut(file).trk.iter_mut() {
|
||||
trk.trkseg.retain(|seg| !ids.contains(&seg.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ClipboardContent::Waypoints(waypoints) => {
|
||||
let ids: HashSet<WaypointId> = waypoints.iter().map(|wpt| wpt.id).collect();
|
||||
for file in state.files.values_mut() {
|
||||
if waypoint_ids(file).any(|id| ids.contains(&id)) {
|
||||
Rc::make_mut(file).wpt.edit(
|
||||
|wpt| ids.contains(&wpt.id),
|
||||
|wpts| {
|
||||
wpts.retain(|wpt| !ids.contains(&wpt.id));
|
||||
true
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the elements of the content at the destination: copies of them, or the elements
|
||||
/// themselves, with their ids, when `cut` (they are removed from where they are first). Returns
|
||||
/// the selection of what was put.
|
||||
pub fn transfer(
|
||||
state: &mut State,
|
||||
content: &ClipboardContent,
|
||||
cut: bool,
|
||||
to: Destination,
|
||||
) -> Result<Selection, CommandError> {
|
||||
// the destination has to be there, before anything is changed
|
||||
let exists = match to {
|
||||
Destination::Files(_) => true,
|
||||
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id),
|
||||
Destination::Segments(file_id, trk_id, _) => state
|
||||
.files
|
||||
.get(&file_id)
|
||||
.is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)),
|
||||
Destination::Waypoints(file_id, _) => state.files.contains_key(&file_id),
|
||||
};
|
||||
let valid = matches!(
|
||||
(content, to),
|
||||
(ClipboardContent::Files(_), Destination::Files(_))
|
||||
| (
|
||||
ClipboardContent::Tracks(_),
|
||||
Destination::Files(_) | Destination::Tracks(..)
|
||||
)
|
||||
| (
|
||||
ClipboardContent::Segments(_),
|
||||
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
|
||||
)
|
||||
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
|
||||
);
|
||||
let empty = match content {
|
||||
ClipboardContent::Files(files) => files.is_empty(),
|
||||
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
|
||||
ClipboardContent::Segments(segments) => segments.is_empty(),
|
||||
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
|
||||
};
|
||||
if !exists || !valid || empty {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
if cut {
|
||||
remove_elements(state, content);
|
||||
}
|
||||
let copy = !cut;
|
||||
match (content, to) {
|
||||
(ClipboardContent::Files(files), Destination::Files(place)) => {
|
||||
let files = files
|
||||
.iter()
|
||||
.map(|file| {
|
||||
if copy {
|
||||
Rc::new(copy_file(file))
|
||||
} else {
|
||||
file.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(add_files(state, files, place))
|
||||
}
|
||||
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
|
||||
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
|
||||
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
|
||||
transfer_waypoints(state, waypoints, copy, file_id, place)
|
||||
}
|
||||
_ => Err(CommandError::NothingToDo),
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer_tracks(
|
||||
state: &mut State,
|
||||
tracks: &[ClipboardTrack],
|
||||
copy: bool,
|
||||
to: Destination,
|
||||
) -> Result<Selection, CommandError> {
|
||||
let tracks: Vec<(Track, &str)> = tracks
|
||||
.iter()
|
||||
.map(|clip| {
|
||||
let track = if copy {
|
||||
copy_track(&clip.track)
|
||||
} else {
|
||||
clip.track.clone()
|
||||
};
|
||||
(track, clip.file_name.as_str())
|
||||
})
|
||||
.collect();
|
||||
match to {
|
||||
Destination::Files(place) => {
|
||||
let files = tracks
|
||||
.into_iter()
|
||||
.map(|(track, file_name)| {
|
||||
let name = track
|
||||
.info
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| file_name.to_owned());
|
||||
Rc::new(new_file(&name, vec![track]))
|
||||
})
|
||||
.collect();
|
||||
Ok(add_files(state, files, place))
|
||||
}
|
||||
Destination::Tracks(file_id, place) => {
|
||||
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect();
|
||||
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
|
||||
let file = file_mut(state, file_id)?;
|
||||
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
|
||||
let at = place.position(&existing);
|
||||
file.trk.splice(at..at, tracks);
|
||||
Ok(Selection::Track {
|
||||
file_id,
|
||||
trk_ids: ids,
|
||||
})
|
||||
}
|
||||
_ => Err(CommandError::NothingToDo),
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer_segments(
|
||||
state: &mut State,
|
||||
segments: &[ClipboardSegment],
|
||||
copy: bool,
|
||||
to: Destination,
|
||||
) -> Result<Selection, CommandError> {
|
||||
let segments: Vec<(TrackSegment, &str)> = segments
|
||||
.iter()
|
||||
.map(|clip| {
|
||||
let segment = if copy {
|
||||
copy_segment(&clip.segment)
|
||||
} else {
|
||||
clip.segment.clone()
|
||||
};
|
||||
(segment, clip.name.as_str())
|
||||
})
|
||||
.collect();
|
||||
// a segment on its own needs a track
|
||||
let own_track = |segment: TrackSegment| Track {
|
||||
trkseg: vec![segment],
|
||||
..Default::default()
|
||||
};
|
||||
match to {
|
||||
Destination::Files(place) => {
|
||||
let files = segments
|
||||
.into_iter()
|
||||
.map(|(segment, name)| Rc::new(new_file(name, vec![own_track(segment)])))
|
||||
.collect();
|
||||
Ok(add_files(state, files, place))
|
||||
}
|
||||
Destination::Tracks(file_id, place) => {
|
||||
let tracks: Vec<Track> = segments
|
||||
.into_iter()
|
||||
.map(|(segment, _)| own_track(segment))
|
||||
.collect();
|
||||
let ids = tracks.iter().map(|trk| trk.id).collect();
|
||||
let file = file_mut(state, file_id)?;
|
||||
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
|
||||
let at = place.position(&existing);
|
||||
file.trk.splice(at..at, tracks);
|
||||
Ok(Selection::Track {
|
||||
file_id,
|
||||
trk_ids: ids,
|
||||
})
|
||||
}
|
||||
Destination::Segments(file_id, trk_id, place) => {
|
||||
let segments: Vec<TrackSegment> =
|
||||
segments.into_iter().map(|(segment, _)| segment).collect();
|
||||
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.id).collect();
|
||||
let trk = file_mut(state, file_id)?
|
||||
.trk
|
||||
.iter_mut()
|
||||
.find(|trk| trk.id == trk_id)
|
||||
.ok_or(CommandError::NothingToDo)?;
|
||||
let existing: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
|
||||
let at = place.position(&existing);
|
||||
trk.trkseg.splice(at..at, segments);
|
||||
Ok(Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids: ids,
|
||||
})
|
||||
}
|
||||
Destination::Waypoints(..) => Err(CommandError::NothingToDo),
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer_waypoints(
|
||||
state: &mut State,
|
||||
waypoints: &[Waypoint],
|
||||
copy: bool,
|
||||
file_id: FileId,
|
||||
place: Place<WaypointId>,
|
||||
) -> Result<Selection, CommandError> {
|
||||
let waypoints: Vec<Waypoint> = waypoints
|
||||
.iter()
|
||||
.map(|wpt| {
|
||||
if copy {
|
||||
copy_waypoint(wpt)
|
||||
} else {
|
||||
wpt.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let ids = waypoints.iter().map(|wpt| wpt.id).collect();
|
||||
let file = file_mut(state, file_id)?;
|
||||
let existing: Vec<WaypointId> = waypoint_ids(file).collect();
|
||||
file.wpt.insert_at(place.position(&existing), waypoints);
|
||||
Ok(Selection::Waypoint {
|
||||
file_id,
|
||||
wpt_ids: ids,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_place_position() {
|
||||
let ids = [1, 2, 3];
|
||||
assert_eq!(Place::End.position(&ids), 3);
|
||||
assert_eq!(Place::After(1).position(&ids), 1);
|
||||
assert_eq!(Place::After(3).position(&ids), 3);
|
||||
// not there: at the end
|
||||
assert_eq!(Place::After(9).position(&ids), 3);
|
||||
assert_eq!(Place::Index(0).position(&ids), 0);
|
||||
assert_eq!(Place::Index(2).position(&ids), 2);
|
||||
assert_eq!(Place::Index(10).position(&ids), 3);
|
||||
assert_eq!(Place::<i32>::End.position(&[]), 0);
|
||||
assert_eq!(Place::<i32>::Index(4).position(&[]), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Shared helpers for the tests of the commands.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Fixture {
|
||||
pub files: StackEntry,
|
||||
pub selection: Selection,
|
||||
pub order: FileOrder,
|
||||
pub clipboard: Option<Clipboard>,
|
||||
pub categories: TrackpointCategories,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn state(&mut self) -> State<'_> {
|
||||
State {
|
||||
categories: &mut self.categories,
|
||||
files: &mut self.files,
|
||||
selection: &mut self.selection,
|
||||
order: &mut self.order,
|
||||
clipboard: &mut self.clipboard,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
mod apply;
|
||||
mod edit;
|
||||
mod file;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fixture;
|
||||
mod pattern;
|
||||
mod tools;
|
||||
|
||||
pub use apply::*;
|
||||
pub use edit::*;
|
||||
pub use file::*;
|
||||
pub use pattern::*;
|
||||
pub use tools::*;
|
||||
|
||||
use crate::State;
|
||||
|
||||
/// 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>),
|
||||
LoadFiles(LoadFiles<'a>),
|
||||
Duplicate(Duplicate),
|
||||
Delete(Delete),
|
||||
Paste(Paste),
|
||||
Move(Move),
|
||||
DeleteAll(DeleteAll),
|
||||
DeleteWaypoint(DeleteWaypoint),
|
||||
Metadata(Metadata<'a>),
|
||||
Style(Style<'a>),
|
||||
NewTrack(NewTrack),
|
||||
NewTrackSegment(NewTrackSegment),
|
||||
Reverse(Reverse),
|
||||
RoundTrip(RoundTrip),
|
||||
Route(Route<'a>),
|
||||
NewWaypoint(NewWaypoint<'a>),
|
||||
EditWaypoint(EditWaypoint<'a>),
|
||||
MoveWaypoint(MoveWaypoint),
|
||||
Crop(Crop),
|
||||
Reduce(Reduce),
|
||||
Split(Split),
|
||||
Time(Time),
|
||||
Merge(Merge),
|
||||
Extract(Extract),
|
||||
InsertAnchor(InsertAnchor),
|
||||
Elevation(Elevation<'a>),
|
||||
Clean(Clean),
|
||||
ChangeLoopStart(ChangeLoopStart),
|
||||
}
|
||||
|
||||
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::LoadFiles(c) => c.apply(state),
|
||||
Command::Duplicate(c) => c.apply(state),
|
||||
Command::Delete(c) => c.apply(state),
|
||||
Command::Paste(c) => c.apply(state),
|
||||
Command::Move(c) => c.apply(state),
|
||||
Command::DeleteAll(c) => c.apply(state),
|
||||
Command::DeleteWaypoint(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::RoundTrip(c) => c.apply(state),
|
||||
Command::Route(c) => c.apply(state),
|
||||
Command::NewWaypoint(c) => c.apply(state),
|
||||
Command::EditWaypoint(c) => c.apply(state),
|
||||
Command::MoveWaypoint(c) => c.apply(state),
|
||||
Command::Crop(c) => c.apply(state),
|
||||
Command::Reduce(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::InsertAnchor(c) => c.apply(state),
|
||||
Command::Elevation(c) => c.apply(state),
|
||||
Command::Clean(c) => c.apply(state),
|
||||
Command::ChangeLoopStart(c) => c.apply(state),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::{File, Track, TrackSegment, Waypoint};
|
||||
|
||||
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
|
||||
|
||||
/// A copy of the segment, with a new id.
|
||||
pub fn copy_segment(segment: &TrackSegment) -> TrackSegment {
|
||||
let mut copy = segment.clone();
|
||||
copy.id = Default::default();
|
||||
copy
|
||||
}
|
||||
|
||||
/// A copy of the track and of its segments, with new ids.
|
||||
pub fn copy_track(track: &Track) -> Track {
|
||||
Track {
|
||||
id: Default::default(),
|
||||
trkseg: track.trkseg.iter().map(copy_segment).collect(),
|
||||
..track.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of the file and of its tracks and segments, with new ids.
|
||||
pub fn copy_file(file: &File) -> File {
|
||||
File {
|
||||
id: Default::default(),
|
||||
trk: file.trk.iter().map(copy_track).collect(),
|
||||
..file.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of the waypoint, with a new id.
|
||||
pub fn copy_waypoint(waypoint: &Waypoint) -> Waypoint {
|
||||
let mut copy = waypoint.clone();
|
||||
copy.id = Default::default();
|
||||
copy
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod copy;
|
||||
mod produce;
|
||||
mod update_segments;
|
||||
mod update_selected;
|
||||
mod update_waypoint;
|
||||
|
||||
pub use copy::*;
|
||||
pub use produce::*;
|
||||
pub use update_segments::*;
|
||||
pub use update_selected::*;
|
||||
pub use update_waypoint::*;
|
||||
@@ -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,91 @@
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{File, FileId, SegmentLocation, State, TrackSegment};
|
||||
|
||||
/// Edits the segments at `locations`, calling `f` with the position of each location in the slice
|
||||
/// and its segment, in that order.
|
||||
///
|
||||
/// Each file concerned is copied once (cheap, trackpoints are shared chunks) and stored back as a
|
||||
/// new snapshot. The segments take care of their own revision (see [`TrackSegment`]).
|
||||
pub fn update_segments(
|
||||
state: &mut State,
|
||||
locations: &[SegmentLocation],
|
||||
mut f: impl FnMut(usize, &mut TrackSegment),
|
||||
) {
|
||||
let mut files: HashMap<FileId, File> = HashMap::new();
|
||||
for (i, location) in locations.iter().enumerate() {
|
||||
let file = files
|
||||
.entry(location.file_id)
|
||||
.or_insert_with(|| (*state.files[&location.file_id]).clone());
|
||||
f(i, &mut file.trk[location.trk].trkseg[location.seg]);
|
||||
}
|
||||
for (file_id, file) in files {
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::engine::command::fixture::Fixture;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (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, &mut Default::default()).unwrap());
|
||||
let id = file.id;
|
||||
fx.files.insert(id, file);
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_the_given_segments_are_edited_in_order() {
|
||||
let (mut fx, id) = fixture();
|
||||
let before = fx.files[&id].clone();
|
||||
let locations = [
|
||||
SegmentLocation {
|
||||
file_id: id,
|
||||
trk: 1,
|
||||
seg: 0,
|
||||
},
|
||||
SegmentLocation {
|
||||
file_id: id,
|
||||
trk: 0,
|
||||
seg: 0,
|
||||
},
|
||||
];
|
||||
let mut calls = vec![];
|
||||
update_segments(&mut fx.state(), &locations, |i, segment| {
|
||||
calls.push(i);
|
||||
segment.update(0, |trkpt| trkpt.ele = 1234.0);
|
||||
});
|
||||
|
||||
assert_eq!(calls, vec![0, 1]);
|
||||
let after = &fx.files[&id];
|
||||
assert!(!Rc::ptr_eq(after, &before));
|
||||
for (trk, seg) in [(1, 0), (0, 0)] {
|
||||
assert_eq!(after.trk[trk].trkseg[seg][0].ele, 1234.0);
|
||||
assert_ne!(
|
||||
after.trk[trk].trkseg[seg].rev_id,
|
||||
before.trk[trk].trkseg[seg].rev_id
|
||||
);
|
||||
}
|
||||
// the other segments are the same ones
|
||||
assert_eq!(
|
||||
after.trk[0].trkseg[1].rev_id,
|
||||
before.trk[0].trkseg[1].rev_id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_location_leaves_the_files_alone() {
|
||||
let (mut fx, id) = fixture();
|
||||
let before = fx.files[&id].clone();
|
||||
update_segments(&mut fx.state(), &[], |_, _| panic!("nothing to edit"));
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint};
|
||||
|
||||
/// 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 = file.wpt.edit(&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, &mut Default::default()).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 = crate::Waypoints::new([crate::WaypointChunk {
|
||||
wpt: wpts,
|
||||
..Default::default()
|
||||
}]);
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
let chunk_id = fx.files[&id].wpt.chunks()[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.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);
|
||||
assert_ne!(fx.files[&id].wpt.chunks()[0].id, chunk_id);
|
||||
let named: Vec<_> = fx.files[&id].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.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,29 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{CommandError, FileId, State, Waypoint, WaypointId};
|
||||
|
||||
/// Changes one waypoint of a file with `f`, whatever is selected. Nothing to do if the file or
|
||||
/// the waypoint does not exist.
|
||||
pub fn update_waypoint(
|
||||
state: &mut State,
|
||||
file_id: FileId,
|
||||
waypoint_id: WaypointId,
|
||||
f: impl Fn(&mut Waypoint),
|
||||
) -> Result<(), CommandError> {
|
||||
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let mut file = (**file).clone();
|
||||
let changed = file.wpt.edit(
|
||||
|wpt| wpt.id == waypoint_id,
|
||||
|wpts| {
|
||||
wpts.iter_mut()
|
||||
.filter(|wpt| wpt.id == waypoint_id)
|
||||
.for_each(&f);
|
||||
true
|
||||
},
|
||||
);
|
||||
if !changed {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Apply, CommandError, State, Trackpoint, replace_trackpoints};
|
||||
|
||||
use super::route::find_target;
|
||||
|
||||
/// Makes the trackpoint `index` of the selection (see `RoutingBuffer`) the start of its segment,
|
||||
/// which is a loop: the trackpoints before it, and itself, are copied after the last trackpoint
|
||||
/// of the segment, and the original ones are removed. The trackpoint is both the first and the
|
||||
/// last one, which closes the loop on it.
|
||||
///
|
||||
/// The copies keep their durations, and start one second after the previous last trackpoint if
|
||||
/// the segment has timestamps (see [`replace_trackpoints`]).
|
||||
///
|
||||
/// It is how the routing tool starts a loop somewhere else. The first and last trackpoints are
|
||||
/// anchors shown at every zoom level, as any end of a segment.
|
||||
#[derive(Debug)]
|
||||
pub struct ChangeLoopStart {
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
impl Apply for ChangeLoopStart {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let index = self.index as usize;
|
||||
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
|
||||
let mut file = (*state.files[&target.location.file_id]).clone();
|
||||
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
|
||||
let (start, len) = (target.start, segment.len());
|
||||
if start == 0 || start >= len {
|
||||
// it already is the start, or it is not a trackpoint
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
// the trackpoint itself ends the loop as well
|
||||
let moved: Vec<Trackpoint> = segment.iter().take(start + 1).cloned().collect();
|
||||
// the copies are shifted after the last trackpoint, which is all that matters of the rest
|
||||
let mut tail = vec![segment[len - 1].clone()];
|
||||
replace_trackpoints(&mut tail, 1, 1, moved, None, None, false);
|
||||
tail.remove(0);
|
||||
|
||||
// only the chunks at the ends of the segment change: the others stay shared
|
||||
segment.splice(len, len, tail);
|
||||
segment.splice(0, start, vec![]);
|
||||
segment.rev_id = Default::default();
|
||||
state.files.insert(target.location.file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, Selection, TrackSegment, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn first_segment(fx: &Fixture, id: FileId) -> TrackSegment {
|
||||
fx.files[&id].trk[0].trkseg[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_points_before_move_to_the_end_and_the_loop_closes_on_the_start() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
let before = first_segment(&fx, id);
|
||||
let n = before.len();
|
||||
assert!(n > 10);
|
||||
|
||||
ChangeLoopStart { index: 4 }.apply(&mut fx.state()).unwrap();
|
||||
let after = first_segment(&fx, id);
|
||||
// the trackpoint is repeated at the end
|
||||
assert_eq!(after.len(), n + 1);
|
||||
assert_ne!(after.rev_id, before.rev_id);
|
||||
let lng = |s: &TrackSegment, i: usize| s[i].coordinates.lng;
|
||||
for i in 0..=n {
|
||||
assert_eq!(lng(&after, i), lng(&before, (i + 4) % n), "{i}");
|
||||
}
|
||||
assert_eq!(lng(&after, n), lng(&after, 0));
|
||||
// the start and the end of the new segment are anchors, whatever they were
|
||||
assert_eq!(after[0].anchor, Some(0));
|
||||
assert_eq!(after[n].anchor, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_moved_part_is_shifted_after_the_last_point() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let before = first_segment(&fx, id);
|
||||
let n = before.len();
|
||||
ChangeLoopStart { index: 30 }
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let after = first_segment(&fx, id);
|
||||
assert_eq!(after.len(), n + 1);
|
||||
let times: Vec<_> = after.iter().map(|p| p.time.unwrap()).collect();
|
||||
assert!(times.windows(2).all(|w| w[0] < w[1]), "{times:?}");
|
||||
// the part that was after the index is untouched
|
||||
for i in 0..n - 30 {
|
||||
assert_eq!(after[i].time, before[30 + i].time);
|
||||
}
|
||||
// the copies follow the last point by a second, and keep their durations
|
||||
let last = before[n - 1].time.unwrap();
|
||||
assert_eq!(after[n - 30].time, Some(last + 1000));
|
||||
for i in n - 30..n {
|
||||
let original = i - (n - 30);
|
||||
assert_eq!(
|
||||
after[i + 1].time.unwrap() - after[i].time.unwrap(),
|
||||
before[original + 1].time.unwrap() - before[original].time.unwrap(),
|
||||
"{i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_do_on_the_start_or_out_of_the_trackpoints() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
let before = fx.files[&id].clone();
|
||||
assert_eq!(
|
||||
ChangeLoopStart { index: 0 }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
let total = first_segment(&fx, id).len() as u32;
|
||||
assert!(matches!(
|
||||
ChangeLoopStart { index: total + 5 }.apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
|
||||
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CleanType {
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
|
||||
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment
|
||||
/// only trackpoints, selected waypoints only waypoints.
|
||||
#[derive(Debug)]
|
||||
pub struct Clean {
|
||||
pub bounds: LngLatBounds,
|
||||
pub type_: CleanType,
|
||||
pub trkpt: bool,
|
||||
pub wpt: bool,
|
||||
}
|
||||
|
||||
impl Clean {
|
||||
fn contains(&self, c: LngLat) -> bool {
|
||||
(self.bounds.sw.lng..=self.bounds.ne.lng).contains(&c.lng)
|
||||
&& (self.bounds.sw.lat..=self.bounds.ne.lat).contains(&c.lat)
|
||||
}
|
||||
|
||||
fn removes(&self, c: LngLat) -> bool {
|
||||
self.contains(c) == (self.type_ == CleanType::Inside)
|
||||
}
|
||||
}
|
||||
|
||||
impl Apply for Clean {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
match &*state.selection {
|
||||
Selection::Waypoint { file_id, .. } | Selection::Waypoints { file_id } => {
|
||||
let file_id = *file_id;
|
||||
if !self.wpt {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
let wpt_ids = match &*state.selection {
|
||||
Selection::Waypoint { wpt_ids, .. } => Some(wpt_ids.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let selected =
|
||||
|wpt: &Waypoint| wpt_ids.as_ref().is_none_or(|ids| ids.contains(&wpt.id));
|
||||
*state.selection = delete_waypoints(state.files, file_id, |wpt| {
|
||||
selected(wpt) && self.removes(wpt.coordinates)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Selection::Empty => Err(CommandError::NothingToDo),
|
||||
_ => {
|
||||
let mut cleaner = Cleaner {
|
||||
clean: &self,
|
||||
changed: false,
|
||||
emptied: HashSet::new(),
|
||||
wpt_files: HashSet::new(),
|
||||
};
|
||||
update_selected(state, &mut cleaner);
|
||||
if cleaner.changed {
|
||||
prune(state, &cleaner.emptied, &cleaner.wpt_files);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CommandError::NothingToDo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Cleaner<'a> {
|
||||
clean: &'a Clean,
|
||||
changed: bool,
|
||||
/// Segments left without trackpoints by the clean.
|
||||
emptied: HashSet<TrackSegmentId>,
|
||||
/// Files that lost waypoints.
|
||||
wpt_files: HashSet<FileId>,
|
||||
}
|
||||
|
||||
impl Editor for Cleaner<'_> {
|
||||
fn file(&mut self, file: &mut File) -> Edit {
|
||||
let mut edit = Edit::Unchanged;
|
||||
for trk in &mut file.trk {
|
||||
if self.track(trk) == Edit::Changed {
|
||||
edit = Edit::Changed;
|
||||
}
|
||||
}
|
||||
if self.clean.wpt
|
||||
&& file.wpt.edit(
|
||||
|wpt| self.clean.removes(wpt.coordinates),
|
||||
|wpts| {
|
||||
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
|
||||
true
|
||||
},
|
||||
)
|
||||
{
|
||||
edit = Edit::Changed;
|
||||
self.wpt_files.insert(file.id);
|
||||
}
|
||||
self.changed |= edit == Edit::Changed;
|
||||
edit
|
||||
}
|
||||
|
||||
fn segment(&mut self, segment: &mut TrackSegment) -> Edit {
|
||||
if !self.clean.trkpt || !segment.iter().any(|pt| self.clean.removes(pt.coordinates)) {
|
||||
return Edit::Unchanged;
|
||||
}
|
||||
let kept: Vec<Trackpoint> = segment
|
||||
.iter()
|
||||
.filter(|pt| !self.clean.removes(pt.coordinates))
|
||||
.cloned()
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
self.emptied.insert(segment.id);
|
||||
}
|
||||
segment.splice(0, segment.len(), kept);
|
||||
self.changed = true;
|
||||
Edit::Changed
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their
|
||||
/// segments and the files that lost something and have nothing left. The selection falls back
|
||||
/// to the closest element still existing.
|
||||
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
|
||||
let ids: Vec<FileId> = state
|
||||
.files
|
||||
.iter()
|
||||
.filter(|(id, file)| {
|
||||
wpt_files.contains(id)
|
||||
|| file
|
||||
.trk
|
||||
.iter()
|
||||
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in ids {
|
||||
let mut file = (*state.files[&id]).clone();
|
||||
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
|
||||
file.trk.retain_mut(|trk| {
|
||||
let len = trk.trkseg.len();
|
||||
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
|
||||
trk.trkseg.len() == len || !trk.trkseg.is_empty()
|
||||
});
|
||||
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
|
||||
state.files.remove(&id);
|
||||
state.order.0.retain(|f| *f != id);
|
||||
} else {
|
||||
state.files.insert(id, Rc::new(file));
|
||||
}
|
||||
}
|
||||
*state.selection = fixed_selection(state);
|
||||
}
|
||||
|
||||
fn fixed_selection(state: &State) -> Selection {
|
||||
let files = &*state.files;
|
||||
match &*state.selection {
|
||||
Selection::File { file_ids } => {
|
||||
let file_ids: HashSet<_> = file_ids
|
||||
.iter()
|
||||
.filter(|id| files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
if file_ids.is_empty() {
|
||||
Selection::Empty
|
||||
} else {
|
||||
Selection::File { file_ids }
|
||||
}
|
||||
}
|
||||
Selection::Track { file_id, trk_ids } => {
|
||||
let Some(file) = files.get(file_id) else {
|
||||
return Selection::Empty;
|
||||
};
|
||||
let trk_ids: HashSet<_> = file
|
||||
.trk
|
||||
.iter()
|
||||
.map(|trk| trk.id)
|
||||
.filter(|id| trk_ids.contains(id))
|
||||
.collect();
|
||||
if trk_ids.is_empty() {
|
||||
Selection::File {
|
||||
file_ids: [*file_id].into(),
|
||||
}
|
||||
} else {
|
||||
Selection::Track {
|
||||
file_id: *file_id,
|
||||
trk_ids,
|
||||
}
|
||||
}
|
||||
}
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => {
|
||||
let Some(file) = files.get(file_id) else {
|
||||
return Selection::Empty;
|
||||
};
|
||||
let Some(trk) = file.trk.iter().find(|trk| trk.id == *trk_id) else {
|
||||
return Selection::File {
|
||||
file_ids: [*file_id].into(),
|
||||
};
|
||||
};
|
||||
let trkseg_ids: HashSet<_> = trk
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|seg| seg.id)
|
||||
.filter(|id| trkseg_ids.contains(id))
|
||||
.collect();
|
||||
if trkseg_ids.is_empty() {
|
||||
Selection::Track {
|
||||
file_id: *file_id,
|
||||
trk_ids: [*trk_id].into(),
|
||||
}
|
||||
} else {
|
||||
Selection::TrackSegment {
|
||||
file_id: *file_id,
|
||||
trk_id: *trk_id,
|
||||
trkseg_ids,
|
||||
}
|
||||
}
|
||||
}
|
||||
Selection::Waypoints { file_id } | Selection::Waypoint { file_id, .. } => {
|
||||
if files.contains_key(file_id) {
|
||||
Selection::File {
|
||||
file_ids: [*file_id].into(),
|
||||
}
|
||||
} else {
|
||||
Selection::Empty
|
||||
}
|
||||
}
|
||||
Selection::Empty => Selection::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, WaypointChunk, Waypoints, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn bounds(w: f64, s: f64, e: f64, n: f64) -> LngLatBounds {
|
||||
LngLatBounds {
|
||||
sw: LngLat { lng: w, lat: s },
|
||||
ne: LngLat { lng: e, lat: n },
|
||||
}
|
||||
}
|
||||
|
||||
fn clean(bounds: LngLatBounds, type_: CleanType) -> Clean {
|
||||
Clean {
|
||||
bounds,
|
||||
type_,
|
||||
trkpt: true,
|
||||
wpt: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn count(fx: &Fixture, id: FileId) -> usize {
|
||||
fx.files
|
||||
.get(&id)
|
||||
.into_iter()
|
||||
.flat_map(|file| &file.trk)
|
||||
.flat_map(|trk| &trk.trkseg)
|
||||
.map(|seg| seg.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inside_everything_removes_all_trackpoints() {
|
||||
let (mut fx, id) = loaded();
|
||||
assert!(count(&fx, id) > 0);
|
||||
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Inside)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(count(&fx, id), 0);
|
||||
// nothing is left of the file
|
||||
assert!(!fx.files.contains_key(&id));
|
||||
assert!(fx.order.0.is_empty());
|
||||
assert_eq!(fx.selection, Selection::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emptied_segments_and_tracks_are_removed() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let seg = &before.trk[0].trkseg[0];
|
||||
let pts: Vec<_> = seg.iter().map(|p| p.coordinates).collect();
|
||||
let mut b = LngLatBounds::default();
|
||||
pts.iter().for_each(|c| b.extend(*c));
|
||||
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
|
||||
let after = &fx.files[&id];
|
||||
assert!(after.trk.iter().all(|t| !t.trkseg.is_empty()));
|
||||
assert!(
|
||||
after
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.all(|s| !s.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outside_everything_changes_nothing() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let result =
|
||||
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
|
||||
assert!(matches!(result, Err(CommandError::NothingToDo)));
|
||||
// the file was not even copied
|
||||
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_clean_only_removes_points_inside_and_bumps_revision() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let first = before.trk[0].trkseg[0].iter().next().unwrap().coordinates;
|
||||
let total = count(&fx, id);
|
||||
// tiny rectangle around the first point
|
||||
let b = bounds(
|
||||
first.lng - 1e-9,
|
||||
first.lat - 1e-9,
|
||||
first.lng + 1e-9,
|
||||
first.lat + 1e-9,
|
||||
);
|
||||
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
|
||||
let removed = total - count(&fx, id);
|
||||
assert!(removed >= 1);
|
||||
let seg = &fx.files[&id].trk[0].trkseg[0];
|
||||
assert_ne!(seg.rev_id, before.trk[0].trkseg[0].rev_id);
|
||||
assert!(seg.iter().all(|pt| !b_contains(&b, pt.coordinates)));
|
||||
}
|
||||
|
||||
fn b_contains(b: &LngLatBounds, c: LngLat) -> bool {
|
||||
c.lng >= b.sw.lng && c.lng <= b.ne.lng && c.lat >= b.sw.lat && c.lat <= b.ne.lat
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_waypoints_flag_and_selection() {
|
||||
let (mut fx, id) = loaded();
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.wpt = Waypoints::new([WaypointChunk {
|
||||
wpt: vec![Waypoint::default(), Waypoint::default()],
|
||||
..Default::default()
|
||||
}]);
|
||||
let ids: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
|
||||
fx.files.insert(id, std::rc::Rc::new(file));
|
||||
let everywhere = bounds(-180.0, -90.0, 180.0, 90.0);
|
||||
let wpt_count = |fx: &Fixture| fx.files.get(&id).map_or(0, |f| f.wpt.len());
|
||||
|
||||
// trackpoints only: waypoints are kept
|
||||
Clean {
|
||||
wpt: false,
|
||||
..clean(everywhere, CleanType::Inside)
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(wpt_count(&fx), 2);
|
||||
|
||||
// one selected waypoint
|
||||
fx.selection = Selection::Waypoint {
|
||||
file_id: id,
|
||||
wpt_ids: HashSet::from([ids[0]]),
|
||||
};
|
||||
clean(everywhere, CleanType::Inside)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(wpt_count(&fx), 1);
|
||||
|
||||
// whole file
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
clean(everywhere, CleanType::Inside)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(wpt_count(&fx), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, File, FileId, SegmentLocation, Selection, State, TrackSegment,
|
||||
TrackSegmentId,
|
||||
};
|
||||
|
||||
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
|
||||
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
|
||||
///
|
||||
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment
|
||||
/// left, if tracks or files are selected (not if only segments are), and the files that have no
|
||||
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
|
||||
#[derive(Debug)]
|
||||
pub struct Crop {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
/// What happens to a selected segment.
|
||||
struct Plan {
|
||||
location: SegmentLocation,
|
||||
/// The trackpoints that are kept, `None` if the segment goes away.
|
||||
keep: Option<(usize, usize)>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl Apply for Crop {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let (start, end) = (self.start as usize, self.end as usize);
|
||||
if start > end {
|
||||
return Err(CommandError::InvalidData(
|
||||
"invalid trackpoints range".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut offset = 0;
|
||||
let plans: Vec<Plan> = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0)
|
||||
.into_iter()
|
||||
.map(|location| {
|
||||
let len =
|
||||
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
|
||||
let keep = (len > 0 && start < offset + len && end >= offset)
|
||||
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
|
||||
offset += len;
|
||||
Plan {
|
||||
location,
|
||||
keep,
|
||||
len,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if plans
|
||||
.iter()
|
||||
.all(|plan| plan.keep == Some((0, plan.len.saturating_sub(1))))
|
||||
{
|
||||
// everything is kept as it is, or there is nothing
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
// the files and tracks to prune, the segments to remove
|
||||
let (prune_tracks, prune_files) = match &*state.selection {
|
||||
Selection::File { .. } => (true, true),
|
||||
Selection::Track { .. } => (true, false),
|
||||
_ => (false, false),
|
||||
};
|
||||
let mut files: HashMap<FileId, File> = HashMap::new();
|
||||
let mut removed: HashSet<TrackSegmentId> = HashSet::new();
|
||||
let mut had_points: HashSet<FileId> = HashSet::new();
|
||||
for plan in &plans {
|
||||
let SegmentLocation { file_id, trk, seg } = plan.location;
|
||||
let file = files
|
||||
.entry(file_id)
|
||||
.or_insert_with(|| (*state.files[&file_id]).clone());
|
||||
if plan.len > 0 {
|
||||
had_points.insert(file_id);
|
||||
}
|
||||
let segment = &mut file.trk[trk].trkseg[seg];
|
||||
match plan.keep {
|
||||
None => {
|
||||
removed.insert(segment.id);
|
||||
}
|
||||
Some((from, to)) => crop_segment(segment, from, to),
|
||||
}
|
||||
}
|
||||
|
||||
let mut gone: Vec<FileId> = vec![];
|
||||
for (file_id, file) in files.iter_mut() {
|
||||
// the tracks that lost segments
|
||||
let touched: Vec<bool> = file
|
||||
.trk
|
||||
.iter_mut()
|
||||
.map(|track| {
|
||||
let before = track.trkseg.len();
|
||||
track
|
||||
.trkseg
|
||||
.retain(|segment| !removed.contains(&segment.id));
|
||||
before != track.trkseg.len()
|
||||
})
|
||||
.collect();
|
||||
if prune_tracks {
|
||||
let mut touched = touched.into_iter();
|
||||
file.trk
|
||||
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
|
||||
}
|
||||
if prune_files && file.trk.is_empty() && had_points.contains(file_id) {
|
||||
gone.push(*file_id);
|
||||
}
|
||||
}
|
||||
|
||||
for (file_id, file) in files {
|
||||
if gone.contains(&file_id) {
|
||||
state.files.remove(&file_id);
|
||||
state.order.0.retain(|id| *id != file_id);
|
||||
} else {
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the trackpoints `from` to `to` of a segment, which are in it. Only the chunks at the ends
|
||||
/// are changed.
|
||||
fn crop_segment(segment: &mut TrackSegment, from: usize, to: usize) {
|
||||
let len = segment.len();
|
||||
if from == 0 && to + 1 >= len {
|
||||
return;
|
||||
}
|
||||
segment.splice(to + 1, len, vec![]);
|
||||
segment.splice(0, from, vec![]);
|
||||
segment.rev_id = Default::default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Load, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn crop(start: u32, end: u32) -> Crop {
|
||||
Crop { start, end }
|
||||
}
|
||||
|
||||
/// The longitudes of the trackpoints of a file, one list per segment.
|
||||
fn paths(file: &File) -> Vec<Vec<f64>> {
|
||||
file.trk
|
||||
.iter()
|
||||
.flat_map(|track| &track.trkseg)
|
||||
.map(|segment| segment.iter().map(|p| p.coordinates.lng).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crop_a_file_keeps_the_range_over_its_segments() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let before = paths(&fx.files[&id]);
|
||||
let flat: Vec<f64> = before.iter().flatten().copied().collect();
|
||||
let (start, end) = (2, flat.len() - 3);
|
||||
|
||||
crop(start as u32, end as u32)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after: Vec<f64> = paths(&fx.files[&id]).iter().flatten().copied().collect();
|
||||
assert_eq!(after, flat[start..=end]);
|
||||
// the ends of the segments that are left are anchors
|
||||
for track in &fx.files[&id].trk {
|
||||
for segment in &track.trkseg {
|
||||
assert!(!segment.is_empty());
|
||||
assert_eq!(segment[0].anchor, Some(0));
|
||||
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_what_is_outside_goes_away() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
let lens: Vec<Vec<usize>> = file
|
||||
.trk
|
||||
.iter()
|
||||
.map(|t| t.trkseg.iter().map(|segment| segment.len()).collect())
|
||||
.collect();
|
||||
assert!(lens.len() >= 2);
|
||||
// from the start of the last track to the end
|
||||
let before_last: usize = lens[..lens.len() - 1].iter().flatten().sum();
|
||||
crop(before_last as u32, u32::MAX)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after = &fx.files[&id];
|
||||
// the other tracks are gone, the last one is whole
|
||||
assert_eq!(after.trk.len(), 1);
|
||||
assert_eq!(after.trk[0].id, file.trk.last().unwrap().id);
|
||||
assert_eq!(
|
||||
after.trk[0]
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|segment| segment.len())
|
||||
.collect::<Vec<_>>(),
|
||||
*lens.last().unwrap()
|
||||
);
|
||||
// the selection followed
|
||||
assert!(matches!(&fx.selection, Selection::File { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crop_inside_a_segment_changes_it_only() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
let trk = &file.trk[0];
|
||||
assert!(trk.trkseg.len() >= 2);
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: trk.id,
|
||||
trkseg_ids: HashSet::from([trk.trkseg[1].id]),
|
||||
};
|
||||
let len = trk.trkseg[1].len();
|
||||
assert!(len > 4);
|
||||
crop(1, len as u32 - 2).apply(&mut fx.state()).unwrap();
|
||||
|
||||
let after = &fx.files[&id].trk[0];
|
||||
assert_eq!(after.trkseg[1].len(), len - 2);
|
||||
assert_ne!(after.trkseg[1].rev_id, trk.trkseg[1].rev_id);
|
||||
// the other segments are not in the selection, whatever the range
|
||||
assert_eq!(after.trkseg[0].rev_id, trk.trkseg[0].rev_id);
|
||||
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_segments_out_of_the_range_are_removed_but_not_their_track() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let trk = fx.files[&id].trk[0].clone();
|
||||
assert!(trk.trkseg.len() >= 2);
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: trk.id,
|
||||
trkseg_ids: trk.trkseg.iter().map(|s| s.id).collect(),
|
||||
};
|
||||
// only the first segment, entirely
|
||||
let first = trk.trkseg[0].len();
|
||||
crop(0, first as u32 - 1).apply(&mut fx.state()).unwrap();
|
||||
let after = &fx.files[&id].trk[0];
|
||||
assert_eq!(after.trkseg.len(), 1);
|
||||
assert_eq!(after.trkseg[0].id, trk.trkseg[0].id);
|
||||
|
||||
// nothing at all: the segments go, the track stays
|
||||
crop(100_000, 100_001).apply(&mut fx.state()).unwrap();
|
||||
assert!(fx.files[&id].trk[0].trkseg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_out_of_the_range_are_deleted() {
|
||||
let (mut fx, first) = loaded("data/simple.gpx");
|
||||
let data = std::fs::read("data/with_time.gpx").unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "second",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let second = fx.order.0[1];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([first, second]),
|
||||
};
|
||||
let first_len: usize = fx.files[&first]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.map(|segment| segment.len())
|
||||
.sum();
|
||||
|
||||
crop(first_len as u32 + 1, u32::MAX)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert!(!fx.files.contains_key(&first));
|
||||
assert_eq!(fx.order.0, [second]);
|
||||
let kept: usize = fx.files[&second]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.map(|segment| segment.len())
|
||||
.sum();
|
||||
assert!(kept > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_do_or_invalid() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
let before = fx.files[&id].clone();
|
||||
// the whole selection
|
||||
assert_eq!(
|
||||
crop(0, u32::MAX).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert!(matches!(
|
||||
crop(5, 2).apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
crop(0, 3).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
fx.selection = Selection::Waypoints { file_id: id };
|
||||
assert_eq!(
|
||||
crop(0, 3).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use crate::{Apply, CommandError, FileId, State, WaypointId, set_waypoint_fields, update_waypoint};
|
||||
|
||||
/// Changes a waypoint of a file, whatever is selected (what the form of the waypoint tool does):
|
||||
/// its name, description, icon, link, position and elevation. The strings that are empty remove
|
||||
/// the field.
|
||||
#[derive(Debug)]
|
||||
pub struct EditWaypoint<'a> {
|
||||
pub file_id: FileId,
|
||||
pub waypoint_id: WaypointId,
|
||||
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 EditWaypoint<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
|
||||
set_waypoint_fields(
|
||||
wpt,
|
||||
(self.lng, self.lat, self.ele),
|
||||
self.name,
|
||||
self.desc,
|
||||
self.icon,
|
||||
self.link,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
File, Link, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_edit_the_given_waypoint() {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![
|
||||
Waypoint {
|
||||
name: Some("old".into()),
|
||||
desc: Some("old description".into()),
|
||||
cmt: Some("old comment".into()),
|
||||
sym: Some("Flag".into()),
|
||||
links: vec![
|
||||
Link {
|
||||
href: "https://old".into(),
|
||||
text: None,
|
||||
},
|
||||
Link {
|
||||
href: "https://second".into(),
|
||||
text: Some("second".into()),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Waypoint {
|
||||
name: Some("other".into()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
let id = file.id;
|
||||
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
|
||||
EditWaypoint {
|
||||
file_id: id,
|
||||
waypoint_id: ids[0],
|
||||
lng: 4.5,
|
||||
lat: 50.5,
|
||||
ele: 12.0,
|
||||
name: "new",
|
||||
desc: "new description",
|
||||
icon: "Summit",
|
||||
link: "",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
|
||||
let edited = wpts[0];
|
||||
assert_eq!(edited.id, ids[0]);
|
||||
assert_eq!(edited.name.as_deref(), Some("new"));
|
||||
assert_eq!(edited.desc.as_deref(), Some("new description"));
|
||||
assert_eq!(edited.cmt.as_deref(), Some("new description"));
|
||||
assert_eq!(edited.sym.as_deref(), Some("Summit"));
|
||||
// an empty link removes the first one, the others are not the form's business
|
||||
assert_eq!(edited.links.len(), 1);
|
||||
assert_eq!(edited.links[0].href, "https://second");
|
||||
assert_eq!(
|
||||
(edited.coordinates.lng, edited.coordinates.lat, edited.ele),
|
||||
(4.5, 50.5, 12.0)
|
||||
);
|
||||
// the other waypoint is as it was
|
||||
assert_eq!(wpts[1].name.as_deref(), Some("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_link_of_the_form_is_the_first_one() {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![Waypoint {
|
||||
links: vec![
|
||||
Link {
|
||||
href: "https://first".into(),
|
||||
text: Some("first".into()),
|
||||
},
|
||||
Link {
|
||||
href: "https://second".into(),
|
||||
text: None,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let (id, wpt_id) = (file.id, waypoint_ids(&file).next().unwrap());
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
let edit = |fx: &mut Fixture, link: &str| {
|
||||
EditWaypoint {
|
||||
file_id: id,
|
||||
waypoint_id: wpt_id,
|
||||
lng: 0.0,
|
||||
lat: 0.0,
|
||||
ele: 0.0,
|
||||
name: "",
|
||||
desc: "",
|
||||
icon: "",
|
||||
link,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
fx.files[&id].wpt.iter().next().unwrap().links.clone()
|
||||
};
|
||||
|
||||
// the same link keeps its text
|
||||
let links = edit(&mut fx, "https://first");
|
||||
assert_eq!(links.len(), 2);
|
||||
assert_eq!(links[0].text.as_deref(), Some("first"));
|
||||
// another one replaces the first, and the text that described the old one
|
||||
let links = edit(&mut fx, "https://other");
|
||||
assert_eq!(links.len(), 2);
|
||||
assert_eq!(
|
||||
(links[0].href.as_str(), links[0].text.clone()),
|
||||
("https://other", None)
|
||||
);
|
||||
assert_eq!(links[1].href, "https://second");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::{Apply, CommandError, State, update_segments};
|
||||
|
||||
/// Sets the elevation of the trackpoints of the selected segments (the ones of the statistics of
|
||||
/// the selection, in the same order): `ele` has one elevation per trackpoint.
|
||||
#[derive(Debug)]
|
||||
pub struct Elevation<'a> {
|
||||
pub ele: &'a [f64],
|
||||
}
|
||||
|
||||
impl Apply for Elevation<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let locations = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0);
|
||||
let count: usize = locations
|
||||
.iter()
|
||||
.map(|location| {
|
||||
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
|
||||
})
|
||||
.sum();
|
||||
if count == 0 {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
if count != self.ele.len() {
|
||||
return Err(CommandError::InvalidData(format!(
|
||||
"{} elevations for {count} trackpoints",
|
||||
self.ele.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut ele = self.ele.iter().copied();
|
||||
update_segments(state, &locations, |_, segment| {
|
||||
segment.update_all(|_, pt| pt.ele = ele.next().unwrap_or(pt.ele));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn trackpoint_elevations(fx: &Fixture, id: FileId) -> Vec<f64> {
|
||||
fx.files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|trk| {
|
||||
trk.trkseg
|
||||
.iter()
|
||||
.flat_map(|seg| seg.iter().map(|pt| pt.ele))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_gets_elevation_on_all_its_trackpoints() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let count = trackpoint_elevations(&fx, id).len();
|
||||
let ele: Vec<f64> = (0..count).map(|i| 100.0 + i as f64).collect();
|
||||
|
||||
Elevation { ele: &ele }.apply(&mut fx.state()).unwrap();
|
||||
|
||||
assert_eq!(trackpoint_elevations(&fx, id), ele);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_segments_only_touch_their_trackpoints() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let before = trackpoint_elevations(&fx, id);
|
||||
let track = &fx.files[&id].trk[0];
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: HashSet::from([track.trkseg[0].id]),
|
||||
};
|
||||
let rev_ids: Vec<_> = track.trkseg.iter().map(|seg| seg.rev_id).collect();
|
||||
let len = track.trkseg[0].len();
|
||||
|
||||
Elevation {
|
||||
ele: &vec![5.0; len],
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after = trackpoint_elevations(&fx, id);
|
||||
assert_eq!(after[..len], vec![5.0; len]);
|
||||
assert_eq!(after[len..], before[len..]);
|
||||
let segments = &fx.files[&id].trk[0].trkseg;
|
||||
assert_ne!(segments[0].rev_id, rev_ids[0]);
|
||||
assert_eq!(segments[1].rev_id, rev_ids[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_number_of_elevations() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
let before = trackpoint_elevations(&fx, id);
|
||||
assert!(matches!(
|
||||
Elevation { ele: &[1.0] }.apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
assert_eq!(trackpoint_elevations(&fx, id), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_selected() {
|
||||
let (mut fx, _) = loaded("data/simple.gpx");
|
||||
fx.selection = Selection::Empty;
|
||||
assert!(matches!(
|
||||
Elevation { ele: &[] }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId,
|
||||
TrackSegment, Waypoint, Waypoints, copy_waypoint, crossarc_lnglat, distance,
|
||||
};
|
||||
|
||||
/// Splits the selected elements that are made of several segments into elements that have one
|
||||
/// each:
|
||||
///
|
||||
/// - files: a file with several tracks becomes one file per track, in which the track is split
|
||||
/// into one track per segment. A file with a single track becomes one file per segment. The new
|
||||
/// files are in the place of the file, which goes, and are selected. They are named after the
|
||||
/// track, or the file, and numbered. The waypoints go to the files that have the segments
|
||||
/// closest to them (to the first file if the file has no trackpoint).
|
||||
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
|
||||
/// are named after the track and numbered.
|
||||
///
|
||||
/// What has a single segment is left as it is. There is nothing to do if nothing was changed.
|
||||
#[derive(Debug)]
|
||||
pub struct Extract;
|
||||
|
||||
impl Apply for Extract {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let selection = state.selection.clone();
|
||||
match &selection {
|
||||
Selection::File { file_ids } => extract_files(state, file_ids),
|
||||
Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids),
|
||||
_ => Err(CommandError::NothingToDo),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_files(state: &mut State, file_ids: &HashSet<FileId>) -> Result<(), CommandError> {
|
||||
// the files that have something to extract, with the files that come out of them
|
||||
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
|
||||
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
|
||||
let file = &state.files[id];
|
||||
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum();
|
||||
if segments > 1 {
|
||||
extracted.push((*id, extract_file(file)));
|
||||
}
|
||||
}
|
||||
if extracted.is_empty() {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
let mut order = vec![];
|
||||
for id in state.order.0.iter() {
|
||||
match extracted.iter().find(|(original, _)| original == id) {
|
||||
Some((_, files)) => order.extend(files.iter().map(|file| file.id)),
|
||||
None => order.push(*id),
|
||||
}
|
||||
}
|
||||
let mut selected: HashSet<FileId> = file_ids.clone();
|
||||
for (original, files) in extracted {
|
||||
state.files.remove(&original);
|
||||
selected.remove(&original);
|
||||
for file in files {
|
||||
selected.insert(file.id);
|
||||
state.files.insert(file.id, Rc::new(file));
|
||||
}
|
||||
}
|
||||
state.order.0 = order;
|
||||
*state.selection = Selection::File { file_ids: selected };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files that a file with several segments becomes.
|
||||
fn extract_file(file: &File) -> Vec<File> {
|
||||
// for each new file: its tracks and its name, and the segments that go with it
|
||||
struct Part {
|
||||
tracks: Vec<Track>,
|
||||
name: String,
|
||||
}
|
||||
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 {
|
||||
// one file per track, the segments of a track follow it
|
||||
let parts = file
|
||||
.trk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, track)| Part {
|
||||
tracks: split_track(track),
|
||||
name: track
|
||||
.info
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)),
|
||||
})
|
||||
.collect();
|
||||
let owner = file
|
||||
.trk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
|
||||
.collect();
|
||||
(parts, owner)
|
||||
} else {
|
||||
// one file per segment
|
||||
let track = &file.trk[0];
|
||||
let base = track
|
||||
.info
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| file.info.name.clone());
|
||||
let parts = track
|
||||
.trkseg
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, segment)| Part {
|
||||
tracks: vec![Track {
|
||||
id: if i == 0 { track.id } else { Default::default() },
|
||||
trkseg: vec![segment.clone()],
|
||||
..track.clone()
|
||||
}],
|
||||
name: format!("{base} ({})", i + 1),
|
||||
})
|
||||
.collect();
|
||||
let owner = (0..track.trkseg.len()).collect();
|
||||
(parts, owner)
|
||||
};
|
||||
|
||||
// the waypoints go with the segments that are the closest to them
|
||||
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect();
|
||||
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
|
||||
for waypoint in file.wpt.iter() {
|
||||
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
|
||||
.into_iter()
|
||||
.map(|segment| owner[segment])
|
||||
.collect();
|
||||
owners.sort_unstable();
|
||||
owners.dedup();
|
||||
if owners.is_empty() {
|
||||
owners.push(0);
|
||||
}
|
||||
for (n, part) in owners.into_iter().enumerate() {
|
||||
// a waypoint that goes to several files has a new id in all but the first
|
||||
waypoints[part].push(if n == 0 {
|
||||
waypoint.clone()
|
||||
} else {
|
||||
copy_waypoint(waypoint)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
parts
|
||||
.into_iter()
|
||||
.zip(waypoints)
|
||||
.map(|(part, waypoints)| {
|
||||
let mut extracted = File {
|
||||
id: Default::default(),
|
||||
trk: part.tracks,
|
||||
wpt: Waypoints::default(),
|
||||
..file.clone()
|
||||
};
|
||||
extracted.info.name = part.name;
|
||||
extracted.wpt.insert_at(0, waypoints);
|
||||
extracted
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_tracks(
|
||||
state: &mut State,
|
||||
file_id: FileId,
|
||||
trk_ids: &HashSet<TrackId>,
|
||||
) -> Result<(), CommandError> {
|
||||
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let mut changed = false;
|
||||
let mut tracks = vec![];
|
||||
let mut selected: HashSet<TrackId> = trk_ids.clone();
|
||||
for track in &file.trk {
|
||||
if trk_ids.contains(&track.id) && track.trkseg.len() > 1 {
|
||||
changed = true;
|
||||
selected.remove(&track.id);
|
||||
let pieces = split_track(track);
|
||||
selected.extend(pieces.iter().map(|piece| piece.id));
|
||||
tracks.extend(pieces);
|
||||
} else {
|
||||
tracks.push(track.clone());
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
let mut file = (**file).clone();
|
||||
file.trk = tracks;
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
*state.selection = Selection::Track {
|
||||
file_id,
|
||||
trk_ids: selected,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tracks that a track becomes: one per segment, the first one with the id of the track. The
|
||||
/// others have the same info, with the name of the track and the number of the segment, if it has
|
||||
/// a name. A track with at most one segment stays as it is.
|
||||
fn split_track(track: &Track) -> Vec<Track> {
|
||||
if track.trkseg.len() <= 1 {
|
||||
return vec![track.clone()];
|
||||
}
|
||||
track
|
||||
.trkseg
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, segment)| {
|
||||
let mut piece = Track {
|
||||
id: if i == 0 { track.id } else { Default::default() },
|
||||
trkseg: vec![segment.clone()],
|
||||
..track.clone()
|
||||
};
|
||||
piece.info.name = track
|
||||
.info
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|name| format!("{name} ({})", i + 1));
|
||||
piece
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The indices of the segments that have the trackpoint that is the closest to `point`, which are
|
||||
/// several when the closest trackpoints are as close.
|
||||
///
|
||||
/// The segments are looked at from the closest to the farthest bounds, which is how they are
|
||||
/// left out: the ones whose bounds are farther than the closest trackpoint found.
|
||||
fn closest_segments(segments: &[&TrackSegment], point: LngLat) -> Vec<usize> {
|
||||
let mut by_bounds: Vec<(f64, usize)> = segments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, segment)| !segment.is_empty())
|
||||
.map(|(i, segment)| {
|
||||
let mut bounds = LngLatBounds::default();
|
||||
segment
|
||||
.iter()
|
||||
.for_each(|trkpt| bounds.extend(trkpt.coordinates));
|
||||
(distance_to_bounds(&bounds, point), i)
|
||||
})
|
||||
.collect();
|
||||
by_bounds.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
let mut closest = f64::MAX;
|
||||
let mut indices: Vec<usize> = vec![];
|
||||
for (bounds_distance, i) in by_bounds {
|
||||
if bounds_distance > closest {
|
||||
break;
|
||||
}
|
||||
for trkpt in segments[i].iter() {
|
||||
// in meters, like the distances to the bounds
|
||||
let d = distance(trkpt.coordinates, point) * 1000.0;
|
||||
if d < closest {
|
||||
closest = d;
|
||||
indices = vec![i];
|
||||
} else if d == closest {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
indices
|
||||
}
|
||||
|
||||
/// 0 inside the bounds, else the distance in meters to the closest side.
|
||||
fn distance_to_bounds(bounds: &LngLatBounds, point: LngLat) -> f64 {
|
||||
let (sw, ne) = (bounds.sw, bounds.ne);
|
||||
if (sw.lng..=ne.lng).contains(&point.lng) && (sw.lat..=ne.lat).contains(&point.lat) {
|
||||
return 0.0;
|
||||
}
|
||||
let nw = LngLat {
|
||||
lng: sw.lng,
|
||||
lat: ne.lat,
|
||||
};
|
||||
let se = LngLat {
|
||||
lng: ne.lng,
|
||||
lat: sw.lat,
|
||||
};
|
||||
[(nw, ne), (ne, se), (se, sw), (sw, nw)]
|
||||
.into_iter()
|
||||
.map(|(a, b)| crossarc_lnglat(a, b, point))
|
||||
.fold(f64::MAX, f64::min)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{TrackInfo, Trackpoint, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A segment of 5 trackpoints along the equator, from the longitude `from`.
|
||||
fn segment(from: f64) -> TrackSegment {
|
||||
let mut segment = TrackSegment::default();
|
||||
let points = (0..5)
|
||||
.map(|i| Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: from + i as f64 * 0.001,
|
||||
lat: 0.0,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
segment.splice(0, 0, points);
|
||||
segment
|
||||
}
|
||||
|
||||
fn track(name: Option<&str>, from: &[f64]) -> Track {
|
||||
Track {
|
||||
info: TrackInfo {
|
||||
name: name.map(str::to_string),
|
||||
..Default::default()
|
||||
},
|
||||
trkseg: from.iter().map(|from| segment(*from)).collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn waypoint(name: &str, lng: f64) -> Waypoint {
|
||||
Waypoint {
|
||||
name: Some(name.into()),
|
||||
coordinates: LngLat { lng, lat: 0.001 },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(file: File) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let id = file.id;
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.order.0.push(id);
|
||||
fx.selection = Selection::File {
|
||||
file_ids: [id].into(),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn file(name: &str, tracks: Vec<Track>, waypoints: Vec<Waypoint>) -> File {
|
||||
let mut file = File {
|
||||
trk: tracks,
|
||||
..Default::default()
|
||||
};
|
||||
file.info.name = name.into();
|
||||
file.wpt.insert_at(0, waypoints);
|
||||
file
|
||||
}
|
||||
|
||||
fn names(fx: &Fixture) -> Vec<String> {
|
||||
fx.order
|
||||
.0
|
||||
.iter()
|
||||
.map(|id| fx.files[id].info.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_with_one_track_becomes_one_file_per_segment() {
|
||||
let original = file(
|
||||
"route",
|
||||
vec![track(Some("climb"), &[0.0, 10.0, 20.0])],
|
||||
// near the first segment, near the last one, and in between, closer to the middle one
|
||||
vec![
|
||||
waypoint("a", 0.002),
|
||||
waypoint("c", 20.002),
|
||||
waypoint("b", 10.0),
|
||||
],
|
||||
);
|
||||
let track_id = original.trk[0].id;
|
||||
let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect();
|
||||
let (mut fx, id) = fixture(original);
|
||||
let other = file("other", vec![track(None, &[50.0])], vec![]);
|
||||
let other_id = other.id;
|
||||
fx.files.insert(other_id, Rc::new(other));
|
||||
fx.order.0.push(other_id);
|
||||
|
||||
Extract.apply(&mut fx.state()).unwrap();
|
||||
|
||||
// in the place of the file, in order, named after the track
|
||||
assert!(!fx.files.contains_key(&id));
|
||||
assert_eq!(names(&fx), ["climb (1)", "climb (2)", "climb (3)", "other"]);
|
||||
assert_eq!(fx.order.0[3], other_id);
|
||||
for (i, file_id) in fx.order.0[..3].iter().enumerate() {
|
||||
let file = &fx.files[file_id];
|
||||
assert_eq!(file.trk.len(), 1);
|
||||
assert_eq!(file.trk[0].trkseg.len(), 1);
|
||||
assert_eq!(file.trk[0].trkseg[0].id, segment_ids[i]);
|
||||
assert_eq!(file.trk[0].info.name.as_deref(), Some("climb"));
|
||||
}
|
||||
assert_eq!(fx.files[&fx.order.0[0]].trk[0].id, track_id);
|
||||
// the waypoints are with the closest segment
|
||||
let waypoints = |i: usize| -> Vec<String> {
|
||||
fx.files[&fx.order.0[i]]
|
||||
.wpt
|
||||
.iter()
|
||||
.map(|w| w.name.clone().unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(waypoints(0), ["a"]);
|
||||
assert_eq!(waypoints(1), ["b"]);
|
||||
assert_eq!(waypoints(2), ["c"]);
|
||||
// the new files are selected, the other one is not
|
||||
assert!(matches!(&fx.selection, Selection::File { file_ids }
|
||||
if file_ids.len() == 3 && !file_ids.contains(&other_id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_with_several_tracks_becomes_one_file_per_track() {
|
||||
let original = file(
|
||||
"route",
|
||||
vec![
|
||||
track(Some("first"), &[0.0, 10.0]),
|
||||
track(None, &[20.0]),
|
||||
track(Some("third"), &[30.0, 40.0]),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
|
||||
let (mut fx, _) = fixture(original);
|
||||
Extract.apply(&mut fx.state()).unwrap();
|
||||
|
||||
// named after the track, or the file
|
||||
assert_eq!(names(&fx), ["first", "route (2)", "third"]);
|
||||
let tracks = |i: usize| &fx.files[&fx.order.0[i]].trk;
|
||||
// a track with several segments is split in as many tracks
|
||||
assert_eq!(tracks(0).len(), 2);
|
||||
assert_eq!(tracks(0)[0].info.name.as_deref(), Some("first (1)"));
|
||||
assert_eq!(tracks(0)[1].info.name.as_deref(), Some("first (2)"));
|
||||
assert_eq!(tracks(0)[0].id, ids[0]);
|
||||
// and one that has a single segment is not renamed
|
||||
assert_eq!(tracks(1).len(), 1);
|
||||
assert_eq!(tracks(1)[0].id, ids[1]);
|
||||
assert_eq!(tracks(1)[0].info.name, None);
|
||||
assert_eq!(tracks(2).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_waypoints_with_closest_segments_that_tie_are_copied() {
|
||||
// the segments share a trackpoint, where the waypoint is: it is as close to both
|
||||
let a = segment(0.0);
|
||||
let b = segment(a[4].coordinates.lng);
|
||||
let shared = Waypoint {
|
||||
name: Some("mid".into()),
|
||||
coordinates: a[4].coordinates,
|
||||
..Default::default()
|
||||
};
|
||||
let original = file(
|
||||
"f",
|
||||
vec![Track {
|
||||
trkseg: vec![a, b],
|
||||
..Default::default()
|
||||
}],
|
||||
vec![shared, waypoint("lost", 100.0)],
|
||||
);
|
||||
let (mut fx, _) = fixture(original);
|
||||
Extract.apply(&mut fx.state()).unwrap();
|
||||
let first = &fx.files[&fx.order.0[0]];
|
||||
let second = &fx.files[&fx.order.0[1]];
|
||||
// the tie goes to both, with different ids
|
||||
let mid_in = |f: &File| {
|
||||
f.wpt
|
||||
.iter()
|
||||
.find(|w| w.name.as_deref() == Some("mid"))
|
||||
.map(|w| w.id)
|
||||
};
|
||||
assert!(mid_in(first).is_some() && mid_in(second).is_some());
|
||||
assert_ne!(mid_in(first), mid_in(second));
|
||||
// a far waypoint goes to its closest segment, which is the last one
|
||||
assert!(second.wpt.iter().any(|w| w.name.as_deref() == Some("lost")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_waypoint_of_a_file_without_trackpoints_is_kept() {
|
||||
let mut empty = TrackSegment::default();
|
||||
empty.rev_id = Default::default();
|
||||
let original = file(
|
||||
"f",
|
||||
vec![Track {
|
||||
trkseg: vec![empty.clone(), TrackSegment::default()],
|
||||
..Default::default()
|
||||
}],
|
||||
vec![waypoint("w", 1.0)],
|
||||
);
|
||||
let (mut fx, _) = fixture(original);
|
||||
Extract.apply(&mut fx.state()).unwrap();
|
||||
assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1);
|
||||
assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tracks_in_place() {
|
||||
let original = file(
|
||||
"route",
|
||||
vec![
|
||||
track(Some("a"), &[0.0]),
|
||||
track(Some("b"), &[10.0, 20.0, 30.0]),
|
||||
track(None, &[40.0, 50.0]),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
|
||||
let (mut fx, id) = fixture(original);
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: [ids[1], ids[2]].into(),
|
||||
};
|
||||
|
||||
Extract.apply(&mut fx.state()).unwrap();
|
||||
|
||||
let file = &fx.files[&id];
|
||||
let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
Some("a".into()),
|
||||
Some("b (1)".into()),
|
||||
Some("b (2)".into()),
|
||||
Some("b (3)".into()),
|
||||
None,
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(file.trk.len(), 6);
|
||||
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
|
||||
assert_eq!(file.trk[0].id, ids[0]);
|
||||
assert_eq!(file.trk[1].id, ids[1]);
|
||||
// the new tracks are selected
|
||||
let Selection::Track { trk_ids, .. } = &fx.selection else {
|
||||
panic!("tracks are selected");
|
||||
};
|
||||
assert_eq!(trk_ids.len(), 5);
|
||||
assert!(trk_ids.contains(&ids[1]) && !trk_ids.contains(&ids[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_extract() {
|
||||
// a single segment
|
||||
let original = file("f", vec![track(Some("t"), &[0.0])], vec![]);
|
||||
let track_id = original.trk[0].id;
|
||||
let segment_id = original.trk[0].trkseg[0].id;
|
||||
let (mut fx, id) = fixture(original);
|
||||
let before = fx.files[&id].clone();
|
||||
assert_eq!(
|
||||
Extract.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: [track_id].into(),
|
||||
};
|
||||
assert_eq!(
|
||||
Extract.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
for selection in [
|
||||
Selection::Empty,
|
||||
Selection::Waypoints { file_id: id },
|
||||
Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track_id,
|
||||
trkseg_ids: [segment_id].into(),
|
||||
},
|
||||
] {
|
||||
fx.selection = selection;
|
||||
assert_eq!(
|
||||
Extract.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Apply, CommandError, LngLat, SegmentLocation, State, Trackpoint, distance, projected};
|
||||
|
||||
/// Under this distance (in meters) to a trackpoint, that trackpoint becomes the anchor instead of
|
||||
/// a new one being inserted next to it.
|
||||
const SNAP_DISTANCE: f64 = 1.0;
|
||||
|
||||
/// Makes an anchor of the routing tool, shown at every zoom level, of the point of the selected
|
||||
/// segments that is the closest to the given coordinates. That is the trackpoint itself if the
|
||||
/// coordinates are on one, otherwise a new trackpoint is inserted on the path, at the position
|
||||
/// of the coordinates projected on it, with an elevation and a time in between the ones of its
|
||||
/// neighbours.
|
||||
#[derive(Debug)]
|
||||
pub struct InsertAnchor {
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
}
|
||||
|
||||
/// Where the anchor goes: between the trackpoints `before` and `before + 1` of a segment, at
|
||||
/// `ratio` of the distance from the first one, which is `projection`. `distance` is the one from
|
||||
/// there to the coordinates, in meters.
|
||||
struct Position {
|
||||
location: SegmentLocation,
|
||||
before: usize,
|
||||
ratio: f64,
|
||||
projection: LngLat,
|
||||
distance: f64,
|
||||
}
|
||||
|
||||
impl Apply for InsertAnchor {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let target = LngLat {
|
||||
lng: self.lng,
|
||||
lat: self.lat,
|
||||
};
|
||||
let mut closest: Option<Position> = None;
|
||||
for location in state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0)
|
||||
{
|
||||
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
||||
let mut points = segment.iter();
|
||||
let Some(mut previous) = points.next() else {
|
||||
continue;
|
||||
};
|
||||
if segment.len() == 1 {
|
||||
// a single point is its own path
|
||||
let d = distance(previous.coordinates, target) * 1000.0;
|
||||
if closest.as_ref().is_none_or(|c| d < c.distance) {
|
||||
closest = Some(Position {
|
||||
location,
|
||||
before: 0,
|
||||
ratio: 0.0,
|
||||
projection: previous.coordinates,
|
||||
distance: d,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (i, point) in points.enumerate() {
|
||||
let projection = projected(previous.coordinates, point.coordinates, target);
|
||||
let d = distance(projection, target) * 1000.0;
|
||||
if closest.as_ref().is_none_or(|c| d < c.distance) {
|
||||
let length = distance(previous.coordinates, point.coordinates);
|
||||
let ratio = if length > 0.0 {
|
||||
distance(previous.coordinates, projection) / length
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
closest = Some(Position {
|
||||
location,
|
||||
before: i,
|
||||
ratio,
|
||||
projection,
|
||||
distance: d,
|
||||
});
|
||||
}
|
||||
previous = point;
|
||||
}
|
||||
}
|
||||
let closest = closest.ok_or(CommandError::NothingToDo)?;
|
||||
|
||||
let mut file = (*state.files[&closest.location.file_id]).clone();
|
||||
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg];
|
||||
let before = closest.before;
|
||||
// on a trackpoint already, if it is closer than `SNAP_DISTANCE`
|
||||
let snapped = if segment.len() == 1 {
|
||||
Some(0)
|
||||
} else {
|
||||
let (a, b) = (&segment[before], &segment[before + 1]);
|
||||
let meters = 1000.0 * distance(a.coordinates, b.coordinates);
|
||||
if closest.ratio * meters < SNAP_DISTANCE {
|
||||
Some(before)
|
||||
} else if (1.0 - closest.ratio) * meters < SNAP_DISTANCE {
|
||||
Some(before + 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(index) = snapped {
|
||||
if segment[index].anchor == Some(0) {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
segment.set_anchor(index, 0);
|
||||
} else {
|
||||
let (a, b) = (&segment[before], &segment[before + 1]);
|
||||
let ratio = closest.ratio;
|
||||
let mut point: Trackpoint = a.clone();
|
||||
point.coordinates = closest.projection;
|
||||
point.ele = (1.0 - ratio) * a.ele + ratio * b.ele;
|
||||
point.time = a
|
||||
.time
|
||||
.zip(b.time)
|
||||
.map(|(a, b)| ((1.0 - ratio) * a as f64 + ratio * b as f64) as i64);
|
||||
point.anchor = Some(0);
|
||||
segment.splice(before + 1, before + 1, vec![point]);
|
||||
}
|
||||
segment.rev_id = Default::default();
|
||||
state.files.insert(closest.location.file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn first_segment(fx: &Fixture, id: FileId) -> crate::TrackSegment {
|
||||
fx.files[&id].trk[0].trkseg[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_point_is_inserted_between_its_neighbours() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let before = first_segment(&fx, id);
|
||||
let (a, b) = (&before[10], &before[11]);
|
||||
// a bit off the line, closer to the middle of the two points
|
||||
let target = LngLat {
|
||||
lng: (a.coordinates.lng + b.coordinates.lng) / 2.0 + 1e-6,
|
||||
lat: (a.coordinates.lat + b.coordinates.lat) / 2.0 + 1e-6,
|
||||
};
|
||||
InsertAnchor {
|
||||
lng: target.lng,
|
||||
lat: target.lat,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after = first_segment(&fx, id);
|
||||
assert_eq!(after.len(), before.len() + 1);
|
||||
assert_ne!(after.rev_id, before.rev_id);
|
||||
let point = &after[11];
|
||||
assert_eq!(point.anchor, Some(0));
|
||||
assert_eq!(after[10].coordinates.lng, a.coordinates.lng);
|
||||
assert_eq!(after[12].coordinates.lng, b.coordinates.lng);
|
||||
// between its neighbours, with the interpolated elevation and time
|
||||
let ratio = (point.time.unwrap() - a.time.unwrap()) as f64
|
||||
/ (b.time.unwrap() - a.time.unwrap()) as f64;
|
||||
assert!((0.3..0.7).contains(&ratio), "{ratio}");
|
||||
let expected = a.ele + ratio * (b.ele - a.ele);
|
||||
assert!(
|
||||
(point.ele - expected).abs() < 0.5,
|
||||
"{} {expected}",
|
||||
point.ele
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_trackpoint_that_is_hit_becomes_the_anchor() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let before = first_segment(&fx, id);
|
||||
assert_eq!(before[10].anchor.is_some(), before[10].anchor == Some(0));
|
||||
let point = &before[10];
|
||||
InsertAnchor {
|
||||
lng: point.coordinates.lng,
|
||||
lat: point.coordinates.lat,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let after = first_segment(&fx, id);
|
||||
assert_eq!(after.len(), before.len());
|
||||
let anchors = (0..after.len())
|
||||
.filter(|i| after[*i].anchor == Some(0))
|
||||
.count();
|
||||
assert!(anchors >= 3, "{anchors}");
|
||||
assert!(
|
||||
after
|
||||
.iter()
|
||||
.zip(before.iter())
|
||||
.all(|(a, b)| a.coordinates.lng == b.coordinates.lng)
|
||||
);
|
||||
// doing it again changes nothing
|
||||
let again = InsertAnchor {
|
||||
lng: point.coordinates.lng,
|
||||
lat: point.coordinates.lat,
|
||||
}
|
||||
.apply(&mut fx.state());
|
||||
assert!(again.is_ok() || again == Err(CommandError::NothingToDo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_do_without_a_segment() {
|
||||
let (mut fx, _) = loaded("data/with_time.gpx");
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
InsertAnchor { lng: 1.0, lat: 1.0 }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId,
|
||||
TrackSegment, TrackSegmentId, Waypoint, Waypoints, replace_trackpoints,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MergeType {
|
||||
/// The trackpoints of the elements are connected in a single segment.
|
||||
Connect,
|
||||
/// The elements are put together, in the first one, which keeps them as they are.
|
||||
Group,
|
||||
}
|
||||
|
||||
/// Merges the selected elements into the first of them:
|
||||
///
|
||||
/// - files: the other files are deleted. The tracks of all of them are in the first file, as
|
||||
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of
|
||||
/// all of them are in the first file too, except the ones that are the same.
|
||||
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
|
||||
/// connected in one segment with `Connect`. The other tracks are removed.
|
||||
/// - segments of a track: they are all connected in a segment at the position of the first one
|
||||
/// with `Connect`. With `Group` they are put one after the other, at that position.
|
||||
///
|
||||
/// The segment made by `Connect` goes on in time, if there are timestamps, at the moving speed
|
||||
/// of the selection, see [`replace_trackpoints`], which `remove_gaps` is given.
|
||||
///
|
||||
/// There is nothing to do when there is nothing to merge: a single file to group, a single track
|
||||
/// that has no more than one segment, a single segment...
|
||||
#[derive(Debug)]
|
||||
pub struct Merge {
|
||||
pub type_: MergeType,
|
||||
pub remove_gaps: bool,
|
||||
}
|
||||
|
||||
impl Apply for Merge {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let selection = state.selection.clone();
|
||||
match &selection {
|
||||
Selection::File { file_ids } => self.merge_files(state, file_ids),
|
||||
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => self.merge_segments(state, *file_id, *trk_id, trkseg_ids),
|
||||
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {
|
||||
Err(CommandError::NothingToDo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Merge {
|
||||
fn merge_files(
|
||||
&self,
|
||||
state: &mut State,
|
||||
file_ids: &HashSet<FileId>,
|
||||
) -> Result<(), CommandError> {
|
||||
// in the order of the files
|
||||
let ordered: Vec<FileId> = state
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.filter(|id| file_ids.contains(id))
|
||||
.chain(file_ids.iter().filter(|id| !state.order.0.contains(id)))
|
||||
.filter(|id| state.files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
let Some(&target_id) = ordered.first() else {
|
||||
return Err(CommandError::NothingToDo);
|
||||
};
|
||||
|
||||
let mut tracks: Vec<Track> = vec![];
|
||||
let mut waypoints: Vec<Waypoint> = vec![];
|
||||
for id in &ordered {
|
||||
let file = &state.files[id];
|
||||
tracks.extend(file.trk.iter().cloned());
|
||||
for waypoint in file.wpt.iter() {
|
||||
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
|
||||
waypoints.push(waypoint.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let segments: Vec<TrackSegment> = tracks
|
||||
.iter()
|
||||
.flat_map(|track| track.trkseg.iter().cloned())
|
||||
.collect();
|
||||
|
||||
match self.type_ {
|
||||
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
|
||||
MergeType::Connect if ordered.len() == 1 && segments.len() <= 1 => {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
MergeType::Connect if !segments.is_empty() => {
|
||||
// a single track, which is the first one, with the connected segment
|
||||
let merged = connect(&segments, segments[0].id, self.remove_gaps);
|
||||
let first = Track {
|
||||
trkseg: vec![merged],
|
||||
..tracks[0].clone()
|
||||
};
|
||||
tracks = vec![first];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut target = (*state.files[&target_id]).clone();
|
||||
target.trk = tracks;
|
||||
target.wpt = Waypoints::default();
|
||||
target.wpt.insert_at(0, waypoints);
|
||||
for id in &ordered[1..] {
|
||||
state.files.remove(id);
|
||||
}
|
||||
state.order.0.retain(|id| !ordered[1..].contains(id));
|
||||
state.files.insert(target_id, Rc::new(target));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_tracks(
|
||||
&self,
|
||||
state: &mut State,
|
||||
file_id: FileId,
|
||||
trk_ids: &HashSet<TrackId>,
|
||||
) -> Result<(), CommandError> {
|
||||
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let selected: Vec<usize> = (0..file.trk.len())
|
||||
.filter(|i| trk_ids.contains(&file.trk[*i].id))
|
||||
.collect();
|
||||
let Some(&first) = selected.first() else {
|
||||
return Err(CommandError::NothingToDo);
|
||||
};
|
||||
let segments: Vec<TrackSegment> = selected
|
||||
.iter()
|
||||
.flat_map(|i| file.trk[*i].trkseg.iter().cloned())
|
||||
.collect();
|
||||
if selected.len() == 1 && (self.type_ == MergeType::Group || segments.len() <= 1) {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
let merged = match self.type_ {
|
||||
MergeType::Group => segments,
|
||||
MergeType::Connect if segments.is_empty() => vec![],
|
||||
MergeType::Connect => {
|
||||
vec![connect(&segments, segments[0].id, self.remove_gaps)]
|
||||
}
|
||||
};
|
||||
let mut file = (**file).clone();
|
||||
file.trk[first].trkseg = merged;
|
||||
let mut index = 0;
|
||||
file.trk.retain(|_| {
|
||||
index += 1;
|
||||
!selected[1..].contains(&(index - 1))
|
||||
});
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_segments(
|
||||
&self,
|
||||
state: &mut State,
|
||||
file_id: FileId,
|
||||
trk_id: TrackId,
|
||||
ids: &HashSet<TrackSegmentId>,
|
||||
) -> Result<(), CommandError> {
|
||||
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let trk = file
|
||||
.trk
|
||||
.iter()
|
||||
.position(|trk| trk.id == trk_id)
|
||||
.ok_or(CommandError::NothingToDo)?;
|
||||
let segments = &file.trk[trk].trkseg;
|
||||
let selected: Vec<usize> = (0..segments.len())
|
||||
.filter(|i| ids.contains(&segments[*i].id))
|
||||
.collect();
|
||||
if selected.len() < 2 {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
let first = selected[0];
|
||||
let chosen: Vec<TrackSegment> = selected.iter().map(|i| segments[*i].clone()).collect();
|
||||
let merged = match self.type_ {
|
||||
MergeType::Connect => vec![connect(&chosen, chosen[0].id, self.remove_gaps)],
|
||||
MergeType::Group => {
|
||||
if selected.iter().enumerate().all(|(i, s)| *s == first + i) {
|
||||
// already one after the other
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
chosen
|
||||
}
|
||||
};
|
||||
|
||||
let mut file = (**file).clone();
|
||||
let mut remaining: Vec<TrackSegment> = file.trk[trk]
|
||||
.trkseg
|
||||
.iter()
|
||||
.filter(|segment| !ids.contains(&segment.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
// the ones before the first selected one are all kept
|
||||
remaining.splice(first..first, merged);
|
||||
file.trk[trk].trkseg = remaining;
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The segments connected in a single one, which has the id `id`: the trackpoints of the second
|
||||
/// go after the ones of the first and so on.
|
||||
///
|
||||
/// If there are timestamps, the new segment goes on in time, see [`replace_trackpoints`]: the
|
||||
/// trackpoints that have none get some, at the moving speed of the segments, and the ones that
|
||||
/// are too early follow the previous ones. The time of the first trackpoint is the one it has, or
|
||||
/// the one it would have if it was as far in time from the first timed one as its distance
|
||||
/// says.
|
||||
fn connect(segments: &[TrackSegment], id: TrackSegmentId, remove_gaps: bool) -> TrackSegment {
|
||||
let mut global = GlobalStatistics::default();
|
||||
let mut distance = 0.0;
|
||||
// the first time, and the distance from the start at which it is
|
||||
let mut first_timed: Option<(i64, f64)> = None;
|
||||
for segment in segments {
|
||||
let stats = Statistics::compute(segment);
|
||||
if first_timed.is_none() {
|
||||
first_timed = segment.iter().zip(&stats.local).find_map(|(point, local)| {
|
||||
point
|
||||
.time
|
||||
.map(|time| (time, distance + local.total_distance))
|
||||
});
|
||||
}
|
||||
distance += stats.global.total_distance;
|
||||
global.merge(&stats.global);
|
||||
}
|
||||
let speed = global.moving_speed().filter(|speed| *speed > 0.0);
|
||||
let start_time = speed
|
||||
.and_then(|speed| first_timed.map(|(time, km)| time - (3_600_000.0 * km / speed) as i64));
|
||||
|
||||
let mut points = vec![];
|
||||
for segment in segments {
|
||||
let end = points.len();
|
||||
replace_trackpoints(
|
||||
&mut points,
|
||||
end,
|
||||
end,
|
||||
segment.iter().cloned().collect(),
|
||||
speed,
|
||||
start_time,
|
||||
remove_gaps,
|
||||
);
|
||||
}
|
||||
let mut merged = TrackSegment::default();
|
||||
merged.splice(0, 0, points);
|
||||
merged.id = id;
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{File, Load, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn load(fx: &mut Fixture, path: &str) -> FileId {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
*fx.order.0.last().unwrap()
|
||||
}
|
||||
|
||||
fn merge(type_: MergeType) -> Merge {
|
||||
Merge {
|
||||
type_,
|
||||
remove_gaps: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_files(fx: &mut Fixture, ids: &[FileId]) {
|
||||
fx.selection = Selection::File {
|
||||
file_ids: ids.iter().copied().collect(),
|
||||
};
|
||||
}
|
||||
|
||||
fn points(file: &File) -> usize {
|
||||
file.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.map(|s| s.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn waypoint(name: &str) -> Waypoint {
|
||||
Waypoint {
|
||||
name: Some(name.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let b = load(&mut fx, "data/simple.gpx");
|
||||
let mut file_a = (*fx.files[&a]).clone();
|
||||
file_a
|
||||
.wpt
|
||||
.insert_at(0, vec![waypoint("x"), waypoint("same")]);
|
||||
fx.files.insert(a, Rc::new(file_a));
|
||||
let mut file_b = (*fx.files[&b]).clone();
|
||||
file_b
|
||||
.wpt
|
||||
.insert_at(0, vec![waypoint("same"), waypoint("y")]);
|
||||
fx.files.insert(b, Rc::new(file_b));
|
||||
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
|
||||
select_files(&mut fx, &[b, a]);
|
||||
|
||||
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||
|
||||
// the first file (in the order of the files) gets everything, the other one goes
|
||||
assert_eq!(fx.order.0, [a]);
|
||||
assert!(!fx.files.contains_key(&b));
|
||||
let merged = &fx.files[&a];
|
||||
assert_eq!(merged.trk.len(), before_a.trk.len() + before_b.trk.len());
|
||||
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
|
||||
assert_eq!(
|
||||
merged.trk.last().unwrap().id,
|
||||
before_b.trk.last().unwrap().id
|
||||
);
|
||||
assert_eq!(points(merged), points(&before_a) + points(&before_b));
|
||||
// the waypoints that are the same are only once
|
||||
let names: Vec<_> = merged.wpt.iter().map(|w| w.name.clone().unwrap()).collect();
|
||||
assert_eq!(names, ["x", "same", "y"]);
|
||||
assert_eq!(merged.info, before_a.info);
|
||||
// a single file has nothing to group
|
||||
select_files(&mut fx, &[a]);
|
||||
assert_eq!(
|
||||
merge(MergeType::Group).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let b = load(&mut fx, "data/simple.gpx");
|
||||
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
|
||||
select_files(&mut fx, &[a, b]);
|
||||
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
|
||||
let merged = &fx.files[&a];
|
||||
assert_eq!(fx.order.0, [a]);
|
||||
// a single track, with a single segment that has all the trackpoints in order
|
||||
assert_eq!(merged.trk.len(), 1);
|
||||
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
|
||||
assert_eq!(merged.trk[0].trkseg.len(), 1);
|
||||
let segment = &merged.trk[0].trkseg[0];
|
||||
assert_eq!(segment.len(), points(&before_a) + points(&before_b));
|
||||
let expected: Vec<f64> = before_a
|
||||
.trk
|
||||
.iter()
|
||||
.chain(&before_b.trk)
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.flat_map(|s| s.iter().map(|p| p.coordinates.lng))
|
||||
.collect();
|
||||
let lngs: Vec<f64> = segment.iter().map(|p| p.coordinates.lng).collect();
|
||||
assert_eq!(lngs, expected);
|
||||
assert_eq!(segment.id, before_a.trk[0].trkseg[0].id);
|
||||
assert_eq!(segment[0].anchor, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_goes_on_in_time() {
|
||||
let mut fx = Fixture::default();
|
||||
// the same times twice: the second part would be too early
|
||||
let a = load(&mut fx, "data/with_time.gpx");
|
||||
let b = load(&mut fx, "data/with_time.gpx");
|
||||
select_files(&mut fx, &[a, b]);
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let segment = &fx.files[&a].trk[0].trkseg[0];
|
||||
let times: Vec<i64> = segment.iter().map(|p| p.time.unwrap()).collect();
|
||||
assert_eq!(times.len(), 160);
|
||||
assert!(times.windows(2).all(|w| w[0] <= w[1]), "{times:?}");
|
||||
assert!(times[80] > times[79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_without_gaps_brings_the_parts_closer() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = load(&mut fx, "data/with_time.gpx");
|
||||
// the second file comes a day later
|
||||
let b = {
|
||||
let data = std::fs::read("data/with_time.gpx").unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "late",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
*fx.order.0.last().unwrap()
|
||||
};
|
||||
let mut file = (*fx.files[&b]).clone();
|
||||
for track in file.trk.iter_mut() {
|
||||
for segment in track.trkseg.iter_mut() {
|
||||
let points: Vec<_> = segment
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut p = p.clone();
|
||||
p.time = p.time.map(|t| t + 86_400_000);
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
let len = segment.len();
|
||||
segment.splice(0, len, points);
|
||||
}
|
||||
}
|
||||
fx.files.insert(b, Rc::new(file));
|
||||
let gap = |fx: &Fixture, remove_gaps: bool| {
|
||||
let mut fx2 = Fixture {
|
||||
files: fx.files.clone(),
|
||||
order: crate::FileOrder(fx.order.0.clone()),
|
||||
selection: Selection::File {
|
||||
file_ids: [a, b].into(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
Merge {
|
||||
type_: MergeType::Connect,
|
||||
remove_gaps,
|
||||
}
|
||||
.apply(&mut fx2.state())
|
||||
.unwrap();
|
||||
let segment = &fx2.files[&a].trk[0].trkseg[0];
|
||||
segment[80].time.unwrap() - segment[79].time.unwrap()
|
||||
};
|
||||
let with_gap = gap(&fx, false);
|
||||
let without = gap(&fx, true);
|
||||
assert!(with_gap > 80_000_000, "{with_gap}");
|
||||
assert!(without < 60_000, "{without}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_tracks() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
assert!(file.trk.len() >= 2);
|
||||
let ids: HashSet<TrackId> = file.trk.iter().map(|t| t.id).collect();
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: ids,
|
||||
};
|
||||
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
||||
|
||||
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id];
|
||||
assert_eq!(merged.trk.len(), 1);
|
||||
assert_eq!(merged.trk[0].id, file.trk[0].id);
|
||||
assert_eq!(merged.trk[0].trkseg.len(), segments);
|
||||
assert_eq!(points(merged), points(&file));
|
||||
|
||||
// connected: a single segment
|
||||
fx.files.insert(id, file.clone());
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id];
|
||||
assert_eq!(merged.trk.len(), 1);
|
||||
assert_eq!(merged.trk[0].trkseg.len(), 1);
|
||||
assert_eq!(merged.trk[0].trkseg[0].len(), points(&file));
|
||||
|
||||
// one track with a single segment: nothing to do
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: [file.trk[0].id].into(),
|
||||
};
|
||||
for type_ in [MergeType::Group, MergeType::Connect] {
|
||||
assert_eq!(
|
||||
merge(type_).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_the_segments_of_a_track() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
let (index, track) = file
|
||||
.trk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, t)| t.trkseg.len() >= 2)
|
||||
.unwrap();
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: [track.id].into(),
|
||||
};
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id].trk[index];
|
||||
assert_eq!(merged.id, track.id);
|
||||
assert_eq!(merged.trkseg.len(), 1);
|
||||
assert_eq!(
|
||||
merged.trkseg[0].len(),
|
||||
track.trkseg.iter().map(|s| s.len()).sum::<usize>()
|
||||
);
|
||||
// the other tracks are untouched
|
||||
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_segments() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
// a track with three segments
|
||||
let mut three = (*fx.files[&id]).clone();
|
||||
let base = three.trk[0].trkseg[0].clone();
|
||||
three.trk[0].trkseg = (0..3).map(|_| crate::copy_segment(&base)).collect();
|
||||
fx.files.insert(id, Rc::new(three));
|
||||
let file = fx.files[&id].clone();
|
||||
let (index, track) = (0, &file.trk[0]);
|
||||
let segments = &track.trkseg;
|
||||
// the first and the last ones
|
||||
let (first, last) = (&segments[0], &segments[2]);
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: [first.id, last.id].into(),
|
||||
};
|
||||
|
||||
// grouped: next to each other, at the place of the first one
|
||||
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||
let ids: Vec<_> = fx.files[&id].trk[index]
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
assert_eq!(ids[..3], [first.id, last.id, segments[1].id]);
|
||||
|
||||
// connected: one segment, with the id of the first one, at its place
|
||||
fx.files.insert(id, file.clone());
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id].trk[index].trkseg;
|
||||
assert_eq!(merged.len(), segments.len() - 1);
|
||||
assert_eq!(merged[0].id, first.id);
|
||||
assert_eq!(merged[0].len(), first.len() + last.len());
|
||||
assert_eq!(merged[1].id, segments[1].id);
|
||||
|
||||
// a single segment, or segments that are already together: nothing to do
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: [first.id].into(),
|
||||
};
|
||||
assert_eq!(
|
||||
merge(MergeType::Connect).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
fx.files.insert(id, file.clone());
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: [first.id, segments[1].id].into(),
|
||||
};
|
||||
assert_eq!(
|
||||
merge(MergeType::Group).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_merge() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/simple.gpx");
|
||||
let before = fx.files[&id].clone();
|
||||
for selection in [Selection::Empty, Selection::Waypoints { file_id: id }] {
|
||||
fx.selection = selection;
|
||||
assert_eq!(
|
||||
merge(MergeType::Connect).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
mod change_loop_start;
|
||||
mod clean;
|
||||
mod crop;
|
||||
mod edit_waypoint;
|
||||
mod elevation;
|
||||
mod extract;
|
||||
mod insert_anchor;
|
||||
mod merge;
|
||||
mod move_waypoint;
|
||||
mod new_waypoint;
|
||||
mod reduce;
|
||||
mod reverse;
|
||||
mod round_trip;
|
||||
mod route;
|
||||
mod split;
|
||||
mod time;
|
||||
|
||||
pub use change_loop_start::*;
|
||||
pub use clean::*;
|
||||
pub use crop::*;
|
||||
pub use edit_waypoint::*;
|
||||
pub use elevation::*;
|
||||
pub use extract::*;
|
||||
pub use insert_anchor::*;
|
||||
pub use merge::*;
|
||||
pub use move_waypoint::*;
|
||||
pub use new_waypoint::*;
|
||||
pub use reduce::*;
|
||||
pub use reverse::*;
|
||||
pub use round_trip::*;
|
||||
pub use route::*;
|
||||
pub use split::*;
|
||||
pub use time::*;
|
||||
@@ -0,0 +1,107 @@
|
||||
use crate::{Apply, CommandError, FileId, LngLat, State, WaypointId, update_waypoint};
|
||||
|
||||
/// Moves a waypoint of a file, whatever is selected (what a drag does): only its position and
|
||||
/// elevation change.
|
||||
#[derive(Debug)]
|
||||
pub struct MoveWaypoint {
|
||||
pub file_id: FileId,
|
||||
pub waypoint_id: WaypointId,
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
pub ele: f64,
|
||||
}
|
||||
|
||||
impl Apply for MoveWaypoint {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
|
||||
wpt.coordinates = LngLat {
|
||||
lng: self.lng,
|
||||
lat: self.lat,
|
||||
};
|
||||
wpt.ele = self.ele;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: (0..3)
|
||||
.map(|i| Waypoint {
|
||||
name: Some(format!("w{i}")),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
});
|
||||
let id = file.id;
|
||||
let ids = waypoint_ids(&file).collect();
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.order.0.push(id);
|
||||
(fx, id, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_the_given_waypoint_moves_whatever_is_selected() {
|
||||
let (mut fx, id, ids) = fixture();
|
||||
let rev = fx.files[&id].wpt.rev_id;
|
||||
|
||||
MoveWaypoint {
|
||||
file_id: id,
|
||||
waypoint_id: ids[1],
|
||||
lng: 4.0,
|
||||
lat: 50.0,
|
||||
ele: 42.0,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
|
||||
assert_eq!(wpts.len(), 3);
|
||||
assert_eq!(
|
||||
(
|
||||
wpts[1].coordinates.lng,
|
||||
wpts[1].coordinates.lat,
|
||||
wpts[1].ele
|
||||
),
|
||||
(4.0, 50.0, 42.0)
|
||||
);
|
||||
// the other fields and the other waypoints are as they were
|
||||
assert_eq!(wpts[1].name.as_deref(), Some("w1"));
|
||||
assert_eq!(wpts[1].id, ids[1]);
|
||||
assert_eq!((wpts[0].coordinates.lng, wpts[0].ele), (0.0, 0.0));
|
||||
assert_eq!((wpts[2].coordinates.lat, wpts[2].ele), (0.0, 0.0));
|
||||
// the coordinates changed, which is noticed by what is derived from them
|
||||
assert_ne!(fx.files[&id].wpt.rev_id, rev);
|
||||
assert_eq!(fx.selection, Selection::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_waypoint_or_file_is_nothing_to_do() {
|
||||
let (mut fx, id, ids) = fixture();
|
||||
let before = fx.files[&id].clone();
|
||||
for (file_id, waypoint_id) in [(id, WaypointId::default()), (FileId::default(), ids[0])] {
|
||||
let result = MoveWaypoint {
|
||||
file_id,
|
||||
waypoint_id,
|
||||
lng: 1.0,
|
||||
lat: 1.0,
|
||||
ele: 1.0,
|
||||
}
|
||||
.apply(&mut fx.state());
|
||||
assert!(matches!(result, Err(CommandError::NothingToDo)));
|
||||
}
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint};
|
||||
|
||||
/// Adds a waypoint at the end of the waypoints of each selected file (or of the file of the
|
||||
/// selected elements). The strings are empty when the waypoint does not have the field.
|
||||
#[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,
|
||||
}
|
||||
|
||||
fn non_empty(text: &str) -> Option<String> {
|
||||
(!text.is_empty()).then(|| text.to_owned())
|
||||
}
|
||||
|
||||
/// Sets the fields of a waypoint: the strings that are empty remove the field.
|
||||
pub fn set_waypoint_fields(
|
||||
waypoint: &mut Waypoint,
|
||||
position: (f64, f64, f64),
|
||||
name: &str,
|
||||
desc: &str,
|
||||
icon: &str,
|
||||
link: &str,
|
||||
) {
|
||||
let (lng, lat, ele) = position;
|
||||
waypoint.coordinates = LngLat { lng, lat };
|
||||
waypoint.ele = ele;
|
||||
waypoint.name = non_empty(name);
|
||||
waypoint.desc = non_empty(desc);
|
||||
// the description is also the comment, as it is what the other applications show
|
||||
waypoint.cmt = non_empty(desc);
|
||||
waypoint.sym = non_empty(icon);
|
||||
// the form has a single link: it is the first one, the others are left as they are
|
||||
match (non_empty(link), waypoint.links.first_mut()) {
|
||||
(Some(href), Some(first)) if first.href != href => *first = Link { href, text: None },
|
||||
(Some(href), None) => waypoint.links.push(Link { href, text: None }),
|
||||
(None, Some(_)) => {
|
||||
waypoint.links.remove(0);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
impl Apply for NewWaypoint<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let file_ids: Vec<FileId> = match &*state.selection {
|
||||
Selection::Empty => vec![],
|
||||
Selection::File { file_ids } => state
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.filter(|id| file_ids.contains(id))
|
||||
.copied()
|
||||
.collect(),
|
||||
Selection::Track { file_id, .. }
|
||||
| Selection::TrackSegment { file_id, .. }
|
||||
| Selection::Waypoints { file_id }
|
||||
| Selection::Waypoint { file_id, .. } => vec![*file_id],
|
||||
};
|
||||
let file_ids: Vec<FileId> = file_ids
|
||||
.into_iter()
|
||||
.filter(|id| state.files.contains_key(id))
|
||||
.collect();
|
||||
if file_ids.is_empty() {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
for id in file_ids {
|
||||
let mut waypoint = Waypoint::default();
|
||||
set_waypoint_fields(
|
||||
&mut waypoint,
|
||||
(self.lng, self.lat, self.ele),
|
||||
self.name,
|
||||
self.desc,
|
||||
self.icon,
|
||||
self.link,
|
||||
);
|
||||
let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap());
|
||||
file.wpt.insert_at(usize::MAX, vec![waypoint]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{New, TrackId, TrackSegmentId, engine::command::fixture::Fixture, waypoint_ids};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn new_waypoint(name: &str) -> NewWaypoint<'_> {
|
||||
NewWaypoint {
|
||||
lng: 4.5,
|
||||
lat: 50.25,
|
||||
ele: 123.0,
|
||||
name,
|
||||
desc: "a description",
|
||||
icon: "Summit",
|
||||
link: "https://example.com",
|
||||
}
|
||||
}
|
||||
|
||||
fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> {
|
||||
fx.files[&id].wpt.iter().cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_waypoint_in_the_selected_file() {
|
||||
let mut fx = Fixture::default();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
New {
|
||||
name: "b",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let (a, b) = (fx.order.0[0], fx.order.0[1]);
|
||||
let rev = fx.files[&b].wpt.rev_id;
|
||||
|
||||
// b is selected
|
||||
new_waypoint("peak").apply(&mut fx.state()).unwrap();
|
||||
assert!(waypoints(&fx, a).is_empty());
|
||||
let created = waypoints(&fx, b);
|
||||
assert_eq!(created.len(), 1);
|
||||
let wpt = &created[0];
|
||||
assert_eq!(
|
||||
(wpt.coordinates.lng, wpt.coordinates.lat, wpt.ele),
|
||||
(4.5, 50.25, 123.0)
|
||||
);
|
||||
assert_eq!(wpt.name.as_deref(), Some("peak"));
|
||||
assert_eq!(wpt.desc.as_deref(), Some("a description"));
|
||||
assert_eq!(wpt.cmt.as_deref(), Some("a description"));
|
||||
assert_eq!(wpt.sym.as_deref(), Some("Summit"));
|
||||
assert_eq!(
|
||||
wpt.links.first().map(|l| l.href.as_str()),
|
||||
Some("https://example.com")
|
||||
);
|
||||
assert_ne!(fx.files[&b].wpt.rev_id, rev);
|
||||
|
||||
// added after the others
|
||||
let mut second = new_waypoint("second");
|
||||
second.lng = 5.0;
|
||||
second.apply(&mut fx.state()).unwrap();
|
||||
let names: Vec<_> = waypoints(&fx, b)
|
||||
.iter()
|
||||
.map(|w| w.name.clone().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, ["peak", "second"]);
|
||||
// and not selected
|
||||
assert_eq!(fx.selected_files(), [b].into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_fields_are_not_set() {
|
||||
let mut fx = Fixture::default();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let a = fx.order.0[0];
|
||||
NewWaypoint {
|
||||
lng: 1.0,
|
||||
lat: 2.0,
|
||||
ele: 0.0,
|
||||
name: "",
|
||||
desc: "",
|
||||
icon: "",
|
||||
link: "",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let wpt = &waypoints(&fx, a)[0];
|
||||
assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none());
|
||||
assert!(wpt.sym.is_none() && wpt.links.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_each_selected_file_gets_its_own_waypoint() {
|
||||
let mut fx = Fixture::default();
|
||||
for name in ["a", "b", "c"] {
|
||||
New {
|
||||
name,
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
}
|
||||
let ids = fx.order.0.clone();
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([ids[0], ids[2]]),
|
||||
};
|
||||
new_waypoint("w").apply(&mut fx.state()).unwrap();
|
||||
assert_eq!(waypoints(&fx, ids[0]).len(), 1);
|
||||
assert!(waypoints(&fx, ids[1]).is_empty());
|
||||
assert_eq!(waypoints(&fx, ids[2]).len(), 1);
|
||||
// not the same waypoint
|
||||
assert_ne!(waypoints(&fx, ids[0])[0].id, waypoints(&fx, ids[2])[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_file_of_the_selected_elements_is_used() {
|
||||
for selection in [
|
||||
|id| Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: [TrackId::default()].into(),
|
||||
},
|
||||
|id| Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: TrackId::default(),
|
||||
trkseg_ids: [TrackSegmentId::default()].into(),
|
||||
},
|
||||
|id| Selection::Waypoints { file_id: id },
|
||||
|id| Selection::Waypoint {
|
||||
file_id: id,
|
||||
wpt_ids: HashSet::new(),
|
||||
},
|
||||
] {
|
||||
let mut fx = Fixture::default();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let a = fx.order.0[0];
|
||||
fx.selection = selection(a);
|
||||
new_waypoint("w").apply(&mut fx.state()).unwrap();
|
||||
assert_eq!(waypoint_ids(&fx.files[&a]).count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_selected_or_unknown_file() {
|
||||
let mut fx = Fixture::default();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
new_waypoint("w").apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
fx.selection = Selection::Waypoints {
|
||||
file_id: FileId::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
new_waypoint("w").apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert_eq!(fx.files.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use crate::{Apply, CommandError, State, reduce_indices, update_segments};
|
||||
|
||||
/// Removes the trackpoints of the selected segments that are less than `tolerance` meters away
|
||||
/// from the line of their neighbours (Ramer-Douglas-Peucker, see [`reduce_indices`]). The ends of the segments are
|
||||
/// kept.
|
||||
#[derive(Debug)]
|
||||
pub struct Reduce {
|
||||
pub tolerance: f64,
|
||||
}
|
||||
|
||||
impl Apply for Reduce {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
if !(self.tolerance.is_finite() && self.tolerance > 0.0) {
|
||||
return Err(CommandError::InvalidData(format!(
|
||||
"tolerance {} should be positive",
|
||||
self.tolerance
|
||||
)));
|
||||
}
|
||||
|
||||
let locations = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0);
|
||||
let mut changed = vec![];
|
||||
let mut kept_points = vec![];
|
||||
for location in &locations {
|
||||
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
||||
let kept = reduce_indices(segment, self.tolerance);
|
||||
if kept.len() == segment.len() {
|
||||
continue;
|
||||
}
|
||||
changed.push(*location);
|
||||
kept_points.push(kept.iter().map(|&i| segment[i].clone()).collect::<Vec<_>>());
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
update_segments(state, &changed, |i, segment| {
|
||||
let len = segment.len();
|
||||
segment.splice(0, len, std::mem::take(&mut kept_points[i]));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture, reduction_distances};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn count(fx: &Fixture, id: FileId) -> usize {
|
||||
fx.files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|trk| &trk.trkseg)
|
||||
.map(|seg| seg.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_larger_tolerance_keeps_fewer_points_and_the_ends() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let before = count(&fx, id);
|
||||
let first = fx.files[&id].trk[0].trkseg[0][0].coordinates;
|
||||
|
||||
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
|
||||
let medium = count(&fx, id);
|
||||
assert!(medium < before);
|
||||
assert_eq!(fx.files[&id].trk[0].trkseg[0][0].coordinates.lng, first.lng);
|
||||
|
||||
Reduce { tolerance: 500.0 }.apply(&mut fx.state()).unwrap();
|
||||
assert!(count(&fx, id) < medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_distances_tell_which_points_are_kept() {
|
||||
for path in ["data/with_time.gpx", "data/with_tracks_and_segments.gpx"] {
|
||||
let (fx, id) = loaded(path);
|
||||
for seg in fx.files[&id].trk.iter().flat_map(|trk| &trk.trkseg) {
|
||||
let distances = reduction_distances(seg);
|
||||
for tolerance in [0.1, 0.5, 1.0, 3.0, 10.0, 30.0, 100.0, 1000.0] {
|
||||
let from_distances: Vec<usize> = (0..seg.len())
|
||||
.filter(|&i| distances[i] > tolerance)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
from_distances,
|
||||
reduce_indices(seg, tolerance),
|
||||
"{path} {tolerance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_the_selected_segments_are_reduced() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let others: usize = fx.files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.skip(1)
|
||||
.map(|s| s.len())
|
||||
.sum();
|
||||
let expected = reduce_indices(&fx.files[&id].trk[0].trkseg[0], 5.0).len();
|
||||
fx.selection = {
|
||||
let track = &fx.files[&id].trk[0];
|
||||
Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: HashSet::from([track.trkseg[0].id]),
|
||||
}
|
||||
};
|
||||
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
|
||||
assert_eq!(count(&fx, id), expected + others);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_tolerance_and_nothing_to_remove() {
|
||||
let (mut fx, _) = loaded("data/simple.gpx");
|
||||
assert!(matches!(
|
||||
Reduce { tolerance: 0.0 }.apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
Reduce { tolerance: 1e-9 }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, File, FileId, Selection, State, TrackId, TrackSegmentId, end_time,
|
||||
reverse_segment, reverse_segments, reverse_tracks, start_time, track_end_time,
|
||||
track_start_time,
|
||||
};
|
||||
|
||||
/// Reverses the direction of the selected elements: the whole of each selected file (its tracks,
|
||||
/// the segments of its tracks and their trackpoints are in the opposite order), of each selected
|
||||
/// track (its segments and their trackpoints), or each selected segment on its own (its
|
||||
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
|
||||
#[derive(Debug)]
|
||||
pub struct Reverse;
|
||||
|
||||
impl Apply for Reverse {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let locations = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0);
|
||||
let points: usize = locations
|
||||
.iter()
|
||||
.map(|location| {
|
||||
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
|
||||
})
|
||||
.sum();
|
||||
if points <= 1 {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
let mut files: Vec<(FileId, File)> = vec![];
|
||||
let mut edit = |file_id: &FileId, f: &dyn Fn(&mut File)| {
|
||||
if let Some(file) = state.files.get(file_id) {
|
||||
let mut file = (**file).clone();
|
||||
f(&mut file);
|
||||
files.push((*file_id, file));
|
||||
}
|
||||
};
|
||||
match &*state.selection {
|
||||
Selection::File { file_ids } => {
|
||||
for file_id in file_ids {
|
||||
edit(file_id, &|file| {
|
||||
let end = file.trk.last().and_then(track_end_time);
|
||||
let start = file.trk.first().and_then(track_start_time);
|
||||
reverse_tracks(&mut file.trk, end, start);
|
||||
});
|
||||
}
|
||||
}
|
||||
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
|
||||
reverse_selected_tracks(file, trk_ids);
|
||||
}),
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => edit(file_id, &|file| {
|
||||
reverse_selected_segments(file, *trk_id, trkseg_ids);
|
||||
}),
|
||||
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {}
|
||||
}
|
||||
|
||||
for (file_id, file) in files {
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
|
||||
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
|
||||
let end = track.trkseg.last().and_then(end_time);
|
||||
let start = track.trkseg.first().and_then(start_time);
|
||||
reverse_segments(&mut track.trkseg, end, start);
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse_selected_segments(
|
||||
file: &mut File,
|
||||
track_id: TrackId,
|
||||
ids: &std::collections::HashSet<TrackSegmentId>,
|
||||
) {
|
||||
for track in file.trk.iter_mut().filter(|track| track.id == track_id) {
|
||||
for segment in track
|
||||
.trkseg
|
||||
.iter_mut()
|
||||
.filter(|segment| ids.contains(&segment.id))
|
||||
{
|
||||
let (end, start) = (end_time(segment), start_time(segment));
|
||||
reverse_segment(segment, end, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{Load, TrackSegment, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn all_segments(fx: &Fixture, id: FileId) -> Vec<TrackSegment> {
|
||||
fx.files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|track| track.trkseg.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lngs(segment: &TrackSegment) -> Vec<f64> {
|
||||
segment.iter().map(|p| p.coordinates.lng).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reversing_a_file_reverses_tracks_segments_and_points() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let before = all_segments(&fx, id);
|
||||
assert!(before.len() >= 3);
|
||||
let (tracks_before, ids_before): (Vec<usize>, Vec<_>) = (
|
||||
fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect(),
|
||||
fx.files[&id].trk.iter().map(|t| t.id).collect(),
|
||||
);
|
||||
|
||||
Reverse.apply(&mut fx.state()).unwrap();
|
||||
|
||||
let after = all_segments(&fx, id);
|
||||
assert_eq!(after.len(), before.len());
|
||||
// the whole path is the opposite one
|
||||
let path =
|
||||
|segments: &[TrackSegment]| -> Vec<f64> { segments.iter().flat_map(lngs).collect() };
|
||||
let mut expected = path(&before);
|
||||
expected.reverse();
|
||||
assert_eq!(path(&after), expected);
|
||||
// the tracks are in the opposite order, each with the same number of segments
|
||||
let tracks_after: Vec<usize> = fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect();
|
||||
let mut reversed = tracks_before.clone();
|
||||
reversed.reverse();
|
||||
assert_eq!(tracks_after, reversed);
|
||||
let ids_after: Vec<_> = fx.files[&id].trk.iter().map(|t| t.id).collect();
|
||||
let mut reversed_ids = ids_before;
|
||||
reversed_ids.reverse();
|
||||
assert_eq!(ids_after, reversed_ids);
|
||||
// the segments changed, and they still end where they should
|
||||
assert!(after.iter().all(|s| s.rev_id != before[0].rev_id));
|
||||
for segment in &after {
|
||||
assert_eq!(segment[0].anchor, Some(0));
|
||||
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reversing_a_file_twice_gives_the_same_path_and_times() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let before = all_segments(&fx, id);
|
||||
let times = |segments: &[TrackSegment]| -> Vec<Option<i64>> {
|
||||
segments
|
||||
.iter()
|
||||
.flat_map(|s| s.iter().map(|p| p.time))
|
||||
.collect()
|
||||
};
|
||||
Reverse.apply(&mut fx.state()).unwrap();
|
||||
Reverse.apply(&mut fx.state()).unwrap();
|
||||
let after = all_segments(&fx, id);
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert_eq!(lngs(a), lngs(b));
|
||||
assert_eq!(a.id, b.id);
|
||||
}
|
||||
assert_eq!(times(&before), times(&after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reversing_segments_keeps_the_others() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
let trk = &file.trk[0];
|
||||
assert!(trk.trkseg.len() >= 2);
|
||||
let target = &trk.trkseg[1];
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: trk.id,
|
||||
trkseg_ids: HashSet::from([target.id]),
|
||||
};
|
||||
Reverse.apply(&mut fx.state()).unwrap();
|
||||
|
||||
let after = &fx.files[&id].trk[0];
|
||||
// the segment is reversed in place
|
||||
let mut expected = lngs(target);
|
||||
expected.reverse();
|
||||
assert_eq!(lngs(&after.trkseg[1]), expected);
|
||||
assert_eq!(after.trkseg[1].id, target.id);
|
||||
assert_ne!(after.trkseg[1].rev_id, target.rev_id);
|
||||
// the other ones are the same
|
||||
for (i, segment) in trk.trkseg.iter().enumerate().filter(|(i, _)| *i != 1) {
|
||||
assert_eq!(after.trkseg[i].rev_id, segment.rev_id, "{i}");
|
||||
}
|
||||
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reversing_tracks_reverses_their_segments_in_place() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
let (index, trk) = file
|
||||
.trk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, t)| t.trkseg.len() >= 2)
|
||||
.unwrap();
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: HashSet::from([trk.id]),
|
||||
};
|
||||
Reverse.apply(&mut fx.state()).unwrap();
|
||||
|
||||
let after = &fx.files[&id];
|
||||
// the track stays where it is, its segments are the opposite way
|
||||
assert_eq!(after.trk[index].id, trk.id);
|
||||
let path = |t: &crate::Track| -> Vec<f64> { t.trkseg.iter().flat_map(lngs).collect() };
|
||||
let mut expected = path(trk);
|
||||
expected.reverse();
|
||||
assert_eq!(path(&after.trk[index]), expected);
|
||||
// and the other tracks do not change
|
||||
for (i, other) in file.trk.iter().enumerate().filter(|(i, _)| *i != index) {
|
||||
assert_eq!(path(&after.trk[i]), path(other));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_times_are_mirrored() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let before = all_segments(&fx, id).remove(0);
|
||||
Reverse.apply(&mut fx.state()).unwrap();
|
||||
let after = all_segments(&fx, id).remove(0);
|
||||
let (start, end) = (start_time(&before).unwrap(), end_time(&before).unwrap());
|
||||
// the same period of time, the first point of the new path is the last one
|
||||
assert_eq!(start_time(&after), Some(start));
|
||||
assert_eq!(end_time(&after), Some(end));
|
||||
let n = before.len();
|
||||
for i in 0..n {
|
||||
assert_eq!(
|
||||
after[i].time.unwrap(),
|
||||
start + end - before[n - 1 - i].time.unwrap()
|
||||
);
|
||||
assert_eq!(after[i].coordinates.lng, before[n - 1 - i].coordinates.lng);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_reverse() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
let before = fx.files[&id].clone();
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
Reverse.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
fx.selection = Selection::Waypoints { file_id: id };
|
||||
assert_eq!(
|
||||
Reverse.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
|
||||
// a single trackpoint has no direction
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
let segment = &mut file.trk[0].trkseg[0];
|
||||
let len = segment.len();
|
||||
segment.splice(1, len, vec![]);
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
assert_eq!(
|
||||
Reverse.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use crate::{Apply, CommandError, State, round_trip, update_segments};
|
||||
|
||||
/// Makes each selected segment come back to where it started: the segments of the selected files
|
||||
/// and tracks, and the selected segments, get a reversed copy of themselves after their last
|
||||
/// trackpoint (see [`round_trip`]).
|
||||
///
|
||||
/// There is nothing to do for segments with fewer than two trackpoints, which have no way back.
|
||||
#[derive(Debug)]
|
||||
pub struct RoundTrip;
|
||||
|
||||
impl Apply for RoundTrip {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let locations: Vec<_> = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0)
|
||||
.into_iter()
|
||||
.filter(|l| state.files[&l.file_id].trk[l.trk].trkseg[l.seg].len() >= 2)
|
||||
.collect();
|
||||
if locations.is_empty() {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
update_segments(state, &locations, |_, segment| round_trip(segment));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
FileId, Load, Selection, TrackSegment, end_time, engine::command::fixture::Fixture,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn segments(fx: &Fixture, id: FileId) -> Vec<TrackSegment> {
|
||||
fx.files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|track| track.trkseg.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lngs(segment: &TrackSegment) -> Vec<f64> {
|
||||
segment.iter().map(|p| p.coordinates.lng).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_segment_of_a_file_comes_back() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let before = segments(&fx, id);
|
||||
RoundTrip.apply(&mut fx.state()).unwrap();
|
||||
let after = segments(&fx, id);
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (a, b) in after.iter().zip(&before) {
|
||||
if b.len() < 2 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
assert_eq!(a.rev_id, b.rev_id);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(a.id, b.id);
|
||||
// the last trackpoint is not repeated
|
||||
assert_eq!(a.len(), 2 * b.len() - 1);
|
||||
let mut expected = lngs(b);
|
||||
expected.extend(lngs(b).into_iter().rev().skip(1));
|
||||
assert_eq!(lngs(a), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_the_selected_segment_comes_back() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
let trk = &file.trk[0];
|
||||
assert!(trk.trkseg.len() >= 2);
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: trk.id,
|
||||
trkseg_ids: HashSet::from([trk.trkseg[1].id]),
|
||||
};
|
||||
RoundTrip.apply(&mut fx.state()).unwrap();
|
||||
let after = &fx.files[&id].trk[0];
|
||||
assert_eq!(after.trkseg[1].len(), 2 * trk.trkseg[1].len() - 1);
|
||||
assert_eq!(after.trkseg[0].rev_id, trk.trkseg[0].rev_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_times_go_on_from_the_end() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let before = segments(&fx, id).remove(0);
|
||||
RoundTrip.apply(&mut fx.state()).unwrap();
|
||||
let after = segments(&fx, id).remove(0);
|
||||
let n = before.len();
|
||||
let end = end_time(&before).unwrap();
|
||||
assert_eq!(after.len(), 2 * n - 1);
|
||||
// the original part is untouched
|
||||
for i in 0..n {
|
||||
assert_eq!(after[i].time, before[i].time);
|
||||
}
|
||||
// the way back goes on from the end, and lasts as long as the way there
|
||||
for i in 1..n {
|
||||
assert_eq!(
|
||||
after[n - 1 + i].time.unwrap(),
|
||||
2 * end - before[n - 1 - i].time.unwrap()
|
||||
);
|
||||
}
|
||||
assert_eq!(end_time(&after), Some(2 * end - before[0].time.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_do_without_a_way_back() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
let before = fx.files[&id].clone();
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
RoundTrip.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
fx.selection = Selection::Waypoints { file_id: id };
|
||||
assert_eq!(
|
||||
RoundTrip.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Apply, Categories, CommandError, FileId, SegmentLocation, Selection, State, Statistics, Track,
|
||||
TrackSegment, Trackpoint, distance, replace_trackpoints,
|
||||
};
|
||||
|
||||
/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Route`], as
|
||||
/// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first
|
||||
/// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index
|
||||
/// of its name in `names`. No intervals means that the attribute is unknown everywhere.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct RouteCategory<'a> {
|
||||
pub starts: &'a [u32],
|
||||
pub values: &'a [u8],
|
||||
pub names: &'a [String],
|
||||
}
|
||||
|
||||
impl RouteCategory<'_> {
|
||||
/// The code in `categories` of the value of each of the `len` new trackpoints. A name is
|
||||
/// added to the table of the categories if it is new.
|
||||
fn codes(
|
||||
&self,
|
||||
categories: &mut Categories,
|
||||
len: usize,
|
||||
) -> Result<Vec<Option<u8>>, CommandError> {
|
||||
if self.starts.len() != self.values.len() {
|
||||
return Err(invalid("intervals of different lengths"));
|
||||
}
|
||||
let mut codes = vec![None; len];
|
||||
for (i, (start, value)) in self.starts.iter().zip(self.values).enumerate() {
|
||||
let end = self.starts.get(i + 1).map_or(len, |end| *end as usize);
|
||||
let start = *start as usize;
|
||||
if start > end || end > len {
|
||||
return Err(invalid("intervals out of the trackpoints"));
|
||||
}
|
||||
if *value == 0 {
|
||||
continue;
|
||||
}
|
||||
let name = self
|
||||
.names
|
||||
.get(usize::from(*value) - 1)
|
||||
.ok_or_else(|| invalid("unknown name of category"))?;
|
||||
codes[start..end].fill(categories.code(name));
|
||||
}
|
||||
Ok(codes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the trackpoints `start..end` of a segment of the selection by the given ones (a pure
|
||||
/// insertion when `start == end`, a pure removal when there are no new points). It is how the
|
||||
/// routing tool reroutes a part of a segment, extends it, or removes the end of it.
|
||||
///
|
||||
/// `start` and `end` are indices in the trackpoints of the selection (see `RoutingBuffer`): the
|
||||
/// range has to be in a single segment, and `start == end == the number of trackpoints` appends
|
||||
/// to the last one. If the selection has no trackpoints at all, they go to its last segment, which
|
||||
/// is created if needed (in the last track, or in a new one, of the last selected file).
|
||||
///
|
||||
/// The timestamps follow: the new points get some if the segment has, at the speed it had on the
|
||||
/// replaced part (see [`replace_trackpoints`]).
|
||||
///
|
||||
/// The new points listed in `anchors` become anchors of the routing tool, shown at every zoom
|
||||
/// level. The anchors of the segment out of the range are kept, and the ends of the segment are
|
||||
/// anchors anyway. The ones right before and after the range, between which the route goes, become
|
||||
/// shown at every zoom level as well.
|
||||
#[derive(Debug)]
|
||||
pub struct Route<'a> {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub lng: &'a [f64],
|
||||
pub lat: &'a [f64],
|
||||
pub ele: &'a [f64],
|
||||
pub surface: RouteCategory<'a>,
|
||||
pub highway: RouteCategory<'a>,
|
||||
pub sac_scale: RouteCategory<'a>,
|
||||
pub mtb_scale: RouteCategory<'a>,
|
||||
/// Indices among the new trackpoints.
|
||||
pub anchors: &'a [u32],
|
||||
}
|
||||
|
||||
fn invalid(message: &str) -> CommandError {
|
||||
CommandError::InvalidData(message.into())
|
||||
}
|
||||
|
||||
/// A segment of the selection, and the range of its trackpoints that is concerned.
|
||||
pub(super) struct Target {
|
||||
pub location: SegmentLocation,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl Apply for Route<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let (start, end) = (self.start as usize, self.end as usize);
|
||||
let len = self.lng.len();
|
||||
if start > end || self.lat.len() != len || self.ele.len() != len {
|
||||
return Err(invalid("invalid trackpoints range"));
|
||||
}
|
||||
if self.anchors.iter().any(|anchor| *anchor as usize >= len) {
|
||||
return Err(invalid("anchor out of the new trackpoints"));
|
||||
}
|
||||
if start == end && len == 0 {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
let categories = &mut *state.categories;
|
||||
let surface = self.surface.codes(&mut categories.surface, len)?;
|
||||
let highway = self.highway.codes(&mut categories.highway, len)?;
|
||||
let sac_scale = self.sac_scale.codes(&mut categories.sac_scale, len)?;
|
||||
let mtb_scale = self.mtb_scale.codes(&mut categories.mtb_scale, len)?;
|
||||
let points: Vec<Trackpoint> = (0..len)
|
||||
.map(|i| Trackpoint {
|
||||
coordinates: crate::LngLat {
|
||||
lng: self.lng[i],
|
||||
lat: self.lat[i],
|
||||
},
|
||||
ele: self.ele[i],
|
||||
surface: surface[i],
|
||||
highway: highway[i],
|
||||
sac_scale: sac_scale[i],
|
||||
mtb_scale: mtb_scale[i],
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let mut points = points;
|
||||
for anchor in self.anchors {
|
||||
points[*anchor as usize].anchor = Some(0);
|
||||
}
|
||||
|
||||
let mut file;
|
||||
let target = match find_target(state, start, end)? {
|
||||
Some(target) => {
|
||||
file = (*state.files[&target.location.file_id]).clone();
|
||||
target
|
||||
}
|
||||
None => {
|
||||
let (file_id, trk) = container(state).ok_or(CommandError::NothingToDo)?;
|
||||
if start != 0 || end != 0 {
|
||||
return Err(invalid("trackpoints range out of bounds"));
|
||||
}
|
||||
file = (*state.files[&file_id]).clone();
|
||||
// the segment of the last track, which may be new
|
||||
let trk = match trk.or_else(|| file.trk.len().checked_sub(1)) {
|
||||
Some(trk) => trk,
|
||||
None => {
|
||||
file.trk.push(Track::default());
|
||||
file.trk.len() - 1
|
||||
}
|
||||
};
|
||||
if file.trk[trk].trkseg.is_empty() {
|
||||
file.trk[trk].trkseg.push(TrackSegment::default());
|
||||
}
|
||||
let seg = file.trk[trk].trkseg.len() - 1;
|
||||
Target {
|
||||
location: SegmentLocation { file_id, trk, seg },
|
||||
start: 0,
|
||||
end: 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
|
||||
let new_len = points.len();
|
||||
let (speed, start_time) = speed_and_start_time(segment, &target, &points);
|
||||
let has_times = !segment.is_empty() && segment[0].time.is_some();
|
||||
if speed.is_some() || has_times {
|
||||
// the timestamps of the whole segment can change
|
||||
let mut all: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||
let old_len = all.len();
|
||||
replace_trackpoints(
|
||||
&mut all,
|
||||
target.start,
|
||||
target.end,
|
||||
points,
|
||||
speed,
|
||||
start_time,
|
||||
false,
|
||||
);
|
||||
segment.splice(0, old_len, all);
|
||||
} else {
|
||||
segment.splice(target.start, target.end, points);
|
||||
}
|
||||
// the anchors next to the new points are the ones that were moved or removed around:
|
||||
// they become permanent
|
||||
for index in [target.start.checked_sub(1), Some(target.start + new_len)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if index < segment.len() && segment[index].anchor.is_some_and(|zoom| zoom != 0) {
|
||||
segment.set_anchor(index, 0);
|
||||
}
|
||||
}
|
||||
segment.rev_id = Default::default();
|
||||
state.files.insert(target.location.file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The segment of the trackpoints `start..end` of the selection.
|
||||
pub(super) fn find_target(
|
||||
state: &State,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Result<Option<Target>, CommandError> {
|
||||
let locations = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0);
|
||||
let segments: Vec<(SegmentLocation, usize)> = locations
|
||||
.into_iter()
|
||||
.map(|location| {
|
||||
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
||||
(location, segment.len())
|
||||
})
|
||||
.collect();
|
||||
let total: usize = segments.iter().map(|(_, len)| len).sum();
|
||||
|
||||
if total == 0 {
|
||||
// an empty segment of the selection, if there is one
|
||||
return match segments.last() {
|
||||
Some((location, _)) if start == 0 && end == 0 => Ok(Some(Target {
|
||||
location: *location,
|
||||
start: 0,
|
||||
end: 0,
|
||||
})),
|
||||
Some(_) => Err(invalid("trackpoints range out of bounds")),
|
||||
None => Ok(None),
|
||||
};
|
||||
}
|
||||
|
||||
let mut offset = 0;
|
||||
for (location, len) in segments.iter().filter(|(_, len)| *len > 0) {
|
||||
// appending to the selection is appending to its last segment
|
||||
let appends = offset + len == total && start == total;
|
||||
if start >= offset && (start < offset + len || appends) {
|
||||
if end > offset + len {
|
||||
return Err(invalid("trackpoints range over several segments"));
|
||||
}
|
||||
return Ok(Some(Target {
|
||||
location: *location,
|
||||
start: start - offset,
|
||||
end: end - offset,
|
||||
}));
|
||||
}
|
||||
offset += len;
|
||||
}
|
||||
Err(invalid("trackpoints range out of bounds"))
|
||||
}
|
||||
|
||||
/// The file, and the track (if the selection is one), where to create a segment in a selection
|
||||
/// that has none.
|
||||
fn container(state: &State) -> Option<(FileId, Option<usize>)> {
|
||||
match &*state.selection {
|
||||
Selection::File { file_ids } => {
|
||||
let id = state
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|id| file_ids.contains(id))
|
||||
.or_else(|| file_ids.iter().next())?;
|
||||
state.files.contains_key(id).then_some((*id, None))
|
||||
}
|
||||
Selection::Track { file_id, trk_ids } => {
|
||||
let trk = state
|
||||
.files
|
||||
.get(file_id)?
|
||||
.trk
|
||||
.iter()
|
||||
.rposition(|trk| trk_ids.contains(&trk.id))?;
|
||||
Some((*file_id, Some(trk)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The speed at which the new points are reached and the time of the first one, when the segment
|
||||
/// has times to keep consistent: the speed is the one that the segment had on the part that is
|
||||
/// replaced, adjusted so that the moving speed of the whole segment stays what it was.
|
||||
///
|
||||
/// The replaced part goes from the trackpoint before the range (the anchor that stays) to the one
|
||||
/// after it, or to the ends of the segment: the new points are the route between them.
|
||||
///
|
||||
/// This is what the routing tool computed from the statistics of the segment before.
|
||||
fn speed_and_start_time(
|
||||
segment: &TrackSegment,
|
||||
target: &Target,
|
||||
points: &[Trackpoint],
|
||||
) -> (Option<f64>, Option<i64>) {
|
||||
let time_at = |index: usize| {
|
||||
(index < segment.len())
|
||||
.then(|| segment[index].time)
|
||||
.flatten()
|
||||
};
|
||||
let start_time = time_at(target.start);
|
||||
if points.is_empty() || segment.is_empty() {
|
||||
return (None, start_time);
|
||||
}
|
||||
let stats = Statistics::compute(segment);
|
||||
let Some(moving_speed) = stats.global.moving_speed().filter(|speed| *speed > 0.0) else {
|
||||
return (None, start_time);
|
||||
};
|
||||
|
||||
// the statistics where the replaced part starts and ends
|
||||
let first_index = target.start.saturating_sub(1);
|
||||
let last_index = target.end.min(segment.len() - 1);
|
||||
let (first, last) = (&stats.local[first_index], &stats.local[last_index]);
|
||||
let moving_distance =
|
||||
|stats: &crate::TrackpointStatistics| stats.moving_distance.unwrap_or(0.0);
|
||||
let moving_time = |stats: &crate::TrackpointStatistics| stats.moving_time.unwrap_or(0);
|
||||
let total_time = |stats: &crate::TrackpointStatistics| stats.total_time.unwrap_or(0);
|
||||
|
||||
let replacing_distance: f64 = points
|
||||
.windows(2)
|
||||
.map(|pair| distance(pair[0].coordinates, pair[1].coordinates))
|
||||
.sum();
|
||||
let replaced_distance = moving_distance(last) - moving_distance(first);
|
||||
let global_moving_distance = stats.global.moving_distance.unwrap_or(0.0);
|
||||
let new_distance = global_moving_distance + replacing_distance - replaced_distance;
|
||||
// in milliseconds
|
||||
let new_time = new_distance / moving_speed * 3_600_000.0;
|
||||
let remaining_time = stats.global.moving_time.unwrap_or(0) as f64
|
||||
- (moving_time(last) - moving_time(first)) as f64;
|
||||
let mut replacing_time = new_time - remaining_time;
|
||||
if replacing_time <= 0.0 {
|
||||
// fall back to the time that the replaced part took
|
||||
replacing_time = (total_time(last) - total_time(first)) as f64;
|
||||
}
|
||||
let speed = replacing_distance / (replacing_time / 3_600_000.0);
|
||||
|
||||
let start_time = start_time.or_else(|| {
|
||||
// The first trackpoint has no time: the new points end when the last replaced one did,
|
||||
// after the time they take and the time that the segment was not moving until then.
|
||||
// (Suspicious: without a time for the last one either, it is "0", the year 1970.)
|
||||
let end_time = time_at(last_index);
|
||||
let stopped = (total_time(last) - moving_time(last)) as f64;
|
||||
Some(end_time.unwrap_or(0) - (replacing_time + stopped) as i64)
|
||||
});
|
||||
(Some(speed), start_time)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{File, Load, TrackSegmentId, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn load(fx: &mut Fixture, path: &str) -> FileId {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
*fx.order.0.last().unwrap()
|
||||
}
|
||||
|
||||
fn select_file(fx: &mut Fixture, id: FileId) {
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
}
|
||||
|
||||
/// The first segment of the file with segments, selected.
|
||||
fn loaded() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let trk = &fx.files[&id].trk[0];
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: trk.id,
|
||||
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn route<'a>(
|
||||
start: u32,
|
||||
end: u32,
|
||||
lng: &'a [f64],
|
||||
lat: &'a [f64],
|
||||
ele: &'a [f64],
|
||||
) -> Route<'a> {
|
||||
Route {
|
||||
start,
|
||||
end,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
surface: Default::default(),
|
||||
highway: Default::default(),
|
||||
sac_scale: Default::default(),
|
||||
mtb_scale: Default::default(),
|
||||
anchors: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn segment(fx: &Fixture, id: FileId, trk: usize, seg: usize) -> &TrackSegment {
|
||||
&fx.files[&id].trk[trk].trkseg[seg]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_replaces_points_of_the_selected_segment_only() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let len = before.trk[0].trkseg[0].len();
|
||||
assert!(len >= 3);
|
||||
|
||||
route(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after = &fx.files[&id];
|
||||
let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]);
|
||||
assert_eq!(new.len(), len + 1);
|
||||
assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng);
|
||||
assert_eq!(new[1].coordinates.lng, 1.0);
|
||||
assert_eq!(new[3].coordinates.lat, 6.0);
|
||||
assert_eq!(new[3].ele, 9.0);
|
||||
assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng);
|
||||
assert_ne!(new.rev_id, old.rev_id);
|
||||
assert_eq!(new.id, old.id);
|
||||
// other segments and tracks keep their revision
|
||||
for (b, a) in before.trk.iter().zip(&after.trk) {
|
||||
for (b, a) in b.trkseg.iter().zip(&a.trkseg) {
|
||||
if a.id != new.id {
|
||||
assert_eq!(a.rev_id, b.rev_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_append_and_remove() {
|
||||
let (mut fx, id) = loaded();
|
||||
let len = fx.files[&id].trk[0].trkseg[0].len() as u32;
|
||||
route(len, len, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let seg = segment(&fx, id, 0, 0);
|
||||
assert_eq!(seg.len(), len as usize + 1);
|
||||
assert_eq!(seg[len as usize].ele, 3.0);
|
||||
|
||||
route(0, len + 1, &[], &[], &[])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(segment(&fx, id, 0, 0).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_invalid_arguments_change_nothing() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let len = before.trk[0].trkseg[0].len() as u32;
|
||||
let results = [
|
||||
route(2, 1, &[], &[], &[]).apply(&mut fx.state()),
|
||||
route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
|
||||
route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
|
||||
route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
|
||||
Route {
|
||||
anchors: &[1],
|
||||
..route(0, 0, &[1.0], &[1.0], &[1.0])
|
||||
}
|
||||
.apply(&mut fx.state()),
|
||||
];
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|r| matches!(r, Err(CommandError::InvalidData(_)))),
|
||||
"{results:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
route(0, 0, &[], &[], &[]).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_needs_a_selection_with_a_file() {
|
||||
let (mut fx, _) = loaded();
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
route(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_indices_are_in_the_selection() {
|
||||
let (mut fx, id) = loaded();
|
||||
let file = fx.files[&id].clone();
|
||||
select_file(&mut fx, id);
|
||||
let lens: Vec<_> = file
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|trk| trk.trkseg.iter().map(|segment| segment.len()))
|
||||
.collect();
|
||||
assert!(lens.len() >= 2);
|
||||
|
||||
// inside the second segment
|
||||
let second = lens[0] as u32;
|
||||
route(second + 1, second + 2, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let (trk, seg) = (0, 1);
|
||||
let segments_before: Vec<_> = file.trk.iter().flat_map(|t| &t.trkseg).collect();
|
||||
let files = fx.files[&id].clone();
|
||||
let segments_after: Vec<_> = files.trk.iter().flat_map(|t| &t.trkseg).collect();
|
||||
for (i, (b, a)) in segments_before.iter().zip(&segments_after).enumerate() {
|
||||
assert_eq!(a.rev_id != b.rev_id, i == 1, "segment {i}");
|
||||
}
|
||||
assert_eq!(segment(&fx, id, trk, seg)[1].coordinates.lng, 1.0);
|
||||
|
||||
// a range over two segments
|
||||
let total: usize = lens.iter().sum();
|
||||
assert!(matches!(
|
||||
route(second - 1, second + 1, &[], &[], &[]).apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
// after the last point: the last segment
|
||||
let before = segments_after.last().unwrap().len();
|
||||
let total = total as u32;
|
||||
route(total, total, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let last = fx.files[&id].trk.last().unwrap().trkseg.last().unwrap();
|
||||
assert_eq!(last.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_creates_the_segment_of_a_selection_that_has_none() {
|
||||
let mut fx = Fixture::default();
|
||||
let file = File::default();
|
||||
let id = file.id;
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.order.0.push(id);
|
||||
select_file(&mut fx, id);
|
||||
|
||||
route(0, 0, &[1.0, 2.0], &[1.0, 2.0], &[3.0, 4.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(fx.files[&id].trk.len(), 1);
|
||||
assert_eq!(segment(&fx, id, 0, 0).len(), 2);
|
||||
// the ends are anchors
|
||||
assert_eq!(segment(&fx, id, 0, 0)[0].anchor, Some(0));
|
||||
assert_eq!(segment(&fx, id, 0, 0)[1].anchor, Some(0));
|
||||
|
||||
// a track with no segments
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.trk[0].trkseg.clear();
|
||||
let trk_id = file.trk[0].id;
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: HashSet::from([trk_id]),
|
||||
};
|
||||
route(0, 0, &[5.0], &[5.0], &[5.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(fx.files[&id].trk.len(), 1);
|
||||
assert_eq!(segment(&fx, id, 0, 0).len(), 1);
|
||||
|
||||
// an empty segment is the target as well
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.trk[0].trkseg[0] = TrackSegment::default();
|
||||
let seg_id: TrackSegmentId = file.trk[0].trkseg[0].id;
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id,
|
||||
trkseg_ids: HashSet::from([seg_id]),
|
||||
};
|
||||
route(0, 0, &[6.0], &[6.0], &[6.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(segment(&fx, id, 0, 0).len(), 1);
|
||||
assert_eq!(fx.files[&id].trk[0].trkseg.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_anchors() {
|
||||
let (mut fx, id) = loaded();
|
||||
let len = segment(&fx, id, 0, 0).len();
|
||||
assert!(len > 4);
|
||||
let anchors_before: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect();
|
||||
|
||||
// 5 new points replacing the 2 in the middle, two of them are anchors
|
||||
let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]);
|
||||
Route {
|
||||
anchors: &[1, 3],
|
||||
..route(1, 3, &lng, &lat, &ele)
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let anchors: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect();
|
||||
assert_eq!(anchors.len(), len + 3);
|
||||
assert_eq!(anchors[2], Some(0));
|
||||
assert_eq!(anchors[4], Some(0));
|
||||
assert_eq!(anchors[1], None);
|
||||
assert_eq!(anchors[3], None);
|
||||
assert_eq!(anchors[5], None);
|
||||
// the others stay as they were, the ends are always anchors
|
||||
assert_eq!(anchors[0], Some(0));
|
||||
assert_eq!(anchors[len + 2], Some(0));
|
||||
assert_eq!(anchors[len + 2 - 1], anchors_before[len - 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_makes_the_anchors_around_the_range_permanent() {
|
||||
let (mut fx, id) = loaded();
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
let len = file.trk[0].trkseg[0].len();
|
||||
assert!(len > 8);
|
||||
// anchors shown from different zoom levels, around and away from the range 4..6
|
||||
for (index, zoom) in [(1, 7), (3, 9), (6, 12), (8, 14)] {
|
||||
file.trk[0].trkseg[0].set_anchor(index, zoom);
|
||||
}
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
|
||||
route(4, 6, &[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let anchors: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect();
|
||||
assert_eq!(anchors.len(), len);
|
||||
// right before and after the new points: permanent
|
||||
assert_eq!(anchors[3], Some(0));
|
||||
assert_eq!(anchors[6], Some(0));
|
||||
// the others are as they were
|
||||
assert_eq!(anchors[1], Some(7));
|
||||
assert_eq!(anchors[8], Some(14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_categories() {
|
||||
let (mut fx, id) = loaded();
|
||||
let names = ["asphalt".to_string(), "gravel".to_string()];
|
||||
let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]);
|
||||
// asphalt, asphalt, unknown, gravel
|
||||
Route {
|
||||
surface: RouteCategory {
|
||||
starts: &[0, 2, 3],
|
||||
values: &[1, 0, 2],
|
||||
names: &names,
|
||||
},
|
||||
highway: RouteCategory {
|
||||
starts: &[0],
|
||||
values: &[1],
|
||||
names: &["track".to_string()],
|
||||
},
|
||||
..route(0, 0, &lng, &lat, &ele)
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let file = fx.files[&id].clone();
|
||||
let seg = &file.trk[0].trkseg[0];
|
||||
let asphalt = fx.categories.surface.code("asphalt");
|
||||
let gravel = fx.categories.surface.code("gravel");
|
||||
let surfaces: Vec<_> = (0..4).map(|i| seg[i].surface).collect();
|
||||
assert_eq!(surfaces, [asphalt, asphalt, None, gravel]);
|
||||
let track = fx.categories.highway.code("track");
|
||||
assert!((0..4).all(|i| seg[i].highway == track));
|
||||
assert!((0..4).all(|i| seg[i].sac_scale.is_none() && seg[i].mtb_scale.is_none()));
|
||||
|
||||
// broken intervals or names
|
||||
let broken = [
|
||||
RouteCategory {
|
||||
starts: &[0, 1],
|
||||
values: &[1],
|
||||
names: &names,
|
||||
},
|
||||
RouteCategory {
|
||||
starts: &[5],
|
||||
values: &[1],
|
||||
names: &names,
|
||||
},
|
||||
RouteCategory {
|
||||
starts: &[0],
|
||||
values: &[3],
|
||||
names: &names,
|
||||
},
|
||||
];
|
||||
for surface in broken {
|
||||
let result = Route {
|
||||
surface,
|
||||
..route(0, 0, &lng, &lat, &ele)
|
||||
}
|
||||
.apply(&mut fx.state());
|
||||
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment of `data/with_time.gpx`, selected: 80 trackpoints at 20 km/h.
|
||||
fn timed() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_time.gpx");
|
||||
select_file(&mut fx, id);
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn times(seg: &TrackSegment) -> Vec<i64> {
|
||||
seg.iter().map(|p| p.time.unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_gives_times_to_the_new_points() {
|
||||
let (mut fx, id) = timed();
|
||||
let seg = segment(&fx, id, 0, 0);
|
||||
let (n, before) = (seg.len(), times(seg));
|
||||
let duration = before[n - 1] - before[0];
|
||||
|
||||
// replace the points 20..40 by the straight line between them, with 5 points
|
||||
let (a, b) = (seg[20].coordinates, seg[39].coordinates);
|
||||
let at = |t: f64| (a.lng + t * (b.lng - a.lng), a.lat + t * (b.lat - a.lat));
|
||||
let positions: Vec<_> = (0..5).map(|i| at(i as f64 / 4.0)).collect();
|
||||
let lng: Vec<_> = positions.iter().map(|p| p.0).collect();
|
||||
let lat: Vec<_> = positions.iter().map(|p| p.1).collect();
|
||||
route(20, 40, &lng, &lat, &[0.0; 5])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let seg = segment(&fx, id, 0, 0);
|
||||
assert_eq!(seg.len(), n - 15);
|
||||
let after = times(seg);
|
||||
assert!(after.windows(2).all(|w| w[0] < w[1]), "{after:?}");
|
||||
// the points before are untouched
|
||||
assert_eq!(after[..20], before[..20]);
|
||||
// the speed of the replaced part is the one of the segment: the duration of the segment
|
||||
// is about the same as on the same distance
|
||||
let stats = Statistics::compute(seg);
|
||||
let speed = stats.global.moving_speed().unwrap();
|
||||
assert!((speed - 20.0).abs() < 1.0, "{speed}");
|
||||
assert!(after[after.len() - 1] - after[0] <= duration);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_between_anchors_that_stay_keeps_them_untouched() {
|
||||
let (mut fx, id) = timed();
|
||||
let seg = segment(&fx, id, 0, 0);
|
||||
let (n, before) = (seg.len(), times(seg));
|
||||
let (a, b) = (seg[20].clone(), seg[39].clone());
|
||||
// 5 points on the line between the anchors 20 and 39, which are not replaced
|
||||
let at = |t: f64| {
|
||||
(
|
||||
a.coordinates.lng + t * (b.coordinates.lng - a.coordinates.lng),
|
||||
a.coordinates.lat + t * (b.coordinates.lat - a.coordinates.lat),
|
||||
)
|
||||
};
|
||||
let positions: Vec<_> = (1..=5).map(|i| at(i as f64 / 6.0)).collect();
|
||||
let lng: Vec<_> = positions.iter().map(|p| p.0).collect();
|
||||
let lat: Vec<_> = positions.iter().map(|p| p.1).collect();
|
||||
route(21, 39, &lng, &lat, &[0.0; 5])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let seg = segment(&fx, id, 0, 0);
|
||||
assert_eq!(seg.len(), n - 18 + 5);
|
||||
let after = times(seg);
|
||||
assert!(after.windows(2).all(|w| w[0] < w[1]), "{after:?}");
|
||||
// the anchors keep their coordinates and times, and what is before them
|
||||
assert_eq!(after[..=20], before[..=20]);
|
||||
assert_eq!(seg[26].coordinates.lng, b.coordinates.lng);
|
||||
assert_eq!(after[26], before[39]);
|
||||
// and what comes after is unchanged as well
|
||||
assert_eq!(after[27..], before[40..]);
|
||||
let speed = Statistics::compute(seg).global.moving_speed().unwrap();
|
||||
assert!((speed - 20.0).abs() < 1.0, "{speed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_appending_to_a_segment_with_times() {
|
||||
let (mut fx, id) = timed();
|
||||
let seg = segment(&fx, id, 0, 0);
|
||||
let n = seg.len();
|
||||
let last = seg[n - 1].clone();
|
||||
// replace the last point and go 1 km further east
|
||||
let lng = [last.coordinates.lng, last.coordinates.lng + 0.009];
|
||||
let lat = [last.coordinates.lat; 2];
|
||||
route(n as u32 - 1, n as u32, &lng, &lat, &[0.0; 2])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let seg = segment(&fx, id, 0, 0);
|
||||
assert_eq!(seg.len(), n + 1);
|
||||
// the replaced point is reached at the speed of the segment: about at its time
|
||||
assert!((seg[n - 1].time.unwrap() - last.time.unwrap()).abs() < 1000);
|
||||
let (a, b) = (seg[n - 1].time.unwrap(), seg[n].time.unwrap());
|
||||
// the new point is reached at about 20 km/h
|
||||
let km = distance(seg[n - 1].coordinates, seg[n].coordinates);
|
||||
let seconds = (b - a) as f64 / 1000.0;
|
||||
let expected = 3600.0 * km / 20.0;
|
||||
assert!(
|
||||
(seconds - expected).abs() < 0.1 * expected,
|
||||
"{seconds} {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_does_not_invent_times() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/simple.gpx");
|
||||
select_file(&mut fx, id);
|
||||
assert!(segment(&fx, id, 0, 0).iter().all(|p| p.time.is_none()));
|
||||
route(0, 2, &[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert!(segment(&fx, id, 0, 0).iter().all(|p| p.time.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, File, State, Track, TrackSegment, Waypoints, copy_segment, copy_track,
|
||||
copy_waypoint,
|
||||
};
|
||||
|
||||
use super::route::find_target;
|
||||
|
||||
/// What is cut in two where the trackpoint is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SplitType {
|
||||
/// The file becomes two files, which both have the waypoints (copies, in the second one).
|
||||
Files,
|
||||
/// The track becomes two tracks.
|
||||
Tracks,
|
||||
/// The segment becomes two segments.
|
||||
Segments,
|
||||
}
|
||||
|
||||
/// Cuts the file, the track or the segment of the trackpoint `at` of the selection (see
|
||||
/// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first
|
||||
/// one of the second part.
|
||||
///
|
||||
/// The first part keeps the ids of what is cut, the second one has new ids, as the elements that
|
||||
/// come after the trackpoint (the following segments of the track, the following tracks of the
|
||||
/// file), which are moved to it. When the file is cut, the new file comes right after it. The
|
||||
/// selection does not change.
|
||||
#[derive(Debug)]
|
||||
pub struct Split {
|
||||
pub at: u32,
|
||||
pub split_type: SplitType,
|
||||
}
|
||||
|
||||
impl Apply for Split {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let at = self.at as usize;
|
||||
let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?;
|
||||
let location = target.location;
|
||||
let (file_id, trk, seg, index) =
|
||||
(location.file_id, location.trk, location.seg, target.start);
|
||||
let mut file = (*state.files[&file_id]).clone();
|
||||
if index >= file.trk[trk].trkseg[seg].len() {
|
||||
return Err(CommandError::InvalidData(
|
||||
"trackpoint out of the segment".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let track = file.trk[trk].clone();
|
||||
let segment = &track.trkseg[seg];
|
||||
let (first, second) = (keep_up_to(segment, index), keep_from(segment, index));
|
||||
// the track is cut: what is before the segment, and its first part, then the second part
|
||||
// and what is after it
|
||||
let split_track = || {
|
||||
let mut first_track = track.clone();
|
||||
first_track.trkseg.truncate(seg);
|
||||
first_track.trkseg.push(first.clone());
|
||||
let mut second_track = Track {
|
||||
id: Default::default(),
|
||||
trkseg: vec![second.clone()],
|
||||
..track.clone()
|
||||
};
|
||||
second_track
|
||||
.trkseg
|
||||
.extend(track.trkseg[seg + 1..].iter().map(copy_segment));
|
||||
(first_track, second_track)
|
||||
};
|
||||
|
||||
match self.split_type {
|
||||
SplitType::Segments => {
|
||||
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
|
||||
}
|
||||
SplitType::Tracks => {
|
||||
let (first_track, second_track) = split_track();
|
||||
file.trk.splice(trk..=trk, [first_track, second_track]);
|
||||
}
|
||||
SplitType::Files => {
|
||||
let (first_track, second_track) = split_track();
|
||||
let mut second_file = File {
|
||||
id: Default::default(),
|
||||
trk: vec![second_track],
|
||||
wpt: Waypoints::default(),
|
||||
..file.clone()
|
||||
};
|
||||
second_file
|
||||
.trk
|
||||
.extend(file.trk[trk + 1..].iter().map(copy_track));
|
||||
second_file
|
||||
.wpt
|
||||
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
|
||||
|
||||
file.trk.truncate(trk);
|
||||
file.trk.push(first_track);
|
||||
let position = state.order.0.iter().position(|id| *id == file_id);
|
||||
state.order.0.insert(
|
||||
position.map_or(state.order.0.len(), |p| p + 1),
|
||||
second_file.id,
|
||||
);
|
||||
state.files.insert(second_file.id, Rc::new(second_file));
|
||||
}
|
||||
}
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The trackpoints of the segment up to `index` (included), in the same segment.
|
||||
fn keep_up_to(segment: &TrackSegment, index: usize) -> TrackSegment {
|
||||
let mut part = segment.clone();
|
||||
let len = part.len();
|
||||
part.splice(index + 1, len, vec![]);
|
||||
part.rev_id = Default::default();
|
||||
part
|
||||
}
|
||||
|
||||
/// The trackpoints of the segment from `index` (included), in a new segment.
|
||||
fn keep_from(segment: &TrackSegment, index: usize) -> TrackSegment {
|
||||
let mut part = copy_segment(segment);
|
||||
part.splice(0, index, vec![]);
|
||||
part.rev_id = Default::default();
|
||||
part
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, Selection, TrackSegmentId, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn split(at: u32, split_type: SplitType) -> Split {
|
||||
Split { at, split_type }
|
||||
}
|
||||
|
||||
fn lngs(segment: &TrackSegment) -> Vec<f64> {
|
||||
segment.iter().map(|p| p.coordinates.lng).collect()
|
||||
}
|
||||
|
||||
fn path(file: &File) -> Vec<f64> {
|
||||
file.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.flat_map(lngs)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The index in the selection of the trackpoint `local` of the segment `seg` of the track `trk`.
|
||||
fn index(file: &File, trk: usize, seg: usize, local: usize) -> u32 {
|
||||
let before: usize = file
|
||||
.trk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(t, track)| {
|
||||
track
|
||||
.trkseg
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(s, segment)| ((t, s), segment.len()))
|
||||
})
|
||||
.take_while(|((t, s), _)| (*t, *s) != (trk, seg))
|
||||
.map(|(_, len)| len)
|
||||
.sum();
|
||||
(before + local) as u32
|
||||
}
|
||||
|
||||
/// A segment with enough trackpoints, and its position.
|
||||
fn long_segment(file: &File) -> (usize, usize) {
|
||||
file.trk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(t, track)| (0..track.trkseg.len()).map(move |s| (t, s)))
|
||||
.find(|(t, s)| file.trk[*t].trkseg[*s].len() > 4)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_segments() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let (trk, seg) = long_segment(&before);
|
||||
let original = &before.trk[trk].trkseg[seg];
|
||||
let len = original.len();
|
||||
|
||||
split(index(&before, trk, seg, 2), SplitType::Segments)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after = &fx.files[&id];
|
||||
assert_eq!(after.trk.len(), before.trk.len());
|
||||
let segments = &after.trk[trk].trkseg;
|
||||
assert_eq!(segments.len(), before.trk[trk].trkseg.len() + 1);
|
||||
let (a, b) = (&segments[seg], &segments[seg + 1]);
|
||||
// the point is the end of the first part and the start of the second
|
||||
assert_eq!(lngs(a), lngs(original)[..=2]);
|
||||
assert_eq!(lngs(b), lngs(original)[2..]);
|
||||
assert_eq!(a.len() + b.len(), len + 1);
|
||||
// the first part keeps the id, the second one is new
|
||||
assert_eq!(a.id, original.id);
|
||||
assert_ne!(b.id, original.id);
|
||||
assert_ne!(a.rev_id, original.rev_id);
|
||||
// the ends are anchors
|
||||
assert_eq!(a[a.len() - 1].anchor, Some(0));
|
||||
assert_eq!(b[0].anchor, Some(0));
|
||||
// the rest of the file is untouched
|
||||
for (i, segment) in before.trk[trk].trkseg.iter().enumerate() {
|
||||
if i != seg {
|
||||
let moved = if i > seg { i + 1 } else { i };
|
||||
assert_eq!(segments[moved].rev_id, segment.rev_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_tracks() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let (trk, seg) = long_segment(&before);
|
||||
|
||||
split(index(&before, trk, seg, 3), SplitType::Tracks)
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after = &fx.files[&id];
|
||||
assert_eq!(after.trk.len(), before.trk.len() + 1);
|
||||
let (a, b) = (&after.trk[trk], &after.trk[trk + 1]);
|
||||
assert_eq!(a.id, before.trk[trk].id);
|
||||
assert_ne!(b.id, a.id);
|
||||
// the segments before are in the first track, the ones after in the second one
|
||||
assert_eq!(a.trkseg.len(), seg + 1);
|
||||
assert_eq!(b.trkseg.len(), before.trk[trk].trkseg.len() - seg);
|
||||
// the segments before are the same ones, with the same trackpoints
|
||||
for (kept, original) in a.trkseg[..seg].iter().zip(&before.trk[trk].trkseg[..seg]) {
|
||||
assert_eq!((kept.id, kept.rev_id), (original.id, original.rev_id));
|
||||
let chunks = |segment: &TrackSegment| -> Vec<_> {
|
||||
segment.chunks().iter().map(|chunk| chunk.id).collect()
|
||||
};
|
||||
assert_eq!(chunks(kept), chunks(original));
|
||||
}
|
||||
// new ids for everything that moved, so that they are unique
|
||||
let ids: HashSet<TrackSegmentId> = after
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| t.trkseg.iter().map(|s| s.id))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
after.trk.iter().map(|t| t.trkseg.len()).sum::<usize>()
|
||||
);
|
||||
// the path goes on, with the point twice
|
||||
let mut expected = path(&before);
|
||||
let at = index(&before, trk, seg, 3) as usize;
|
||||
expected.insert(at, expected[at]);
|
||||
assert_eq!(path(after), expected);
|
||||
// the info of the track is the same
|
||||
assert_eq!(a.info, b.info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_files() {
|
||||
let (mut fx, id) = loaded();
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
let wpt = crate::Waypoint {
|
||||
name: Some("w".into()),
|
||||
..Default::default()
|
||||
};
|
||||
file.wpt.insert_at(0, vec![wpt.clone()]);
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
let before = fx.files[&id].clone();
|
||||
let (trk, seg) = long_segment(&before);
|
||||
let at = index(&before, trk, seg, 1);
|
||||
|
||||
split(at, SplitType::Files).apply(&mut fx.state()).unwrap();
|
||||
|
||||
assert_eq!(fx.files.len(), 2);
|
||||
let second_id = fx.order.0[1];
|
||||
assert_eq!(fx.order.0[0], id);
|
||||
assert_ne!(second_id, id);
|
||||
let (a, b) = (&fx.files[&id], &fx.files[&second_id]);
|
||||
// the first file stops at the point, the second one starts there
|
||||
let all = path(&before);
|
||||
assert_eq!(path(a), all[..=at as usize]);
|
||||
assert_eq!(path(b), all[at as usize..]);
|
||||
assert_eq!(a.info, b.info);
|
||||
// each has the waypoints, the copy has its own ids
|
||||
assert_eq!(a.wpt.len(), 1);
|
||||
assert_eq!(b.wpt.len(), 1);
|
||||
assert_ne!(a.wpt[0].id, b.wpt[0].id);
|
||||
assert_eq!(b.wpt[0].name, wpt.name);
|
||||
// nothing is shared by id between the two
|
||||
let ids = |f: &File| -> HashSet<_> {
|
||||
f.trk
|
||||
.iter()
|
||||
.flat_map(|t| std::iter::once(t.id.0).chain(t.trkseg.iter().map(|s| s.id.0)))
|
||||
.collect()
|
||||
};
|
||||
assert!(ids(a).is_disjoint(&ids(b)));
|
||||
// the selection is the same
|
||||
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.contains(&id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_or_nothing() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let total = path(&before).len() as u32;
|
||||
assert!(matches!(
|
||||
split(total + 5, SplitType::Segments).apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
// after the last trackpoint: appending, not a trackpoint
|
||||
assert!(matches!(
|
||||
split(total, SplitType::Segments).apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
split(0, SplitType::Files).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use crate::{
|
||||
Apply, CommandError, State, TrackSegment, artificial_segment_times, changed_segment_times,
|
||||
update_segments,
|
||||
};
|
||||
|
||||
/// How the timestamps of the selection are set.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TimeKind {
|
||||
/// Starts at `Time::start_time`. The trackpoints that have timestamps keep their durations
|
||||
/// multiplied by `ratio`, and the ones that have none follow the previous one at `speed`
|
||||
/// (km/h).
|
||||
Change { speed: f64, ratio: f64 },
|
||||
/// Makes timestamps up for the whole selection, which lasts `total_time` seconds: the longer
|
||||
/// and the steeper an interval, the more time it takes.
|
||||
Artificial { total_time: f64 },
|
||||
}
|
||||
|
||||
/// Sets the timestamps of the selected segments, one after the other: each one goes on from the
|
||||
/// end of the previous one, the first one starts at `start_time` (in milliseconds).
|
||||
#[derive(Debug)]
|
||||
pub struct Time {
|
||||
pub start_time: i64,
|
||||
pub kind: TimeKind,
|
||||
}
|
||||
|
||||
impl Apply for Time {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
match self.kind {
|
||||
TimeKind::Change { speed, ratio } => {
|
||||
if !(speed.is_finite() && speed > 0.0 && ratio.is_finite() && ratio > 0.0) {
|
||||
return Err(CommandError::InvalidData(format!(
|
||||
"speed {speed} and ratio {ratio} should be positive"
|
||||
)));
|
||||
}
|
||||
}
|
||||
TimeKind::Artificial { total_time } => {
|
||||
if !(total_time.is_finite() && total_time >= 0.0) {
|
||||
return Err(CommandError::InvalidData(format!(
|
||||
"total time {total_time} should not be negative"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let locations = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0);
|
||||
let segments: Vec<&TrackSegment> = locations
|
||||
.iter()
|
||||
.map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg])
|
||||
.collect();
|
||||
if segments.iter().all(|segment| segment.is_empty()) {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
let times = match self.kind {
|
||||
TimeKind::Change { speed, ratio } => {
|
||||
changed_segment_times(&segments, self.start_time, speed, ratio)
|
||||
}
|
||||
TimeKind::Artificial { total_time } => {
|
||||
artificial_segment_times(&segments, self.start_time, total_time)
|
||||
}
|
||||
};
|
||||
|
||||
update_segments(state, &locations, |i, segment| {
|
||||
let times = ×[i];
|
||||
segment.update_all(|j, pt| pt.time = times.get(j).copied().flatten());
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn all_times(fx: &Fixture, id: FileId) -> Vec<Option<i64>> {
|
||||
fx.files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|trk| {
|
||||
trk.trkseg
|
||||
.iter()
|
||||
.flat_map(|seg| seg.iter().map(|pt| pt.time))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artificial_timestamps_last_the_total_time() {
|
||||
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||
|
||||
Time {
|
||||
start_time: 1_000_000,
|
||||
kind: TimeKind::Artificial { total_time: 3600.0 },
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
|
||||
assert_eq!(times[0], 1_000_000);
|
||||
assert!(times.windows(2).all(|w| w[0] <= w[1]));
|
||||
assert!((times.last().unwrap() - 1_000_000 - 3_600_000).abs() <= times.len() as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_change_starts_at_the_start_time_and_follows_the_speed() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
Time {
|
||||
start_time: 5_000,
|
||||
kind: TimeKind::Change {
|
||||
speed: 10.0,
|
||||
ratio: 1.0,
|
||||
},
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
|
||||
assert_eq!(times[0], 5_000);
|
||||
assert!(times.windows(2).all(|w| w[0] <= w[1]));
|
||||
assert!(times.last() > times.first());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_change_shifts_existing_timestamps_and_scales_them() {
|
||||
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||
let before: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
|
||||
|
||||
Time {
|
||||
start_time: 0,
|
||||
kind: TimeKind::Change {
|
||||
speed: 10.0,
|
||||
ratio: 2.0,
|
||||
},
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
|
||||
assert_eq!(after[0], 0);
|
||||
let (d_before, d_after) = (before[1] - before[0], after[1] - after[0]);
|
||||
assert!((d_after - 2 * d_before).abs() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_input_and_empty_selection() {
|
||||
let (mut fx, id) = loaded("data/simple.gpx");
|
||||
let before = all_times(&fx, id);
|
||||
assert!(matches!(
|
||||
Time {
|
||||
start_time: 0,
|
||||
kind: TimeKind::Change {
|
||||
speed: 0.0,
|
||||
ratio: 1.0
|
||||
}
|
||||
}
|
||||
.apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
assert_eq!(all_times(&fx, id), before);
|
||||
|
||||
fx.selection = Selection::Empty;
|
||||
assert!(matches!(
|
||||
Time {
|
||||
start_time: 0,
|
||||
kind: TimeKind::Artificial { total_time: 10.0 }
|
||||
}
|
||||
.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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() {
|
||||
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,
|
||||
name: "file",
|
||||
}
|
||||
.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 = crate::Waypoints::new([
|
||||
WaypointChunk {
|
||||
wpt: vec![wpt(1.0, 2.0)],
|
||||
..Default::default()
|
||||
},
|
||||
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,155 @@
|
||||
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 desc: Option<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 desc: Option<String>,
|
||||
/// Style of the track, only when the file defines it.
|
||||
pub color: Option<String>,
|
||||
pub opacity: Option<f64>,
|
||||
pub width: Option<f64>,
|
||||
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>,
|
||||
pub sym: Option<String>,
|
||||
}
|
||||
|
||||
impl FileStructure {
|
||||
pub fn new(file: &File) -> Self {
|
||||
Self {
|
||||
id: file.id,
|
||||
name: file.info.name.clone(),
|
||||
desc: file.info.desc.clone(),
|
||||
tracks: file
|
||||
.trk
|
||||
.iter()
|
||||
.map(|trk| TrackNode {
|
||||
id: trk.id,
|
||||
name: trk.info.name.clone(),
|
||||
desc: trk.info.desc.clone(),
|
||||
color: trk.info.color.clone(),
|
||||
opacity: trk.info.opacity,
|
||||
width: trk.info.width,
|
||||
segments: trk
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|seg| SegmentNode {
|
||||
id: seg.id,
|
||||
rev_id: seg.rev_id,
|
||||
len: seg.len(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
waypoints: file
|
||||
.wpt
|
||||
.iter()
|
||||
.map(|wpt| WaypointNode {
|
||||
id: wpt.id,
|
||||
name: wpt.name.clone(),
|
||||
sym: wpt.sym.clone(),
|
||||
})
|
||||
.collect(),
|
||||
wpt_rev_id: file.wpt.rev_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
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,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let file = &fx.files[&fx.order.0[0]];
|
||||
let node = FileStructure::new(file);
|
||||
assert_eq!(
|
||||
(node.id, &node.name, &node.desc),
|
||||
(file.id, &file.info.name, &file.info.desc)
|
||||
);
|
||||
assert_eq!(node.tracks.len(), file.trk.len());
|
||||
for (n, t) in node.tracks.iter().zip(&file.trk) {
|
||||
assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc));
|
||||
assert_eq!(
|
||||
(&n.color, n.opacity, n.width),
|
||||
(&t.info.color, t.info.opacity, t.info.width)
|
||||
);
|
||||
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 = crate::Waypoints::new([
|
||||
WaypointChunk {
|
||||
wpt: vec![wpt("a"), wpt("b")],
|
||||
..Default::default()
|
||||
},
|
||||
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,92 @@
|
||||
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,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mod coordinates_cache;
|
||||
mod file_structure;
|
||||
mod file_structure_cache;
|
||||
mod routing_buffer;
|
||||
mod statistics_buffer;
|
||||
mod statistics_cache;
|
||||
|
||||
pub use coordinates_cache::*;
|
||||
pub use file_structure::*;
|
||||
pub use file_structure_cache::*;
|
||||
pub use routing_buffer::*;
|
||||
pub use statistics_buffer::*;
|
||||
pub use statistics_cache::*;
|
||||
@@ -0,0 +1,162 @@
|
||||
use crate::{FileId, Selection, StackEntry, TrackSegmentId, TrackSegmentRevisionId};
|
||||
|
||||
/// What the routing tool needs about the selected segments (see
|
||||
/// [`Selection::segment_locations`]): where their anchors are among their trackpoints.
|
||||
///
|
||||
/// Trackpoints are numbered like in the [`crate::StatisticsBuffer`], over all the selected
|
||||
/// segments one after the other, except that the segments that are not covered by the routing
|
||||
/// tool (waypoints) are left out.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoutingBuffer {
|
||||
/// The index of each anchor in the selection, in order. The first and last trackpoints of a
|
||||
/// segment always are anchors.
|
||||
pub anchor_indices: Vec<u32>,
|
||||
/// The lowest map zoom level at which each anchor is shown.
|
||||
pub anchor_zooms: Vec<u8>,
|
||||
/// Index in the selection of the first trackpoint of each selected segment that has some,
|
||||
/// which tells which segment an anchor or a trackpoint belongs to.
|
||||
pub segment_starts: Vec<u32>,
|
||||
/// Id of each selected segment that has trackpoints, as `segment_starts`.
|
||||
pub segment_ids: Vec<TrackSegmentId>,
|
||||
/// Changes when the selected segments, or their trackpoints, change. Commands that refer to
|
||||
/// trackpoints by their index in the selection carry it, to be refused if the selection is
|
||||
/// not the one the indices were taken from anymore.
|
||||
pub revision: u32,
|
||||
/// What the revision stands for.
|
||||
signature: Vec<(TrackSegmentId, TrackSegmentRevisionId)>,
|
||||
}
|
||||
|
||||
impl RoutingBuffer {
|
||||
pub fn update(&mut self, files: Option<&StackEntry>, selection: &Selection, order: &[FileId]) {
|
||||
self.anchor_indices.clear();
|
||||
self.anchor_zooms.clear();
|
||||
self.segment_starts.clear();
|
||||
self.segment_ids.clear();
|
||||
|
||||
let mut signature = vec![];
|
||||
let mut index = 0;
|
||||
for location in files
|
||||
.map(|files| selection.segment_locations(files, order))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
let segment = &files.unwrap()[&location.file_id].trk[location.trk].trkseg[location.seg];
|
||||
signature.push((segment.id, segment.rev_id));
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.segment_starts.push(index as u32);
|
||||
self.segment_ids.push(segment.id);
|
||||
for trkpt in segment.iter() {
|
||||
if let Some(zoom) = trkpt.anchor {
|
||||
self.anchor_indices.push(index as u32);
|
||||
self.anchor_zooms.push(zoom);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if signature != self.signature {
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.signature = signature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded() -> (StackEntry, FileId) {
|
||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let id = file.id;
|
||||
let mut files = StackEntry::default();
|
||||
files.insert(id, Rc::new(file));
|
||||
(files, id)
|
||||
}
|
||||
|
||||
fn file_selection(id: FileId) -> Selection {
|
||||
Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchors_and_starts_follow_the_selected_segments() {
|
||||
let (files, id) = loaded();
|
||||
let lengths: Vec<usize> = files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|trk| trk.trkseg.iter().map(|segment| segment.len()))
|
||||
.collect();
|
||||
let mut buffer = RoutingBuffer::default();
|
||||
buffer.update(Some(&files), &file_selection(id), &[id]);
|
||||
|
||||
// an empty segment has no start
|
||||
assert_eq!(buffer.segment_ids.len(), buffer.segment_starts.len());
|
||||
let mut start = 0;
|
||||
let expected: Vec<u32> = lengths
|
||||
.iter()
|
||||
.filter(|len| **len > 0)
|
||||
.map(|len| {
|
||||
start += len;
|
||||
(start - len) as u32
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(buffer.segment_starts, expected);
|
||||
// the ends of the segments are always anchors, shown at every zoom level
|
||||
assert_eq!(buffer.anchor_indices.len(), buffer.anchor_zooms.len());
|
||||
assert!(buffer.anchor_indices.windows(2).all(|w| w[0] < w[1]));
|
||||
let total: usize = lengths.iter().sum();
|
||||
let ends = expected
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(expected.iter().skip(1).map(|start| start - 1))
|
||||
.chain([total as u32 - 1]);
|
||||
for end in ends {
|
||||
let position = buffer.anchor_indices.iter().position(|i| *i == end);
|
||||
assert_eq!(position.map(|p| buffer.anchor_zooms[p]), Some(0), "{end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_without_segments() {
|
||||
let (files, id) = loaded();
|
||||
let mut buffer = RoutingBuffer::default();
|
||||
buffer.update(Some(&files), &Selection::Empty, &[id]);
|
||||
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
|
||||
// waypoints are not covered by the routing tool
|
||||
buffer.update(Some(&files), &Selection::Waypoints { file_id: id }, &[id]);
|
||||
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
|
||||
buffer.update(None, &file_selection(id), &[id]);
|
||||
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_revision_changes_with_the_selected_segments() {
|
||||
let (mut files, id) = loaded();
|
||||
let mut buffer = RoutingBuffer::default();
|
||||
buffer.update(Some(&files), &file_selection(id), &[id]);
|
||||
let first = buffer.revision;
|
||||
|
||||
// the same segments: the indices still mean the same trackpoints
|
||||
buffer.update(Some(&files), &file_selection(id), &[id]);
|
||||
assert_eq!(buffer.revision, first);
|
||||
|
||||
// other trackpoints in the same segment
|
||||
let mut file = (*files[&id]).clone();
|
||||
file.trk[0].trkseg[0].rev_id = Default::default();
|
||||
files.insert(id, Rc::new(file));
|
||||
buffer.update(Some(&files), &file_selection(id), &[id]);
|
||||
assert_ne!(buffer.revision, first);
|
||||
let second = buffer.revision;
|
||||
|
||||
// another selection
|
||||
buffer.update(Some(&files), &Selection::Empty, &[id]);
|
||||
assert_ne!(buffer.revision, second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options};
|
||||
|
||||
/// Value of the timestamps of the trackpoints that have none.
|
||||
pub const NO_TIME: i64 = i64::MIN;
|
||||
|
||||
/// Flattened statistics of the selected segments, one entry per trackpoint, in selection order.
|
||||
///
|
||||
/// The distance, time, moving and elevation values are cumulative over the whole selection (the
|
||||
/// second segment continues where the first one ended), so the statistics of any range of
|
||||
/// trackpoints are the difference between its two ends: see [`StatisticsBuffer::slice`].
|
||||
/// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are
|
||||
/// [`NO_TIME`], missing measures are NaN.
|
||||
///
|
||||
/// The optional values (`moving_distance`, `total_time`, `moving_time`, `speed`, `time`, `hr`,
|
||||
/// `cad`, `atemp`, `power`) are `None` when no trackpoint of the selection has one, and the OSM
|
||||
/// attributes are stored as [`Intervals`]. The cumulative ones can only be missing before the
|
||||
/// first trackpoint that has them, where they are 0.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StatisticsBuffer {
|
||||
/// Statistics of the whole selection.
|
||||
pub global: GlobalStatistics,
|
||||
pub total_distance: Vec<f64>,
|
||||
pub moving_distance: Option<Vec<f64>>,
|
||||
pub total_time: Option<Vec<i64>>,
|
||||
pub moving_time: Option<Vec<i64>>,
|
||||
pub speed: Option<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>,
|
||||
pub lng: Vec<f64>,
|
||||
pub lat: Vec<f64>,
|
||||
pub ele: Vec<f64>,
|
||||
/// Timestamps, in milliseconds since the epoch.
|
||||
pub time: Option<Vec<i64>>,
|
||||
pub hr: Option<Vec<f64>>,
|
||||
pub cad: Option<Vec<f64>>,
|
||||
pub atemp: Option<Vec<f64>>,
|
||||
pub power: Option<Vec<f64>>,
|
||||
/// Surface of the trackpoints, see [`Intervals`].
|
||||
pub surface: Intervals,
|
||||
/// Highway of the trackpoints, as the surface.
|
||||
pub highway: Intervals,
|
||||
/// SAC hiking scale of the trackpoints, as the surface.
|
||||
pub sac_scale: Intervals,
|
||||
/// Mountain biking scale of the trackpoints, as the surface.
|
||||
pub mtb_scale: Intervals,
|
||||
}
|
||||
|
||||
/// A value per trackpoint that rarely changes from one trackpoint to the next, stored as the
|
||||
/// intervals of trackpoints that share it.
|
||||
///
|
||||
/// The values are 0 when unknown, else 1 + the code in the categories of the engine (so that it
|
||||
/// is never 0 for a known value). `starts[i]` is the index of the first trackpoint of the
|
||||
/// interval `i`, which ends where the next one starts, or at the last trackpoint for the last one.
|
||||
/// Both are empty when there are no trackpoints.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Intervals {
|
||||
pub starts: Vec<u32>,
|
||||
pub values: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Intervals {
|
||||
fn clear(&mut self) {
|
||||
self.starts.clear();
|
||||
self.values.clear();
|
||||
}
|
||||
|
||||
/// Adds the value of the trackpoint at `index`, the indices being pushed in order.
|
||||
fn push(&mut self, index: usize, value: u8) {
|
||||
if self.values.last() != Some(&value) {
|
||||
self.starts.push(index as u32);
|
||||
self.values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
|
||||
/// fits.
|
||||
fn unknown_or_next(code: Option<u8>) -> u8 {
|
||||
code.and_then(|code| code.checked_add(1)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Adds the value of the trackpoint at `index` to an optional column, the indices being pushed in
|
||||
/// order. The column is created at the first present value, the previous trackpoints getting
|
||||
/// `missing`.
|
||||
fn push_optional<T: Copy>(column: &mut Option<Vec<T>>, index: usize, value: Option<T>, missing: T) {
|
||||
match (column.as_mut(), value) {
|
||||
(Some(column), value) => column.push(value.unwrap_or(missing)),
|
||||
(None, Some(value)) => {
|
||||
let mut new_column = vec![missing; index];
|
||||
new_column.push(value);
|
||||
*column = Some(new_column);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatisticsBuffer {
|
||||
pub fn len(&self) -> usize {
|
||||
self.total_distance.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.total_distance.is_empty()
|
||||
}
|
||||
|
||||
/// `selected`: the segments and their statistics, in order.
|
||||
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
|
||||
self.global = GlobalStatistics::default();
|
||||
self.total_distance.clear();
|
||||
self.moving_distance = None;
|
||||
self.total_time = None;
|
||||
self.moving_time = None;
|
||||
self.speed = None;
|
||||
self.elevation_gain.clear();
|
||||
self.elevation_loss.clear();
|
||||
self.slope.clear();
|
||||
self.slope_segment_slope.clear();
|
||||
self.slope_segment_distance.clear();
|
||||
self.lng.clear();
|
||||
self.lat.clear();
|
||||
self.ele.clear();
|
||||
self.time = None;
|
||||
self.hr = None;
|
||||
self.cad = None;
|
||||
self.atemp = None;
|
||||
self.power = None;
|
||||
self.surface.clear();
|
||||
self.highway.clear();
|
||||
self.sac_scale.clear();
|
||||
self.mtb_scale.clear();
|
||||
|
||||
for (segment, stats) in selected {
|
||||
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
|
||||
let index = self.total_distance.len();
|
||||
let cumul_stats = &self.global;
|
||||
self.total_distance
|
||||
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
|
||||
push_optional(
|
||||
&mut self.moving_distance,
|
||||
index,
|
||||
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance),
|
||||
0.0,
|
||||
);
|
||||
push_optional(
|
||||
&mut self.total_time,
|
||||
index,
|
||||
sum_options(cumul_stats.total_time, trkpt_stats.total_time),
|
||||
0,
|
||||
);
|
||||
push_optional(
|
||||
&mut self.moving_time,
|
||||
index,
|
||||
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time),
|
||||
0,
|
||||
);
|
||||
push_optional(&mut self.speed, index, trkpt_stats.speed, f64::NAN);
|
||||
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);
|
||||
self.lng.push(trkpt.coordinates.lng);
|
||||
self.lat.push(trkpt.coordinates.lat);
|
||||
self.ele.push(trkpt.ele);
|
||||
push_optional(&mut self.time, index, trkpt.time, NO_TIME);
|
||||
push_optional(&mut self.hr, index, trkpt.hr.map(f64::from), f64::NAN);
|
||||
push_optional(&mut self.cad, index, trkpt.cad.map(f64::from), f64::NAN);
|
||||
push_optional(&mut self.atemp, index, trkpt.atemp.map(f64::from), f64::NAN);
|
||||
push_optional(&mut self.power, index, trkpt.power.map(f64::from), f64::NAN);
|
||||
self.surface.push(index, unknown_or_next(trkpt.surface));
|
||||
self.highway.push(index, unknown_or_next(trkpt.highway));
|
||||
self.sac_scale.push(index, unknown_or_next(trkpt.sac_scale));
|
||||
self.mtb_scale.push(index, unknown_or_next(trkpt.mtb_scale));
|
||||
}
|
||||
self.global.merge(&stats.global);
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics of the trackpoints from `start` to `end` (both included) of the selection, for
|
||||
/// example the part of an elevation profile the user dragged over. `None` if the range is
|
||||
/// not inside the selection.
|
||||
///
|
||||
/// It is done in constant time, from the cumulative values at both ends: the bounds and
|
||||
/// the averages are the ones of the whole selection, they are not computed for the range.
|
||||
pub fn slice(&self, start: usize, end: usize) -> Option<GlobalStatistics> {
|
||||
if start > end || end >= self.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let delta = |values: &[f64]| values[end] - values[start];
|
||||
// the total time is the span of the timestamps, which is negative where they go backwards
|
||||
let delta_time = |values: &[i64]| (values[end] - values[start]).max(0);
|
||||
let time = |i: usize| {
|
||||
let time = self.time.as_ref()?[i];
|
||||
(time != NO_TIME).then_some(time)
|
||||
};
|
||||
Some(GlobalStatistics {
|
||||
total_distance: delta(&self.total_distance),
|
||||
moving_distance: self.moving_distance.as_deref().map(delta),
|
||||
total_time: self.total_time.as_deref().map(delta_time),
|
||||
moving_time: self.moving_time.as_deref().map(delta_time),
|
||||
elevation_gain: delta(&self.elevation_gain),
|
||||
elevation_loss: delta(&self.elevation_loss),
|
||||
start_time: time(start),
|
||||
end_time: time(end),
|
||||
bounds: self.global.bounds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn computed(path: &str) -> (TrackSegment, Statistics) {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let segment = file.trk[0].trkseg[0].clone();
|
||||
let stats = Statistics::compute(&segment);
|
||||
(segment, stats)
|
||||
}
|
||||
|
||||
/// The value of the trackpoint at `index`, 0 before the first interval.
|
||||
fn value_at(intervals: &Intervals, index: usize) -> u8 {
|
||||
match intervals
|
||||
.starts
|
||||
.partition_point(|start| *start as usize <= index)
|
||||
{
|
||||
0 => 0,
|
||||
next => intervals.values[next - 1],
|
||||
}
|
||||
}
|
||||
|
||||
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
|
||||
vec![
|
||||
buffer.total_distance.len(),
|
||||
buffer.elevation_gain.len(),
|
||||
buffer.elevation_loss.len(),
|
||||
buffer.slope.len(),
|
||||
buffer.slope_segment_slope.len(),
|
||||
buffer.slope_segment_distance.len(),
|
||||
buffer.lng.len(),
|
||||
buffer.lat.len(),
|
||||
buffer.ele.len(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[]);
|
||||
assert!(buffer.is_empty());
|
||||
assert!(buffer.speed.is_none());
|
||||
assert_eq!(buffer.global.total_distance, 0.0);
|
||||
assert!(buffer.slice(0, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_segment_matches_local_stats() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
|
||||
let n = s.local.len();
|
||||
assert!(all_lengths(&buffer).iter().all(|len| *len == n));
|
||||
assert_eq!(buffer.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);
|
||||
}
|
||||
// the trackpoints
|
||||
for (i, trkpt) in segment.iter().enumerate() {
|
||||
assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
|
||||
assert_eq!(buffer.lat[i], trkpt.coordinates.lat);
|
||||
assert_eq!(buffer.ele[i], trkpt.ele);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_is_the_merge_of_the_segments() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||
|
||||
let global = &buffer.global;
|
||||
assert!((global.total_distance - 2.0 * s.global.total_distance).abs() < 1e-9);
|
||||
assert!((global.elevation_gain - 2.0 * s.global.elevation_gain).abs() < 1e-9);
|
||||
assert!((global.elevation_loss - 2.0 * s.global.elevation_loss).abs() < 1e-9);
|
||||
// and it is the last cumulative value of the points
|
||||
assert_eq!(
|
||||
global.total_distance,
|
||||
*buffer.total_distance.last().unwrap()
|
||||
);
|
||||
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
assert_eq!(buffer.global.total_distance, s.global.total_distance);
|
||||
buffer.update(&[]);
|
||||
assert_eq!(buffer.global.total_distance, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distances_accumulate_over_segments() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let n = s.local.len();
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s), (&segment, &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 (segment, s) = computed("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(&[(&segment, &s), (&segment, &s)]);
|
||||
|
||||
let total_time = buffer.total_time.as_ref().unwrap();
|
||||
assert_eq!(total_time[n - 1], duration);
|
||||
assert_eq!(total_time[n], duration);
|
||||
assert_eq!(total_time[2 * n - 1], 2 * duration);
|
||||
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
|
||||
assert!(buffer.time.unwrap().iter().all(|t| *t != NO_TIME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_absent_optional_values_have_no_buffer() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
assert!(segment.iter().all(|trkpt| trkpt.time.is_none()));
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
assert!(buffer.time.is_none());
|
||||
assert!(buffer.hr.is_none());
|
||||
assert!(buffer.cad.is_none());
|
||||
assert!(buffer.atemp.is_none());
|
||||
assert!(buffer.power.is_none());
|
||||
assert_eq!(buffer.global.total_time, None);
|
||||
assert_eq!(buffer.len(), s.local.len());
|
||||
// they need times
|
||||
assert!(buffer.speed.is_none());
|
||||
assert!(buffer.moving_distance.is_none());
|
||||
assert!(buffer.total_time.is_none());
|
||||
assert!(buffer.moving_time.is_none());
|
||||
// the slices do not need them
|
||||
let slice = buffer.slice(0, 2).unwrap();
|
||||
assert_eq!((slice.start_time, slice.end_time), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_present_optional_values_have_one_entry_per_trackpoint() {
|
||||
let (segment, s) = computed("data/with_time.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
assert_eq!(buffer.time.as_ref().unwrap().len(), buffer.len());
|
||||
assert!(buffer.hr.is_none());
|
||||
|
||||
let data = std::fs::read("data/with_hr.gpx").unwrap();
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let segment = file.trk[0].trkseg[0].clone();
|
||||
let s = Statistics::compute(&segment);
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
let hr = buffer.hr.as_ref().unwrap();
|
||||
assert_eq!(hr.len(), buffer.len());
|
||||
assert!(hr.iter().any(|v| !v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_value_missing_for_some_trackpoints_is_a_sentinel() {
|
||||
let (mut segment, _) = computed("data/with_time.gpx");
|
||||
let n = segment.len();
|
||||
let mut point = segment[1].clone();
|
||||
point.time = None;
|
||||
point.hr = Some(120);
|
||||
segment.splice(1, 2, vec![point]);
|
||||
let s = Statistics::compute(&segment);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
let time = buffer.time.as_ref().unwrap();
|
||||
assert_eq!(time.len(), n);
|
||||
assert_eq!(time[1], NO_TIME);
|
||||
assert_ne!(time[0], NO_TIME);
|
||||
// the first trackpoint has no heart rate: it is filled once the first one is found
|
||||
let hr = buffer.hr.as_ref().unwrap();
|
||||
assert_eq!(hr.len(), n);
|
||||
assert_eq!(hr[1], 120.0);
|
||||
assert!(hr[0].is_nan() && hr[2].is_nan());
|
||||
assert!(buffer.cad.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cumulative_values_are_zero_before_the_first_one() {
|
||||
let (mut segment, _) = computed("data/with_time.gpx");
|
||||
let n = segment.len();
|
||||
let mut first = segment[0].clone();
|
||||
first.time = None;
|
||||
segment.splice(0, 1, vec![first]);
|
||||
let s = Statistics::compute(&segment);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
|
||||
let total_time = buffer.total_time.as_ref().unwrap();
|
||||
assert_eq!(total_time.len(), n);
|
||||
assert_eq!(total_time[0], 0);
|
||||
assert_eq!(total_time[n - 1], s.global.total_time.unwrap());
|
||||
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
|
||||
// so a slice that starts on the first trackpoint is right
|
||||
assert_eq!(
|
||||
buffer.slice(0, n - 1).unwrap().total_time,
|
||||
s.global.total_time
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_surface_and_highway_intervals() {
|
||||
let (segment, s) = computed("data/with_highway.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
|
||||
// 0 for the trackpoints that have none, else the code of the engine plus one
|
||||
let surface = &buffer.surface;
|
||||
assert_eq!(surface.starts, [0, 2, 3]);
|
||||
assert_eq!(surface.values, [1, 0, 2]);
|
||||
let highway = &buffer.highway;
|
||||
assert_eq!(
|
||||
(&highway.starts[..], &highway.values[..]),
|
||||
(&[0, 2, 3, 4][..], &[1, 0, 2, 0][..])
|
||||
);
|
||||
let sac_scale = &buffer.sac_scale;
|
||||
assert_eq!(
|
||||
(&sac_scale.starts[..], &sac_scale.values[..]),
|
||||
(&[0, 3, 4][..], &[0, 1, 2][..])
|
||||
);
|
||||
let mtb_scale = &buffer.mtb_scale;
|
||||
assert_eq!(
|
||||
(&mtb_scale.starts[..], &mtb_scale.values[..]),
|
||||
(&[0, 3][..], &[0, 1][..])
|
||||
);
|
||||
assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
|
||||
|
||||
// and they can be looked up
|
||||
let values: Vec<u8> = (0..6).map(|i| value_at(&buffer.surface, i)).collect();
|
||||
assert_eq!(values, [1, 1, 0, 2, 2, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intervals_merge_equal_neighbours_across_segments() {
|
||||
let (mut segment, _) = computed("data/simple.gpx");
|
||||
let points: Vec<_> = segment
|
||||
.iter()
|
||||
.map(|trkpt| {
|
||||
let mut trkpt = trkpt.clone();
|
||||
trkpt.surface = Some(4);
|
||||
trkpt
|
||||
})
|
||||
.collect();
|
||||
segment.splice(0, points.len(), points);
|
||||
let s = Statistics::compute(&segment);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||
// one interval for the two segments
|
||||
assert_eq!(buffer.surface.starts, [0]);
|
||||
assert_eq!(buffer.surface.values, [5]);
|
||||
// and a single one of unknown values when nothing is known
|
||||
assert_eq!(buffer.highway.values, [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codes_of_the_trackpoints_are_used_as_they_are() {
|
||||
let (mut segment, _) = computed("data/simple.gpx");
|
||||
let mut point = segment[0].clone();
|
||||
point.surface = Some(7);
|
||||
point.highway = Some(254);
|
||||
point.sac_scale = Some(0);
|
||||
point.mtb_scale = Some(3);
|
||||
segment.splice(0, 1, vec![point]);
|
||||
let s = Statistics::compute(&segment);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
let at = |i: usize| {
|
||||
(
|
||||
value_at(&buffer.surface, i),
|
||||
value_at(&buffer.highway, i),
|
||||
value_at(&buffer.sac_scale, i),
|
||||
value_at(&buffer.mtb_scale, i),
|
||||
)
|
||||
};
|
||||
assert_eq!(at(0), (8, 255, 1, 4));
|
||||
assert_eq!(at(1), (0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_intervals_without_surface_and_highway() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
// a single interval of unknown values
|
||||
assert_eq!(buffer.surface.starts, [0]);
|
||||
assert_eq!(buffer.surface.values, [0]);
|
||||
assert_eq!(buffer.highway.starts, [0]);
|
||||
assert_eq!(buffer.highway.values, [0]);
|
||||
assert_eq!(buffer.sac_scale.values, [0]);
|
||||
assert_eq!(buffer.mtb_scale.values, [0]);
|
||||
// and they are reset by the next update
|
||||
buffer.update(&[]);
|
||||
assert_eq!(buffer.surface, Intervals::default());
|
||||
assert_eq!(buffer.highway, Intervals::default());
|
||||
assert_eq!(buffer.sac_scale, Intervals::default());
|
||||
assert_eq!(buffer.mtb_scale, Intervals::default());
|
||||
assert_eq!(value_at(&buffer.surface, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_replaces_previous_content() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
assert_eq!(buffer.total_distance.len(), s.local.len());
|
||||
assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len()));
|
||||
buffer.update(&[]);
|
||||
assert!(buffer.total_distance.is_empty());
|
||||
assert!(all_lengths(&buffer).iter().all(|len| *len == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_of_the_whole_selection_is_the_global_statistics() {
|
||||
let (segment, s) = computed("data/with_time.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||
|
||||
let slice = buffer.slice(0, buffer.len() - 1).unwrap();
|
||||
let global = &buffer.global;
|
||||
assert!((slice.total_distance - global.total_distance).abs() < 1e-9);
|
||||
assert!((slice.elevation_gain - global.elevation_gain).abs() < 1e-9);
|
||||
assert!((slice.elevation_loss - global.elevation_loss).abs() < 1e-9);
|
||||
assert_eq!(slice.total_time, global.total_time);
|
||||
assert_eq!(slice.moving_time, global.moving_time);
|
||||
assert_eq!(slice.start_time, global.start_time);
|
||||
assert_eq!(slice.end_time, global.end_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slices_of_corrupt_timestamps_are_never_negative_nor_infinite() {
|
||||
let (mut segment, _) = computed("data/with_time.gpx");
|
||||
let n = segment.len();
|
||||
assert!(n > 40);
|
||||
// timestamps that go back, then forward, and some that are missing
|
||||
let times: Vec<_> = segment.iter().map(|p| p.time.unwrap()).collect();
|
||||
segment.update_all(|i, pt| {
|
||||
pt.time = match i {
|
||||
10..=19 => Some(times[29 - i]),
|
||||
20..=24 => None,
|
||||
25..=29 => Some(times[0] - 1_000 * i as i64),
|
||||
_ => pt.time,
|
||||
};
|
||||
});
|
||||
let stats = Statistics::compute(&segment);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &stats)]);
|
||||
|
||||
for start in 0..n {
|
||||
for end in start..n {
|
||||
let slice = buffer.slice(start, end).unwrap();
|
||||
for time in [slice.total_time, slice.moving_time].into_iter().flatten() {
|
||||
assert!(time >= 0, "time of {start}..={end}");
|
||||
}
|
||||
for speed in [slice.total_speed(), slice.moving_speed()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(
|
||||
speed.is_finite() && speed >= 0.0,
|
||||
"speed of {start}..={end}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_of_a_range() {
|
||||
let (segment, s) = computed("data/with_time.gpx");
|
||||
let n = s.local.len();
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
|
||||
let (start, end) = (n / 4, n / 2);
|
||||
let slice = buffer.slice(start, end).unwrap();
|
||||
assert!(
|
||||
(slice.total_distance - (buffer.total_distance[end] - buffer.total_distance[start]))
|
||||
.abs()
|
||||
< 1e-12
|
||||
);
|
||||
assert_eq!(
|
||||
slice.total_time,
|
||||
Some(
|
||||
buffer.total_time.as_ref().unwrap()[end]
|
||||
- buffer.total_time.as_ref().unwrap()[start]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
slice.moving_time,
|
||||
Some(
|
||||
buffer.moving_time.as_ref().unwrap()[end]
|
||||
- buffer.moving_time.as_ref().unwrap()[start]
|
||||
)
|
||||
);
|
||||
assert_eq!(slice.start_time, Some(buffer.time.as_ref().unwrap()[start]));
|
||||
assert_eq!(slice.end_time, Some(buffer.time.as_ref().unwrap()[end]));
|
||||
assert!(slice.total_distance < buffer.global.total_distance);
|
||||
assert!(slice.total_speed().is_some());
|
||||
|
||||
// a single point has nothing between its ends
|
||||
let point = buffer.slice(start, start).unwrap();
|
||||
assert_eq!(point.total_distance, 0.0);
|
||||
assert_eq!(point.total_time, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_keeps_the_bounds_of_the_selection() {
|
||||
let data = std::fs::read("data/with_hr.gpx").unwrap();
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let segment = file.trk[0].trkseg[0].clone();
|
||||
let s = Statistics::compute(&segment);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
|
||||
let slice = buffer.slice(1, 3).unwrap();
|
||||
let (global, bounds) = (&buffer.global, &slice.bounds);
|
||||
assert_eq!(
|
||||
(bounds.sw.lng, bounds.sw.lat),
|
||||
(global.bounds.sw.lng, global.bounds.sw.lat)
|
||||
);
|
||||
assert_eq!(
|
||||
(bounds.ne.lng, bounds.ne.lat),
|
||||
(global.bounds.ne.lng, global.bounds.ne.lat)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_durations_are_not_limited_to_24_days() {
|
||||
// 30 days of recording, in milliseconds
|
||||
let (segment, mut s) = computed("data/with_time.gpx");
|
||||
let month = 30 * 24 * 3_600_000_i64;
|
||||
s.global.total_time = Some(month);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||
assert_eq!(buffer.global.total_time, Some(2 * month));
|
||||
assert!(2 * month > i64::from(i32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_across_segments() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let n = s.local.len();
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||
|
||||
// from the middle of the first segment to the middle of the second one
|
||||
let slice = buffer.slice(n / 2, n + n / 2).unwrap();
|
||||
let expected = buffer.total_distance[n + n / 2] - buffer.total_distance[n / 2];
|
||||
assert!((slice.total_distance - expected).abs() < 1e-9);
|
||||
assert_eq!(slice.total_time, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_out_of_range() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
let n = buffer.len();
|
||||
assert!(buffer.slice(0, n).is_none());
|
||||
assert!(buffer.slice(n, n).is_none());
|
||||
assert!(buffer.slice(5, 4).is_none());
|
||||
assert!(buffer.slice(0, n - 1).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
use std::collections::{HashMap, HashSet, hash_map::Entry};
|
||||
|
||||
use crate::{
|
||||
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
|
||||
TrackSegmentRevisionId,
|
||||
};
|
||||
|
||||
/// A segment and its statistics.
|
||||
pub type SelectedSegment<'a> = (&'a TrackSegment, &'a 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics of the segments of the selection.
|
||||
pub fn get<'a>(
|
||||
&'a self,
|
||||
state: Option<&'a StackEntry>,
|
||||
selection: &Selection,
|
||||
) -> Vec<&'a Statistics> {
|
||||
self.selected(state, selection, &[])
|
||||
.into_iter()
|
||||
.map(|(_, stats)| stats)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The segments of the selection with their statistics. The segments of several selected
|
||||
/// files follow `order` (the files missing from it come last).
|
||||
pub fn selected<'a>(
|
||||
&'a self,
|
||||
state: Option<&'a StackEntry>,
|
||||
selection: &Selection,
|
||||
order: &[FileId],
|
||||
) -> Vec<SelectedSegment<'a>> {
|
||||
let mut selected = vec![];
|
||||
if let Some(state) = state {
|
||||
match selection {
|
||||
Selection::Empty => (),
|
||||
Selection::File { file_ids } => {
|
||||
let ordered = order
|
||||
.iter()
|
||||
.filter(|id| file_ids.contains(id))
|
||||
.chain(file_ids.iter().filter(|id| !order.contains(id)));
|
||||
for id in ordered {
|
||||
if let Some(file) = state.get(id) {
|
||||
self.add_file_stats(file, &mut selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 selected);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Selection::Waypoints { file_id } => {
|
||||
if let Some(file) = state.get(file_id) {
|
||||
self.add_file_stats(file, &mut selected);
|
||||
}
|
||||
}
|
||||
Selection::Waypoint {
|
||||
file_id,
|
||||
wpt_ids: _,
|
||||
} => {
|
||||
if let Some(file) = state.get(file_id) {
|
||||
self.add_file_stats(file, &mut selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
/// Global statistics of a file: its segments merged, in order.
|
||||
pub fn file_global(&self, file: &File) -> GlobalStatistics {
|
||||
let mut selected = vec![];
|
||||
self.add_file_stats(file, &mut selected);
|
||||
let mut global = GlobalStatistics::default();
|
||||
for (_, stats) in selected {
|
||||
global.merge(&stats.global);
|
||||
}
|
||||
global
|
||||
}
|
||||
|
||||
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
|
||||
for trk in file.trk.iter() {
|
||||
self.add_track_stats(trk, selected);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_track_stats<'a>(&'a self, trk: &'a Track, selected: &mut Vec<SelectedSegment<'a>>) {
|
||||
for trkseg in trk.trkseg.iter() {
|
||||
self.add_segment_stats(trkseg, selected);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_segment_stats<'a>(
|
||||
&'a self,
|
||||
trkseg: &'a TrackSegment,
|
||||
selected: &mut Vec<SelectedSegment<'a>>,
|
||||
) {
|
||||
if let Some(stats) = self.map.get(&trkseg.rev_id) {
|
||||
selected.push((trkseg, stats));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
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, &mut Default::default()).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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_segments_of_several_files_follow_the_given_order() {
|
||||
let (mut state, first) = state("data/simple.gpx");
|
||||
let (other, second) = {
|
||||
let data = std::fs::read("data/with_time.gpx").unwrap();
|
||||
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
|
||||
(file.clone(), file)
|
||||
};
|
||||
state.insert(other.id, other);
|
||||
let mut cache = StatisticsCache::default();
|
||||
cache.update(Some(&state));
|
||||
let selection = Selection::File {
|
||||
file_ids: HashSet::from([first.id, second.id]),
|
||||
};
|
||||
|
||||
let ids = |order: &[FileId]| -> Vec<_> {
|
||||
cache
|
||||
.selected(Some(&state), &selection, order)
|
||||
.iter()
|
||||
.map(|(segment, _)| segment.id)
|
||||
.collect()
|
||||
};
|
||||
let (a, b) = (first.trk[0].trkseg[0].id, second.trk[0].trkseg[0].id);
|
||||
assert_eq!(ids(&[first.id, second.id]), vec![a, b]);
|
||||
assert_eq!(ids(&[second.id, first.id]), vec![b, a]);
|
||||
// the files that are not in the order come last
|
||||
assert_eq!(ids(&[second.id]), vec![b, a]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
mod action;
|
||||
mod command;
|
||||
mod derived;
|
||||
mod outcome;
|
||||
mod state;
|
||||
|
||||
pub use action::*;
|
||||
pub use command::*;
|
||||
pub use derived::*;
|
||||
pub use outcome::*;
|
||||
pub use state::*;
|
||||
|
||||
use crate::{
|
||||
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint,
|
||||
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Engine {
|
||||
stack: Stack,
|
||||
selection: Selection,
|
||||
order: FileOrder,
|
||||
statistics_cache: StatisticsCache,
|
||||
coordinates_cache: CoordinatesCache,
|
||||
structure_cache: FileStructureCache,
|
||||
statistics_buffer: StatisticsBuffer,
|
||||
routing_buffer: RoutingBuffer,
|
||||
categories: TrackpointCategories,
|
||||
clipboard: Option<Clipboard>,
|
||||
/// What the tests of the last action read, see `Engine::run`.
|
||||
#[cfg(test)]
|
||||
last: Outcome,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn statistics(&self) -> &StatisticsBuffer {
|
||||
&self.statistics_buffer
|
||||
}
|
||||
|
||||
/// The anchors of the routing tool among the trackpoints of the selection.
|
||||
pub fn routing(&self) -> &RoutingBuffer {
|
||||
&self.routing_buffer
|
||||
}
|
||||
|
||||
/// The names of the surfaces and highways that the trackpoints refer to by code.
|
||||
pub fn categories(&self) -> &TrackpointCategories {
|
||||
&self.categories
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Global statistics (distance, time, elevation, bounds...) of a file.
|
||||
pub fn file_statistics(&self, id: &FileId) -> Option<GlobalStatistics> {
|
||||
let file = self.stack.current()?.get(id)?;
|
||||
Some(self.statistics_cache.file_global(file))
|
||||
}
|
||||
|
||||
/// What was copied or cut and is waiting to be pasted.
|
||||
pub fn clipboard(&self) -> Option<&Clipboard> {
|
||||
self.clipboard.as_ref()
|
||||
}
|
||||
|
||||
/// Whether the clipboard can be pasted with the current selection.
|
||||
pub fn can_paste(&self) -> bool {
|
||||
self.clipboard
|
||||
.as_ref()
|
||||
.is_some_and(|clipboard| clipboard.can_paste(&self.selection))
|
||||
}
|
||||
|
||||
/// A waypoint of a file, with all its data. `None` if it does not exist.
|
||||
pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> {
|
||||
self.stack
|
||||
.current()?
|
||||
.get(file_id)?
|
||||
.wpt
|
||||
.iter()
|
||||
.find(|wpt| wpt.id == *id)
|
||||
}
|
||||
|
||||
/// A trackpoint of a segment of a file. `None` if it does not exist.
|
||||
pub fn trackpoint(
|
||||
&self,
|
||||
file_id: &FileId,
|
||||
segment_id: &TrackSegmentId,
|
||||
index: usize,
|
||||
) -> Option<&Trackpoint> {
|
||||
let segment = self
|
||||
.stack
|
||||
.current()?
|
||||
.get(file_id)?
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|trk| &trk.trkseg)
|
||||
.find(|seg| seg.id == *segment_id)?;
|
||||
(index < segment.len()).then(|| &segment[index])
|
||||
}
|
||||
|
||||
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
|
||||
/// which `Reduce` keeps it, see [`reduction_distances`].
|
||||
pub fn reduction_distances(&self) -> Vec<f64> {
|
||||
let Some(files) = self.stack.current() else {
|
||||
return vec![];
|
||||
};
|
||||
self.selection
|
||||
.segment_locations(files, &self.order.0)
|
||||
.iter()
|
||||
.flat_map(|location| {
|
||||
reduction_distances(
|
||||
&files[&location.file_id].trk[location.trk].trkseg[location.seg],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What there is to keep of the engine, see [`crate::Persister`].
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
files: self.stack.current().cloned().unwrap_or_default(),
|
||||
order: self.order.0.clone(),
|
||||
categories: self.categories.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts files in an engine that has none, typically the ones read from the storage. They are
|
||||
/// the state to start from: they cannot be undone. The files are reported as added, and the
|
||||
/// order, the selection and the clipboard as changed.
|
||||
pub fn restore(&mut self, restored: Restored) -> Outcome {
|
||||
let files: StackEntry = restored
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|file| (file.id, file))
|
||||
.collect();
|
||||
self.categories = restored.categories;
|
||||
self.order = FileOrder(restored.order);
|
||||
self.selection = Selection::Empty;
|
||||
self.clipboard = None;
|
||||
let mut diff = Some(Diff {
|
||||
added: files.keys().copied().collect(),
|
||||
..Default::default()
|
||||
});
|
||||
self.stack = Stack::restored(files);
|
||||
self.refresh(diff.as_ref());
|
||||
sort_diff(&mut diff, &self.order.0);
|
||||
let outcome = Outcome {
|
||||
changed: true,
|
||||
diff,
|
||||
order_changed: true,
|
||||
selection_changed: true,
|
||||
clipboard_changed: true,
|
||||
error: None,
|
||||
};
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.last = outcome.clone();
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// A file as GPX (UTF-8), `None` if it does not exist.
|
||||
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
|
||||
let file = self.stack.current()?.get(id)?;
|
||||
Some(write(file, &self.categories, options))
|
||||
}
|
||||
|
||||
/// The data that the given files have, which the options of `export` can leave out.
|
||||
pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions {
|
||||
let Some(files) = self.stack.current() else {
|
||||
return ExportOptions::NONE;
|
||||
};
|
||||
ids.iter()
|
||||
.filter_map(|id| files.get(id))
|
||||
.fold(ExportOptions::NONE, |data, file| {
|
||||
data.union(file.exportable_data())
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether there is something to undo.
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.stack.can_undo()
|
||||
}
|
||||
|
||||
/// Whether there is something to redo.
|
||||
pub fn can_redo(&self) -> bool {
|
||||
self.stack.can_redo()
|
||||
}
|
||||
|
||||
/// Currently selected elements.
|
||||
pub fn selection(&self) -> &Selection {
|
||||
&self.selection
|
||||
}
|
||||
|
||||
/// 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. Says what it changed.
|
||||
pub fn execute(&mut self, action: Action) -> Outcome {
|
||||
let selection_before = self.selection.clone();
|
||||
let order_before = self.order.0.clone();
|
||||
let clipboard_before = self.clipboard.clone();
|
||||
let is_edit = matches!(action, Action::Edit(_));
|
||||
let mut error = None;
|
||||
// the files change iff there is a diff, but selection and order actions have none
|
||||
let mut diff = match action {
|
||||
Action::Edit(command) => self.edit(command).unwrap_or_else(|err| {
|
||||
error = Some(err);
|
||||
None
|
||||
}),
|
||||
Action::Undo => self.stack.undo(),
|
||||
Action::Redo => self.stack.redo(),
|
||||
Action::Select { selection, mode } => {
|
||||
self.selection.select(self.stack.current(), selection, mode);
|
||||
None
|
||||
}
|
||||
Action::SelectAll => {
|
||||
if let Some(files) = self.stack.current() {
|
||||
self.selection.select_all(files, &self.order.0);
|
||||
}
|
||||
None
|
||||
}
|
||||
Action::ArrowSelect { down, add } => {
|
||||
if let Some(files) = self.stack.current() {
|
||||
self.selection
|
||||
.select_neighbour(files, &self.order.0, down, add);
|
||||
}
|
||||
None
|
||||
}
|
||||
Action::Copy => {
|
||||
self.copy(false);
|
||||
None
|
||||
}
|
||||
Action::Cut => {
|
||||
self.copy(true);
|
||||
None
|
||||
}
|
||||
Action::Reorder { file_ids, index } => {
|
||||
self.order.move_files(&file_ids, index);
|
||||
None
|
||||
}
|
||||
};
|
||||
let changed = diff.is_some()
|
||||
|| self.selection != selection_before
|
||||
|| self.order.0 != order_before
|
||||
|| self.clipboard != clipboard_before;
|
||||
if is_edit && !changed && error.is_none() {
|
||||
error = Some(CommandError::NothingToDo);
|
||||
}
|
||||
if changed {
|
||||
self.refresh(diff.as_ref());
|
||||
sort_diff(&mut diff, &self.order.0);
|
||||
}
|
||||
// after the refresh, which also syncs the order and selection with the files (undo, redo)
|
||||
let outcome = Outcome {
|
||||
changed,
|
||||
diff,
|
||||
order_changed: self.order.0 != order_before,
|
||||
selection_changed: self.selection != selection_before,
|
||||
clipboard_changed: self.clipboard != clipboard_before,
|
||||
error,
|
||||
};
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.last = outcome.clone();
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Puts the selection in the clipboard, if there is one.
|
||||
fn copy(&mut self, cut: bool) {
|
||||
if let Some(files) = self.stack.current()
|
||||
&& let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut)
|
||||
{
|
||||
self.clipboard = Some(clipboard);
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(&mut self, command: Command) -> Result<Option<Diff>, CommandError> {
|
||||
self.stack.create_and_push_next(|files| {
|
||||
let mut state = State {
|
||||
categories: &mut self.categories,
|
||||
files,
|
||||
selection: &mut self.selection,
|
||||
order: &mut self.order,
|
||||
clipboard: &mut self.clipboard,
|
||||
};
|
||||
command.apply(&mut state)
|
||||
})
|
||||
}
|
||||
|
||||
/// Brings everything derived from the files back in line with the current stack entry.
|
||||
fn refresh(&mut self, diff: Option<&Diff>) {
|
||||
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, diff);
|
||||
self.routing_buffer
|
||||
.update(current, &self.selection, &self.order.0);
|
||||
self.statistics_buffer
|
||||
.update(
|
||||
&self
|
||||
.statistics_cache
|
||||
.selected(current, &self.selection, &self.order.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the files of a diff in the order of the files (the stack does not know it), so that
|
||||
/// whatever is done with them one after the other, like giving them colors, follows it.
|
||||
fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
|
||||
let position = |id: &FileId| order.iter().position(|other| other == id);
|
||||
if let Some(diff) = diff {
|
||||
diff.added.sort_by_key(position);
|
||||
diff.modified.sort_by_key(position);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Engine {
|
||||
/// Executes an action and tells whether it changed anything. What it did is kept for the
|
||||
/// accessors below, which the tests read right after.
|
||||
pub(crate) fn run(&mut self, action: Action) -> bool {
|
||||
self.execute(action).changed
|
||||
}
|
||||
|
||||
pub(crate) fn last_diff(&self) -> Option<&Diff> {
|
||||
self.last.diff.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn last_error(&self) -> Option<&CommandError> {
|
||||
self.last.error.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn order_changed(&self) -> bool {
|
||||
self.last.order_changed
|
||||
}
|
||||
|
||||
pub(crate) fn selection_changed(&self) -> bool {
|
||||
self.last.selection_changed
|
||||
}
|
||||
|
||||
pub(crate) fn clipboard_changed(&self) -> bool {
|
||||
self.last.clipboard_changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user