mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 10:37:17 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
|
||||
Generated
+396
@@ -11,6 +11,26 @@ 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"
|
||||
@@ -23,6 +43,18 @@ 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"
|
||||
@@ -52,6 +84,15 @@ dependencies = [
|
||||
"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"
|
||||
@@ -68,6 +109,30 @@ 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"
|
||||
@@ -114,7 +179,9 @@ name = "gpx-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"postcard",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -124,9 +191,41 @@ 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]]
|
||||
@@ -153,6 +252,37 @@ 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"
|
||||
@@ -170,6 +300,21 @@ 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"
|
||||
@@ -182,6 +327,25 @@ 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"
|
||||
@@ -189,6 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -197,12 +362,31 @@ 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"
|
||||
@@ -236,12 +420,85 @@ 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"
|
||||
@@ -254,6 +511,21 @@ 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"
|
||||
@@ -276,6 +548,35 @@ dependencies = [
|
||||
"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"
|
||||
@@ -290,9 +591,20 @@ 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"
|
||||
@@ -306,6 +618,17 @@ dependencies = [
|
||||
"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"
|
||||
@@ -338,6 +661,64 @@ 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"
|
||||
@@ -396,3 +777,18 @@ 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"
|
||||
@@ -1,3 +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
|
||||
```
|
||||
@@ -5,5 +5,7 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.45"
|
||||
postcard = { version = "1.1.3", features = ["use-std"] }
|
||||
quick-xml = "0.42.0"
|
||||
uuid = { version = "1.26.1", features = ["v4"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
uuid = { version = "1.26.1", features = ["serde", "v4"] }
|
||||
@@ -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>
|
||||
@@ -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,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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +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)]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
core::gpx::{TrackSegment, TrackSegmentIndex},
|
||||
core::utils::crossarc,
|
||||
core::utils::{crossarc, crossarc_lnglat},
|
||||
};
|
||||
|
||||
pub fn ramer_douglas_peucker<F>(
|
||||
@@ -10,47 +10,67 @@ pub fn ramer_douglas_peucker<F>(
|
||||
) -> 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()],
|
||||
2 => vec![trkseg.first_index().unwrap(), trkseg.last_index().unwrap()],
|
||||
1 => vec![(trkseg.first_index().unwrap(), None)],
|
||||
2 => vec![
|
||||
(trkseg.first_index().unwrap(), None),
|
||||
(trkseg.last_index().unwrap(), None),
|
||||
],
|
||||
_ => {
|
||||
let first = trkseg.first_index().unwrap();
|
||||
let last = trkseg.last_index().unwrap();
|
||||
|
||||
let mut indices = vec![first];
|
||||
ramer_douglas_peucker_helper(trkseg, first, last, mapping, epsilon, &mut indices);
|
||||
indices.push(last);
|
||||
indices
|
||||
let mut kept = vec![(first, None)];
|
||||
ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept);
|
||||
kept.push((last, None));
|
||||
kept
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ramer_douglas_peucker_helper<F>(
|
||||
fn ramer_douglas_peucker_helper<M>(
|
||||
trkseg: &TrackSegment,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
mapping: &F,
|
||||
measure: &M,
|
||||
epsilon: f64,
|
||||
indices: &mut Vec<TrackSegmentIndex>,
|
||||
kept: &mut Vec<(TrackSegmentIndex, Option<f64>)>,
|
||||
) where
|
||||
F: Fn(TrackSegmentIndex) -> (f64, f64),
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
let mut max_idx = None;
|
||||
let mut max_dist = 0.0;
|
||||
|
||||
let start_pt = mapping(start);
|
||||
let end_pt = mapping(end);
|
||||
|
||||
let mut cur = trkseg.next_index(Some(start));
|
||||
while let Some(idx) = cur {
|
||||
if idx == end {
|
||||
break;
|
||||
}
|
||||
|
||||
let pt = mapping(idx);
|
||||
let dist = crossarc(start_pt.0, start_pt.1, end_pt.0, end_pt.1, pt.0, pt.1);
|
||||
let dist = measure(start, end, idx);
|
||||
if dist > max_dist {
|
||||
max_idx = Some(idx);
|
||||
max_dist = dist;
|
||||
@@ -60,9 +80,88 @@ fn ramer_douglas_peucker_helper<F>(
|
||||
}
|
||||
|
||||
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
|
||||
ramer_douglas_peucker_helper(trkseg, start, idx, mapping, epsilon, indices);
|
||||
indices.push(idx);
|
||||
ramer_douglas_peucker_helper(trkseg, idx, end, mapping, epsilon, indices);
|
||||
ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
|
||||
kept.push((idx, Some(max_dist)));
|
||||
ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,13 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct TrackpointChunkId(Uuid);
|
||||
|
||||
impl Default for TrackpointChunkId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
uuid_id!(TrackpointChunkId);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TrackpointChunk {
|
||||
@@ -19,26 +38,31 @@ pub struct TrackpointChunk {
|
||||
pub trkpt: Vec<Trackpoint>,
|
||||
}
|
||||
|
||||
impl PartialEq for TrackpointChunk {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackpointChunk {
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.trkpt.len() == MAX_TRKPT_CHUNK_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WaypointChunkId(Uuid);
|
||||
|
||||
impl Default for WaypointChunkId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
uuid_id!(WaypointChunkId);
|
||||
|
||||
const MAX_WPT_CHUNK_SIZE: usize = 128;
|
||||
|
||||
@@ -48,15 +72,27 @@ pub struct WaypointChunk {
|
||||
pub wpt: Vec<Waypoint>,
|
||||
}
|
||||
|
||||
impl PartialEq for WaypointChunk {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
impl Chunk for WaypointChunk {
|
||||
type Item = Waypoint;
|
||||
const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
|
||||
|
||||
impl WaypointChunk {
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.wpt.len() == 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +123,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_equality_is_by_id() {
|
||||
let a = TrackpointChunk::default();
|
||||
let b = TrackpointChunk::default();
|
||||
assert_ne!(a, b);
|
||||
let c = TrackpointChunk {
|
||||
id: TrackpointChunkId(a.id.0),
|
||||
trkpt: vec![Trackpoint::default()],
|
||||
};
|
||||
assert_eq!(a, c);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
/// 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,
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
use std::rc::Rc;
|
||||
use crate::{Link, Track, Waypoints};
|
||||
|
||||
use uuid::Uuid;
|
||||
use super::common::uuid_id;
|
||||
|
||||
use crate::{Link, Track, WaypointChunk};
|
||||
uuid_id!(FileId);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct FileId(pub Uuid);
|
||||
|
||||
impl Default for FileId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct FileWaypointsRevisionId(pub Uuid);
|
||||
|
||||
impl Default for FileWaypointsRevisionId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct File {
|
||||
pub id: FileId,
|
||||
pub info: FileInfo,
|
||||
pub trk: Vec<Track>,
|
||||
pub wpt: Vec<Rc<WaypointChunk>>,
|
||||
pub wpt_rev_id: FileWaypointsRevisionId,
|
||||
// TODO routes
|
||||
pub wpt: Waypoints,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
@@ -37,13 +17,15 @@ pub struct FileInfo {
|
||||
pub name: String,
|
||||
pub desc: Option<String>,
|
||||
pub author: Option<Author>,
|
||||
pub link: Option<Link>,
|
||||
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>,
|
||||
}
|
||||
@@ -1,17 +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::*;
|
||||
@@ -1,249 +1,112 @@
|
||||
use std::{ops::Index, rc::Rc};
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
|
||||
use uuid::Uuid;
|
||||
use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors};
|
||||
|
||||
use crate::{Trackpoint, TrackpointChunk};
|
||||
use super::common::uuid_id;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TrackSegmentId(pub Uuid);
|
||||
uuid_id!(TrackSegmentId);
|
||||
|
||||
impl Default for TrackSegmentId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
uuid_id!(TrackSegmentRevisionId);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TrackSegmentRevisionId(pub Uuid);
|
||||
|
||||
impl Default for TrackSegmentRevisionId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TrackSegment {
|
||||
pub id: TrackSegmentId,
|
||||
pub rev_id: TrackSegmentRevisionId,
|
||||
chunks: Vec<Rc<TrackpointChunk>>,
|
||||
cumul_length: Vec<usize>,
|
||||
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.push_shared(Rc::new(chunk));
|
||||
self.points.push(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
|
||||
if chunk.trkpt.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.cumul_length
|
||||
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.trkpt.len());
|
||||
self.chunks.push(chunk);
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// Replaces the points in `start..end` by `points`. Panics if the range is out of bounds.
|
||||
///
|
||||
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the
|
||||
/// range are copied and refilled.
|
||||
/// 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>) {
|
||||
assert!(
|
||||
start <= end && end <= self.len(),
|
||||
"splice range out of bounds"
|
||||
);
|
||||
let old = std::mem::take(&mut self.chunks);
|
||||
self.cumul_length.clear();
|
||||
|
||||
let mut pending = TrackpointChunk::default();
|
||||
let mut inserted = false;
|
||||
let mut offset = 0;
|
||||
for chunk in old {
|
||||
let (lo, hi) = (offset, offset + chunk.trkpt.len());
|
||||
offset = hi;
|
||||
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
|
||||
if hi < start || (hi == start && chunk.is_full()) {
|
||||
self.push_shared(chunk);
|
||||
continue;
|
||||
}
|
||||
if !inserted {
|
||||
self.fill(&mut pending, chunk.trkpt[..start - lo].iter().cloned());
|
||||
self.fill(&mut pending, points.iter().cloned());
|
||||
inserted = true;
|
||||
}
|
||||
if hi <= end {
|
||||
continue;
|
||||
}
|
||||
if lo >= end {
|
||||
self.flush(&mut pending);
|
||||
self.push_shared(chunk);
|
||||
} else {
|
||||
self.fill(&mut pending, chunk.trkpt[end - lo..].iter().cloned());
|
||||
}
|
||||
}
|
||||
if !inserted {
|
||||
self.fill(&mut pending, points);
|
||||
}
|
||||
self.flush(&mut pending);
|
||||
self.points.splice(start, end, points);
|
||||
self.rev_id = Default::default();
|
||||
self.ensure_end_anchors();
|
||||
}
|
||||
|
||||
fn fill(
|
||||
&mut self,
|
||||
pending: &mut TrackpointChunk,
|
||||
points: impl IntoIterator<Item = Trackpoint>,
|
||||
) {
|
||||
for trkpt in points {
|
||||
pending.trkpt.push(trkpt);
|
||||
if pending.is_full() {
|
||||
self.flush(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self, pending: &mut TrackpointChunk) {
|
||||
self.push(std::mem::take(pending));
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.cumul_length.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.chunks.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> TrackSegmentIterator<'_> {
|
||||
TrackSegmentIterator::new(self)
|
||||
}
|
||||
|
||||
pub fn first_index(&self) -> Option<TrackSegmentIndex> {
|
||||
self.next_index(None)
|
||||
}
|
||||
|
||||
pub fn last_index(&self) -> Option<TrackSegmentIndex> {
|
||||
self.prev_index(None)
|
||||
}
|
||||
|
||||
pub fn next_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
|
||||
let mut next = cur.map_or_default(|idx| TrackSegmentIndex {
|
||||
chunk: idx.chunk,
|
||||
pos: idx.pos + 1,
|
||||
flat: idx.flat + 1,
|
||||
});
|
||||
loop {
|
||||
if next.chunk >= self.chunks.len() {
|
||||
return None;
|
||||
}
|
||||
if next.pos == self.chunks[next.chunk].trkpt.len() {
|
||||
next.chunk += 1;
|
||||
next.pos = 0;
|
||||
} else {
|
||||
return Some(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
|
||||
let mut prev = cur.unwrap_or(TrackSegmentIndex {
|
||||
chunk: self.chunks.len(),
|
||||
pos: 0,
|
||||
flat: self.cumul_length.last().copied().unwrap_or_default(),
|
||||
});
|
||||
if prev.pos == 0 {
|
||||
while prev.chunk > 0 {
|
||||
prev.chunk -= 1;
|
||||
if !self.chunks[prev.chunk].trkpt.is_empty() {
|
||||
prev.pos = self.chunks[prev.chunk].trkpt.len() - 1;
|
||||
prev.flat -= 1;
|
||||
return Some(prev);
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
prev.pos -= 1;
|
||||
prev.flat -= 1;
|
||||
Some(prev)
|
||||
}
|
||||
}
|
||||
|
||||
fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> {
|
||||
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
|
||||
if chunk >= self.chunks.len() {
|
||||
return None;
|
||||
}
|
||||
let pos = if chunk > 0 {
|
||||
idx - self.cumul_length[chunk - 1]
|
||||
} else {
|
||||
idx
|
||||
/// 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;
|
||||
};
|
||||
if pos >= self.chunks[chunk].trkpt.len() {
|
||||
None
|
||||
} else {
|
||||
Some(TrackSegmentIndex {
|
||||
chunk,
|
||||
pos,
|
||||
flat: idx,
|
||||
})
|
||||
for index in [0, last] {
|
||||
if self[index].anchor != Some(0) {
|
||||
self.set_anchor(index, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<TrackSegmentIndex> for TrackSegment {
|
||||
type Output = Trackpoint;
|
||||
|
||||
fn index(&self, idx: TrackSegmentIndex) -> &Self::Output {
|
||||
&self.chunks[idx.chunk].trkpt[idx.pos]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<usize> for TrackSegment {
|
||||
type Output = Trackpoint;
|
||||
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
&self[self.locate(idx).unwrap()]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
|
||||
pub struct TrackSegmentIndex {
|
||||
pub chunk: usize,
|
||||
pub pos: usize,
|
||||
pub flat: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackSegmentIterator<'a> {
|
||||
trkseg: &'a TrackSegment,
|
||||
idx: Option<TrackSegmentIndex>,
|
||||
}
|
||||
|
||||
impl<'a> TrackSegmentIterator<'a> {
|
||||
pub fn new(trkseg: &'a TrackSegment) -> Self {
|
||||
Self {
|
||||
trkseg,
|
||||
idx: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for TrackSegmentIterator<'a> {
|
||||
type Item = &'a Trackpoint;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.idx = self.trkseg.next_index(self.idx);
|
||||
self.idx.map(|idx| &self.trkseg[idx])
|
||||
/// 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));
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
let idx = self.idx.map_or_default(|idx| idx.flat) + n;
|
||||
self.idx = self.trkseg.locate(idx);
|
||||
self.idx.map(|idx| &self.trkseg[idx])
|
||||
/// 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 {
|
||||
@@ -309,17 +172,66 @@ mod tests {
|
||||
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);
|
||||
let before = trkseg.chunks.clone();
|
||||
// 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()[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],
|
||||
&trkseg.chunks()[trkseg.chunks().len() - 2],
|
||||
&before[3]
|
||||
));
|
||||
}
|
||||
@@ -332,10 +244,10 @@ mod tests {
|
||||
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!(trkseg.chunks().len() <= 3);
|
||||
assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096));
|
||||
assert_eq!(trkseg[9_999].ele, 9_999.0);
|
||||
assert_eq!(*trkseg.cumul_length.last().unwrap(), 10_000);
|
||||
assert_eq!(trkseg.len(), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -381,7 +293,7 @@ mod tests {
|
||||
let nb_chunks = 10;
|
||||
let trkseg = create_track_segment(nb_chunks);
|
||||
let _ = trkseg[TrackSegmentIndex {
|
||||
chunk: trkseg.chunks.len(),
|
||||
chunk: trkseg.chunks().len(),
|
||||
pos: 0,
|
||||
flat: 0,
|
||||
}];
|
||||
@@ -451,4 +363,49 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Link, TrackSegment};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TrackId(pub Uuid);
|
||||
use super::common::uuid_id;
|
||||
|
||||
impl Default for TrackId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
uuid_id!(TrackId);
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Track {
|
||||
pub id: TrackId,
|
||||
pub info: TrackInfo,
|
||||
@@ -24,7 +17,7 @@ pub struct TrackInfo {
|
||||
pub cmt: Option<String>,
|
||||
pub desc: Option<String>,
|
||||
pub src: Option<String>,
|
||||
pub link: Option<Link>,
|
||||
pub links: Vec<Link>,
|
||||
pub type_: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub opacity: Option<f64>,
|
||||
|
||||
@@ -17,4 +17,8 @@ pub struct Trackpoint {
|
||||
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>,
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Link, LngLat};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct WaypointId(pub Uuid);
|
||||
use super::common::uuid_id;
|
||||
|
||||
impl Default for WaypointId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
uuid_id!(WaypointId);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Waypoint {
|
||||
@@ -20,7 +13,23 @@ pub struct Waypoint {
|
||||
pub name: Option<String>,
|
||||
pub cmt: Option<String>,
|
||||
pub desc: Option<String>,
|
||||
pub link: Option<Link>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod parse;
|
||||
mod write;
|
||||
|
||||
pub use parse::*;
|
||||
pub use write::*;
|
||||
+544
-237
@@ -1,11 +1,10 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
|
||||
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
|
||||
TrackpointChunk, Waypoint, WaypointChunk,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use quick_xml::Error;
|
||||
use quick_xml::escape::resolve_predefined_entity;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::events::attributes::Attributes;
|
||||
use quick_xml::reader::Reader;
|
||||
@@ -22,6 +21,7 @@ enum GPXElement {
|
||||
Track(Track),
|
||||
Segment(TrackSegment),
|
||||
Trackpoint(Trackpoint),
|
||||
RoutePoint(LngLat),
|
||||
Waypoint(Waypoint),
|
||||
Elevation,
|
||||
Time,
|
||||
@@ -52,6 +52,254 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
|
||||
coordinates
|
||||
}
|
||||
|
||||
/// Whether the element is a point of the detailed path of a route point (`gpxx:rpt`).
|
||||
fn is_route_point_extension(name: &str) -> bool {
|
||||
name == "rpt" || name.ends_with(":rpt")
|
||||
}
|
||||
|
||||
/// Whether the element holds a value, which is its text.
|
||||
fn holds_text(element: Option<&GPXElement>) -> bool {
|
||||
matches!(
|
||||
element,
|
||||
Some(
|
||||
GPXElement::Name
|
||||
| GPXElement::Comment
|
||||
| GPXElement::Description
|
||||
| GPXElement::Source
|
||||
| GPXElement::Text
|
||||
| GPXElement::Elevation
|
||||
| GPXElement::Time
|
||||
| GPXElement::Temperature
|
||||
| GPXElement::Heartrate
|
||||
| GPXElement::Cadence
|
||||
| GPXElement::Power
|
||||
| GPXElement::Surface
|
||||
| GPXElement::Highway
|
||||
| GPXElement::SacScale
|
||||
| GPXElement::MtbScale
|
||||
| GPXElement::Symbol
|
||||
| GPXElement::Type
|
||||
| GPXElement::Color
|
||||
| GPXElement::Opacity
|
||||
| GPXElement::Width
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Gives the text `e` of the element at the top of the stack, which is removed, to what holds it.
|
||||
fn apply_text(
|
||||
stack: &mut Vec<GPXElement>,
|
||||
gpx: &mut File,
|
||||
categories: &mut TrackpointCategories,
|
||||
e: &str,
|
||||
) {
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Name) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Metadata) => {
|
||||
gpx.info.name = e.to_owned();
|
||||
}
|
||||
Some(GPXElement::Author(author)) => {
|
||||
author.name = Some(e.to_owned());
|
||||
}
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.name = Some(e.to_owned());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.name = Some(e.to_owned());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Comment) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.cmt = Some(e.to_owned());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.cmt = Some(e.to_owned());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Description) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Metadata) => {
|
||||
gpx.info.desc = Some(e.to_owned());
|
||||
}
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.desc = Some(e.to_owned());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.desc = Some(e.to_owned());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Source) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.src = Some(e.to_owned());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Text) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Link(link)) = stack.last_mut() {
|
||||
link.text = Some(e.to_owned());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Elevation) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Trackpoint(trkpt)) => {
|
||||
trkpt.ele = e.parse().unwrap_or_default();
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.ele = e.parse().unwrap_or_default();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Time) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.time = DateTime::parse_from_rfc3339(e)
|
||||
.ok()
|
||||
.map(|time| time.timestamp_millis());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Temperature) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.atemp = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Heartrate) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.hr = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Cadence) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.cad = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Power) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.power = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Surface) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.surface = categories.surface.code(e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Highway) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.highway = categories.highway.code(e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::SacScale) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.sac_scale = categories.sac_scale.code(e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::MtbScale) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.mtb_scale = categories.mtb_scale.code(e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Symbol) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
|
||||
wpt.sym = Some(e.to_owned());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Type) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.type_ = Some(e.to_owned());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.type_ = Some(e.to_owned());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Color) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.color = Some(e.to_owned());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Opacity) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.opacity = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Width) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.width = e.parse().ok();
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// The email of the author, given as the `id` and `domain` attributes of the element.
|
||||
fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
|
||||
let Some(GPXElement::Author(author)) = stack.last_mut() else {
|
||||
return;
|
||||
};
|
||||
let (mut id, mut domain) = (None, None);
|
||||
for attr in attributes.flatten() {
|
||||
match attr.key.as_ref() {
|
||||
"id" => id = Some(attr.value.to_string()),
|
||||
"domain" => domain = Some(attr.value.to_string()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
if let (Some(id), Some(domain)) = (id, domain) {
|
||||
author.email = Some(format!("{id}@{domain}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The link with the given `href`, if it is an attribute of the element.
|
||||
fn parse_link(attributes: Attributes<'_>) -> Link {
|
||||
let mut link = Link::default();
|
||||
for attr in attributes.flatten() {
|
||||
if attr.key.as_ref() == "href" {
|
||||
link.href = attr.value.to_string();
|
||||
}
|
||||
}
|
||||
link
|
||||
}
|
||||
|
||||
/// Gives a link to what holds it, which is at the top of the stack.
|
||||
fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) {
|
||||
match stack.last_mut() {
|
||||
// the author has a single link, the other elements have as many as they want
|
||||
Some(GPXElement::Author(author)) => author.link = Some(link),
|
||||
Some(GPXElement::Track(trk)) => trk.info.links.push(link),
|
||||
Some(GPXElement::Waypoint(wpt)) => wpt.links.push(link),
|
||||
Some(GPXElement::Metadata) => gpx.info.links.push(link),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
|
||||
/// `categories`, which learns the values it does not know yet.
|
||||
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
|
||||
@@ -61,6 +309,12 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
let mut stack: Vec<GPXElement> = vec![];
|
||||
let mut trkpt_chunk = TrackpointChunk::default();
|
||||
let mut wpt_chunk = WaypointChunk::default();
|
||||
// Routes are read as tracks of a single segment, which is what `rte_segment` collects the points of. A route
|
||||
// point that comes with the detailed path of the route to the next one (`gpxx:rpt`) is replaced by it.
|
||||
let mut rte_segment = TrackSegment::default();
|
||||
let mut rpt_points: Vec<Trackpoint> = vec![];
|
||||
// the text of the element at the top of the stack, up to now
|
||||
let mut text = String::new();
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => match e.name().as_ref() {
|
||||
@@ -70,21 +324,14 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
"desc" => stack.push(GPXElement::Description),
|
||||
"src" => stack.push(GPXElement::Source),
|
||||
"author" => stack.push(GPXElement::Author(Author::default())),
|
||||
"link" => {
|
||||
let mut link = Link::default();
|
||||
for attr in e.attributes().flatten() {
|
||||
if attr.key.as_ref() == "href" {
|
||||
link.href = attr.value.to_string();
|
||||
}
|
||||
}
|
||||
stack.push(GPXElement::Link(link));
|
||||
}
|
||||
"link" => stack.push(GPXElement::Link(parse_link(e.attributes()))),
|
||||
"email" => set_email(&mut stack, e.attributes()),
|
||||
"text" => stack.push(GPXElement::Text),
|
||||
"trk" => stack.push(GPXElement::Track(Track::default())),
|
||||
"trk" | "rte" => stack.push(GPXElement::Track(Track::default())),
|
||||
"trkseg" => {
|
||||
stack.push(GPXElement::Segment(TrackSegment::default()));
|
||||
}
|
||||
"trkpt" => {
|
||||
"trkpt" | "rtept" => {
|
||||
stack.push(GPXElement::Trackpoint(Trackpoint {
|
||||
coordinates: parse_coordinates(e.attributes()),
|
||||
..Default::default()
|
||||
@@ -96,6 +343,9 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
name if is_route_point_extension(name) => {
|
||||
stack.push(GPXElement::RoutePoint(parse_coordinates(e.attributes())));
|
||||
}
|
||||
"ele" => stack.push(GPXElement::Elevation),
|
||||
"time" => stack.push(GPXElement::Time),
|
||||
e if e.ends_with("atemp") => stack.push(GPXElement::Temperature),
|
||||
@@ -116,6 +366,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
},
|
||||
// Self-closing points (`<trkpt lat=".." lon=".."/>`), which have no children
|
||||
Ok(Event::Empty(e)) => match e.name().as_ref() {
|
||||
"link" => attach_link(&mut stack, &mut gpx, parse_link(e.attributes())),
|
||||
"email" => set_email(&mut stack, e.attributes()),
|
||||
"trkpt" => {
|
||||
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
|
||||
trkpt_chunk.trkpt.push(Trackpoint {
|
||||
@@ -127,245 +379,156 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
}
|
||||
}
|
||||
}
|
||||
"rtept" => {
|
||||
if let Some(GPXElement::Track(_)) = stack.last() {
|
||||
trkpt_chunk.trkpt.push(Trackpoint {
|
||||
coordinates: parse_coordinates(e.attributes()),
|
||||
..Default::default()
|
||||
});
|
||||
if trkpt_chunk.is_full() {
|
||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
name if is_route_point_extension(name) => {
|
||||
if let Some(GPXElement::Trackpoint(_)) = stack.last() {
|
||||
rpt_points.push(Trackpoint {
|
||||
coordinates: parse_coordinates(e.attributes()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
"wpt" => {
|
||||
wpt_chunk.wpt.push(Waypoint {
|
||||
coordinates: parse_coordinates(e.attributes()),
|
||||
..Default::default()
|
||||
});
|
||||
if wpt_chunk.is_full() {
|
||||
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
|
||||
gpx.wpt.push(std::mem::take(&mut wpt_chunk));
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
Ok(Event::End(e)) => match e.name().as_ref() {
|
||||
"gpx" => {
|
||||
if !wpt_chunk.wpt.is_empty() {
|
||||
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
|
||||
Ok(Event::End(e)) => {
|
||||
if holds_text(stack.last()) {
|
||||
let value = std::mem::take(&mut text);
|
||||
if value.is_empty() {
|
||||
stack.pop();
|
||||
} else {
|
||||
apply_text(&mut stack, &mut gpx, categories, &value);
|
||||
}
|
||||
}
|
||||
"metadata" => {
|
||||
stack.pop();
|
||||
}
|
||||
"author" => {
|
||||
if let Some(GPXElement::Author(author)) = stack.pop() {
|
||||
gpx.info.author = Some(author);
|
||||
}
|
||||
}
|
||||
"link" => {
|
||||
if let Some(GPXElement::Link(link)) = stack.pop() {
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Author(author)) => {
|
||||
author.link = Some(link);
|
||||
}
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.link = Some(link);
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.link = Some(link);
|
||||
}
|
||||
_ => (),
|
||||
match e.name().as_ref() {
|
||||
"gpx" => {
|
||||
if !wpt_chunk.wpt.is_empty() {
|
||||
gpx.wpt.push(std::mem::take(&mut wpt_chunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
"trk" => {
|
||||
if let Some(GPXElement::Track(trk)) = stack.pop() {
|
||||
gpx.trk.push(trk);
|
||||
"metadata" => {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
"trkseg" => {
|
||||
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
|
||||
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
|
||||
{
|
||||
// `push` ignores the chunk if it is empty
|
||||
trkseg.push(std::mem::take(&mut trkpt_chunk));
|
||||
trk.trkseg.push(trkseg);
|
||||
"author" => {
|
||||
if let Some(GPXElement::Author(author)) = stack.pop() {
|
||||
gpx.info.author = Some(author);
|
||||
}
|
||||
}
|
||||
}
|
||||
"trkpt" => {
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
|
||||
&& let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
|
||||
{
|
||||
trkpt_chunk.trkpt.push(trkpt);
|
||||
if trkpt_chunk.is_full() {
|
||||
"link" => {
|
||||
if let Some(GPXElement::Link(link)) = stack.pop() {
|
||||
attach_link(&mut stack, &mut gpx, link);
|
||||
}
|
||||
}
|
||||
"trk" => {
|
||||
if let Some(GPXElement::Track(trk)) = stack.pop() {
|
||||
gpx.trk.push(trk);
|
||||
}
|
||||
}
|
||||
"rte" => {
|
||||
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
|
||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
||||
if !rte_segment.is_empty() {
|
||||
let mut trkseg = std::mem::take(&mut rte_segment);
|
||||
trkseg.compute_anchors();
|
||||
trk.trkseg.push(trkseg);
|
||||
}
|
||||
gpx.trk.push(trk);
|
||||
}
|
||||
}
|
||||
"rtept" => {
|
||||
if let Some(GPXElement::Trackpoint(rtept)) = stack.pop()
|
||||
&& let Some(GPXElement::Track(_)) = stack.last()
|
||||
{
|
||||
let points = if rpt_points.is_empty() {
|
||||
vec![rtept]
|
||||
} else {
|
||||
std::mem::take(&mut rpt_points)
|
||||
};
|
||||
for point in points {
|
||||
trkpt_chunk.trkpt.push(point);
|
||||
if trkpt_chunk.is_full() {
|
||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
name if is_route_point_extension(name) => {
|
||||
if let Some(GPXElement::RoutePoint(coordinates)) = stack.pop() {
|
||||
rpt_points.push(Trackpoint {
|
||||
coordinates,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
"trkseg" => {
|
||||
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
|
||||
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
|
||||
{
|
||||
// `push` ignores the chunk if it is empty
|
||||
trkseg.push(std::mem::take(&mut trkpt_chunk));
|
||||
trkseg.compute_anchors();
|
||||
trk.trkseg.push(trkseg);
|
||||
}
|
||||
}
|
||||
}
|
||||
"wpt" => {
|
||||
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
|
||||
wpt_chunk.wpt.push(wpt);
|
||||
if wpt_chunk.is_full() {
|
||||
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
|
||||
"trkpt" => {
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
|
||||
&& let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
|
||||
{
|
||||
trkpt_chunk.trkpt.push(trkpt);
|
||||
if trkpt_chunk.is_full() {
|
||||
trkseg.push(std::mem::take(&mut trkpt_chunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
Ok(Event::Text(e)) => match stack.last_mut() {
|
||||
Some(GPXElement::Name) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Metadata) => {
|
||||
gpx.info.name = e.to_string();
|
||||
"wpt" => {
|
||||
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
|
||||
wpt_chunk.wpt.push(wpt);
|
||||
if wpt_chunk.is_full() {
|
||||
gpx.wpt.push(std::mem::take(&mut wpt_chunk));
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Author(author)) => {
|
||||
author.name = Some(e.to_string());
|
||||
}
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.name = Some(e.to_string());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.name = Some(e.to_string());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
// The text of an element comes in pieces: the entities (`&`) are events of their own
|
||||
Ok(Event::Text(e)) => {
|
||||
if holds_text(stack.last()) {
|
||||
text.push_str(&e);
|
||||
}
|
||||
}
|
||||
Ok(Event::CData(e)) => {
|
||||
if holds_text(stack.last()) {
|
||||
text.push_str(&e);
|
||||
}
|
||||
}
|
||||
Ok(Event::GeneralRef(e)) => {
|
||||
if holds_text(stack.last()) {
|
||||
if let Some(c) = e.resolve_char_ref()? {
|
||||
text.push(c);
|
||||
} else if let Some(entity) = resolve_predefined_entity(&e) {
|
||||
text.push_str(entity);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Comment) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.cmt = Some(e.to_string());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.cmt = Some(e.to_string());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Description) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Metadata) => {
|
||||
gpx.info.desc = Some(e.to_string());
|
||||
}
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.desc = Some(e.to_string());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.desc = Some(e.to_string());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Source) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.src = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Text) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Link(link)) = stack.last_mut() {
|
||||
link.text = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Elevation) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Trackpoint(trkpt)) => {
|
||||
trkpt.ele = e.parse().unwrap_or_default();
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.ele = e.parse().unwrap_or_default();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Time) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.time = DateTime::parse_from_rfc3339(e.as_ref())
|
||||
.ok()
|
||||
.map(|time| time.timestamp_millis());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Temperature) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.atemp = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Heartrate) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.hr = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Cadence) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.cad = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Power) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.power = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Surface) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.surface = categories.surface.code(&e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Highway) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.highway = categories.highway.code(&e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::SacScale) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.sac_scale = categories.sac_scale.code(&e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::MtbScale) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
|
||||
trkpt.mtb_scale = categories.mtb_scale.code(&e);
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Symbol) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
|
||||
wpt.sym = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Type) => {
|
||||
stack.pop();
|
||||
match stack.last_mut() {
|
||||
Some(GPXElement::Track(trk)) => {
|
||||
trk.info.type_ = Some(e.to_string());
|
||||
}
|
||||
Some(GPXElement::Waypoint(wpt)) => {
|
||||
wpt.type_ = Some(e.to_string());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Color) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.color = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Opacity) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.opacity = e.parse().ok();
|
||||
}
|
||||
}
|
||||
Some(GPXElement::Width) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||
trk.info.width = e.parse().ok();
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(e),
|
||||
_ => (),
|
||||
@@ -387,12 +550,32 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_entities_and_cdata_in_text() {
|
||||
let data = r#"<gpx><metadata><name>Tom & Jerry <3 éA "x" 'y'</name>
|
||||
<desc><![CDATA[a <b> & c]]> and > more</desc></metadata>
|
||||
<wpt lat="1" lon="2"><name></name><desc/><ele>3</ele><cmt>&</cmt></wpt>
|
||||
<trk><name>t & u</name><trkseg><trkpt lat="1" lon="2"><ele>4</ele></trkpt></trkseg></trk>
|
||||
</gpx>"#;
|
||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||
assert_eq!(gpx.info.name, "Tom & Jerry <3 éA \"x\" 'y'");
|
||||
assert_eq!(gpx.info.desc.as_deref(), Some("a <b> & c and > more"));
|
||||
let wpt = gpx.wpt.iter().next().unwrap();
|
||||
// empty elements leave the value unknown, and do not get in the way of the next ones
|
||||
assert_eq!(wpt.name, None);
|
||||
assert_eq!(wpt.desc, None);
|
||||
assert_eq!(wpt.ele, 3.0);
|
||||
assert_eq!(wpt.cmt.as_deref(), Some("&"));
|
||||
assert_eq!(gpx.trk[0].info.name.as_deref(), Some("t & u"));
|
||||
assert_eq!(gpx.trk[0].trkseg[0][0].ele, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_self_closing_points() {
|
||||
let gpx = parse_data("self_closing_points");
|
||||
|
||||
assert_eq!(gpx.info.name, "self closing points");
|
||||
let wpt: Vec<_> = gpx.wpt.iter().flat_map(|chunk| &chunk.wpt).collect();
|
||||
let wpt: Vec<_> = gpx.wpt.iter().collect();
|
||||
assert_eq!(wpt.len(), 3);
|
||||
assert_eq!(wpt[0].coordinates.lat, 50.0);
|
||||
assert_eq!(wpt[0].name, None);
|
||||
@@ -436,8 +619,8 @@ mod tests {
|
||||
.is_some_and(|d| d == "track description")
|
||||
);
|
||||
assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source"));
|
||||
assert!(trk.info.link.is_some());
|
||||
let link = trk.info.link.as_ref().unwrap();
|
||||
assert_eq!(trk.info.links.len(), 1);
|
||||
let link = &trk.info.links[0];
|
||||
assert_eq!(link.href, "https://gpx.studio");
|
||||
assert!(link.text.as_ref().is_some_and(|t| t == "track link text"));
|
||||
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
|
||||
@@ -481,6 +664,132 @@ mod tests {
|
||||
assert_eq!(trkpt.ele, 115.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_routes_as_tracks() {
|
||||
let gpx = parse_data("with_routes");
|
||||
|
||||
assert_eq!(gpx.trk.len(), 2);
|
||||
let trk = &gpx.trk[0];
|
||||
assert_eq!(trk.info.name.as_deref(), Some("route 1"));
|
||||
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
|
||||
assert_eq!(trk.trkseg.len(), 1);
|
||||
let trkseg = &trk.trkseg[0];
|
||||
assert_eq!(trkseg.len(), 49);
|
||||
let trkpt = &trkseg[0];
|
||||
assert_eq!(trkpt.coordinates.lat, 50.790867);
|
||||
assert_eq!(trkpt.coordinates.lng, 4.404968);
|
||||
assert_eq!(trkpt.ele, 109.0);
|
||||
|
||||
let trk = &gpx.trk[1];
|
||||
assert_eq!(trk.info.name.as_deref(), Some("route 2"));
|
||||
assert_eq!(trk.trkseg[0].len(), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_route_point_extensions() {
|
||||
let gpx = parse_data("with_route_extensions");
|
||||
|
||||
// a route without points has no segment
|
||||
assert_eq!(gpx.trk.len(), 2);
|
||||
assert!(gpx.trk[1].trkseg.is_empty());
|
||||
|
||||
let trk = &gpx.trk[0];
|
||||
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
|
||||
assert_eq!(trk.trkseg.len(), 1);
|
||||
let points: Vec<_> = trk.trkseg[0].iter().collect();
|
||||
// the detailed path replaces the route point it comes with
|
||||
let coordinates: Vec<_> = points
|
||||
.iter()
|
||||
.map(|p| (p.coordinates.lat, p.coordinates.lng))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
coordinates,
|
||||
[(1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)]
|
||||
);
|
||||
assert_eq!(points[2].ele, 20.0);
|
||||
assert!(points[2].time.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_every_link_and_the_email() {
|
||||
let gpx = parse_data("with_links");
|
||||
|
||||
// the file has as many links as it says, self-closing or not
|
||||
let hrefs = |links: &[Link]| links.iter().map(|l| l.href.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
hrefs(&gpx.info.links),
|
||||
["https://example.com/file-1", "https://example.com/file-2"]
|
||||
);
|
||||
assert_eq!(gpx.info.links[0].text.as_deref(), Some("first"));
|
||||
assert_eq!(gpx.info.links[1].text, None);
|
||||
|
||||
// the author has one link and an email, which is given by two attributes
|
||||
let author = gpx.info.author.as_ref().unwrap();
|
||||
assert_eq!(author.name.as_deref(), Some("someone"));
|
||||
assert_eq!(author.email.as_deref(), Some("someone@example.com"));
|
||||
assert_eq!(
|
||||
author.link.as_ref().unwrap().href,
|
||||
"https://example.com/author"
|
||||
);
|
||||
|
||||
let waypoints: Vec<_> = gpx.wpt.iter().collect();
|
||||
assert_eq!(
|
||||
hrefs(&waypoints[0].links),
|
||||
[
|
||||
"https://example.com/wpt-1",
|
||||
"https://example.com/wpt-2",
|
||||
"https://example.com/wpt-3"
|
||||
]
|
||||
);
|
||||
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
|
||||
assert!(waypoints[1].links.is_empty());
|
||||
|
||||
// tracks, and routes which are read as tracks
|
||||
assert_eq!(gpx.trk.len(), 2);
|
||||
assert_eq!(
|
||||
hrefs(&gpx.trk[0].info.links),
|
||||
["https://example.com/trk-1", "https://example.com/trk-2"]
|
||||
);
|
||||
assert_eq!(
|
||||
hrefs(&gpx.trk[1].info.links),
|
||||
["https://example.com/rte-1", "https://example.com/rte-2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_email_variants() {
|
||||
let author = |xml: &str| {
|
||||
let data = format!("<gpx><metadata><author>{xml}</author></metadata></gpx>");
|
||||
parse(data.as_bytes(), &mut Default::default())
|
||||
.unwrap()
|
||||
.info
|
||||
.author
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
author(r#"<email id="a" domain="b.c"></email>"#)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("a@b.c")
|
||||
);
|
||||
assert_eq!(
|
||||
author(r#"<name>n</name><email id="a" domain="b.c"/>"#)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("a@b.c")
|
||||
);
|
||||
// an email that is not complete is not one
|
||||
assert_eq!(author(r#"<email id="a"/>"#).email, None);
|
||||
assert_eq!(author("<name>n</name>").email, None);
|
||||
// outside of an author it means nothing
|
||||
let gpx = parse(
|
||||
br#"<gpx><metadata><name>n</name><email id="a" domain="b.c"/></metadata></gpx>"#,
|
||||
&mut Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(gpx.info.author.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_segments() {
|
||||
let gpx = parse_data("with_segments");
|
||||
@@ -549,9 +858,7 @@ mod tests {
|
||||
let gpx = parse_data("with_waypoint");
|
||||
|
||||
assert_eq!(gpx.wpt.len(), 1);
|
||||
let chunk = &gpx.wpt[0];
|
||||
assert_eq!(chunk.wpt.len(), 1);
|
||||
let wpt = &chunk.wpt[0];
|
||||
let wpt = &gpx.wpt[0];
|
||||
assert_eq!(wpt.coordinates.lat, 50.7836710064975);
|
||||
assert_eq!(wpt.coordinates.lng, 4.410764082658738);
|
||||
assert!(wpt.name.as_ref().is_some_and(|n| n == "waypoint name"));
|
||||
@@ -561,8 +868,8 @@ mod tests {
|
||||
.as_ref()
|
||||
.is_some_and(|d| d == "waypoint description")
|
||||
);
|
||||
assert!(wpt.link.is_some());
|
||||
let link = wpt.link.as_ref().unwrap();
|
||||
assert_eq!(wpt.links.len(), 1);
|
||||
let link = &wpt.links[0];
|
||||
assert_eq!(link.href, "https://gpx.studio");
|
||||
assert!(
|
||||
link.text
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
use std::io;
|
||||
|
||||
use chrono::DateTime;
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{BytesDecl, BytesText, Event};
|
||||
|
||||
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
|
||||
|
||||
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
||||
/// The same type tells which data a file has, see [`File::exportable_data`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExportOptions {
|
||||
pub time: bool,
|
||||
pub hr: bool,
|
||||
pub cad: bool,
|
||||
pub atemp: bool,
|
||||
pub power: bool,
|
||||
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
||||
pub osm: bool,
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
pub const ALL: Self = Self::of(true);
|
||||
pub const NONE: Self = Self::of(false);
|
||||
|
||||
const fn of(value: bool) -> Self {
|
||||
Self {
|
||||
time: value,
|
||||
hr: value,
|
||||
cad: value,
|
||||
atemp: value,
|
||||
power: value,
|
||||
osm: value,
|
||||
}
|
||||
}
|
||||
|
||||
/// The options that are on in `self` or in `other`.
|
||||
pub fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
time: self.time || other.time,
|
||||
hr: self.hr || other.hr,
|
||||
cad: self.cad || other.cad,
|
||||
atemp: self.atemp || other.atemp,
|
||||
power: self.power || other.power,
|
||||
osm: self.osm || other.osm,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl File {
|
||||
/// The data that the trackpoints of the file have (and waypoints for the time), which the
|
||||
/// options of [`write`] can leave out.
|
||||
pub fn exportable_data(&self) -> ExportOptions {
|
||||
let mut data = ExportOptions::NONE;
|
||||
for point in self
|
||||
.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`.
|
||||
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,
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
fn write_track(
|
||||
w: &mut XmlWriter,
|
||||
trk: &Track,
|
||||
name: Option<&str>,
|
||||
categories: &TrackpointCategories,
|
||||
options: ExportOptions,
|
||||
) -> io::Result<()> {
|
||||
let info = &trk.info;
|
||||
w.create_element("trk").write_inner_content(|w| {
|
||||
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(())
|
||||
})?;
|
||||
}
|
||||
for segment in &trk.trkseg {
|
||||
w.create_element("trkseg").write_inner_content(|w| {
|
||||
for point in segment.iter() {
|
||||
write_trackpoint(w, point, categories, options)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_trackpoint(
|
||||
w: &mut XmlWriter,
|
||||
point: &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, "trkpt", 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() {
|
||||
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))?;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
+131
-108
@@ -3,6 +3,20 @@ use crate::{
|
||||
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,
|
||||
@@ -41,10 +55,6 @@ impl Statistics {
|
||||
self.accumulate_distance_and_time(prev, cur);
|
||||
self.update_time_bounds(cur.time);
|
||||
self.update_bounds(cur.coordinates);
|
||||
self.global.hr.add(cur.hr.map(f64::from));
|
||||
self.global.cad.add(cur.cad.map(f64::from));
|
||||
self.global.atemp.add(cur.atemp.map(f64::from));
|
||||
self.global.power.add(cur.power.map(f64::from));
|
||||
self.local
|
||||
.push(TrackpointStatistics::from_partial_stats(self));
|
||||
}
|
||||
@@ -55,18 +65,18 @@ impl Statistics {
|
||||
|
||||
self.global.total_distance += dist;
|
||||
|
||||
if let Some(time) = time {
|
||||
let speed = speed(dist, time);
|
||||
if (0.5..=1500.0).contains(&speed) {
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,22 +94,35 @@ impl Statistics {
|
||||
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(),
|
||||
Some(10_000),
|
||||
|i, j| time_diff(trkseg[j].time, trkseg[i].time),
|
||||
TimeSpan::Known(10_000),
|
||||
|i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|
||||
|i, left, right| {
|
||||
self.local[i.flat].speed =
|
||||
time_diff(trkseg[right].time, trkseg[left].time).map(|t| {
|
||||
speed(
|
||||
self.local[right.flat].total_distance
|
||||
- self.local[left.flat].total_distance,
|
||||
t,
|
||||
)
|
||||
});
|
||||
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,
|
||||
)
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -211,32 +234,6 @@ impl Statistics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Average of an optional measure (heart rate, cadence...) over the trackpoints that have it.
|
||||
/// Keeps the sum so that averages can be merged.
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Average {
|
||||
pub sum: f64,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
impl Average {
|
||||
pub fn add(&mut self, value: Option<f64>) {
|
||||
if let Some(value) = value {
|
||||
self.sum += value;
|
||||
self.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &Average) {
|
||||
self.sum += other.sum;
|
||||
self.count += other.count;
|
||||
}
|
||||
|
||||
pub fn avg(&self) -> Option<f64> {
|
||||
(self.count > 0).then(|| self.sum / self.count as f64)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct GlobalStatistics {
|
||||
pub total_distance: f64,
|
||||
@@ -248,21 +245,20 @@ pub struct GlobalStatistics {
|
||||
pub start_time: Option<i64>,
|
||||
pub end_time: Option<i64>,
|
||||
pub bounds: LngLatBounds,
|
||||
pub hr: Average,
|
||||
pub cad: Average,
|
||||
pub atemp: Average,
|
||||
pub power: Average,
|
||||
}
|
||||
|
||||
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.map(|t| speed(self.total_distance, t))
|
||||
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)
|
||||
.map(|(d, t)| speed(d, t))
|
||||
.and_then(|(d, t)| speed(d, t))
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &GlobalStatistics) {
|
||||
@@ -275,10 +271,6 @@ impl GlobalStatistics {
|
||||
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);
|
||||
self.hr.merge(&other.hr);
|
||||
self.cad.merge(&other.cad);
|
||||
self.atemp.merge(&other.atemp);
|
||||
self.power.merge(&other.power);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +336,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -455,50 +524,4 @@ mod tests {
|
||||
assert_eq!(cumul.total_time, Some(7_000));
|
||||
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_average() {
|
||||
let mut average = Average::default();
|
||||
assert_eq!(average.avg(), None);
|
||||
average.add(None);
|
||||
assert_eq!(average.avg(), None);
|
||||
average.add(Some(100.0));
|
||||
average.add(Some(110.0));
|
||||
assert_eq!(average.avg(), Some(105.0));
|
||||
|
||||
let mut other = Average::default();
|
||||
other.add(Some(130.0));
|
||||
average.merge(&other);
|
||||
assert_eq!(average.count, 3);
|
||||
assert_eq!(average.avg(), Some(113.33333333333333));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_measures_of_the_trackpoints_are_averaged() {
|
||||
for (path, measure) in [
|
||||
("data/with_hr.gpx", 0),
|
||||
("data/with_cad.gpx", 1),
|
||||
("data/with_temp.gpx", 2),
|
||||
("data/with_power_1.gpx", 3),
|
||||
] {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let gpx = parse(&data, &mut Default::default()).unwrap();
|
||||
let trkseg = &gpx.trk[0].trkseg[0];
|
||||
let global = Statistics::compute(trkseg).global;
|
||||
let values: Vec<f64> = trkseg
|
||||
.iter()
|
||||
.filter_map(|trkpt| match measure {
|
||||
0 => trkpt.hr.map(f64::from),
|
||||
1 => trkpt.cad.map(f64::from),
|
||||
2 => trkpt.atemp.map(f64::from),
|
||||
_ => trkpt.power.map(f64::from),
|
||||
})
|
||||
.collect();
|
||||
let average = [global.hr, global.cad, global.atemp, global.power][measure];
|
||||
assert!(!values.is_empty(), "{path}");
|
||||
assert_eq!(average.count as usize, values.len(), "{path}");
|
||||
let expected = values.iter().sum::<f64>() / values.len() as f64;
|
||||
assert!((average.avg().unwrap() - expected).abs() < 1e-9, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
mod statistics;
|
||||
mod compute;
|
||||
mod utils;
|
||||
|
||||
pub use statistics::*;
|
||||
pub use compute::*;
|
||||
pub use utils::*;
|
||||
@@ -22,9 +22,10 @@ pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||
Some(a? - b?)
|
||||
}
|
||||
|
||||
/// Computes the speed for a given distance in kilometers and a time in milliseconds
|
||||
pub fn speed(distance: f64, time: i64) -> f64 {
|
||||
distance / (time as f64 / 3_600_000.0)
|
||||
/// 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 {
|
||||
@@ -125,7 +126,7 @@ mod tests {
|
||||
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) - 1.0).abs() < 1e-12);
|
||||
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,9 +155,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_speed() {
|
||||
// 1 km in 1 h
|
||||
assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12);
|
||||
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) - 20.0).abs() < 1e-12);
|
||||
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]
|
||||
|
||||
@@ -12,8 +12,6 @@ pub trait Apply {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CommandError {
|
||||
/// The command is not implemented yet.
|
||||
NotImplemented(&'static str),
|
||||
/// The command would not change anything.
|
||||
NothingToDo,
|
||||
/// The input data could not be parsed.
|
||||
@@ -23,7 +21,6 @@ pub enum CommandError {
|
||||
impl fmt::Display for CommandError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotImplemented(name) => write!(f, "{name} is not implemented yet"),
|
||||
Self::NothingToDo => write!(f, "nothing to do"),
|
||||
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
use crate::{
|
||||
Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint, Elevation, Extract,
|
||||
Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment,
|
||||
NewWaypoint, Paste, Reverse, SpliceTrackpoints, Split, Style, Time,
|
||||
};
|
||||
|
||||
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
||||
/// effect is implemented in its own file (see `Apply`).
|
||||
///
|
||||
/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument
|
||||
/// buffers, so a command is built without copying; the engine only copies what it keeps.
|
||||
#[derive(Debug)]
|
||||
pub enum Command<'a> {
|
||||
New(New<'a>),
|
||||
Load(Load<'a>),
|
||||
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),
|
||||
SpliceTrackpoints(SpliceTrackpoints<'a>),
|
||||
NewWaypoint(NewWaypoint<'a>),
|
||||
EditWaypoint(EditWaypoint<'a>),
|
||||
MoveWaypoint(MoveWaypoint),
|
||||
Crop(Crop),
|
||||
Split(Split),
|
||||
Time(Time),
|
||||
Merge(Merge),
|
||||
Extract(Extract),
|
||||
Elevation(Elevation<'a>),
|
||||
Clean(Clean),
|
||||
}
|
||||
|
||||
impl Apply for Command<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
match self {
|
||||
Command::New(c) => c.apply(state),
|
||||
Command::Load(c) => c.apply(state),
|
||||
Command::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::SpliceTrackpoints(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::Split(c) => c.apply(state),
|
||||
Command::Time(c) => c.apply(state),
|
||||
Command::Merge(c) => c.apply(state),
|
||||
Command::Extract(c) => c.apply(state),
|
||||
Command::Elevation(c) => c.apply(state),
|
||||
Command::Clean(c) => c.apply(state),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,18 @@ mod tests {
|
||||
#[test]
|
||||
fn test_metadata_applies_to_selected_file() {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "first" }.apply(&mut fx.state()).unwrap();
|
||||
New { name: "second" }.apply(&mut fx.state()).unwrap(); // now selected
|
||||
New {
|
||||
name: "first",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
New {
|
||||
name: "second",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap(); // now selected
|
||||
let selected = *fx.selected_files().iter().next().unwrap();
|
||||
|
||||
Metadata {
|
||||
@@ -95,7 +105,12 @@ mod tests {
|
||||
|
||||
/// A new file with `nb_tracks` tracks (without name), selected.
|
||||
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
|
||||
New { name: "file" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "file",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
for _ in 0..nb_tracks {
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
}
|
||||
|
||||
@@ -37,7 +37,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_new_track() {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "file" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "file",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
let file = fx.files.values().next().unwrap();
|
||||
@@ -48,7 +53,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_new_track_inherits_agreeing_style_only() {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "file" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "file",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
|
||||
|
||||
@@ -26,7 +26,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_new_track_segment_on_each_track_of_selected_file() {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "file" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "file",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrack.apply(&mut fx.state()).unwrap();
|
||||
NewTrackSegment.apply(&mut fx.state()).unwrap();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks,
|
||||
};
|
||||
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.
|
||||
@@ -83,7 +81,7 @@ pub(crate) fn delete_waypoints(
|
||||
) -> Result<Selection, CommandError> {
|
||||
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let mut file = (**file).clone();
|
||||
let changed = edit_waypoint_chunks(&mut file, &filter, |wpts| {
|
||||
let changed = file.wpt.edit(&filter, |wpts| {
|
||||
wpts.retain(|wpt| !filter(wpt));
|
||||
true
|
||||
});
|
||||
@@ -130,7 +128,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_delete_selected_files() {
|
||||
let (mut fx, id) = loaded();
|
||||
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
|
||||
crate::New {
|
||||
name: "keep",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let keep = fx.order.0[1];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
@@ -186,10 +189,10 @@ mod tests {
|
||||
let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect();
|
||||
let ids = wpts.iter().map(|w| w.id).collect();
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.wpt = vec![Rc::new(crate::WaypointChunk {
|
||||
file.wpt = crate::Waypoints::new([crate::WaypointChunk {
|
||||
wpt: wpts,
|
||||
..Default::default()
|
||||
})];
|
||||
}]);
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
ids
|
||||
}
|
||||
@@ -202,16 +205,12 @@ mod tests {
|
||||
file_id: id,
|
||||
wpt_ids: HashSet::from([ids[1]]),
|
||||
};
|
||||
let rev = fx.files[&id].wpt_rev_id;
|
||||
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()
|
||||
.flat_map(|c| c.wpt.iter().map(|w| w.id))
|
||||
.collect();
|
||||
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]));
|
||||
}
|
||||
@@ -263,7 +262,12 @@ mod tests {
|
||||
},
|
||||
] {
|
||||
let (mut fx, id) = loaded();
|
||||
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
|
||||
crate::New {
|
||||
name: "keep",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let keep = fx.order.0[1];
|
||||
fx.selection = selection(id, &fx);
|
||||
Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
|
||||
|
||||
@@ -28,8 +28,18 @@ mod tests {
|
||||
DeleteAll.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
crate::New { name: "a" }.apply(&mut fx.state()).unwrap();
|
||||
crate::New { name: "b" }.apply(&mut fx.state()).unwrap();
|
||||
crate::New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
crate::New {
|
||||
name: "b",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert!(DeleteAll.apply(&mut fx.state()).is_ok());
|
||||
assert!(fx.files.is_empty());
|
||||
assert!(fx.order.0.is_empty());
|
||||
|
||||
@@ -37,7 +37,7 @@ mod tests {
|
||||
fn test_deletes_one_waypoint_without_touching_the_selection() {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: (0..3)
|
||||
.map(|i| Waypoint {
|
||||
name: Some(format!("w{i}")),
|
||||
@@ -45,7 +45,7 @@ mod tests {
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}));
|
||||
});
|
||||
let id = file.id;
|
||||
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
@@ -64,7 +64,6 @@ mod tests {
|
||||
let names: Vec<_> = fx.files[&id]
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.map(|wpt| wpt.name.clone().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, ["w0", "w2"]);
|
||||
@@ -80,10 +79,10 @@ mod tests {
|
||||
fn test_deleting_the_selected_waypoint_selects_its_file() {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
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));
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
|
||||
copy_track, copy_waypoint, insert_waypoints,
|
||||
copy_track, copy_waypoint,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -112,19 +112,14 @@ fn duplicate_waypoints(
|
||||
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()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.filter(|wpt| filter(wpt))
|
||||
.collect();
|
||||
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();
|
||||
insert_waypoints(&mut file, Some(last), copies);
|
||||
file.wpt.insert_after(Some(last), copies);
|
||||
files.insert(file_id, Rc::new(file));
|
||||
Ok(Selection::Waypoint {
|
||||
file_id,
|
||||
@@ -195,7 +190,12 @@ mod tests {
|
||||
fn test_duplicate_files_go_after_last_selected() {
|
||||
let mut fx = Fixture::default();
|
||||
for name in ["a", "b", "c"] {
|
||||
crate::New { name }.apply(&mut fx.state()).unwrap();
|
||||
crate::New {
|
||||
name,
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
}
|
||||
let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]];
|
||||
fx.selection = Selection::File {
|
||||
@@ -238,57 +238,44 @@ mod tests {
|
||||
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
|
||||
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.wpt = vec![Rc::new(crate::WaypointChunk {
|
||||
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[0].clone();
|
||||
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;
|
||||
let rev = fx.files[&id].wpt.rev_id;
|
||||
Duplicate.apply(&mut fx.state()).unwrap();
|
||||
assert_ne!(fx.files[&id].wpt_rev_id, rev);
|
||||
let all: Vec<_> = fx.files[&id]
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|c| c.wpt.iter().map(|w| w.id))
|
||||
.collect();
|
||||
assert_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 chunk is cut after the selected waypoint: [w0] [copy] [w1 w2]
|
||||
// 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.len(), 3);
|
||||
assert_ne!(fx.files[&id].wpt[0].id, original_chunk.id);
|
||||
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 of a chunk does not cut it
|
||||
let last = fx.files[&id].wpt.last().unwrap().wpt.last().unwrap().id;
|
||||
let chunks = fx.files[&id].wpt.clone();
|
||||
// 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 file = &fx.files[&id];
|
||||
assert_eq!(file.wpt.len(), 4);
|
||||
assert!(
|
||||
file.wpt[..3]
|
||||
.iter()
|
||||
.zip(&chunks)
|
||||
.all(|(a, b)| Rc::ptr_eq(a, b))
|
||||
);
|
||||
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.iter().map(|c| c.wpt.len()).sum::<usize>(),
|
||||
10
|
||||
);
|
||||
assert_eq!(fx.files[&id].wpt.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -82,7 +82,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_load_invalid_file_changes_nothing() {
|
||||
let mut fx = Fixture::default();
|
||||
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
|
||||
crate::New {
|
||||
name: "keep",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let selected = fx.selected_files();
|
||||
|
||||
let result = Load {
|
||||
@@ -132,9 +137,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_load_several_files() {
|
||||
let mut fx = Fixture::default();
|
||||
crate::New { name: "before" }
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
crate::New {
|
||||
name: "before",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let before = fx.order.0[0];
|
||||
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
|
||||
|
||||
@@ -178,7 +186,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_load_several_invalid_files_changes_nothing() {
|
||||
let mut fx = Fixture::default();
|
||||
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
|
||||
crate::New {
|
||||
name: "keep",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let selected = fx.selected_files();
|
||||
|
||||
let result = LoadFiles {
|
||||
|
||||
@@ -71,7 +71,7 @@ mod tests {
|
||||
|
||||
use crate::{
|
||||
File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId,
|
||||
engine::command::fixture::Fixture, new_file, waypoint_ids,
|
||||
Waypoints, engine::command::fixture::Fixture, new_file, waypoint_ids,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
@@ -93,10 +93,10 @@ mod tests {
|
||||
.collect(),
|
||||
);
|
||||
if waypoints > 0 {
|
||||
file.wpt = vec![Rc::new(WaypointChunk {
|
||||
file.wpt = Waypoints::new([WaypointChunk {
|
||||
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
|
||||
..Default::default()
|
||||
})];
|
||||
}]);
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use crate::{Apply, CommandError, File, State, produce};
|
||||
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce};
|
||||
|
||||
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a
|
||||
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single
|
||||
/// undo step.
|
||||
#[derive(Debug)]
|
||||
pub struct New<'a> {
|
||||
pub name: &'a str,
|
||||
pub trackpoint: Option<NewTrackpoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct NewTrackpoint {
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
pub ele: f64,
|
||||
}
|
||||
|
||||
impl Apply for New<'_> {
|
||||
@@ -10,6 +21,26 @@ impl Apply for New<'_> {
|
||||
produce(state, |_| {
|
||||
let mut file = File::default();
|
||||
file.info.name = self.name.to_owned();
|
||||
if let Some(point) = self.trackpoint {
|
||||
let mut segment = TrackSegment::default();
|
||||
// the ends of a segment are anchors of the routing tool
|
||||
segment.splice(
|
||||
0,
|
||||
0,
|
||||
vec![Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: point.lng,
|
||||
lat: point.lat,
|
||||
},
|
||||
ele: point.ele,
|
||||
..Default::default()
|
||||
}],
|
||||
);
|
||||
file.trk.push(Track {
|
||||
trkseg: vec![segment],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
vec![file]
|
||||
});
|
||||
Ok(())
|
||||
@@ -25,7 +56,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_new() {
|
||||
let mut fx = Fixture::default();
|
||||
assert!(New { name: "new" }.apply(&mut fx.state()).is_ok());
|
||||
assert!(
|
||||
New {
|
||||
name: "new",
|
||||
trackpoint: None
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(fx.files.len(), 1);
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert_eq!(file.info.name, "new");
|
||||
@@ -33,4 +71,31 @@ mod tests {
|
||||
assert_eq!(fx.selected_files(), [file.id].into());
|
||||
assert_eq!(fx.order.0, vec![file.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_with_a_trackpoint() {
|
||||
let mut fx = Fixture::default();
|
||||
let point = NewTrackpoint {
|
||||
lng: 1.0,
|
||||
lat: 2.0,
|
||||
ele: 3.0,
|
||||
};
|
||||
New {
|
||||
name: "new",
|
||||
trackpoint: Some(point),
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert_eq!(file.trk.len(), 1);
|
||||
assert_eq!(file.trk[0].trkseg.len(), 1);
|
||||
let segment = &file.trk[0].trkseg[0];
|
||||
assert_eq!(segment.len(), 1);
|
||||
assert_eq!(segment[0].coordinates.lng, 1.0);
|
||||
assert_eq!(segment[0].coordinates.lat, 2.0);
|
||||
assert_eq!(segment[0].ele, 3.0);
|
||||
// the only trackpoint is an anchor of the routing tool
|
||||
assert_eq!(segment[0].anchor, Some(0));
|
||||
assert_eq!(fx.selected_files(), [file.id].into());
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,8 @@ mod tests {
|
||||
|
||||
use crate::{
|
||||
Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment,
|
||||
TrackSegmentId, Waypoint, WaypointId, engine::command::fixture::Fixture, new_file,
|
||||
TrackSegmentId, Waypoint, WaypointId, Waypoints, engine::command::fixture::Fixture,
|
||||
new_file,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
@@ -162,10 +163,10 @@ mod tests {
|
||||
.collect(),
|
||||
);
|
||||
if waypoints > 0 {
|
||||
file.wpt = vec![Rc::new(crate::WaypointChunk {
|
||||
file.wpt = Waypoints::new([crate::WaypointChunk {
|
||||
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
|
||||
..Default::default()
|
||||
})];
|
||||
}]);
|
||||
}
|
||||
file
|
||||
}
|
||||
@@ -718,8 +719,6 @@ mod tests {
|
||||
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);
|
||||
let s = segment_ids(&fx, a, 0);
|
||||
let w = wpt_ids(&fx, a);
|
||||
|
||||
copy(&mut fx, tracks(a, &t), false);
|
||||
fx.selection = files(&[b]);
|
||||
@@ -735,7 +734,7 @@ mod tests {
|
||||
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, w) = (track_ids(&fx, a), segment_ids(&fx, a, 0), wpt_ids(&fx, a));
|
||||
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);
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, edit_waypoint_chunks, insert_waypoints_at,
|
||||
copy_segment, copy_track, copy_waypoint,
|
||||
};
|
||||
|
||||
/// Where elements are put, among the others of the same list.
|
||||
@@ -52,10 +52,7 @@ pub enum Destination {
|
||||
}
|
||||
|
||||
pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
|
||||
file.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.map(|wpt| wpt.id)
|
||||
file.wpt.iter().map(|wpt| wpt.id)
|
||||
}
|
||||
|
||||
fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> {
|
||||
@@ -119,8 +116,7 @@ pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
|
||||
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)) {
|
||||
edit_waypoint_chunks(
|
||||
Rc::make_mut(file),
|
||||
Rc::make_mut(file).wpt.edit(
|
||||
|wpt| ids.contains(&wpt.id),
|
||||
|wpts| {
|
||||
wpts.retain(|wpt| !ids.contains(&wpt.id));
|
||||
@@ -152,16 +148,19 @@ pub fn transfer(
|
||||
.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 = match (content, to) {
|
||||
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(..)) => true,
|
||||
_ => false,
|
||||
};
|
||||
| (
|
||||
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(),
|
||||
@@ -334,7 +333,7 @@ fn transfer_waypoints(
|
||||
let ids = waypoints.iter().map(|wpt| wpt.id).collect();
|
||||
let file = file_mut(state, file_id)?;
|
||||
let existing: Vec<WaypointId> = waypoint_ids(file).collect();
|
||||
insert_waypoints_at(file, place.position(&existing), waypoints);
|
||||
file.wpt.insert_at(place.position(&existing), waypoints);
|
||||
Ok(Selection::Waypoint {
|
||||
file_id,
|
||||
wpt_ids: ids,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
mod apply;
|
||||
mod command;
|
||||
mod edit;
|
||||
mod file;
|
||||
#[cfg(test)]
|
||||
@@ -8,8 +7,83 @@ mod pattern;
|
||||
mod tools;
|
||||
|
||||
pub use apply::*;
|
||||
pub use command::*;
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{File, Waypoint, WaypointChunk};
|
||||
|
||||
/// Edits the waypoints of the chunks containing a waypoint accepted by `filter`.
|
||||
///
|
||||
/// `f` receives a copy of the chunk's waypoints and returns whether it changed them. A
|
||||
/// changed chunk is replaced by a new one (with a new id, so that derived data is recomputed)
|
||||
/// and chunks left empty are dropped. Returns whether anything changed.
|
||||
pub fn edit_waypoint_chunks(
|
||||
file: &mut File,
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
mut f: impl FnMut(&mut Vec<Waypoint>) -> bool,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let mut chunks = Vec::with_capacity(file.wpt.len());
|
||||
for chunk in file.wpt.drain(..) {
|
||||
if !chunk.wpt.iter().any(&filter) {
|
||||
chunks.push(chunk);
|
||||
continue;
|
||||
}
|
||||
let mut wpt = chunk.wpt.clone();
|
||||
if !f(&mut wpt) {
|
||||
chunks.push(chunk);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if !wpt.is_empty() {
|
||||
chunks.push(Rc::new(WaypointChunk {
|
||||
wpt,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
}
|
||||
file.wpt = chunks;
|
||||
if changed {
|
||||
file.wpt_rev_id = Default::default();
|
||||
}
|
||||
changed
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{File, Waypoint, WaypointChunk, WaypointId};
|
||||
|
||||
/// Inserts waypoints in the file, right after the waypoint `after`, or at the end if there is
|
||||
/// none (or if it is not in the file).
|
||||
///
|
||||
/// See [`insert_waypoints_at`].
|
||||
pub fn insert_waypoints(file: &mut File, after: Option<WaypointId>, waypoints: Vec<Waypoint>) {
|
||||
let index = after
|
||||
.and_then(|after| {
|
||||
file.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.position(|wpt| wpt.id == after)
|
||||
})
|
||||
.map_or(usize::MAX, |i| i + 1);
|
||||
insert_waypoints_at(file, index, waypoints);
|
||||
}
|
||||
|
||||
/// Inserts waypoints in the file, so that the first one is at `index` among the waypoints of
|
||||
/// the file (at the end if the index is past it).
|
||||
///
|
||||
/// Only the chunk holding the waypoint that was at `index` is cut (when the waypoints are not
|
||||
/// inserted between two chunks), all the other chunks are kept as they are.
|
||||
pub fn insert_waypoints_at(file: &mut File, index: usize, waypoints: Vec<Waypoint>) {
|
||||
if waypoints.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut inserted = Vec::new();
|
||||
let mut chunk = WaypointChunk::default();
|
||||
for wpt in waypoints {
|
||||
chunk.wpt.push(wpt);
|
||||
if chunk.is_full() {
|
||||
inserted.push(Rc::new(std::mem::take(&mut chunk)));
|
||||
}
|
||||
}
|
||||
if !chunk.wpt.is_empty() {
|
||||
inserted.push(Rc::new(chunk));
|
||||
}
|
||||
|
||||
// the chunk holding the waypoint at `index`, and the position of that waypoint in it
|
||||
let mut start = 0;
|
||||
let position = file.wpt.iter().enumerate().find_map(|(i, chunk)| {
|
||||
let offset = index
|
||||
.checked_sub(start)
|
||||
.filter(|offset| *offset < chunk.wpt.len());
|
||||
start += chunk.wpt.len();
|
||||
offset.map(|offset| (i, offset))
|
||||
});
|
||||
match position {
|
||||
None => file.wpt.extend(inserted),
|
||||
Some((i, 0)) => {
|
||||
file.wpt.splice(i..i, inserted);
|
||||
}
|
||||
Some((i, offset)) => {
|
||||
let cut = &file.wpt[i];
|
||||
let part = |wpt: &[Waypoint]| {
|
||||
Rc::new(WaypointChunk {
|
||||
wpt: wpt.to_vec(),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let mut chunks = vec![part(&cut.wpt[..offset])];
|
||||
chunks.extend(inserted);
|
||||
chunks.push(part(&cut.wpt[offset..]));
|
||||
file.wpt.splice(i..=i, chunks);
|
||||
}
|
||||
}
|
||||
file.wpt_rev_id = Default::default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn waypoint() -> Waypoint {
|
||||
Waypoint::default()
|
||||
}
|
||||
|
||||
fn file_with(chunks: &[usize]) -> (File, Vec<WaypointId>) {
|
||||
let mut file = File::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));
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
wpt,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
(file, ids)
|
||||
}
|
||||
|
||||
fn ids(file: &File) -> Vec<WaypointId> {
|
||||
file.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.wpt.iter().map(|w| w.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_at_the_end() {
|
||||
let (mut file, before) = file_with(&[2, 1]);
|
||||
let rev = file.wpt_rev_id;
|
||||
let new = vec![waypoint(), waypoint()];
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
insert_waypoints(&mut file, None, new);
|
||||
assert_eq!(ids(&file), [before.clone(), new_ids].concat());
|
||||
// the chunks that were there are kept
|
||||
assert_eq!(file.wpt.len(), 3);
|
||||
assert_ne!(file.wpt_rev_id, rev);
|
||||
// an unknown waypoint is an insertion at the end too
|
||||
let (mut file, before) = file_with(&[2]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
insert_waypoints(&mut file, Some(WaypointId::default()), vec![one]);
|
||||
assert_eq!(ids(&file), [before, vec![one_id]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_chunks() {
|
||||
let (mut file, before) = file_with(&[2, 2]);
|
||||
let first = file.wpt[0].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
insert_waypoints(&mut file, Some(before[1]), vec![one]);
|
||||
assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat());
|
||||
// 2 chunks + the inserted one, nothing was cut
|
||||
assert_eq!(file.wpt.len(), 3);
|
||||
assert!(Rc::ptr_eq(&file.wpt[0], &first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_in_the_middle_of_a_chunk_cuts_it() {
|
||||
let (mut file, before) = file_with(&[3, 1]);
|
||||
let last = file.wpt[1].clone();
|
||||
let new = vec![waypoint(), waypoint()];
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
insert_waypoints(&mut file, Some(before[0]), new);
|
||||
assert_eq!(
|
||||
ids(&file),
|
||||
[&before[..1], &new_ids[..], &before[1..]].concat()
|
||||
);
|
||||
// the cut chunk, the inserted one and the end of the cut one, then the untouched chunk
|
||||
assert_eq!(file.wpt.len(), 4);
|
||||
assert!(Rc::ptr_eq(&file.wpt[3], &last));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_nothing() {
|
||||
let (mut file, before) = file_with(&[2]);
|
||||
let rev = file.wpt_rev_id;
|
||||
insert_waypoints(&mut file, Some(before[0]), vec![]);
|
||||
assert_eq!(ids(&file), before);
|
||||
assert_eq!(file.wpt_rev_id, rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_many_fills_chunks() {
|
||||
let mut file = File::default();
|
||||
let new: Vec<_> = (0..300).map(|_| waypoint()).collect();
|
||||
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||
insert_waypoints(&mut file, None, new);
|
||||
assert_eq!(ids(&file), new_ids);
|
||||
assert_eq!(file.wpt.len(), 3);
|
||||
assert!(file.wpt[..2].iter().all(|chunk| chunk.is_full()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_at_an_index() {
|
||||
// at the start
|
||||
let (mut file, before) = file_with(&[2, 1]);
|
||||
let first = file.wpt[0].clone();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
insert_waypoints_at(&mut file, 0, vec![one]);
|
||||
assert_eq!(ids(&file), [vec![one_id], before.clone()].concat());
|
||||
// the existing chunks are kept
|
||||
assert_eq!(file.wpt.len(), 3);
|
||||
assert!(Rc::ptr_eq(&file.wpt[1], &first));
|
||||
|
||||
// between two chunks
|
||||
let (mut file, before) = file_with(&[2, 1]);
|
||||
let (first, last) = (file.wpt[0].clone(), file.wpt[1].clone());
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
insert_waypoints_at(&mut file, 2, vec![one]);
|
||||
assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat());
|
||||
assert_eq!(file.wpt.len(), 3);
|
||||
assert!(Rc::ptr_eq(&file.wpt[0], &first) && Rc::ptr_eq(&file.wpt[2], &last));
|
||||
|
||||
// inside a chunk
|
||||
let (mut file, before) = file_with(&[3]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
insert_waypoints_at(&mut file, 1, vec![one]);
|
||||
assert_eq!(ids(&file), [&before[..1], &[one_id], &before[1..]].concat());
|
||||
assert_eq!(file.wpt.len(), 3);
|
||||
|
||||
// at the end, or past it
|
||||
for index in [3, 100] {
|
||||
let (mut file, before) = file_with(&[3]);
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
insert_waypoints_at(&mut file, index, vec![one]);
|
||||
assert_eq!(ids(&file), [before, vec![one_id]].concat());
|
||||
}
|
||||
|
||||
// in a file without waypoints
|
||||
let mut file = File::default();
|
||||
let one = waypoint();
|
||||
let one_id = one.id;
|
||||
insert_waypoints_at(&mut file, 0, vec![one]);
|
||||
assert_eq!(ids(&file), vec![one_id]);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
mod copy;
|
||||
mod edit_waypoint_chunks;
|
||||
mod insert_waypoints;
|
||||
mod produce;
|
||||
mod update_segments;
|
||||
mod update_selected;
|
||||
mod update_waypoint;
|
||||
|
||||
pub use copy::*;
|
||||
pub use edit_waypoint_chunks::*;
|
||||
pub use insert_waypoints::*;
|
||||
pub use produce::*;
|
||||
pub use update_segments::*;
|
||||
pub use update_selected::*;
|
||||
pub use update_waypoint::*;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint, edit_waypoint_chunks,
|
||||
};
|
||||
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)]
|
||||
@@ -87,7 +85,7 @@ fn edit_waypoints<E: Editor + ?Sized>(
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
editor: &mut E,
|
||||
) -> Edit {
|
||||
let changed = edit_waypoint_chunks(file, &filter, |wpts| {
|
||||
let changed = file.wpt.edit(&filter, |wpts| {
|
||||
edit_where(wpts, &filter, |wpt| editor.waypoint(wpt)) == Edit::Changed
|
||||
});
|
||||
if changed {
|
||||
@@ -287,35 +285,34 @@ mod tests {
|
||||
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
|
||||
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.wpt = vec![Rc::new(crate::WaypointChunk {
|
||||
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[0].id;
|
||||
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[0].wpt.iter().all(|w| w.name.is_none()));
|
||||
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;
|
||||
let rev = fx.files[&id].wpt.rev_id;
|
||||
update_selected(&mut fx.state(), &mut Name);
|
||||
assert_ne!(fx.files[&id].wpt_rev_id, rev);
|
||||
let chunk = &fx.files[&id].wpt[0];
|
||||
assert_ne!(chunk.id, chunk_id);
|
||||
let named: Vec<_> = chunk.wpt.iter().map(|w| w.name.is_some()).collect();
|
||||
assert_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[0].wpt.iter().all(|w| w.name.is_some()));
|
||||
assert!(fx.files[&id].wpt.iter().all(|w| w.name.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{CommandError, FileId, State, Waypoint, WaypointId, edit_waypoint_chunks};
|
||||
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.
|
||||
@@ -12,8 +12,7 @@ pub fn update_waypoint(
|
||||
) -> Result<(), CommandError> {
|
||||
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let mut file = (**file).clone();
|
||||
let changed = edit_waypoint_chunks(
|
||||
&mut file,
|
||||
let changed = file.wpt.edit(
|
||||
|wpt| wpt.id == waypoint_id,
|
||||
|wpts| {
|
||||
wpts.iter_mut()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@ use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
|
||||
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, edit_waypoint_chunks,
|
||||
update_selected,
|
||||
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -91,8 +90,7 @@ impl Editor for Cleaner<'_> {
|
||||
}
|
||||
}
|
||||
if self.clean.wpt
|
||||
&& edit_waypoint_chunks(
|
||||
file,
|
||||
&& file.wpt.edit(
|
||||
|wpt| self.clean.removes(wpt.coordinates),
|
||||
|wpts| {
|
||||
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
|
||||
@@ -244,7 +242,7 @@ fn fixed_selection(state: &State) -> Selection {
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, engine::command::fixture::Fixture};
|
||||
use crate::{FileId, Load, WaypointChunk, Waypoints, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -331,7 +329,8 @@ mod tests {
|
||||
let result =
|
||||
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
|
||||
assert!(matches!(result, Err(CommandError::NothingToDo)));
|
||||
assert_eq!(fx.files[&id], before);
|
||||
// the file was not even copied
|
||||
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -363,18 +362,14 @@ mod tests {
|
||||
fn test_waypoints_flag_and_selection() {
|
||||
let (mut fx, id) = loaded();
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.wpt = vec![std::rc::Rc::new(crate::WaypointChunk {
|
||||
file.wpt = Waypoints::new([WaypointChunk {
|
||||
wpt: vec![Waypoint::default(), Waypoint::default()],
|
||||
..Default::default()
|
||||
})];
|
||||
let ids: Vec<_> = file.wpt[0].wpt.iter().map(|w| w.id).collect();
|
||||
}]);
|
||||
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.iter().map(|c| c.wpt.len()).sum::<usize>())
|
||||
};
|
||||
let wpt_count = |fx: &Fixture| fx.files.get(&id).map_or(0, |f| f.wpt.len());
|
||||
|
||||
// trackpoints only: waypoints are kept
|
||||
Clean {
|
||||
|
||||
@@ -1,14 +1,335 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
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> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("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));
|
||||
}
|
||||
}
|
||||
@@ -45,17 +45,23 @@ mod tests {
|
||||
fn test_edit_the_given_waypoint() {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
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()),
|
||||
link: Some(Link {
|
||||
href: "https://old".into(),
|
||||
text: None,
|
||||
}),
|
||||
links: vec![
|
||||
Link {
|
||||
href: "https://old".into(),
|
||||
text: None,
|
||||
},
|
||||
Link {
|
||||
href: "https://second".into(),
|
||||
text: Some("second".into()),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Waypoint {
|
||||
@@ -64,7 +70,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}));
|
||||
});
|
||||
let id = file.id;
|
||||
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
@@ -83,19 +89,16 @@ mod tests {
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let wpts: Vec<_> = fx.files[&id]
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|c| c.wpt.iter())
|
||||
.collect();
|
||||
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 it
|
||||
assert!(edited.link.is_none());
|
||||
// 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)
|
||||
@@ -103,4 +106,57 @@ mod tests {
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,133 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
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> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,570 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
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> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,586 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
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> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("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));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +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 splice_trackpoints;
|
||||
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 splice_trackpoints::*;
|
||||
pub use round_trip::*;
|
||||
pub use route::*;
|
||||
pub use split::*;
|
||||
pub use time::*;
|
||||
@@ -36,7 +36,7 @@ mod tests {
|
||||
fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) {
|
||||
let mut fx = Fixture::default();
|
||||
let mut file = File::default();
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: (0..3)
|
||||
.map(|i| Waypoint {
|
||||
name: Some(format!("w{i}")),
|
||||
@@ -44,7 +44,7 @@ mod tests {
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}));
|
||||
});
|
||||
let id = file.id;
|
||||
let ids = waypoint_ids(&file).collect();
|
||||
fx.files.insert(id, Rc::new(file));
|
||||
@@ -55,7 +55,7 @@ mod tests {
|
||||
#[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;
|
||||
let rev = fx.files[&id].wpt.rev_id;
|
||||
|
||||
MoveWaypoint {
|
||||
file_id: id,
|
||||
@@ -67,11 +67,7 @@ mod tests {
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let wpts: Vec<_> = fx.files[&id]
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|c| c.wpt.iter())
|
||||
.collect();
|
||||
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
|
||||
assert_eq!(wpts.len(), 3);
|
||||
assert_eq!(
|
||||
(
|
||||
@@ -87,7 +83,7 @@ mod tests {
|
||||
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_ne!(fx.files[&id].wpt.rev_id, rev);
|
||||
assert_eq!(fx.selection, Selection::Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint,
|
||||
insert_waypoints_at,
|
||||
};
|
||||
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.
|
||||
@@ -39,7 +36,15 @@ pub fn set_waypoint_fields(
|
||||
// the description is also the comment, as it is what the other applications show
|
||||
waypoint.cmt = non_empty(desc);
|
||||
waypoint.sym = non_empty(icon);
|
||||
waypoint.link = non_empty(link).map(|href| Link { href, text: None });
|
||||
// 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<'_> {
|
||||
@@ -77,7 +82,7 @@ impl Apply for NewWaypoint<'_> {
|
||||
self.link,
|
||||
);
|
||||
let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap());
|
||||
insert_waypoints_at(file, usize::MAX, vec![waypoint]);
|
||||
file.wpt.insert_at(usize::MAX, vec![waypoint]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -104,20 +109,26 @@ mod tests {
|
||||
}
|
||||
|
||||
fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> {
|
||||
fx.files[&id]
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.wpt.iter().cloned())
|
||||
.collect()
|
||||
fx.files[&id].wpt.iter().cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_waypoint_in_the_selected_file() {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "a" }.apply(&mut fx.state()).unwrap();
|
||||
New { name: "b" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
New {
|
||||
name: "b",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let (a, b) = (fx.order.0[0], fx.order.0[1]);
|
||||
let rev = fx.files[&b].wpt_rev_id;
|
||||
let rev = fx.files[&b].wpt.rev_id;
|
||||
|
||||
// b is selected
|
||||
new_waypoint("peak").apply(&mut fx.state()).unwrap();
|
||||
@@ -134,10 +145,10 @@ mod tests {
|
||||
assert_eq!(wpt.cmt.as_deref(), Some("a description"));
|
||||
assert_eq!(wpt.sym.as_deref(), Some("Summit"));
|
||||
assert_eq!(
|
||||
wpt.link.as_ref().map(|l| l.href.as_str()),
|
||||
wpt.links.first().map(|l| l.href.as_str()),
|
||||
Some("https://example.com")
|
||||
);
|
||||
assert_ne!(fx.files[&b].wpt_rev_id, rev);
|
||||
assert_ne!(fx.files[&b].wpt.rev_id, rev);
|
||||
|
||||
// added after the others
|
||||
let mut second = new_waypoint("second");
|
||||
@@ -155,7 +166,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_empty_fields_are_not_set() {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "a" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let a = fx.order.0[0];
|
||||
NewWaypoint {
|
||||
lng: 1.0,
|
||||
@@ -170,14 +186,19 @@ mod tests {
|
||||
.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.link.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 }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name,
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
}
|
||||
let ids = fx.order.0.clone();
|
||||
fx.selection = Selection::File {
|
||||
@@ -210,7 +231,12 @@ mod tests {
|
||||
},
|
||||
] {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "a" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let a = fx.order.0[0];
|
||||
fx.selection = selection(a);
|
||||
new_waypoint("w").apply(&mut fx.state()).unwrap();
|
||||
@@ -221,7 +247,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_nothing_selected_or_unknown_file() {
|
||||
let mut fx = Fixture::default();
|
||||
New { name: "a" }.apply(&mut fx.state()).unwrap();
|
||||
New {
|
||||
name: "a",
|
||||
trackpoint: None,
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
new_waypoint("w").apply(&mut fx.state()),
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,294 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
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> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("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()));
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Apply, CommandError, File, FileId, LngLat, Selection, State, Trackpoint};
|
||||
|
||||
/// Replaces the trackpoints `start..end` of the last track segment of the selection by the
|
||||
/// given ones (a pure insertion when `start == end`, a pure removal when there are no new
|
||||
/// points).
|
||||
///
|
||||
/// The selection can be made of several files, tracks or track segments: the indices are
|
||||
/// meant for its very last segment, in file order, then track order, then segment order.
|
||||
#[derive(Debug)]
|
||||
pub struct SpliceTrackpoints<'a> {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub lng: &'a [f64],
|
||||
pub lat: &'a [f64],
|
||||
pub ele: &'a [f64],
|
||||
}
|
||||
|
||||
impl Apply for SpliceTrackpoints<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let (start, end) = (self.start as usize, self.end as usize);
|
||||
if start > end || self.lng.len() != self.lat.len() || self.lat.len() != self.ele.len() {
|
||||
return Err(CommandError::InvalidData(
|
||||
"invalid trackpoints range".into(),
|
||||
));
|
||||
}
|
||||
let (file_id, trk, seg) = last_segment(state).ok_or(CommandError::NothingToDo)?;
|
||||
let mut file = (*state.files[&file_id]).clone();
|
||||
let segment = &mut file.trk[trk].trkseg[seg];
|
||||
if end > segment.len() {
|
||||
return Err(CommandError::InvalidData(
|
||||
"trackpoints range out of bounds".into(),
|
||||
));
|
||||
}
|
||||
if start == end && self.lng.is_empty() {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
let points = (0..self.lng.len())
|
||||
.map(|i| Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: self.lng[i],
|
||||
lat: self.lat[i],
|
||||
},
|
||||
ele: self.ele[i],
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
segment.splice(start, end, points);
|
||||
segment.rev_id = Default::default();
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Position (file, track index, segment index) of the last segment of the selection.
|
||||
fn last_segment(state: &State) -> Option<(FileId, usize, usize)> {
|
||||
let last_of_track = |file: &File, trk: usize| file.trk[trk].trkseg.len().checked_sub(1);
|
||||
let last_of_file = |file: &File, filter: &dyn Fn(usize) -> bool| {
|
||||
(0..file.trk.len())
|
||||
.rev()
|
||||
.filter(|&trk| filter(trk))
|
||||
.find_map(|trk| last_of_track(file, trk).map(|seg| (trk, seg)))
|
||||
};
|
||||
match &*state.selection {
|
||||
Selection::File { file_ids } => state
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|id| file_ids.contains(id))
|
||||
.find_map(|id| {
|
||||
let (trk, seg) = last_of_file(state.files.get(id)?, &|_| true)?;
|
||||
Some((*id, trk, seg))
|
||||
}),
|
||||
Selection::Track { file_id, trk_ids } => {
|
||||
let file = state.files.get(file_id)?;
|
||||
let (trk, seg) = last_of_file(file, &|trk| trk_ids.contains(&file.trk[trk].id))?;
|
||||
Some((*file_id, trk, seg))
|
||||
}
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => {
|
||||
let file = state.files.get(file_id)?;
|
||||
let trk = file.trk.iter().position(|trk| trk.id == *trk_id)?;
|
||||
let seg = file.trk[trk]
|
||||
.trkseg
|
||||
.iter()
|
||||
.rposition(|seg| trkseg_ids.contains(&seg.id))?;
|
||||
Some((*file_id, trk, seg))
|
||||
}
|
||||
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, Load, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loaded() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let id = fx.order.0[0];
|
||||
let trk = &fx.files[&id].trk[0];
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: trk.id,
|
||||
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
|
||||
};
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
fn splice<'a>(
|
||||
start: u32,
|
||||
end: u32,
|
||||
lng: &'a [f64],
|
||||
lat: &'a [f64],
|
||||
ele: &'a [f64],
|
||||
) -> SpliceTrackpoints<'a> {
|
||||
SpliceTrackpoints {
|
||||
start,
|
||||
end,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_replaces_points_of_selected_segment_only() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let len = before.trk[0].trkseg[0].len();
|
||||
assert!(len >= 3);
|
||||
|
||||
splice(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
|
||||
let after = &fx.files[&id];
|
||||
let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]);
|
||||
assert_eq!(new.len(), len + 1);
|
||||
assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng);
|
||||
assert_eq!(new[1].coordinates.lng, 1.0);
|
||||
assert_eq!(new[3].coordinates.lat, 6.0);
|
||||
assert_eq!(new[3].ele, 9.0);
|
||||
assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng);
|
||||
assert_ne!(new.rev_id, old.rev_id);
|
||||
assert_eq!(new.id, old.id);
|
||||
// other segments and tracks keep their revision
|
||||
for (b, a) in before.trk.iter().zip(&after.trk) {
|
||||
for (b, a) in b.trkseg.iter().zip(&a.trkseg) {
|
||||
if a.id != new.id {
|
||||
assert_eq!(a.rev_id, b.rev_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_append_and_remove() {
|
||||
let (mut fx, id) = loaded();
|
||||
let len = fx.files[&id].trk[0].trkseg[0].len() as u32;
|
||||
splice(len, len, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let seg = &fx.files[&id].trk[0].trkseg[0];
|
||||
assert_eq!(seg.len(), len as usize + 1);
|
||||
assert_eq!(seg[len as usize].ele, 3.0);
|
||||
|
||||
splice(0, len + 1, &[], &[], &[])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(fx.files[&id].trk[0].trkseg[0].len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_invalid_arguments_change_nothing() {
|
||||
let (mut fx, id) = loaded();
|
||||
let before = fx.files[&id].clone();
|
||||
let len = before.trk[0].trkseg[0].len() as u32;
|
||||
let results = [
|
||||
splice(2, 1, &[], &[], &[]).apply(&mut fx.state()),
|
||||
splice(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
|
||||
splice(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
|
||||
];
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|r| matches!(r, Err(CommandError::InvalidData(_))))
|
||||
);
|
||||
assert_eq!(
|
||||
splice(0, 0, &[], &[], &[]).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_needs_a_segment_selection() {
|
||||
let (mut fx, _) = loaded();
|
||||
fx.selection = Selection::Empty;
|
||||
assert_eq!(
|
||||
splice(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_targets_last_segment_of_the_selection() {
|
||||
let (mut fx, id) = loaded();
|
||||
let file = fx.files[&id].clone();
|
||||
assert!(file.trk.len() >= 2);
|
||||
let last_trk = file.trk.len() - 1;
|
||||
let last_seg = file.trk[last_trk].trkseg.len() - 1;
|
||||
|
||||
// whole file selected: last segment of its last track
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([id]),
|
||||
};
|
||||
splice(0, 0, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let after = &fx.files[&id];
|
||||
for (t, (b, a)) in file.trk.iter().zip(&after.trk).enumerate() {
|
||||
for (s, (b, a)) in b.trkseg.iter().zip(&a.trkseg).enumerate() {
|
||||
let target = (t, s) == (last_trk, last_seg);
|
||||
assert_eq!(a.len(), b.len() + usize::from(target));
|
||||
assert_eq!(a.rev_id != b.rev_id, target);
|
||||
}
|
||||
}
|
||||
|
||||
// several tracks selected: last selected one (in file order, whatever the set order)
|
||||
let ids: Vec<_> = file.trk.iter().map(|t| t.id).collect();
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: HashSet::from([ids[1], ids[0]]),
|
||||
};
|
||||
let len = |fx: &Fixture, t: usize| {
|
||||
let trk = &fx.files[&id].trk[t];
|
||||
trk.trkseg.last().unwrap().len()
|
||||
};
|
||||
let (l0, l1) = (len(&fx, 0), len(&fx, 1));
|
||||
splice(0, 0, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(len(&fx, 0), l0);
|
||||
assert_eq!(len(&fx, 1), l1 + 1);
|
||||
|
||||
// several segments selected
|
||||
let trk = &fx.files[&id].trk[0];
|
||||
let seg_ids: Vec<_> = trk.trkseg.iter().map(|s| s.id).collect();
|
||||
if seg_ids.len() >= 2 {
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: trk.id,
|
||||
trkseg_ids: seg_ids.iter().copied().collect(),
|
||||
};
|
||||
let lens: Vec<_> = trk.trkseg.iter().map(|s| s.len()).collect();
|
||||
splice(0, 0, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let after: Vec<_> = fx.files[&id].trk[0]
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|s| s.len())
|
||||
.collect();
|
||||
let n = lens.len();
|
||||
assert_eq!(after[..n - 1], lens[..n - 1]);
|
||||
assert_eq!(after[n - 1], lens[n - 1] + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_on_multiple_files_uses_last_in_file_order() {
|
||||
let (mut fx, first) = loaded();
|
||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
let second = fx.order.0[1];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([second, first]),
|
||||
};
|
||||
let total = |fx: &Fixture, id: FileId| -> usize {
|
||||
fx.files[&id]
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.map(|s| s.len())
|
||||
.sum()
|
||||
};
|
||||
let (a, b) = (total(&fx, first), total(&fx, second));
|
||||
splice(0, 0, &[1.0], &[2.0], &[3.0])
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
assert_eq!(total(&fx, first), a);
|
||||
assert_eq!(total(&fx, second), b + 1);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,339 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
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> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("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));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,193 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
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 struct Time {
|
||||
pub start_time: i64,
|
||||
pub kind: TimeKind,
|
||||
}
|
||||
|
||||
impl Apply for Time {
|
||||
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,14 @@ impl CoordinatesCache {
|
||||
if self
|
||||
.waypoints
|
||||
.get(&file.id)
|
||||
.is_none_or(|(r, _)| *r != file.wpt_rev_id)
|
||||
.is_none_or(|(r, _)| *r != file.wpt.rev_id)
|
||||
{
|
||||
let mut coordinates = Vec::new();
|
||||
for wpt in file.wpt.iter().flat_map(|chunk| &chunk.wpt) {
|
||||
for wpt in file.wpt.iter() {
|
||||
coordinates.extend([wpt.coordinates.lng, wpt.coordinates.lat]);
|
||||
}
|
||||
self.waypoints
|
||||
.insert(file.id, (file.wpt_rev_id, coordinates));
|
||||
.insert(file.id, (file.wpt.rev_id, coordinates));
|
||||
}
|
||||
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
|
||||
segments.insert(seg.id);
|
||||
@@ -138,17 +138,16 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let mut file = (*fx.files[&id]).clone();
|
||||
file.wpt_rev_id = Default::default();
|
||||
file.wpt = vec![
|
||||
Rc::new(WaypointChunk {
|
||||
file.wpt = crate::Waypoints::new([
|
||||
WaypointChunk {
|
||||
wpt: vec![wpt(1.0, 2.0)],
|
||||
..Default::default()
|
||||
}),
|
||||
Rc::new(WaypointChunk {
|
||||
},
|
||||
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));
|
||||
|
||||
@@ -76,21 +76,19 @@ impl FileStructure {
|
||||
waypoints: file
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.map(|wpt| WaypointNode {
|
||||
id: wpt.id,
|
||||
name: wpt.name.clone(),
|
||||
sym: wpt.sym.clone(),
|
||||
})
|
||||
.collect(),
|
||||
wpt_rev_id: file.wpt_rev_id,
|
||||
wpt_rev_id: file.wpt.rev_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
|
||||
|
||||
@@ -133,18 +131,17 @@ mod tests {
|
||||
name: Some(n.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let before = file.wpt_rev_id;
|
||||
file.wpt_rev_id = Default::default();
|
||||
file.wpt = vec![
|
||||
Rc::new(WaypointChunk {
|
||||
let before = file.wpt.rev_id;
|
||||
file.wpt = crate::Waypoints::new([
|
||||
WaypointChunk {
|
||||
wpt: vec![wpt("a"), wpt("b")],
|
||||
..Default::default()
|
||||
}),
|
||||
Rc::new(WaypointChunk {
|
||||
},
|
||||
WaypointChunk {
|
||||
wpt: vec![wpt("c")],
|
||||
..Default::default()
|
||||
}),
|
||||
];
|
||||
},
|
||||
]);
|
||||
let node = FileStructure::new(&file);
|
||||
let names: Vec<_> = node
|
||||
.waypoints
|
||||
@@ -153,6 +150,6 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names, ["a", "b", "c"]);
|
||||
assert_ne!(node.wpt_rev_id, before);
|
||||
assert_eq!(node.wpt_rev_id, file.wpt_rev_id);
|
||||
assert_eq!(node.wpt_rev_id, file.wpt.rev_id);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
mod coordinates_cache;
|
||||
mod file_structure;
|
||||
mod file_structure_cache;
|
||||
mod routing_buffer;
|
||||
mod statistics_buffer;
|
||||
mod statistics_cache;
|
||||
|
||||
pub use coordinates_cache::*;
|
||||
pub use file_structure::*;
|
||||
pub use file_structure_cache::*;
|
||||
pub use routing_buffer::*;
|
||||
pub use statistics_buffer::*;
|
||||
pub use statistics_cache::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,20 @@ pub const NO_TIME: i64 = i64::MIN;
|
||||
/// 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: Vec<f64>,
|
||||
pub total_time: Vec<i64>,
|
||||
pub moving_time: Vec<i64>,
|
||||
pub speed: 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>,
|
||||
@@ -28,20 +33,47 @@ pub struct StatisticsBuffer {
|
||||
pub lat: Vec<f64>,
|
||||
pub ele: Vec<f64>,
|
||||
/// Timestamps, in milliseconds since the epoch.
|
||||
pub time: Vec<i64>,
|
||||
pub hr: Vec<f64>,
|
||||
pub cad: Vec<f64>,
|
||||
pub atemp: Vec<f64>,
|
||||
pub power: Vec<f64>,
|
||||
/// Surface of the trackpoints: 0 when unknown, else 1 + its code in the categories of the
|
||||
/// engine (so that it is never 0 for a known surface).
|
||||
pub surface: Vec<u8>,
|
||||
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: Vec<u8>,
|
||||
pub highway: Intervals,
|
||||
/// SAC hiking scale of the trackpoints, as the surface.
|
||||
pub sac_scale: Vec<u8>,
|
||||
pub sac_scale: Intervals,
|
||||
/// Mountain biking scale of the trackpoints, as the surface.
|
||||
pub mtb_scale: Vec<u8>,
|
||||
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
|
||||
@@ -50,6 +82,21 @@ 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()
|
||||
@@ -63,10 +110,10 @@ impl StatisticsBuffer {
|
||||
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
|
||||
self.global = GlobalStatistics::default();
|
||||
self.total_distance.clear();
|
||||
self.moving_distance.clear();
|
||||
self.total_time.clear();
|
||||
self.moving_time.clear();
|
||||
self.speed.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();
|
||||
@@ -75,35 +122,41 @@ impl StatisticsBuffer {
|
||||
self.lng.clear();
|
||||
self.lat.clear();
|
||||
self.ele.clear();
|
||||
self.time.clear();
|
||||
self.hr.clear();
|
||||
self.cad.clear();
|
||||
self.atemp.clear();
|
||||
self.power.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();
|
||||
|
||||
let optional = |value: Option<f64>| value.unwrap_or(f64::NAN);
|
||||
|
||||
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);
|
||||
self.moving_distance.push(
|
||||
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance)
|
||||
.unwrap_or_default(),
|
||||
push_optional(
|
||||
&mut self.moving_distance,
|
||||
index,
|
||||
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance),
|
||||
0.0,
|
||||
);
|
||||
self.total_time.push(
|
||||
sum_options(cumul_stats.total_time, trkpt_stats.total_time).unwrap_or_default(),
|
||||
push_optional(
|
||||
&mut self.total_time,
|
||||
index,
|
||||
sum_options(cumul_stats.total_time, trkpt_stats.total_time),
|
||||
0,
|
||||
);
|
||||
self.moving_time.push(
|
||||
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time)
|
||||
.unwrap_or_default(),
|
||||
push_optional(
|
||||
&mut self.moving_time,
|
||||
index,
|
||||
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time),
|
||||
0,
|
||||
);
|
||||
self.speed.push(trkpt_stats.speed.unwrap_or_default());
|
||||
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
|
||||
@@ -116,15 +169,15 @@ impl StatisticsBuffer {
|
||||
self.lng.push(trkpt.coordinates.lng);
|
||||
self.lat.push(trkpt.coordinates.lat);
|
||||
self.ele.push(trkpt.ele);
|
||||
self.time.push(trkpt.time.unwrap_or(NO_TIME));
|
||||
self.hr.push(optional(trkpt.hr.map(f64::from)));
|
||||
self.cad.push(optional(trkpt.cad.map(f64::from)));
|
||||
self.atemp.push(optional(trkpt.atemp.map(f64::from)));
|
||||
self.power.push(optional(trkpt.power.map(f64::from)));
|
||||
self.surface.push(unknown_or_next(trkpt.surface));
|
||||
self.highway.push(unknown_or_next(trkpt.highway));
|
||||
self.sac_scale.push(unknown_or_next(trkpt.sac_scale));
|
||||
self.mtb_scale.push(unknown_or_next(trkpt.mtb_scale));
|
||||
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);
|
||||
}
|
||||
@@ -142,28 +195,22 @@ impl StatisticsBuffer {
|
||||
}
|
||||
|
||||
let delta = |values: &[f64]| values[end] - values[start];
|
||||
let delta_time = |values: &[i64]| values[end] - values[start];
|
||||
let time = |i: usize| (self.time[i] != NO_TIME).then_some(self.time[i]);
|
||||
// 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
|
||||
.global
|
||||
.moving_distance
|
||||
.map(|_| delta(&self.moving_distance)),
|
||||
total_time: self.global.total_time.map(|_| delta_time(&self.total_time)),
|
||||
moving_time: self
|
||||
.global
|
||||
.moving_time
|
||||
.map(|_| delta_time(&self.moving_time)),
|
||||
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,
|
||||
hr: self.global.hr,
|
||||
cad: self.global.cad,
|
||||
atemp: self.global.atemp,
|
||||
power: self.global.power,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -182,13 +229,20 @@ mod tests {
|
||||
(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.moving_distance.len(),
|
||||
buffer.total_time.len(),
|
||||
buffer.moving_time.len(),
|
||||
buffer.speed.len(),
|
||||
buffer.elevation_gain.len(),
|
||||
buffer.elevation_loss.len(),
|
||||
buffer.slope.len(),
|
||||
@@ -197,15 +251,6 @@ mod tests {
|
||||
buffer.lng.len(),
|
||||
buffer.lat.len(),
|
||||
buffer.ele.len(),
|
||||
buffer.time.len(),
|
||||
buffer.hr.len(),
|
||||
buffer.cad.len(),
|
||||
buffer.atemp.len(),
|
||||
buffer.power.len(),
|
||||
buffer.surface.len(),
|
||||
buffer.highway.len(),
|
||||
buffer.sac_scale.len(),
|
||||
buffer.mtb_scale.len(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -214,7 +259,7 @@ mod tests {
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[]);
|
||||
assert!(buffer.is_empty());
|
||||
assert!(buffer.speed.is_empty());
|
||||
assert!(buffer.speed.is_none());
|
||||
assert_eq!(buffer.global.total_distance, 0.0);
|
||||
assert!(buffer.slice(0, 0).is_none());
|
||||
}
|
||||
@@ -286,37 +331,153 @@ mod tests {
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||
|
||||
assert_eq!(buffer.total_time[n - 1], duration);
|
||||
assert_eq!(buffer.total_time[n], duration);
|
||||
assert_eq!(buffer.total_time[2 * n - 1], 2 * duration);
|
||||
assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1]));
|
||||
assert!(buffer.time.iter().all(|t| *t != NO_TIME));
|
||||
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_missing_times_and_measures_are_nan() {
|
||||
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.iter().all(|t| *t == NO_TIME));
|
||||
assert!(buffer.hr.iter().all(|v| v.is_nan()));
|
||||
assert_eq!(buffer.global.hr.count, 0);
|
||||
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_surface_and_highway_codes() {
|
||||
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
|
||||
assert_eq!(buffer.surface, [1, 1, 0, 2, 2]);
|
||||
assert_eq!(buffer.highway, [1, 1, 0, 2, 0]);
|
||||
assert_eq!(buffer.sac_scale, [0, 0, 0, 1, 2]);
|
||||
assert_eq!(buffer.mtb_scale, [0, 0, 0, 1, 1]);
|
||||
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]
|
||||
@@ -331,26 +492,37 @@ mod tests {
|
||||
let s = Statistics::compute(&segment);
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
assert_eq!((buffer.surface[0], buffer.highway[0]), (8, 255));
|
||||
assert_eq!((buffer.sac_scale[0], buffer.mtb_scale[0]), (1, 4));
|
||||
assert_eq!((buffer.surface[1], buffer.highway[1]), (0, 0));
|
||||
assert_eq!((buffer.sac_scale[1], buffer.mtb_scale[1]), (0, 0));
|
||||
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_no_codes_without_surface_and_highway() {
|
||||
fn test_unknown_intervals_without_surface_and_highway() {
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
assert_eq!(buffer.surface.len(), s.local.len());
|
||||
assert!(buffer.surface.iter().all(|c| *c == 0));
|
||||
assert!(buffer.highway.iter().all(|c| *c == 0));
|
||||
assert!(buffer.sac_scale.iter().all(|c| *c == 0));
|
||||
assert!(buffer.mtb_scale.iter().all(|c| *c == 0));
|
||||
// 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!(buffer.surface.is_empty() && buffer.highway.is_empty());
|
||||
assert!(buffer.sac_scale.is_empty() && buffer.mtb_scale.is_empty());
|
||||
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]
|
||||
@@ -383,6 +555,44 @@ mod tests {
|
||||
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");
|
||||
@@ -399,14 +609,20 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
slice.total_time,
|
||||
Some(buffer.total_time[end] - buffer.total_time[start])
|
||||
Some(
|
||||
buffer.total_time.as_ref().unwrap()[end]
|
||||
- buffer.total_time.as_ref().unwrap()[start]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
slice.moving_time,
|
||||
Some(buffer.moving_time[end] - buffer.moving_time[start])
|
||||
Some(
|
||||
buffer.moving_time.as_ref().unwrap()[end]
|
||||
- buffer.moving_time.as_ref().unwrap()[start]
|
||||
)
|
||||
);
|
||||
assert_eq!(slice.start_time, Some(buffer.time[start]));
|
||||
assert_eq!(slice.end_time, Some(buffer.time[end]));
|
||||
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());
|
||||
|
||||
@@ -417,21 +633,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slice_keeps_the_bounds_and_averages_of_the_selection() {
|
||||
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)]);
|
||||
assert!(buffer.global.hr.count > 0);
|
||||
|
||||
let slice = buffer.slice(1, 3).unwrap();
|
||||
let (global, bounds) = (&buffer.global, &slice.bounds);
|
||||
assert_eq!(slice.hr, global.hr);
|
||||
assert_eq!(slice.cad, global.cad);
|
||||
assert_eq!(slice.atemp, global.atemp);
|
||||
assert_eq!(slice.power, global.power);
|
||||
assert_eq!(
|
||||
(bounds.sw.lng, bounds.sw.lat),
|
||||
(global.bounds.sw.lng, global.bounds.sw.lat)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,372 @@
|
||||
mod action;
|
||||
mod command;
|
||||
mod derived;
|
||||
mod engine;
|
||||
mod outcome;
|
||||
mod state;
|
||||
|
||||
pub use action::*;
|
||||
pub use command::*;
|
||||
pub use derived::*;
|
||||
pub use engine::*;
|
||||
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;
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::{CommandError, Diff};
|
||||
|
||||
/// What an action did, returned by [`crate::Engine::execute`] and [`crate::Engine::restore`].
|
||||
///
|
||||
/// It describes that action only: it is not kept by the engine, so reading it never depends on
|
||||
/// what was done since.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// Whether anything changed: the files, the selection, the order of the files or the
|
||||
/// clipboard.
|
||||
pub changed: bool,
|
||||
/// Which files the action added, removed or modified, in the order of the files. `None` when
|
||||
/// the files are as they were.
|
||||
pub diff: Option<Diff>,
|
||||
pub order_changed: bool,
|
||||
pub selection_changed: bool,
|
||||
pub clipboard_changed: bool,
|
||||
/// Why an edit did nothing: the command failed, or it had nothing to change. `None` if it
|
||||
/// succeeded, and for the actions that do not edit the files.
|
||||
pub error: Option<CommandError>,
|
||||
}
|
||||
@@ -120,20 +120,14 @@ impl Clipboard {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
Selection::Waypoints { file_id } => ClipboardContent::Waypoints(
|
||||
files
|
||||
.get(file_id)?
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.wpt.iter().cloned())
|
||||
.collect(),
|
||||
),
|
||||
Selection::Waypoints { file_id } => {
|
||||
ClipboardContent::Waypoints(files.get(file_id)?.wpt.iter().cloned().collect())
|
||||
}
|
||||
Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints(
|
||||
files
|
||||
.get(file_id)?
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.filter(|wpt| wpt_ids.contains(&wpt.id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
@@ -233,14 +227,14 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![Waypoint::default(), Waypoint::default()],
|
||||
..Default::default()
|
||||
}));
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
});
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![Waypoint::default()],
|
||||
..Default::default()
|
||||
}));
|
||||
});
|
||||
file
|
||||
}
|
||||
|
||||
@@ -267,11 +261,7 @@ mod tests {
|
||||
let file = file("source");
|
||||
let (t0, t2) = (file.trk[0].id, file.trk[2].id);
|
||||
let (s0, s1) = (file.trk[1].trkseg[0].id, file.trk[1].trkseg[1].id);
|
||||
let wpt: Vec<_> = file
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|c| c.wpt.iter().map(|w| w.id))
|
||||
.collect();
|
||||
let wpt: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
|
||||
let files = files_of(vec![file.clone()]);
|
||||
|
||||
// tracks, in the order of the file, with the name of their file
|
||||
@@ -447,7 +437,7 @@ mod tests {
|
||||
let waypoints = Selection::Waypoints { file_id: file.id };
|
||||
let waypoint = Selection::Waypoint {
|
||||
file_id: file.id,
|
||||
wpt_ids: [file.wpt[0].wpt[0].id].into(),
|
||||
wpt_ids: [file.wpt[0].id].into(),
|
||||
};
|
||||
let targets = [
|
||||
Selection::Empty,
|
||||
|
||||
@@ -40,7 +40,109 @@ pub enum Selection {
|
||||
},
|
||||
}
|
||||
|
||||
/// Where a track segment is: its file, then its track and its own position in it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SegmentLocation {
|
||||
pub file_id: FileId,
|
||||
pub trk: usize,
|
||||
pub seg: usize,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Combines `selection` with this one according to `mode`, ignoring the elements of
|
||||
/// `selection` that do not exist in `files`. Nothing to add or toggle leaves the selection
|
||||
/// as it is, while replacing it with nothing deselects everything.
|
||||
pub fn select(
|
||||
&mut self,
|
||||
files: Option<&StackEntry>,
|
||||
mut selection: Selection,
|
||||
mode: SelectMode,
|
||||
) {
|
||||
match files {
|
||||
Some(files) => selection.retain_existing(files),
|
||||
None => selection = Selection::Empty,
|
||||
}
|
||||
match mode {
|
||||
SelectMode::Replace => *self = selection,
|
||||
_ if selection == Selection::Empty => {}
|
||||
SelectMode::Add => self.extend(selection),
|
||||
SelectMode::Toggle => self.toggle(selection),
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects all the elements of the same kind as the selected ones, see
|
||||
/// [`Selection::all_at_level`]. Does nothing when there is nothing more to select.
|
||||
pub fn select_all(&mut self, files: &StackEntry, order: &[FileId]) {
|
||||
if let Some(all) = self.all_at_level(files, order) {
|
||||
*self = all;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the selection to the next (`down`) or previous element, see
|
||||
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
|
||||
/// replacing it.
|
||||
pub fn select_neighbour(
|
||||
&mut self,
|
||||
files: &StackEntry,
|
||||
order: &[FileId],
|
||||
down: bool,
|
||||
add: bool,
|
||||
) {
|
||||
if let Some(next) = self.neighbour(files, order, down) {
|
||||
if add {
|
||||
self.extend(next);
|
||||
} else {
|
||||
*self = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// The track segments covered by the selection, in order: the segments of several selected
|
||||
/// files follow `order` (the files missing from it come last), then the tracks and segments
|
||||
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
|
||||
pub fn segment_locations(&self, files: &StackEntry, order: &[FileId]) -> Vec<SegmentLocation> {
|
||||
let mut locations = vec![];
|
||||
let mut add = |file_id: FileId,
|
||||
trk_filter: &dyn Fn(&crate::Track) -> bool,
|
||||
seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| {
|
||||
let Some(file) = files.get(&file_id) else {
|
||||
return;
|
||||
};
|
||||
for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) {
|
||||
for (seg, segment) in track.trkseg.iter().enumerate() {
|
||||
if seg_filter(segment) {
|
||||
locations.push(SegmentLocation { file_id, trk, seg });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => (),
|
||||
Selection::File { file_ids } => {
|
||||
let ordered = order
|
||||
.iter()
|
||||
.filter(|id| file_ids.contains(id))
|
||||
.chain(file_ids.iter().filter(|id| !order.contains(id)));
|
||||
for id in ordered {
|
||||
add(*id, &|_| true, &|_| true);
|
||||
}
|
||||
}
|
||||
Selection::Track { file_id, trk_ids } => {
|
||||
add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true)
|
||||
}
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => add(*file_id, &|trk| trk.id == *trk_id, &|seg| {
|
||||
trkseg_ids.contains(&seg.id)
|
||||
}),
|
||||
}
|
||||
locations
|
||||
}
|
||||
|
||||
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
|
||||
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
|
||||
pub fn retain_existing(&mut self, files: &StackEntry) {
|
||||
@@ -74,12 +176,7 @@ impl Selection {
|
||||
Selection::Waypoints { file_id } => !files.contains_key(file_id),
|
||||
Selection::Waypoint { file_id, wpt_ids } => match files.get(file_id) {
|
||||
Some(file) => {
|
||||
wpt_ids.retain(|id| {
|
||||
file.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.any(|wpt| wpt.id == *id)
|
||||
});
|
||||
wpt_ids.retain(|id| file.wpt.iter().any(|wpt| wpt.id == *id));
|
||||
wpt_ids.is_empty()
|
||||
}
|
||||
None => true,
|
||||
@@ -197,13 +294,10 @@ impl Selection {
|
||||
}
|
||||
|
||||
/// Position of the first and last of the `selected` ids among the `all` ids.
|
||||
fn selected_range<T: PartialEq>(
|
||||
fn selected_range<T: std::hash::Hash + Eq>(
|
||||
all: impl Iterator<Item = T>,
|
||||
selected: &HashSet<T>,
|
||||
) -> Option<(usize, usize)>
|
||||
where
|
||||
T: std::hash::Hash + Eq,
|
||||
{
|
||||
) -> Option<(usize, usize)> {
|
||||
let mut range: Option<(usize, usize)> = None;
|
||||
for (i, id) in all.enumerate() {
|
||||
if selected.contains(&id) {
|
||||
@@ -272,13 +366,7 @@ impl Selection {
|
||||
Selection::Waypoints { .. } => None,
|
||||
Selection::Waypoint { file_id, .. } => Some(Selection::Waypoint {
|
||||
file_id: *file_id,
|
||||
wpt_ids: files
|
||||
.get(file_id)?
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.map(|wpt| wpt.id)
|
||||
.collect(),
|
||||
wpt_ids: files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -332,13 +420,8 @@ impl Selection {
|
||||
})
|
||||
}
|
||||
Selection::Waypoint { file_id, wpt_ids } => {
|
||||
let ids: Vec<WaypointId> = files
|
||||
.get(file_id)?
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.map(|wpt| wpt.id)
|
||||
.collect();
|
||||
let ids: Vec<WaypointId> =
|
||||
files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect();
|
||||
Some(Selection::Waypoint {
|
||||
file_id: *file_id,
|
||||
wpt_ids: [neighbour(&ids, wpt_ids, down)?].into(),
|
||||
@@ -392,12 +475,12 @@ mod tests {
|
||||
trkseg: vec![TrackSegment::default()],
|
||||
..Default::default()
|
||||
});
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![Waypoint::default()],
|
||||
..Default::default()
|
||||
}));
|
||||
});
|
||||
let (file_id, trk_id) = (file.id, file.trk[0].id);
|
||||
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].wpt[0].id);
|
||||
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
|
||||
let mut files = StackEntry::default();
|
||||
files.insert(file_id, Rc::new(file));
|
||||
|
||||
@@ -543,15 +626,15 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![Waypoint::default(), Waypoint::default()],
|
||||
..Default::default()
|
||||
}));
|
||||
file.wpt.push(Rc::new(WaypointChunk {
|
||||
});
|
||||
file.wpt.push(WaypointChunk {
|
||||
wpt: vec![Waypoint::default()],
|
||||
..Default::default()
|
||||
}));
|
||||
let tree = Tree {
|
||||
});
|
||||
Tree {
|
||||
file: file.id,
|
||||
tracks: file.trk.iter().map(|t| t.id).collect(),
|
||||
segments: file
|
||||
@@ -559,16 +642,10 @@ mod tests {
|
||||
.iter()
|
||||
.map(|t| t.trkseg.iter().map(|s| s.id).collect())
|
||||
.collect(),
|
||||
waypoints: file
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.map(|w| w.id)
|
||||
.collect(),
|
||||
waypoints: file.wpt.iter().map(|w| w.id).collect(),
|
||||
order: vec![file.id],
|
||||
files: StackEntry::from([(file.id, Rc::new(file))]),
|
||||
};
|
||||
tree
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,36 +3,67 @@ use std::{
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use crate::{File, FileId};
|
||||
use crate::{CommandError, File, FileId};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Stack {
|
||||
entries: Vec<StackEntry>,
|
||||
index: Option<usize>,
|
||||
/// Whether the first entry is the state the stack starts from, which is not undone: files
|
||||
/// restored from the storage.
|
||||
floor: bool,
|
||||
}
|
||||
|
||||
impl Stack {
|
||||
/// A stack whose first entry is `entry`, from which nothing can be undone.
|
||||
pub fn restored(entry: StackEntry) -> Self {
|
||||
Self {
|
||||
entries: vec![entry],
|
||||
index: Some(0),
|
||||
floor: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Option<&StackEntry> {
|
||||
self.index.map_or_default(|i| self.get(i))
|
||||
}
|
||||
|
||||
pub fn create_and_push_next<F>(&mut self, f: F) -> Option<Diff>
|
||||
/// Applies `f` to a copy of the current entry and makes the copy the new current entry.
|
||||
///
|
||||
/// Nothing is pushed, and `None` is returned, if `f` fails (the error is returned) or if it
|
||||
/// leaves every file as it was: a step that undoes nothing is not worth a place in the
|
||||
/// history. Files are told apart by their identity, not their content.
|
||||
pub fn create_and_push_next<F>(&mut self, f: F) -> Result<Option<Diff>, CommandError>
|
||||
where
|
||||
F: FnOnce(&mut StackEntry) -> Result<(), String>,
|
||||
F: FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
||||
{
|
||||
self.record_diff(|stack| {
|
||||
let mut next = stack.current().map_or_default(|c| c.clone());
|
||||
if f(&mut next).is_ok() {
|
||||
stack.push(next);
|
||||
}
|
||||
})
|
||||
let mut next = self.current().map_or_default(|c| c.clone());
|
||||
f(&mut next)?;
|
||||
if self.is_current(&next) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(self.record_diff(|stack| stack.push(next)))
|
||||
}
|
||||
|
||||
/// Whether `entry` holds exactly the files of the current entry.
|
||||
fn is_current(&self, entry: &StackEntry) -> bool {
|
||||
let current = self.current();
|
||||
let len = current.map_or(0, |current| current.len());
|
||||
entry.len() == len
|
||||
&& current.is_none_or(|current| {
|
||||
entry
|
||||
.iter()
|
||||
.all(|(id, file)| current.get(id).is_some_and(|c| Rc::ptr_eq(c, file)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Option<Diff> {
|
||||
self.record_diff(|stack| {
|
||||
if let Some(i) = stack.index {
|
||||
if i == 0 {
|
||||
stack.index = None;
|
||||
if !stack.floor {
|
||||
stack.index = None;
|
||||
}
|
||||
} else {
|
||||
stack.index = Some(i - 1);
|
||||
}
|
||||
@@ -56,7 +87,7 @@ impl Stack {
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.index.is_some()
|
||||
self.index.is_some_and(|i| i > 0 || !self.floor)
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
@@ -97,7 +128,7 @@ impl Stack {
|
||||
for id in prev_ids.intersection(&cur_ids) {
|
||||
let before = prev.map_or_default(|e| e.get(id));
|
||||
let after = cur.map_or_default(|e| e.get(id));
|
||||
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) && before != after {
|
||||
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) {
|
||||
modified.push(*id);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +154,16 @@ pub struct Diff {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
impl Stack {
|
||||
/// A step that is expected not to fail.
|
||||
fn push_ok(
|
||||
&mut self,
|
||||
f: impl FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
||||
) -> Option<Diff> {
|
||||
self.create_and_push_next(f).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_file() {
|
||||
let mut stack = Stack::default();
|
||||
@@ -131,7 +172,7 @@ mod tests {
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.current().is_none());
|
||||
|
||||
let diff = stack.create_and_push_next(|e| {
|
||||
let diff = stack.push_ok(|e| {
|
||||
let file = Rc::new(File::default());
|
||||
e.insert(file.id, file);
|
||||
Ok(())
|
||||
@@ -153,7 +194,7 @@ mod tests {
|
||||
let file = Rc::new(File::default());
|
||||
let id = file.id;
|
||||
stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
e.insert(id, file);
|
||||
Ok(())
|
||||
})
|
||||
@@ -166,9 +207,9 @@ mod tests {
|
||||
let mut stack = Stack::default();
|
||||
let diff = stack.create_and_push_next(|e| {
|
||||
e.insert(FileId::default(), Rc::new(File::default()));
|
||||
Err("nope".to_string())
|
||||
Err(CommandError::InvalidData("nope".to_string()))
|
||||
});
|
||||
assert!(diff.is_none());
|
||||
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.current().is_none());
|
||||
}
|
||||
@@ -203,7 +244,7 @@ mod tests {
|
||||
let id = add_file(&mut stack);
|
||||
|
||||
let diff = stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
let mut file = (**e.get(&id).unwrap()).clone();
|
||||
file.info.name = "renamed".to_string();
|
||||
e.insert(id, Rc::new(file));
|
||||
@@ -213,12 +254,20 @@ mod tests {
|
||||
assert_eq!(diff.modified, vec![id]);
|
||||
assert!(diff.added.is_empty() && diff.removed.is_empty());
|
||||
|
||||
// an unchanged file is not reported as modified
|
||||
let diff = stack.create_and_push_next(|_| Ok(())).unwrap();
|
||||
assert!(diff.modified.is_empty());
|
||||
// a step that changes nothing is not a step
|
||||
assert!(stack.push_ok(|_| Ok(())).is_none());
|
||||
assert!(
|
||||
stack
|
||||
.push_ok(|e| {
|
||||
let file = e[&id].clone();
|
||||
e.insert(id, file);
|
||||
Ok(())
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let diff = stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
e.remove(&id);
|
||||
Ok(())
|
||||
})
|
||||
@@ -231,6 +280,34 @@ mod tests {
|
||||
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
|
||||
let mut stack = Stack::default();
|
||||
let id = add_file(&mut stack);
|
||||
|
||||
// a copy, even if it has the same content, is another file for the stack: files are
|
||||
// told apart by their identity, comparing them would read all of them
|
||||
let diff = stack
|
||||
.push_ok(|e| {
|
||||
let copy = (**e.get(&id).unwrap()).clone();
|
||||
e.insert(id, Rc::new(copy));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diff.modified, vec![id]);
|
||||
|
||||
// and the files that were not replaced are not
|
||||
let other = add_file(&mut stack);
|
||||
let diff = stack
|
||||
.push_ok(|e| {
|
||||
let copy = (**e.get(&other).unwrap()).clone();
|
||||
e.insert(other, Rc::new(copy));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diff.modified, vec![other]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_after_undo_drops_redo_branch() {
|
||||
let mut stack = Stack::default();
|
||||
@@ -249,4 +326,104 @@ mod tests {
|
||||
assert!(current.contains_key(&third));
|
||||
assert!(!current.contains_key(&second));
|
||||
}
|
||||
|
||||
fn restored_entry() -> (StackEntry, FileId) {
|
||||
let file = Rc::new(File::default());
|
||||
let id = file.id;
|
||||
(StackEntry::from([(id, file)]), id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_starts_from_its_entry() {
|
||||
let (entry, id) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
|
||||
assert!(stack.current().unwrap().contains_key(&id));
|
||||
// there is nothing to go back to, nor forward to
|
||||
assert!(!stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(stack.redo().is_none());
|
||||
assert!(stack.current().unwrap().contains_key(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_cannot_undo_an_empty_entry_either() {
|
||||
let mut stack = Stack::restored(StackEntry::new());
|
||||
|
||||
assert!(stack.current().is_some_and(|files| files.is_empty()));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.undo().is_none());
|
||||
// the stack is not the one of an engine that has done nothing
|
||||
assert!(stack.current().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_undoes_what_comes_after_it_and_no_further() {
|
||||
let (entry, restored) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
|
||||
let added = add_file(&mut stack);
|
||||
assert!(stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
assert_eq!(stack.current().unwrap().len(), 2);
|
||||
|
||||
// back to the restored files
|
||||
let diff = stack.undo().unwrap();
|
||||
assert_eq!(diff.removed, vec![added]);
|
||||
assert!(diff.added.is_empty() && diff.modified.is_empty());
|
||||
assert_eq!(stack.current().unwrap().len(), 1);
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.can_redo());
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
|
||||
let diff = stack.redo().unwrap();
|
||||
assert_eq!(diff.added, vec![added]);
|
||||
assert!(stack.can_undo());
|
||||
assert!(!stack.can_redo());
|
||||
|
||||
// and again
|
||||
assert!(stack.undo().is_some());
|
||||
assert!(stack.undo().is_none());
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_removing_its_files_can_be_undone() {
|
||||
let (entry, restored) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
|
||||
let diff = stack
|
||||
.push_ok(|e| {
|
||||
e.remove(&restored);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diff.removed, vec![restored]);
|
||||
assert!(stack.current().unwrap().is_empty());
|
||||
|
||||
let diff = stack.undo().unwrap();
|
||||
assert_eq!(diff.added, vec![restored]);
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert!(!stack.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restored_stack_push_after_undo_drops_redo_branch() {
|
||||
let (entry, restored) = restored_entry();
|
||||
let mut stack = Stack::restored(entry);
|
||||
add_file(&mut stack);
|
||||
stack.undo().unwrap();
|
||||
assert!(stack.can_redo());
|
||||
|
||||
let other = add_file(&mut stack);
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.can_undo());
|
||||
let diff = stack.undo().unwrap();
|
||||
assert_eq!(diff.removed, vec![other]);
|
||||
assert!(stack.current().unwrap().contains_key(&restored));
|
||||
assert!(!stack.can_undo());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,7 @@
|
||||
mod core;
|
||||
mod engine;
|
||||
mod storage;
|
||||
|
||||
pub use core::*;
|
||||
pub use engine::*;
|
||||
pub use storage::*;
|
||||
@@ -0,0 +1,593 @@
|
||||
//! The binary form of what is stored.
|
||||
//!
|
||||
//! Every blob starts with a version byte, then the data (postcard, which does not name the fields:
|
||||
//! the form is exactly the types that wrote it). So a form never changes once it has been
|
||||
//! written: the types of each version live in their own module (see [`v1`]) and are frozen, and
|
||||
//! a change is a new version next to them. Blobs of any known version can be read; they are
|
||||
//! written again in the latest version when they are written again. Chunks are never rewritten
|
||||
//! unless they change, so blobs of several versions live together in a storage.
|
||||
//!
|
||||
//! To change the form: add `vN`, make `LATEST` its number, convert from it in the decoders, and
|
||||
//! keep the golden bytes of the older versions in the tests.
|
||||
|
||||
mod v1;
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Trackpoint,
|
||||
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
|
||||
WaypointChunkId, Waypoints,
|
||||
};
|
||||
|
||||
/// The version that is written.
|
||||
pub const LATEST: u8 = 1;
|
||||
|
||||
/// Why a blob could not be read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecodeError {
|
||||
/// It was written by a version that this one does not know: a newer one, or not a blob.
|
||||
UnknownVersion(u8),
|
||||
/// It has a known version, but not what it should have.
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
fn encode<T: Serialize + ?Sized>(value: &T) -> Vec<u8> {
|
||||
postcard::to_extend(value, vec![LATEST]).expect("serializing to memory")
|
||||
}
|
||||
|
||||
/// The version of a blob, and its data.
|
||||
fn split(bytes: &[u8]) -> Result<(u8, &[u8]), DecodeError> {
|
||||
bytes
|
||||
.split_first()
|
||||
.map(|(v, data)| (*v, data))
|
||||
.ok_or(DecodeError::Corrupt)
|
||||
}
|
||||
|
||||
fn parse<'a, T: Deserialize<'a>>(data: &'a [u8]) -> Result<T, DecodeError> {
|
||||
postcard::from_bytes(data).map_err(|_| DecodeError::Corrupt)
|
||||
}
|
||||
|
||||
/// A file without its trackpoints and waypoints, which are in chunks.
|
||||
pub struct FileRecord {
|
||||
info: FileInfo,
|
||||
tracks: Vec<TrackRecord>,
|
||||
waypoints: Vec<WaypointChunkId>,
|
||||
}
|
||||
|
||||
struct TrackRecord {
|
||||
id: TrackId,
|
||||
info: TrackInfo,
|
||||
segments: Vec<SegmentRecord>,
|
||||
}
|
||||
|
||||
struct SegmentRecord {
|
||||
id: TrackSegmentId,
|
||||
chunks: Vec<TrackpointChunkId>,
|
||||
}
|
||||
|
||||
impl From<v1::FileRecord1> for FileRecord {
|
||||
fn from(record: v1::FileRecord1) -> Self {
|
||||
Self {
|
||||
info: record.info.into(),
|
||||
tracks: record
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| TrackRecord {
|
||||
id: TrackId(track.id),
|
||||
info: track.info.into(),
|
||||
segments: track
|
||||
.segments
|
||||
.into_iter()
|
||||
.map(|segment| SegmentRecord {
|
||||
id: TrackSegmentId(segment.id),
|
||||
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileRecord {
|
||||
/// The file made of the record and its chunks, `None` if one is missing. Chunks that several
|
||||
/// files use stay shared.
|
||||
pub fn build(
|
||||
self,
|
||||
id: FileId,
|
||||
trackpoint_chunks: &impl Fn(&TrackpointChunkId) -> Option<Rc<TrackpointChunk>>,
|
||||
waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option<Rc<WaypointChunk>>,
|
||||
) -> Option<File> {
|
||||
let mut file = File {
|
||||
id,
|
||||
info: self.info,
|
||||
..Default::default()
|
||||
};
|
||||
for track in self.tracks {
|
||||
let mut trkseg = Vec::with_capacity(track.segments.len());
|
||||
for segment in track.segments {
|
||||
let mut built = TrackSegment::default();
|
||||
built.id = segment.id;
|
||||
for chunk in &segment.chunks {
|
||||
built.push_shared(trackpoint_chunks(chunk)?);
|
||||
}
|
||||
trkseg.push(built);
|
||||
}
|
||||
file.trk.push(Track {
|
||||
id: track.id,
|
||||
info: track.info,
|
||||
trkseg,
|
||||
});
|
||||
}
|
||||
let mut wpt = Waypoints::default();
|
||||
for chunk in &self.waypoints {
|
||||
wpt.push_shared(waypoint_chunks(chunk)?);
|
||||
}
|
||||
file.wpt = wpt;
|
||||
Some(file)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_file(file: &File) -> Vec<u8> {
|
||||
encode(&v1::FileRecord1::new(file))
|
||||
}
|
||||
|
||||
pub fn decode_file(bytes: &[u8]) -> Result<FileRecord, DecodeError> {
|
||||
match split(bytes)? {
|
||||
(1, data) => Ok(parse::<v1::FileRecord1>(data)?.into()),
|
||||
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec<u8> {
|
||||
encode(
|
||||
&chunk
|
||||
.trkpt
|
||||
.iter()
|
||||
.map(v1::Trackpoint1::from)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Result<TrackpointChunk, DecodeError> {
|
||||
let trkpt = match split(bytes)? {
|
||||
(1, data) => parse::<Vec<v1::Trackpoint1>>(data)?
|
||||
.into_iter()
|
||||
.map(Trackpoint::from)
|
||||
.collect(),
|
||||
(version, _) => return Err(DecodeError::UnknownVersion(version)),
|
||||
};
|
||||
Ok(TrackpointChunk {
|
||||
id: TrackpointChunkId(id),
|
||||
trkpt,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec<u8> {
|
||||
encode(
|
||||
&chunk
|
||||
.wpt
|
||||
.iter()
|
||||
.map(v1::Waypoint1::from)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Result<WaypointChunk, DecodeError> {
|
||||
let wpt = match split(bytes)? {
|
||||
(1, data) => parse::<Vec<v1::Waypoint1>>(data)?
|
||||
.into_iter()
|
||||
.map(Waypoint::from)
|
||||
.collect(),
|
||||
(version, _) => return Err(DecodeError::UnknownVersion(version)),
|
||||
};
|
||||
Ok(WaypointChunk {
|
||||
id: WaypointChunkId(id),
|
||||
wpt,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_order(order: &[FileId]) -> Vec<u8> {
|
||||
encode(&order.iter().map(|id| id.0).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn decode_order(bytes: &[u8]) -> Result<Vec<FileId>, DecodeError> {
|
||||
match split(bytes)? {
|
||||
(1, data) => Ok(parse::<Vec<Uuid>>(data)?.into_iter().map(FileId).collect()),
|
||||
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_categories(categories: &TrackpointCategories) -> Vec<u8> {
|
||||
encode(&v1::Categories1::from(categories))
|
||||
}
|
||||
|
||||
pub fn decode_categories(bytes: &[u8]) -> Result<TrackpointCategories, DecodeError> {
|
||||
match split(bytes)? {
|
||||
(1, data) => Ok(parse::<v1::Categories1>(data)?.into()),
|
||||
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Author, Link, LngLat};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn id(n: u128) -> Uuid {
|
||||
Uuid::from_u128(n)
|
||||
}
|
||||
|
||||
fn link(href: &str) -> Link {
|
||||
Link {
|
||||
href: href.into(),
|
||||
text: Some(format!("text of {href}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn trackpoint(n: u8) -> Trackpoint {
|
||||
let f = f64::from(n);
|
||||
Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: 4.0 + f,
|
||||
lat: 50.0 + f,
|
||||
},
|
||||
ele: 100.5 + f,
|
||||
time: Some(1_700_000_000_000 + i64::from(n)),
|
||||
atemp: Some(-3),
|
||||
hr: Some(140 + u16::from(n)),
|
||||
cad: Some(80),
|
||||
power: Some(250),
|
||||
surface: Some(1),
|
||||
highway: Some(2),
|
||||
sac_scale: Some(3),
|
||||
mtb_scale: Some(4),
|
||||
anchor: Some(n),
|
||||
}
|
||||
}
|
||||
|
||||
fn waypoint() -> Waypoint {
|
||||
Waypoint {
|
||||
id: crate::WaypointId(id(7)),
|
||||
coordinates: LngLat {
|
||||
lng: 5.5,
|
||||
lat: 51.5,
|
||||
},
|
||||
ele: 321.0,
|
||||
time: Some(1_700_000_000_000),
|
||||
name: Some("summit".into()),
|
||||
cmt: Some("comment".into()),
|
||||
desc: Some("description".into()),
|
||||
links: vec![
|
||||
link("https://example.com/summit"),
|
||||
link("https://example.com/summit-2"),
|
||||
],
|
||||
sym: Some("Summit".into()),
|
||||
type_: Some("peak".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file that has a value for every field, with fixed ids.
|
||||
fn sample() -> File {
|
||||
let mut file = File {
|
||||
id: FileId(id(1)),
|
||||
info: FileInfo {
|
||||
name: "sample".into(),
|
||||
desc: Some("about".into()),
|
||||
author: Some(Author {
|
||||
name: Some("someone".into()),
|
||||
email: Some("someone@example.com".into()),
|
||||
link: Some(link("https://example.com/someone")),
|
||||
}),
|
||||
links: vec![
|
||||
link("https://example.com/file"),
|
||||
link("https://example.com/file-2"),
|
||||
],
|
||||
time: Some(1_700_000_000_000),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut segment = TrackSegment::default();
|
||||
segment.id = TrackSegmentId(id(3));
|
||||
segment.push(TrackpointChunk {
|
||||
id: TrackpointChunkId(id(4)),
|
||||
trkpt: vec![trackpoint(0), trackpoint(1), Trackpoint::default()],
|
||||
});
|
||||
let mut empty = TrackSegment::default();
|
||||
empty.id = TrackSegmentId(id(6));
|
||||
file.trk.push(Track {
|
||||
id: TrackId(id(2)),
|
||||
info: TrackInfo {
|
||||
name: Some("track".into()),
|
||||
cmt: Some("cmt".into()),
|
||||
desc: Some("desc".into()),
|
||||
src: Some("src".into()),
|
||||
links: vec![
|
||||
link("https://example.com/track"),
|
||||
link("https://example.com/track-2"),
|
||||
],
|
||||
type_: Some("Cycling".into()),
|
||||
color: Some("ff0000".into()),
|
||||
opacity: Some(0.5),
|
||||
width: Some(6.0),
|
||||
},
|
||||
trkseg: vec![segment, empty],
|
||||
});
|
||||
file.wpt.push(WaypointChunk {
|
||||
id: WaypointChunkId(id(5)),
|
||||
wpt: vec![
|
||||
waypoint(),
|
||||
Waypoint {
|
||||
id: crate::WaypointId(id(8)),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
});
|
||||
file
|
||||
}
|
||||
|
||||
fn categories() -> TrackpointCategories {
|
||||
let mut categories = TrackpointCategories::default();
|
||||
categories.surface.code("asphalt");
|
||||
categories.surface.code("gravel");
|
||||
categories.highway.code("path");
|
||||
categories.sac_scale.code("hiking");
|
||||
categories.mtb_scale.code("mtb0");
|
||||
categories
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn unhex(text: &str) -> Vec<u8> {
|
||||
(0..text.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The chunks of a file, by identity.
|
||||
fn chunks_of(
|
||||
file: &File,
|
||||
) -> (
|
||||
HashMap<TrackpointChunkId, Rc<TrackpointChunk>>,
|
||||
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
|
||||
) {
|
||||
let trackpoints = file
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|track| &track.trkseg)
|
||||
.flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone())))
|
||||
.collect();
|
||||
let waypoints = file
|
||||
.wpt
|
||||
.chunks()
|
||||
.iter()
|
||||
.map(|c| (c.id, c.clone()))
|
||||
.collect();
|
||||
(trackpoints, waypoints)
|
||||
}
|
||||
|
||||
fn rebuilt(file: &File, record: FileRecord) -> Option<File> {
|
||||
let (trackpoints, waypoints) = chunks_of(file);
|
||||
record.build(file.id, &|id| trackpoints.get(id).cloned(), &|id| {
|
||||
waypoints.get(id).cloned()
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_same_file(a: &File, b: &File) {
|
||||
assert_eq!(a.id, b.id);
|
||||
assert_eq!(a.info, b.info);
|
||||
assert_eq!(a.trk.len(), b.trk.len());
|
||||
for (a, b) in a.trk.iter().zip(&b.trk) {
|
||||
assert_eq!((a.id, &a.info), (b.id, &b.info));
|
||||
assert_eq!(a.trkseg.len(), b.trkseg.len());
|
||||
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
|
||||
assert_eq!(a.id, b.id);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.chunks().iter().zip(b.chunks()) {
|
||||
assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(a.wpt.len(), b.wpt.len());
|
||||
for (a, b) in a.wpt.chunks().iter().zip(b.wpt.chunks()) {
|
||||
assert!(Rc::ptr_eq(a, b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_record_rebuilds_the_file_with_the_same_chunks() {
|
||||
let file = sample();
|
||||
let record = decode_file(&encode_file(&file)).unwrap();
|
||||
assert_same_file(&rebuilt(&file, record).unwrap(), &file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_record_of_a_missing_chunk_builds_nothing() {
|
||||
let file = sample();
|
||||
let (trackpoints, waypoints) = chunks_of(&file);
|
||||
let record = || decode_file(&encode_file(&file)).unwrap();
|
||||
assert!(
|
||||
record()
|
||||
.build(file.id, &|_| None, &|id| waypoints.get(id).cloned())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
record()
|
||||
.build(file.id, &|id| trackpoints.get(id).cloned(), &|_| None)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_without_data_builds_an_empty_file() {
|
||||
let file = File::default();
|
||||
let built = decode_file(&encode_file(&file))
|
||||
.unwrap()
|
||||
.build(file.id, &|_| None, &|_| None)
|
||||
.unwrap();
|
||||
assert!(built.trk.is_empty() && built.wpt.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_keep_their_identity_and_items() {
|
||||
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||
let decoded = decode_trackpoints(chunk.id.0, &encode_trackpoints(chunk)).unwrap();
|
||||
assert_eq!(decoded.id, chunk.id);
|
||||
assert_eq!(decoded.trkpt.len(), 3);
|
||||
for (a, b) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
||||
assert_eq!(format!("{a:?}"), format!("{b:?}"));
|
||||
}
|
||||
|
||||
let chunk = &sample().wpt.chunks()[0].clone();
|
||||
let decoded = decode_waypoints(chunk.id.0, &encode_waypoints(chunk)).unwrap();
|
||||
assert_eq!(decoded.id, chunk.id);
|
||||
for (a, b) in decoded.wpt.iter().zip(&chunk.wpt) {
|
||||
assert_eq!(a.id, b.id);
|
||||
assert!(a.same_content(b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_order_and_categories_round_trip() {
|
||||
let order = vec![FileId(id(9)), FileId(id(8))];
|
||||
assert_eq!(decode_order(&encode_order(&order)).unwrap(), order);
|
||||
let categories = categories();
|
||||
assert_eq!(
|
||||
decode_categories(&encode_categories(&categories)).unwrap(),
|
||||
categories
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blobs_say_their_version_and_unknown_ones_are_told_apart_from_corrupt_ones() {
|
||||
assert_eq!(encode_file(&sample())[0], LATEST);
|
||||
assert_eq!(encode_order(&[])[0], LATEST);
|
||||
|
||||
let mut bytes = encode_order(&[FileId(id(1))]);
|
||||
bytes[0] = LATEST + 1;
|
||||
assert_eq!(
|
||||
decode_order(&bytes),
|
||||
Err(DecodeError::UnknownVersion(LATEST + 1))
|
||||
);
|
||||
assert_eq!(decode_order(&[0]), Err(DecodeError::UnknownVersion(0)));
|
||||
assert!(matches!(
|
||||
decode_file(&bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_trackpoints(id(1), &bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_waypoints(id(1), &bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_categories(&bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
|
||||
// nothing at all, or a known version with something else
|
||||
assert_eq!(decode_order(&[]), Err(DecodeError::Corrupt));
|
||||
assert_eq!(
|
||||
decode_file(&[LATEST, 0xff, 0xff]).err(),
|
||||
Some(DecodeError::Corrupt)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_trackpoints(id(1), &[LATEST, 9, 9, 9]).err(),
|
||||
Some(DecodeError::Corrupt)
|
||||
);
|
||||
assert_eq!(decode_categories(&[LATEST]), Err(DecodeError::Corrupt));
|
||||
}
|
||||
|
||||
// The stored forms of version 1, as they were written when the version was made. They must
|
||||
// stay readable, whatever happens to the engine types: if a test below fails, the form was
|
||||
// changed by mistake, or a version was not added when it should have been.
|
||||
const FILE_V1: &str = concat!(
|
||||
"010673616d706c65010561626f7574010107736f6d656f6e650113736f6d656f6e65406578616d706c652e63",
|
||||
"6f6d011b68747470733a2f2f6578616d706c652e636f6d2f736f6d656f6e65012374657874206f6620687474",
|
||||
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
|
||||
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
|
||||
"70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
|
||||
"6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472",
|
||||
"61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261",
|
||||
"636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a",
|
||||
"2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70",
|
||||
"6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000",
|
||||
"0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000",
|
||||
"000000000000000000000000000600011000000000000000000000000000000005",
|
||||
);
|
||||
const TRACKPOINTS_V1: &str = concat!(
|
||||
"01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101",
|
||||
"0101020103010401000000000000001440000000000080494000000000006059400182a0abfef9620105018d",
|
||||
"01015001fa010101010201030104010100000000000000000000000000000000000000000000000000000000",
|
||||
"000000000000",
|
||||
);
|
||||
const WAYPOINTS_V1: &str = concat!(
|
||||
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
|
||||
"80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e021a68747470733a",
|
||||
"2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c",
|
||||
"652e636f6d2f73756d6d69741c68747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32012474",
|
||||
"657874206f662068747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32010653756d6d697401",
|
||||
"047065616b100000000000000000000000000000000800000000000000000000000000000000000000000000",
|
||||
"000000000000000000",
|
||||
);
|
||||
const ORDER_V1: &str =
|
||||
"010210000000000000000000000000000000091000000000000000000000000000000008";
|
||||
const CATEGORIES_V1: &str =
|
||||
"01020761737068616c740667726176656c010470617468010668696b696e6701046d746230";
|
||||
|
||||
#[test]
|
||||
fn test_version_1_is_still_written_as_it_was() {
|
||||
let file = sample();
|
||||
assert_eq!(hex(&encode_file(&file)), FILE_V1);
|
||||
assert_eq!(
|
||||
hex(&encode_trackpoints(&file.trk[0].trkseg[0].chunks()[0])),
|
||||
TRACKPOINTS_V1
|
||||
);
|
||||
assert_eq!(hex(&encode_waypoints(&file.wpt.chunks()[0])), WAYPOINTS_V1);
|
||||
assert_eq!(
|
||||
hex(&encode_order(&[FileId(id(9)), FileId(id(8))])),
|
||||
ORDER_V1
|
||||
);
|
||||
assert_eq!(hex(&encode_categories(&categories())), CATEGORIES_V1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_1_is_still_read_as_it_was() {
|
||||
let file = sample();
|
||||
assert_same_file(
|
||||
&rebuilt(&file, decode_file(&unhex(FILE_V1)).unwrap()).unwrap(),
|
||||
&file,
|
||||
);
|
||||
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1)).unwrap();
|
||||
let expected = &file.trk[0].trkseg[0].chunks()[0];
|
||||
assert_eq!(
|
||||
format!("{:?}", chunk.trkpt),
|
||||
format!("{:?}", expected.trkpt)
|
||||
);
|
||||
let chunk = decode_waypoints(id(5), &unhex(WAYPOINTS_V1)).unwrap();
|
||||
assert_eq!(chunk.wpt.len(), 2);
|
||||
assert!(chunk.wpt[0].same_content(&waypoint()));
|
||||
assert_eq!(chunk.wpt[0].id, waypoint().id);
|
||||
assert_eq!(
|
||||
decode_order(&unhex(ORDER_V1)).unwrap(),
|
||||
vec![FileId(id(9)), FileId(id(8))]
|
||||
);
|
||||
assert_eq!(
|
||||
decode_categories(&unhex(CATEGORIES_V1)).unwrap(),
|
||||
categories()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Version 1 of the stored form. It is frozen: the engine types may change, these may not (a test
|
||||
//! compares the bytes they give with the ones of a fixture). A change of the form is a new
|
||||
//! version, next to this one, that the decoders convert from.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, Trackpoint, TrackpointCategories,
|
||||
Waypoint, WaypointId,
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Link1 {
|
||||
href: String,
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Link> for Link1 {
|
||||
fn from(link: &Link) -> Self {
|
||||
Self {
|
||||
href: link.href.clone(),
|
||||
text: link.text.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Link1> for Link {
|
||||
fn from(link: Link1) -> Self {
|
||||
Self {
|
||||
href: link.href,
|
||||
text: link.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Author1 {
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
link: Option<Link1>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FileInfo1 {
|
||||
name: String,
|
||||
desc: Option<String>,
|
||||
author: Option<Author1>,
|
||||
links: Vec<Link1>,
|
||||
time: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<&FileInfo> for FileInfo1 {
|
||||
fn from(info: &FileInfo) -> Self {
|
||||
Self {
|
||||
name: info.name.clone(),
|
||||
desc: info.desc.clone(),
|
||||
author: info.author.as_ref().map(|author| Author1 {
|
||||
name: author.name.clone(),
|
||||
email: author.email.clone(),
|
||||
link: author.link.as_ref().map(Into::into),
|
||||
}),
|
||||
links: info.links.iter().map(Into::into).collect(),
|
||||
time: info.time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FileInfo1> for FileInfo {
|
||||
fn from(info: FileInfo1) -> Self {
|
||||
Self {
|
||||
name: info.name,
|
||||
desc: info.desc,
|
||||
author: info.author.map(|author| Author {
|
||||
name: author.name,
|
||||
email: author.email,
|
||||
link: author.link.map(Into::into),
|
||||
}),
|
||||
links: info.links.into_iter().map(Into::into).collect(),
|
||||
time: info.time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TrackInfo1 {
|
||||
name: Option<String>,
|
||||
cmt: Option<String>,
|
||||
desc: Option<String>,
|
||||
src: Option<String>,
|
||||
links: Vec<Link1>,
|
||||
type_: Option<String>,
|
||||
color: Option<String>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<&TrackInfo> for TrackInfo1 {
|
||||
fn from(info: &TrackInfo) -> Self {
|
||||
Self {
|
||||
name: info.name.clone(),
|
||||
cmt: info.cmt.clone(),
|
||||
desc: info.desc.clone(),
|
||||
src: info.src.clone(),
|
||||
links: info.links.iter().map(Into::into).collect(),
|
||||
type_: info.type_.clone(),
|
||||
color: info.color.clone(),
|
||||
opacity: info.opacity,
|
||||
width: info.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TrackInfo1> for TrackInfo {
|
||||
fn from(info: TrackInfo1) -> Self {
|
||||
Self {
|
||||
name: info.name,
|
||||
cmt: info.cmt,
|
||||
desc: info.desc,
|
||||
src: info.src,
|
||||
links: info.links.into_iter().map(Into::into).collect(),
|
||||
type_: info.type_,
|
||||
color: info.color,
|
||||
opacity: info.opacity,
|
||||
width: info.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file without its trackpoints and waypoints, which are in chunks.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FileRecord1 {
|
||||
pub info: FileInfo1,
|
||||
pub tracks: Vec<TrackRecord1>,
|
||||
pub waypoints: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TrackRecord1 {
|
||||
pub id: Uuid,
|
||||
pub info: TrackInfo1,
|
||||
pub segments: Vec<SegmentRecord1>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SegmentRecord1 {
|
||||
pub id: Uuid,
|
||||
pub chunks: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl FileRecord1 {
|
||||
pub fn new(file: &File) -> Self {
|
||||
Self {
|
||||
info: (&file.info).into(),
|
||||
tracks: file
|
||||
.trk
|
||||
.iter()
|
||||
.map(|track| TrackRecord1 {
|
||||
id: track.id.0,
|
||||
info: (&track.info).into(),
|
||||
segments: track
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|segment| SegmentRecord1 {
|
||||
id: segment.id.0,
|
||||
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Trackpoint1 {
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
time: Option<i64>,
|
||||
atemp: Option<i16>,
|
||||
hr: Option<u16>,
|
||||
cad: Option<u16>,
|
||||
power: Option<u16>,
|
||||
surface: Option<u8>,
|
||||
highway: Option<u8>,
|
||||
sac_scale: Option<u8>,
|
||||
mtb_scale: Option<u8>,
|
||||
anchor: Option<u8>,
|
||||
}
|
||||
|
||||
impl From<&Trackpoint> for Trackpoint1 {
|
||||
fn from(point: &Trackpoint) -> Self {
|
||||
Self {
|
||||
lng: point.coordinates.lng,
|
||||
lat: point.coordinates.lat,
|
||||
ele: point.ele,
|
||||
time: point.time,
|
||||
atemp: point.atemp,
|
||||
hr: point.hr,
|
||||
cad: point.cad,
|
||||
power: point.power,
|
||||
surface: point.surface,
|
||||
highway: point.highway,
|
||||
sac_scale: point.sac_scale,
|
||||
mtb_scale: point.mtb_scale,
|
||||
anchor: point.anchor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Trackpoint1> for Trackpoint {
|
||||
fn from(point: Trackpoint1) -> Self {
|
||||
Self {
|
||||
coordinates: LngLat {
|
||||
lng: point.lng,
|
||||
lat: point.lat,
|
||||
},
|
||||
ele: point.ele,
|
||||
time: point.time,
|
||||
atemp: point.atemp,
|
||||
hr: point.hr,
|
||||
cad: point.cad,
|
||||
power: point.power,
|
||||
surface: point.surface,
|
||||
highway: point.highway,
|
||||
sac_scale: point.sac_scale,
|
||||
mtb_scale: point.mtb_scale,
|
||||
anchor: point.anchor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Waypoint1 {
|
||||
id: Uuid,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
time: Option<i64>,
|
||||
name: Option<String>,
|
||||
cmt: Option<String>,
|
||||
desc: Option<String>,
|
||||
links: Vec<Link1>,
|
||||
sym: Option<String>,
|
||||
type_: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Waypoint> for Waypoint1 {
|
||||
fn from(waypoint: &Waypoint) -> Self {
|
||||
Self {
|
||||
id: waypoint.id.0,
|
||||
lng: waypoint.coordinates.lng,
|
||||
lat: waypoint.coordinates.lat,
|
||||
ele: waypoint.ele,
|
||||
time: waypoint.time,
|
||||
name: waypoint.name.clone(),
|
||||
cmt: waypoint.cmt.clone(),
|
||||
desc: waypoint.desc.clone(),
|
||||
links: waypoint.links.iter().map(Into::into).collect(),
|
||||
sym: waypoint.sym.clone(),
|
||||
type_: waypoint.type_.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Waypoint1> for Waypoint {
|
||||
fn from(waypoint: Waypoint1) -> Self {
|
||||
Self {
|
||||
id: WaypointId(waypoint.id),
|
||||
coordinates: LngLat {
|
||||
lng: waypoint.lng,
|
||||
lat: waypoint.lat,
|
||||
},
|
||||
ele: waypoint.ele,
|
||||
time: waypoint.time,
|
||||
name: waypoint.name,
|
||||
cmt: waypoint.cmt,
|
||||
desc: waypoint.desc,
|
||||
links: waypoint.links.into_iter().map(Into::into).collect(),
|
||||
sym: waypoint.sym,
|
||||
type_: waypoint.type_,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of each table of categories, in the order of their codes.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Categories1 {
|
||||
surface: Vec<String>,
|
||||
highway: Vec<String>,
|
||||
sac_scale: Vec<String>,
|
||||
mtb_scale: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&TrackpointCategories> for Categories1 {
|
||||
fn from(categories: &TrackpointCategories) -> Self {
|
||||
let names = |table: &Categories| table.names().to_vec();
|
||||
Self {
|
||||
surface: names(&categories.surface),
|
||||
highway: names(&categories.highway),
|
||||
sac_scale: names(&categories.sac_scale),
|
||||
mtb_scale: names(&categories.mtb_scale),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Categories1> for TrackpointCategories {
|
||||
fn from(categories: Categories1) -> Self {
|
||||
Self {
|
||||
surface: Categories::from_names(categories.surface),
|
||||
highway: Categories::from_names(categories.highway),
|
||||
sac_scale: Categories::from_names(categories.sac_scale),
|
||||
mtb_scale: Categories::from_names(categories.mtb_scale),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
||||
|
||||
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
||||
/// and what the tests use.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MemoryStorage {
|
||||
state: RefCell<State>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
files: HashMap<FileId, Vec<u8>>,
|
||||
chunks: HashMap<ChunkKey, Vec<u8>>,
|
||||
order: Option<Vec<u8>>,
|
||||
categories: Option<Vec<u8>>,
|
||||
settings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// How much there is in a storage.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StorageCounts {
|
||||
pub files: usize,
|
||||
pub trackpoint_chunks: usize,
|
||||
pub waypoint_chunks: usize,
|
||||
}
|
||||
|
||||
impl MemoryStorage {
|
||||
/// How many files and chunks are stored.
|
||||
pub fn counts(&self) -> StorageCounts {
|
||||
let state = self.state.borrow();
|
||||
let chunks = |kind| state.chunks.keys().filter(|key| key.kind == kind).count();
|
||||
StorageCounts {
|
||||
files: state.files.len(),
|
||||
trackpoint_chunks: chunks(ChunkKind::Trackpoints),
|
||||
waypoint_chunks: chunks(ChunkKind::Waypoints),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage for MemoryStorage {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
let state = self.state.borrow();
|
||||
Ok(StoredData {
|
||||
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
order: state.order.clone(),
|
||||
categories: state.categories.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.files.extend(batch.put_files.iter().cloned());
|
||||
for id in &batch.delete_files {
|
||||
state.files.remove(id);
|
||||
}
|
||||
state.chunks.extend(batch.put_chunks.iter().cloned());
|
||||
for key in &batch.delete_chunks {
|
||||
state.chunks.remove(key);
|
||||
}
|
||||
if let Some(order) = &batch.order {
|
||||
state.order = Some(order.clone());
|
||||
}
|
||||
if let Some(categories) = &batch.categories {
|
||||
state.categories = Some(categories.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
let state = self.state.borrow();
|
||||
Ok(state
|
||||
.settings
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
self.state
|
||||
.borrow_mut()
|
||||
.settings
|
||||
.insert(key.to_owned(), value.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
self.state.borrow_mut().settings.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn block_on<F: Future>(future: F) -> F::Output {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
match future.as_mut().poll(&mut context) {
|
||||
Poll::Ready(value) => value,
|
||||
Poll::Pending => panic!("the memory storage does not wait"),
|
||||
}
|
||||
}
|
||||
|
||||
fn counts(files: usize, trackpoint_chunks: usize, waypoint_chunks: usize) -> StorageCounts {
|
||||
StorageCounts {
|
||||
files,
|
||||
trackpoint_chunks,
|
||||
waypoint_chunks,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_storage_has_nothing() {
|
||||
let storage = MemoryStorage::default();
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data, StoredData::default());
|
||||
assert_eq!(storage.counts(), counts(0, 0, 0));
|
||||
assert!(block_on(storage.load_settings()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_puts_and_deletes() {
|
||||
let storage = MemoryStorage::default();
|
||||
let (a, b) = (FileId::default(), FileId::default());
|
||||
let (t, w) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
block_on(storage.commit(&Batch {
|
||||
put_files: vec![(a, vec![1]), (b, vec![2])],
|
||||
put_chunks: vec![
|
||||
(ChunkKey::trackpoints(t), vec![3]),
|
||||
(ChunkKey::waypoints(w), vec![4]),
|
||||
],
|
||||
order: Some(vec![5]),
|
||||
categories: Some(vec![6]),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), counts(2, 1, 1));
|
||||
|
||||
let mut data = block_on(storage.load()).unwrap();
|
||||
data.files.sort_by_key(|(_, bytes)| bytes.clone());
|
||||
assert_eq!(data.files, vec![(a, vec![1]), (b, vec![2])]);
|
||||
data.chunks.sort_by_key(|(_, bytes)| bytes.clone());
|
||||
assert_eq!(
|
||||
data.chunks,
|
||||
vec![
|
||||
(ChunkKey::trackpoints(t), vec![3]),
|
||||
(ChunkKey::waypoints(w), vec![4])
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
(data.order, data.categories),
|
||||
(Some(vec![5]), Some(vec![6]))
|
||||
);
|
||||
|
||||
// a put replaces, a delete removes, what is not mentioned stays
|
||||
block_on(storage.commit(&Batch {
|
||||
put_files: vec![(a, vec![10])],
|
||||
delete_files: vec![b],
|
||||
delete_chunks: vec![ChunkKey::trackpoints(t)],
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), counts(1, 0, 1));
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data.files, vec![(a, vec![10])]);
|
||||
assert_eq!(
|
||||
(data.order, data.categories),
|
||||
(Some(vec![5]), Some(vec![6]))
|
||||
);
|
||||
|
||||
block_on(storage.commit(&Batch {
|
||||
delete_chunks: vec![ChunkKey::waypoints(w)],
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), counts(1, 0, 0));
|
||||
assert_eq!(block_on(storage.load()).unwrap().order, Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings() {
|
||||
let storage = MemoryStorage::default();
|
||||
block_on(storage.put_setting("a", "1")).unwrap();
|
||||
block_on(storage.put_setting("b", "2")).unwrap();
|
||||
block_on(storage.put_setting("a", "3")).unwrap();
|
||||
let mut settings = block_on(storage.load_settings()).unwrap();
|
||||
settings.sort();
|
||||
assert_eq!(
|
||||
settings,
|
||||
vec![("a".into(), "3".into()), ("b".into(), "2".into())]
|
||||
);
|
||||
|
||||
block_on(storage.delete_setting("a")).unwrap();
|
||||
// deleting what is not there is fine
|
||||
block_on(storage.delete_setting("missing")).unwrap();
|
||||
assert_eq!(
|
||||
block_on(storage.load_settings()).unwrap(),
|
||||
vec![("b".into(), "2".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_are_not_files() {
|
||||
let storage = MemoryStorage::default();
|
||||
block_on(storage.put_setting("a", "1")).unwrap();
|
||||
assert_eq!(block_on(storage.load()).unwrap(), StoredData::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Keeping the files and the settings between sessions.
|
||||
//!
|
||||
//! The engine does not know where the data goes: the host (the browser, a desktop app...)
|
||||
//! implements [`Storage`], and a [`Persister`] works out what to write after each change.
|
||||
//!
|
||||
//! The files are not stored as a whole. Their trackpoints and waypoints are stored in chunks (see
|
||||
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
|
||||
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
|
||||
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
|
||||
|
||||
mod codec;
|
||||
mod memory;
|
||||
mod persister;
|
||||
|
||||
pub use memory::*;
|
||||
pub use persister::*;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::FileId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StorageError(pub String);
|
||||
|
||||
impl std::fmt::Display for StorageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "storage error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StorageError {}
|
||||
|
||||
/// What a chunk holds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChunkKind {
|
||||
Trackpoints,
|
||||
Waypoints,
|
||||
}
|
||||
|
||||
/// Where a chunk is stored: chunks are told apart by their identity within their kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ChunkKey {
|
||||
pub kind: ChunkKind,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl ChunkKey {
|
||||
pub fn trackpoints(id: Uuid) -> Self {
|
||||
Self {
|
||||
kind: ChunkKind::Trackpoints,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waypoints(id: Uuid) -> Self {
|
||||
Self {
|
||||
kind: ChunkKind::Waypoints,
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that is stored about the files, as opaque bytes (see the module documentation).
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct StoredData {
|
||||
/// The record of each file.
|
||||
pub files: Vec<(FileId, Vec<u8>)>,
|
||||
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
/// The order of the files.
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// The categories of the trackpoints, which their chunks refer to.
|
||||
pub categories: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A change of the stored data, to be applied all at once: a stored state is always one that the
|
||||
/// engine had.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Batch {
|
||||
pub put_files: Vec<(FileId, Vec<u8>)>,
|
||||
pub delete_files: Vec<FileId>,
|
||||
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
pub delete_chunks: Vec<ChunkKey>,
|
||||
pub order: Option<Vec<u8>>,
|
||||
pub categories: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self == &Self::default()
|
||||
}
|
||||
|
||||
/// How many chunks of the kind the batch writes.
|
||||
pub fn puts(&self, kind: ChunkKind) -> usize {
|
||||
self.put_chunks
|
||||
.iter()
|
||||
.filter(|(key, _)| key.kind == kind)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// How many chunks of the kind the batch deletes.
|
||||
pub fn deletes(&self, kind: ChunkKind) -> usize {
|
||||
self.delete_chunks
|
||||
.iter()
|
||||
.filter(|key| key.kind == kind)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that
|
||||
/// keeps nothing after the session.
|
||||
///
|
||||
/// The methods are async because storage often is (IndexedDB), the engine itself is not: it
|
||||
/// changes its files at once and the changes are written afterwards, see [`Persister`].
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait Storage {
|
||||
/// Reads the files.
|
||||
async fn load(&self) -> Result<StoredData, StorageError>;
|
||||
|
||||
/// Applies a batch: either all of it is stored, or none.
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError>;
|
||||
|
||||
/// Reads the settings, as `(key, value)`. The engine does not look into them.
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError>;
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_batch_is_empty_until_it_holds_something() {
|
||||
assert!(Batch::default().is_empty());
|
||||
let holds = [
|
||||
Batch {
|
||||
put_files: vec![(FileId::default(), vec![])],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
delete_files: vec![FileId::default()],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
put_chunks: vec![(ChunkKey::waypoints(Uuid::new_v4()), vec![])],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
delete_chunks: vec![ChunkKey::trackpoints(Uuid::new_v4())],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
categories: Some(vec![]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
assert!(holds.iter().all(|batch| !batch.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_error_is_readable() {
|
||||
let error = StorageError("disk full".into());
|
||||
assert_eq!(error.to_string(), "storage error: disk full");
|
||||
let _: &dyn std::error::Error = &error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::codec::{
|
||||
DecodeError, decode_categories, decode_file, decode_order, decode_trackpoints,
|
||||
decode_waypoints, encode_categories, encode_file, encode_order, encode_trackpoints,
|
||||
encode_waypoints,
|
||||
};
|
||||
use crate::{
|
||||
Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories,
|
||||
TrackpointChunk, WaypointChunk,
|
||||
};
|
||||
|
||||
/// What there is to keep of the engine: the files of the current state, their order, and the
|
||||
/// categories their trackpoints refer to. See [`crate::Engine::snapshot`].
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub files: StackEntry,
|
||||
pub order: Vec<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
}
|
||||
|
||||
/// What was read from the storage, to start an engine with (see [`crate::Engine::restore`]).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Restored {
|
||||
pub files: Vec<Rc<File>>,
|
||||
pub order: Vec<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
/// The files that could not be read: they are dropped at the next save.
|
||||
pub unreadable: usize,
|
||||
/// Whether some of what is stored was written by a newer version, which this one cannot read.
|
||||
/// Nothing is written then, so that the newer version finds its data again: the files that
|
||||
/// could be read are only kept in memory, and the persister does not save.
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
|
||||
/// already holds.
|
||||
///
|
||||
/// A file whose `Rc` is the saved one has not changed, which is what makes a save cheap: only the
|
||||
/// files that changed are looked at to find the chunks to write, and the others are only counted
|
||||
/// to find the chunks that nothing uses anymore.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Persister {
|
||||
/// The files as they are saved.
|
||||
files: HashMap<FileId, Rc<File>>,
|
||||
/// The files that have a record, including the ones that could not be read.
|
||||
stored_files: HashSet<FileId>,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
order: Option<Vec<FileId>>,
|
||||
categories: Option<TrackpointCategories>,
|
||||
/// Set by `restore`, see [`Restored::read_only`].
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
/// Remembers whether something that was read comes from a newer version.
|
||||
#[derive(Default)]
|
||||
struct Newer(Cell<bool>);
|
||||
|
||||
impl Newer {
|
||||
fn read<T>(&self, result: Result<T, DecodeError>) -> Option<T> {
|
||||
match result {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) => {
|
||||
self.0
|
||||
.set(self.0.get() || matches!(error, DecodeError::UnknownVersion(_)));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a save writes, and what is stored once it is written.
|
||||
struct Plan {
|
||||
batch: Batch,
|
||||
snapshot: Snapshot,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
}
|
||||
|
||||
impl Persister {
|
||||
/// Reads the files of the storage. The persister then knows that they are saved.
|
||||
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
|
||||
let data = storage.load().await?;
|
||||
*self = Self::default();
|
||||
|
||||
// what comes from a newer version is not corrupt: it must not be overwritten
|
||||
let newer = Newer::default();
|
||||
let categories: Option<TrackpointCategories> = data
|
||||
.categories
|
||||
.as_deref()
|
||||
.and_then(|bytes| newer.read(decode_categories(bytes)));
|
||||
let stored_order: Option<Vec<FileId>> = data
|
||||
.order
|
||||
.as_deref()
|
||||
.and_then(|bytes| newer.read(decode_order(bytes)));
|
||||
|
||||
let chunk_bytes: HashMap<ChunkKey, Vec<u8>> = data.chunks.into_iter().collect();
|
||||
// chunks are decoded once, whatever the number of files that use them
|
||||
let trackpoint_cache: RefCell<HashMap<Uuid, Option<Rc<TrackpointChunk>>>> =
|
||||
Default::default();
|
||||
let waypoint_cache: RefCell<HashMap<Uuid, Option<Rc<WaypointChunk>>>> = Default::default();
|
||||
let trackpoint_chunk = |id: &crate::TrackpointChunkId| {
|
||||
trackpoint_cache
|
||||
.borrow_mut()
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
|
||||
newer.read(decode_trackpoints(id.0, bytes)).map(Rc::new)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
let waypoint_chunk = |id: &crate::WaypointChunkId| {
|
||||
waypoint_cache
|
||||
.borrow_mut()
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = chunk_bytes.get(&ChunkKey::waypoints(id.0))?;
|
||||
newer.read(decode_waypoints(id.0, bytes)).map(Rc::new)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut restored = Restored {
|
||||
categories: categories.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
||||
for (id, bytes) in &data.files {
|
||||
self.stored_files.insert(*id);
|
||||
let file = newer
|
||||
.read(decode_file(bytes))
|
||||
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
||||
match file {
|
||||
Some(file) => {
|
||||
files.insert(*id, Rc::new(file));
|
||||
}
|
||||
None => restored.unreadable += 1,
|
||||
}
|
||||
}
|
||||
|
||||
restored.order = stored_order
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|id| files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
let ordered: HashSet<FileId> = restored.order.iter().copied().collect();
|
||||
restored
|
||||
.order
|
||||
.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
|
||||
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
|
||||
|
||||
self.files = files;
|
||||
self.chunks = chunk_bytes.into_keys().collect();
|
||||
self.order = stored_order;
|
||||
self.categories = categories;
|
||||
self.read_only = newer.0.get();
|
||||
restored.read_only = self.read_only;
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// Writes what changed since the last save. Nothing is written if nothing changed.
|
||||
pub async fn save<S: Storage>(
|
||||
&mut self,
|
||||
storage: &S,
|
||||
snapshot: Snapshot,
|
||||
) -> Result<(), StorageError> {
|
||||
if self.read_only {
|
||||
return Ok(());
|
||||
}
|
||||
let plan = self.plan(snapshot);
|
||||
if plan.batch.is_empty() {
|
||||
self.files = plan.snapshot.files;
|
||||
return Ok(());
|
||||
}
|
||||
storage.commit(&plan.batch).await?;
|
||||
|
||||
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
||||
self.files = plan.snapshot.files;
|
||||
self.chunks = plan.chunks;
|
||||
self.order = Some(plan.snapshot.order);
|
||||
self.categories = Some(plan.snapshot.categories);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plan(&self, snapshot: Snapshot) -> Plan {
|
||||
let mut batch = Batch::default();
|
||||
// the chunks that the files use, which the storage has afterwards
|
||||
let mut chunks = HashSet::new();
|
||||
|
||||
for (id, file) in &snapshot.files {
|
||||
let changed = self
|
||||
.files
|
||||
.get(id)
|
||||
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
|
||||
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
|
||||
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
||||
let key = ChunkKey::trackpoints(chunk.id.0);
|
||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||
batch.put_chunks.push((key, encode_trackpoints(chunk)));
|
||||
}
|
||||
}
|
||||
for chunk in file.wpt.chunks() {
|
||||
let key = ChunkKey::waypoints(chunk.id.0);
|
||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||
batch.put_chunks.push((key, encode_waypoints(chunk)));
|
||||
}
|
||||
}
|
||||
if changed || !self.stored_files.contains(id) {
|
||||
batch.put_files.push((*id, encode_file(file)));
|
||||
}
|
||||
}
|
||||
|
||||
batch.delete_files = self
|
||||
.stored_files
|
||||
.iter()
|
||||
.filter(|id| !snapshot.files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
batch.delete_chunks = self.chunks.difference(&chunks).copied().collect();
|
||||
if self.order.as_ref() != Some(&snapshot.order) {
|
||||
batch.order = Some(encode_order(&snapshot.order));
|
||||
}
|
||||
if self.categories.as_ref() != Some(&snapshot.categories) {
|
||||
batch.categories = Some(encode_categories(&snapshot.categories));
|
||||
}
|
||||
|
||||
Plan {
|
||||
batch,
|
||||
snapshot,
|
||||
chunks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use crate::{
|
||||
Action, ChunkKind, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse,
|
||||
write,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn block_on<F: Future>(future: F) -> F::Output {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
loop {
|
||||
if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory storage that remembers the batches it was given.
|
||||
#[derive(Default)]
|
||||
struct Recording {
|
||||
inner: MemoryStorage,
|
||||
batches: RefCell<Vec<Batch>>,
|
||||
}
|
||||
|
||||
impl Storage for Recording {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
self.inner.load().await
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
||||
self.batches.borrow_mut().push(batch.clone());
|
||||
self.inner.commit(batch).await
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
self.inner.load_settings().await
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
self.inner.put_setting(key, value).await
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
self.inner.delete_setting(key).await
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_of(paths: &[&str]) -> Snapshot {
|
||||
let mut snapshot = Snapshot::default();
|
||||
for path in paths {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = parse(&data, &mut snapshot.categories).unwrap();
|
||||
snapshot.order.push(file.id);
|
||||
snapshot.files.insert(file.id, Rc::new(file));
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn gpx(file: &File, categories: &TrackpointCategories) -> Vec<u8> {
|
||||
write(file, categories, ExportOptions::ALL)
|
||||
}
|
||||
|
||||
const FILES: [&str; 3] = [
|
||||
"data/with_tracks_and_segments.gpx",
|
||||
"data/with_waypoint.gpx",
|
||||
"data/with_surface.gpx",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_saved_files_are_restored() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.unreadable, 0);
|
||||
assert_eq!(restored.order, snapshot.order);
|
||||
assert_eq!(restored.categories, snapshot.categories);
|
||||
for file in &restored.files {
|
||||
let original = &snapshot.files[&file.id];
|
||||
assert_eq!(
|
||||
gpx(file, &restored.categories),
|
||||
gpx(original, &snapshot.categories)
|
||||
);
|
||||
// identities that the rest of the engine refers to are kept
|
||||
assert_eq!(file.trk[0].id, original.trk[0].id);
|
||||
assert_eq!(file.trk[0].trkseg[0].id, original.trk[0].trkseg[0].id);
|
||||
assert_eq!(
|
||||
file.trk[0].trkseg[0].chunks()[0].id,
|
||||
original.trk[0].trkseg[0].chunks()[0].id
|
||||
);
|
||||
}
|
||||
// what was just read is saved
|
||||
let again = Snapshot {
|
||||
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||
order: restored.order,
|
||||
categories: restored.categories,
|
||||
};
|
||||
block_on(persister.save(&storage, again)).unwrap();
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_is_written_when_nothing_changed() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_edit_writes_what_it_changed_only() {
|
||||
let storage = Recording::default();
|
||||
let mut snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
let counts = storage.inner.counts();
|
||||
|
||||
// renaming a file writes its record, not its chunks
|
||||
let id = snapshot.order[0];
|
||||
let mut file = (*snapshot.files[&id]).clone();
|
||||
file.info.name = "renamed".into();
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
{
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert!(batch.put_chunks.is_empty());
|
||||
assert!(batch.delete_chunks.is_empty());
|
||||
assert!(batch.order.is_none() && batch.categories.is_none());
|
||||
}
|
||||
assert_eq!(storage.inner.counts(), counts);
|
||||
|
||||
// changing a trackpoint replaces the chunk that holds it only
|
||||
let mut file = (*snapshot.files[&id]).clone();
|
||||
let chunks_before = file
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.map(|s| s.chunks().len())
|
||||
.sum::<usize>();
|
||||
file.trk[0].trkseg[0].update(0, |point| point.ele += 1.0);
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
{
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert_eq!(batch.puts(ChunkKind::Trackpoints), 1);
|
||||
assert_eq!(batch.deletes(ChunkKind::Trackpoints), 1);
|
||||
assert_eq!(batch.puts(ChunkKind::Waypoints), 0);
|
||||
}
|
||||
assert_eq!(storage.inner.counts(), counts);
|
||||
assert!(chunks_before >= 1);
|
||||
|
||||
// a deleted file takes its chunks and its record away
|
||||
snapshot.files.remove(&id);
|
||||
snapshot.order.retain(|other| *other != id);
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
let after = storage.inner.counts();
|
||||
assert_eq!(after.files, counts.files - 1);
|
||||
assert!(after.trackpoint_chunks < counts.trackpoint_chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_shared_by_files_are_stored_once_and_stay_shared() {
|
||||
let storage = Recording::default();
|
||||
let mut snapshot = snapshot_of(&["data/with_time.gpx"]);
|
||||
let original = snapshot.files[&snapshot.order[0]].clone();
|
||||
let mut copy = (*original).clone();
|
||||
copy.id = FileId::default();
|
||||
snapshot.order.push(copy.id);
|
||||
snapshot.files.insert(copy.id, Rc::new(copy));
|
||||
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
let chunks = original.trk[0].trkseg[0].chunks().len();
|
||||
assert_eq!(storage.inner.counts().trackpoint_chunks, chunks);
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files.len(), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
&restored.files[0].trk[0].trkseg[0].chunks()[0],
|
||||
&restored.files[1].trk[0].trkseg[0].chunks()[0]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unreadable_files_are_skipped_and_dropped_at_the_next_save() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
|
||||
// damage a record, and lose a chunk of another file
|
||||
let broken = snapshot.order[0];
|
||||
block_on(storage.inner.commit(&Batch {
|
||||
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.unreadable, 1);
|
||||
assert!(!restored.read_only);
|
||||
assert_eq!(restored.files.len(), FILES.len() - 1);
|
||||
assert!(restored.files.iter().all(|file| file.id != broken));
|
||||
|
||||
let kept = Snapshot {
|
||||
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||
order: restored.order,
|
||||
categories: restored.categories,
|
||||
};
|
||||
block_on(persister.save(&storage, kept)).unwrap();
|
||||
assert_eq!(storage.inner.counts().files, FILES.len() - 1);
|
||||
}
|
||||
|
||||
/// A storage holding the files, with one blob replaced by `damage`.
|
||||
fn stored_with(damage: impl FnOnce(&Snapshot, &mut Batch)) -> (Recording, Snapshot) {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
let mut batch = Batch::default();
|
||||
damage(&snapshot, &mut batch);
|
||||
block_on(storage.inner.commit(&batch)).unwrap();
|
||||
storage.batches.borrow_mut().clear();
|
||||
(storage, snapshot)
|
||||
}
|
||||
|
||||
fn from_the_future() -> Vec<u8> {
|
||||
vec![super::super::codec::LATEST + 1, 1, 2, 3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_what_a_newer_version_wrote_is_left_alone() {
|
||||
type Damage = (&'static str, fn(&Snapshot, &mut Batch));
|
||||
let damages: [Damage; 4] = [
|
||||
("a file", |snapshot, batch| {
|
||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||
}),
|
||||
("a chunk", |snapshot, batch| {
|
||||
let file = &snapshot.files[&snapshot.order[0]];
|
||||
let chunk = &file.trk[0].trkseg[0].chunks()[0];
|
||||
batch
|
||||
.put_chunks
|
||||
.push((ChunkKey::trackpoints(chunk.id.0), from_the_future()));
|
||||
}),
|
||||
("the order", |_, batch| {
|
||||
batch.order = Some(from_the_future())
|
||||
}),
|
||||
("the categories", |_, batch| {
|
||||
batch.categories = Some(from_the_future())
|
||||
}),
|
||||
];
|
||||
for (what, damage) in damages {
|
||||
let (storage, snapshot) = stored_with(damage);
|
||||
let before = storage.inner.counts();
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert!(restored.read_only, "{what}");
|
||||
|
||||
// whatever is saved afterwards, nothing is written: the newer version needs its data
|
||||
let mut changed = snapshot.clone();
|
||||
changed.order.clear();
|
||||
changed.files.clear();
|
||||
block_on(persister.save(&storage, changed)).unwrap();
|
||||
assert!(storage.batches.borrow().is_empty(), "{what}");
|
||||
assert_eq!(storage.inner.counts(), before, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restoring_again_forgets_that_the_storage_was_newer() {
|
||||
let (storage, _) = stored_with(|snapshot, batch| {
|
||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||
});
|
||||
let mut persister = Persister::default();
|
||||
assert!(block_on(persister.restore(&storage)).unwrap().read_only);
|
||||
|
||||
// the same persister, on a storage that is not newer
|
||||
let clean = Recording::default();
|
||||
let restored = block_on(persister.restore(&clean)).unwrap();
|
||||
assert!(!restored.read_only);
|
||||
block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap();
|
||||
assert_eq!(clean.inner.counts().files, FILES.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_engine_starts_from_what_is_restored() {
|
||||
let storage = Recording::default();
|
||||
let mut engine = Engine::default();
|
||||
for path in FILES {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}))));
|
||||
}
|
||||
let snapshot = engine.snapshot();
|
||||
assert_eq!(snapshot.files.len(), FILES.len());
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine.restore(restored);
|
||||
assert_eq!(engine.snapshot().order, snapshot.order);
|
||||
let diff = engine.last_diff().unwrap();
|
||||
assert_eq!(diff.added.len(), FILES.len());
|
||||
assert!(engine.order_changed());
|
||||
for id in &snapshot.order {
|
||||
assert!(engine.file_structure(id).is_some());
|
||||
}
|
||||
|
||||
// the files that were restored are not undone
|
||||
assert!(!engine.can_undo());
|
||||
assert!(!engine.run(Action::Undo));
|
||||
let data = std::fs::read(FILES[0]).unwrap();
|
||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}))));
|
||||
assert!(engine.can_undo());
|
||||
assert!(engine.run(Action::Undo));
|
||||
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
||||
assert!(!engine.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ crate-type = ["cdylib", "rlib"]
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1.7"
|
||||
gpx-engine = { path = "../engine" }
|
||||
idb = "0.6.5"
|
||||
js-sys = "0.3.106"
|
||||
uuid = { version = "1.26.1", features = ["js"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4.79"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Editing the metadata, the style and the structure of the files.
|
||||
|
||||
use gpx_engine::{self as engine, Command};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::Outcome;
|
||||
|
||||
use crate::session::*;
|
||||
|
||||
// Edit commands
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn metadata(name: &str, desc: &str) -> Outcome {
|
||||
edit(Command::Metadata(engine::Metadata { name, desc }))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn style(color: Option<String>, opacity: Option<f64>, width: Option<f64>) -> Outcome {
|
||||
edit(Command::Style(engine::Style {
|
||||
color: color.as_deref(),
|
||||
opacity,
|
||||
width,
|
||||
}))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn new_track() -> Outcome {
|
||||
edit(Command::NewTrack(engine::NewTrack))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn new_track_segment() -> Outcome {
|
||||
edit(Command::NewTrackSegment(engine::NewTrackSegment))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Creating, loading, deleting, moving, copying and pasting files and their elements.
|
||||
|
||||
use gpx_engine::{self as engine, Action, Command};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
|
||||
// File commands
|
||||
|
||||
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that
|
||||
/// hold that trackpoint (at `ele`, 0 by default).
|
||||
#[wasm_bindgen]
|
||||
pub fn new_file(name: &str, lng: Option<f64>, lat: Option<f64>, ele: Option<f64>) -> Outcome {
|
||||
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
|
||||
lng,
|
||||
lat,
|
||||
ele: ele.unwrap_or_default(),
|
||||
});
|
||||
edit(Command::New(engine::New { name, trackpoint }))
|
||||
}
|
||||
|
||||
/// Loads files as a single command (one undo step). The files are in `data`, one after the
|
||||
/// other, `lengths` being the number of bytes of each one. `names`: the name of each file when
|
||||
/// its data has none (the name on disk, without extension). The files that cannot be read are
|
||||
/// skipped, the first one that was read is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn load_files(data: &[u8], lengths: &[u32], names: Vec<String>) -> Outcome {
|
||||
if lengths.len() != names.len() {
|
||||
return fail(format!(
|
||||
"{} lengths for {} names",
|
||||
lengths.len(),
|
||||
names.len()
|
||||
));
|
||||
}
|
||||
let mut files = Vec::with_capacity(names.len());
|
||||
let mut offset = 0;
|
||||
for (length, name) in lengths.iter().zip(&names) {
|
||||
let end = offset + *length as usize;
|
||||
let Some(data) = data.get(offset..end) else {
|
||||
return fail(format!("the data of {name} is shorter than its length"));
|
||||
};
|
||||
files.push(engine::Load { data, name });
|
||||
offset = end;
|
||||
}
|
||||
edit(Command::LoadFiles(engine::LoadFiles { files }))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn duplicate() -> Outcome {
|
||||
edit(Command::Duplicate(engine::Duplicate))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub fn delete(whole_files: bool) -> Outcome {
|
||||
edit(Command::Delete(engine::Delete { whole_files }))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn delete_all() -> Outcome {
|
||||
edit(Command::DeleteAll(engine::DeleteAll))
|
||||
}
|
||||
|
||||
/// Moves the files to `index` among the other files, in the given order (not undoable).
|
||||
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
|
||||
#[wasm_bindgen]
|
||||
pub fn reorder(file_ids_bytes: &[u8], index: usize) -> Outcome {
|
||||
match file_ids(file_ids_bytes) {
|
||||
Some(file_ids) => execute(Action::Reorder { file_ids, index }),
|
||||
None => fail("the file ids are not a list of UUIDs"),
|
||||
}
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
|
||||
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn copy() -> Outcome {
|
||||
execute(Action::Copy)
|
||||
}
|
||||
|
||||
/// Like `copy`, but the elements are moved when they are pasted.
|
||||
#[wasm_bindgen]
|
||||
pub fn cut() -> Outcome {
|
||||
execute(Action::Cut)
|
||||
}
|
||||
|
||||
/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always
|
||||
/// the elements as they were when they were copied or cut, even if they were changed or deleted
|
||||
/// since. It can be pasted several times: what was copied is copied again, what was cut is moved
|
||||
/// the first time (and the clipboard is not cut after that), then it is copied.
|
||||
#[wasm_bindgen]
|
||||
pub fn paste() -> Outcome {
|
||||
edit(Command::Paste(engine::Paste))
|
||||
}
|
||||
|
||||
/// Whether the clipboard can be pasted with the current selection.
|
||||
#[wasm_bindgen]
|
||||
pub fn can_paste() -> bool {
|
||||
with_engine(|e| e.can_paste()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Moves elements (given as a `Selection`) to a place of the file tree, keeping their ids, like
|
||||
/// dragging and dropping them does. The moved elements are selected.
|
||||
///
|
||||
/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks
|
||||
/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes
|
||||
/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the
|
||||
/// waypoints of a file.
|
||||
#[wasm_bindgen]
|
||||
pub fn move_elements(what: Selection, to: MoveTarget) -> Outcome {
|
||||
match parse_selection(&what).zip(parse_move_target(&to)) {
|
||||
Some((what, to)) => edit(Command::Move(engine::Move { what, to })),
|
||||
None => fail("the elements or the target are not valid"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Undo and redo.
|
||||
|
||||
use gpx_engine::Action;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::Outcome;
|
||||
|
||||
use crate::session::*;
|
||||
|
||||
// Undo-redo
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn undo() -> Outcome {
|
||||
execute(Action::Undo)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn redo() -> Outcome {
|
||||
execute(Action::Redo)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn can_undo() -> bool {
|
||||
with_engine(|e| e.can_undo()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn can_redo() -> bool {
|
||||
with_engine(|e| e.can_redo()).unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub(crate) mod edit;
|
||||
pub(crate) mod files;
|
||||
pub(crate) mod history;
|
||||
pub(crate) mod routing;
|
||||
pub(crate) mod tools;
|
||||
pub(crate) mod waypoints;
|
||||
@@ -0,0 +1,144 @@
|
||||
//! The commands that refer to trackpoints by their index in the routing buffers.
|
||||
|
||||
use gpx_engine::{self as engine, Command};
|
||||
use js_sys::{Array, Uint8Array, Uint32Array};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::ids::*;
|
||||
use crate::queries::routing::with_routing;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
use crate::types::*;
|
||||
|
||||
// Routing
|
||||
//
|
||||
// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only
|
||||
// mean something for the `revision` they were read with (`routing_revision`), and the commands
|
||||
// that use them do nothing if the selection is not the one of that revision anymore.
|
||||
|
||||
/// Makes the call `f`, unless the selection is not the one of the revision anymore.
|
||||
pub(crate) fn at_revision(revision: u32, f: impl FnOnce() -> Outcome) -> Outcome {
|
||||
if with_routing(|r| r.revision == revision).unwrap_or(false) {
|
||||
f()
|
||||
} else {
|
||||
fail("the selection changed since the routing buffers were read")
|
||||
}
|
||||
}
|
||||
|
||||
/// A category of the trackpoints of a route: see `RouteCategory` in `engine`.
|
||||
pub(crate) struct ParsedCategory {
|
||||
starts: Vec<u32>,
|
||||
values: Vec<u8>,
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ParsedCategory {
|
||||
fn parse(attributes: &JsValue, key: &str) -> Option<Self> {
|
||||
let Some(category) = property(attributes, key) else {
|
||||
return Some(Self {
|
||||
starts: vec![],
|
||||
values: vec![],
|
||||
names: vec![],
|
||||
});
|
||||
};
|
||||
Some(Self {
|
||||
starts: property(&category, "starts")?
|
||||
.unchecked_into::<Uint32Array>()
|
||||
.to_vec(),
|
||||
values: property(&category, "values")?
|
||||
.unchecked_into::<Uint8Array>()
|
||||
.to_vec(),
|
||||
names: Array::from(&property(&category, "names")?)
|
||||
.iter()
|
||||
.map(|name| name.as_string())
|
||||
.collect::<Option<_>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn view(&self) -> engine::RouteCategory<'_> {
|
||||
engine::RouteCategory {
|
||||
starts: &self.starts,
|
||||
values: &self.values,
|
||||
names: &self.names,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment
|
||||
/// (see `RoutingBuffer`). A pure insertion if `start == end`, a pure removal without new points.
|
||||
/// `attributes` holds the surface, highway, SAC scale and MTB scale of the new points, as
|
||||
/// intervals, and `anchors` the indices among the new points of the ones that become anchors.
|
||||
/// Does nothing, and says why, if the revision of the routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn route(
|
||||
revision: u32,
|
||||
start: u32,
|
||||
end: u32,
|
||||
lng: &[f64],
|
||||
lat: &[f64],
|
||||
ele: &[f64],
|
||||
attributes: RouteAttributes,
|
||||
anchors: &[u32],
|
||||
) -> Outcome {
|
||||
let parse = |key| ParsedCategory::parse(&attributes, key);
|
||||
let (Some(surface), Some(highway), Some(sac_scale), Some(mtb_scale)) = (
|
||||
parse("surface"),
|
||||
parse("highway"),
|
||||
parse("sacScale"),
|
||||
parse("mtbScale"),
|
||||
) else {
|
||||
return fail("the attributes of the route are not valid");
|
||||
};
|
||||
if !same_len(lng, lat, ele) {
|
||||
return fail("lng, lat and ele differ in length");
|
||||
}
|
||||
at_revision(revision, || {
|
||||
edit(Command::Route(engine::Route {
|
||||
start,
|
||||
end,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
surface: surface.view(),
|
||||
highway: highway.view(),
|
||||
sac_scale: sac_scale.view(),
|
||||
mtb_scale: mtb_scale.view(),
|
||||
anchors,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Makes an anchor of the point of the selected segments that is the closest to the coordinates,
|
||||
/// inserting a trackpoint on the path if none is there. Does nothing, and says why, if the
|
||||
/// revision of the routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
pub fn insert_anchor(revision: u32, lng: f64, lat: f64) -> Outcome {
|
||||
at_revision(revision, || {
|
||||
edit(Command::InsertAnchor(engine::InsertAnchor { lng, lat }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the
|
||||
/// trackpoints before it go to the end, and the loop closes on it). Does nothing, and says why, if the revision of the
|
||||
/// routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
pub fn change_loop_start(revision: u32, index: u32) -> Outcome {
|
||||
at_revision(revision, || {
|
||||
edit(Command::ChangeLoopStart(engine::ChangeLoopStart { index }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
|
||||
/// selection in two, there: the trackpoint ends the first part and starts the second one. Does
|
||||
/// nothing, and returns false, if the revision of the routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
pub fn split(revision: u32, at: u32, split_type: SplitType) -> Outcome {
|
||||
at_revision(revision, || {
|
||||
edit(Command::Split(engine::Split {
|
||||
at,
|
||||
split_type: split_type.into(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The tools: reversing, cropping, reducing, timestamps, merging, cleaning...
|
||||
|
||||
use gpx_engine::{self as engine, Command, LngLat, LngLatBounds};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::Outcome;
|
||||
|
||||
use crate::session::*;
|
||||
use crate::types::*;
|
||||
|
||||
// Tools
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn reverse() -> Outcome {
|
||||
edit(Command::Reverse(engine::Reverse))
|
||||
}
|
||||
|
||||
/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so that it goes back to where it
|
||||
/// started.
|
||||
#[wasm_bindgen]
|
||||
pub fn round_trip() -> Outcome {
|
||||
edit(Command::RoundTrip(engine::RoundTrip))
|
||||
}
|
||||
|
||||
/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its
|
||||
/// segments one after the other, and removes the others (and what is left empty).
|
||||
#[wasm_bindgen]
|
||||
pub fn crop(start: u32, end: u32) -> Outcome {
|
||||
if start > end {
|
||||
return fail(format!("the range {start}..{end} is reversed"));
|
||||
}
|
||||
edit(Command::Crop(engine::Crop { start, end }))
|
||||
}
|
||||
|
||||
/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the
|
||||
/// line of their neighbours, except the ends of the segments.
|
||||
#[wasm_bindgen]
|
||||
pub fn reduce(tolerance: f64) -> Outcome {
|
||||
edit(Command::Reduce(engine::Reduce { tolerance }))
|
||||
}
|
||||
|
||||
/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the
|
||||
/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the
|
||||
/// previous one at `speed` (km/h).
|
||||
#[wasm_bindgen(js_name = changeTimestamps)]
|
||||
pub fn change_timestamps(start_time: f64, speed: f64, ratio: f64) -> Outcome {
|
||||
edit(Command::Time(engine::Time {
|
||||
start_time: start_time as i64,
|
||||
kind: engine::TimeKind::Change { speed, ratio },
|
||||
}))
|
||||
}
|
||||
|
||||
/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and
|
||||
/// lasts `total_time` seconds.
|
||||
#[wasm_bindgen(js_name = createArtificialTimestamps)]
|
||||
pub fn create_artificial_timestamps(start_time: f64, total_time: f64) -> Outcome {
|
||||
edit(Command::Time(engine::Time {
|
||||
start_time: start_time as i64,
|
||||
kind: engine::TimeKind::Artificial { total_time },
|
||||
}))
|
||||
}
|
||||
|
||||
/// Merges the selection into its first element (see `MergeType`). `remove_gaps` brings the parts
|
||||
/// that are connected closer in time, if they have timestamps.
|
||||
#[wasm_bindgen]
|
||||
pub fn merge(type_: MergeType, remove_gaps: bool) -> Outcome {
|
||||
edit(Command::Merge(engine::Merge {
|
||||
type_: type_.into(),
|
||||
remove_gaps,
|
||||
}))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn extract() -> Outcome {
|
||||
edit(Command::Extract(engine::Extract))
|
||||
}
|
||||
|
||||
/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.
|
||||
#[wasm_bindgen]
|
||||
pub fn elevation(ele: &[f64]) -> Outcome {
|
||||
edit(Command::Elevation(engine::Elevation { ele }))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn clean(
|
||||
west: f64,
|
||||
south: f64,
|
||||
east: f64,
|
||||
north: f64,
|
||||
type_: CleanType,
|
||||
trkpt: bool,
|
||||
wpt: bool,
|
||||
) -> Outcome {
|
||||
edit(Command::Clean(engine::Clean {
|
||||
bounds: LngLatBounds {
|
||||
sw: LngLat {
|
||||
lng: west,
|
||||
lat: south,
|
||||
},
|
||||
ne: LngLat {
|
||||
lng: east,
|
||||
lat: north,
|
||||
},
|
||||
},
|
||||
type_: type_.into(),
|
||||
trkpt,
|
||||
wpt,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Creating, editing, moving and deleting waypoints.
|
||||
|
||||
use gpx_engine::{self as engine, Command};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::Outcome;
|
||||
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn new_waypoint(
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
icon: &str,
|
||||
link: &str,
|
||||
) -> Outcome {
|
||||
edit(Command::NewWaypoint(engine::NewWaypoint {
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
name,
|
||||
desc,
|
||||
icon,
|
||||
link,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Moves a waypoint of a file, whatever is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn move_waypoint(file_id: &str, waypoint_id: &str, lng: f64, lat: f64, ele: f64) -> Outcome {
|
||||
match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) {
|
||||
(Some(file_id), Some(waypoint_id)) => edit(Command::MoveWaypoint(engine::MoveWaypoint {
|
||||
file_id,
|
||||
waypoint_id,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
})),
|
||||
_ => fail("the file or waypoint id is not a UUID"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a waypoint of a file, whatever is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn delete_waypoint(file_id: &str, waypoint_id: &str) -> Outcome {
|
||||
match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) {
|
||||
(Some(file_id), Some(waypoint_id)) => {
|
||||
edit(Command::DeleteWaypoint(engine::DeleteWaypoint {
|
||||
file_id,
|
||||
waypoint_id,
|
||||
}))
|
||||
}
|
||||
_ => fail("the file or waypoint id is not a UUID"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link, position
|
||||
/// and elevation. The strings that are empty remove the field.
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update_waypoint(
|
||||
file_id: &str,
|
||||
waypoint_id: &str,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
icon: &str,
|
||||
link: &str,
|
||||
) -> Outcome {
|
||||
match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) {
|
||||
(Some(file_id), Some(waypoint_id)) => edit(Command::EditWaypoint(engine::EditWaypoint {
|
||||
file_id,
|
||||
waypoint_id,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
name,
|
||||
desc,
|
||||
icon,
|
||||
link,
|
||||
})),
|
||||
_ => fail("the file or waypoint id is not a UUID"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Turning engine types into JS objects and back.
|
||||
|
||||
use gpx_engine::{self as engine, FileId};
|
||||
use js_sys::{Array, Object, Reflect};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::*;
|
||||
|
||||
pub(crate) fn property(object: &JsValue, key: &str) -> Option<JsValue> {
|
||||
Reflect::get(object, &key.into())
|
||||
.ok()
|
||||
.filter(|value| !value.is_undefined())
|
||||
}
|
||||
|
||||
pub(crate) fn uuid_property(object: &JsValue, key: &str) -> Option<uuid::Uuid> {
|
||||
uuid::Uuid::parse_str(&property(object, key)?.as_string()?).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn uuids_property(object: &JsValue, key: &str) -> Option<Vec<uuid::Uuid>> {
|
||||
Array::from(&property(object, key)?)
|
||||
.iter()
|
||||
.map(|id| uuid::Uuid::parse_str(&id.as_string()?).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The inverse of `selection_object`.
|
||||
pub(crate) fn parse_selection(value: &JsValue) -> Option<engine::Selection> {
|
||||
use engine::Selection as S;
|
||||
let file_id = || uuid_property(value, "fileId").map(FileId);
|
||||
Some(match property(value, "type")?.as_string()?.as_str() {
|
||||
"empty" => S::Empty,
|
||||
"file" => S::File {
|
||||
file_ids: uuids_property(value, "fileIds")?
|
||||
.into_iter()
|
||||
.map(FileId)
|
||||
.collect(),
|
||||
},
|
||||
"track" => S::Track {
|
||||
file_id: file_id()?,
|
||||
trk_ids: uuids_property(value, "trackIds")?
|
||||
.into_iter()
|
||||
.map(engine::TrackId)
|
||||
.collect(),
|
||||
},
|
||||
"segment" => S::TrackSegment {
|
||||
file_id: file_id()?,
|
||||
trk_id: engine::TrackId(uuid_property(value, "trackId")?),
|
||||
trkseg_ids: uuids_property(value, "segmentIds")?
|
||||
.into_iter()
|
||||
.map(engine::TrackSegmentId)
|
||||
.collect(),
|
||||
},
|
||||
"waypoints" => S::Waypoints {
|
||||
file_id: file_id()?,
|
||||
},
|
||||
"waypoint" => S::Waypoint {
|
||||
file_id: file_id()?,
|
||||
wpt_ids: uuids_property(value, "waypointIds")?
|
||||
.into_iter()
|
||||
.map(engine::WaypointId)
|
||||
.collect(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_move_target(value: &JsValue) -> Option<engine::MoveTarget> {
|
||||
use engine::MoveTarget as T;
|
||||
// a negative index is the start, an infinite one is the end
|
||||
let index = property(value, "index")?.as_f64()?.max(0.0) as usize;
|
||||
let file_id = || uuid_property(value, "fileId").map(FileId);
|
||||
Some(match property(value, "type")?.as_string()?.as_str() {
|
||||
"files" => T::Files { index },
|
||||
"tracks" => T::Tracks {
|
||||
file_id: file_id()?,
|
||||
index,
|
||||
},
|
||||
"segments" => T::Segments {
|
||||
file_id: file_id()?,
|
||||
trk_id: engine::TrackId(uuid_property(value, "trackId")?),
|
||||
index,
|
||||
},
|
||||
"waypoints" => T::Waypoints {
|
||||
file_id: file_id()?,
|
||||
index,
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set(object: &Object, key: &str, value: impl Into<JsValue>) {
|
||||
Reflect::set(object, &key.into(), &value.into()).unwrap();
|
||||
}
|
||||
|
||||
pub(crate) fn array<T>(items: &[T], f: impl Fn(&T) -> JsValue) -> Array {
|
||||
items.iter().map(f).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn ids(items: &[FileId]) -> Array {
|
||||
array(items, |id| id.0.to_string().into())
|
||||
}
|
||||
|
||||
pub(crate) fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object {
|
||||
let node = Object::new();
|
||||
set(&node, "id", id.to_string());
|
||||
if let Some(name) = name {
|
||||
set(&node, "name", name);
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
pub(crate) fn structure_object(file: &engine::FileStructure) -> Object {
|
||||
let node = named_node(file.id.0, Some(&file.name));
|
||||
if let Some(desc) = &file.desc {
|
||||
set(&node, "desc", desc.as_str());
|
||||
}
|
||||
set(
|
||||
&node,
|
||||
"tracks",
|
||||
array(&file.tracks, |trk| {
|
||||
let node = named_node(trk.id.0, trk.name.as_deref());
|
||||
if let Some(desc) = &trk.desc {
|
||||
set(&node, "desc", desc.as_str());
|
||||
}
|
||||
if let Some(color) = &trk.color {
|
||||
set(&node, "color", color.as_str());
|
||||
}
|
||||
if let Some(opacity) = trk.opacity {
|
||||
set(&node, "opacity", opacity);
|
||||
}
|
||||
if let Some(width) = trk.width {
|
||||
set(&node, "width", width);
|
||||
}
|
||||
set(
|
||||
&node,
|
||||
"segments",
|
||||
array(&trk.segments, |seg| {
|
||||
let node = named_node(seg.id.0, None);
|
||||
set(&node, "rev", seg.rev_id.0.to_string());
|
||||
set(&node, "length", seg.len as f64);
|
||||
node.into()
|
||||
}),
|
||||
);
|
||||
node.into()
|
||||
}),
|
||||
);
|
||||
set(
|
||||
&node,
|
||||
"waypoints",
|
||||
array(&file.waypoints, |wpt| {
|
||||
let node = named_node(wpt.id.0, wpt.name.as_deref());
|
||||
if let Some(sym) = &wpt.sym {
|
||||
set(&node, "sym", sym.as_str());
|
||||
}
|
||||
node.into()
|
||||
}),
|
||||
);
|
||||
set(&node, "waypointsRev", file.wpt_rev_id.0.to_string());
|
||||
node
|
||||
}
|
||||
|
||||
pub(crate) fn statistics_object(stats: &engine::GlobalStatistics) -> GlobalStatistics {
|
||||
let object = Object::new();
|
||||
let optional = |key: &str, value: Option<f64>| {
|
||||
if let Some(value) = value {
|
||||
set(&object, key, value);
|
||||
}
|
||||
};
|
||||
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
|
||||
set(&object, "totalDistance", stats.total_distance);
|
||||
optional("movingDistance", stats.moving_distance);
|
||||
optional("totalTime", seconds(stats.total_time));
|
||||
optional("movingTime", seconds(stats.moving_time));
|
||||
set(&object, "elevationGain", stats.elevation_gain);
|
||||
set(&object, "elevationLoss", stats.elevation_loss);
|
||||
optional("startTime", stats.start_time.map(|t| t as f64));
|
||||
optional("endTime", stats.end_time.map(|t| t as f64));
|
||||
optional("totalSpeed", stats.total_speed());
|
||||
optional("movingSpeed", stats.moving_speed());
|
||||
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
|
||||
if sw.lng <= ne.lng && sw.lat <= ne.lat {
|
||||
let bounds = Object::new();
|
||||
set(&bounds, "west", sw.lng);
|
||||
set(&bounds, "south", sw.lat);
|
||||
set(&bounds, "east", ne.lng);
|
||||
set(&bounds, "north", ne.lat);
|
||||
set(&object, "bounds", bounds);
|
||||
}
|
||||
object.unchecked_into()
|
||||
}
|
||||
|
||||
pub(crate) fn uuids<T>(
|
||||
items: impl IntoIterator<Item = T>,
|
||||
uuid: impl Fn(T) -> uuid::Uuid,
|
||||
) -> Array {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| JsValue::from(uuid(item).to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn selection_object(selection: &engine::Selection) -> Object {
|
||||
use engine::Selection as S;
|
||||
let object = Object::new();
|
||||
match selection {
|
||||
S::Empty => set(&object, "type", "empty"),
|
||||
S::File { file_ids } => {
|
||||
set(&object, "type", "file");
|
||||
set(&object, "fileIds", uuids(file_ids, |id| id.0));
|
||||
}
|
||||
S::Track { file_id, trk_ids } => {
|
||||
set(&object, "type", "track");
|
||||
set(&object, "fileId", file_id.0.to_string());
|
||||
set(&object, "trackIds", uuids(trk_ids, |id| id.0));
|
||||
}
|
||||
S::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => {
|
||||
set(&object, "type", "segment");
|
||||
set(&object, "fileId", file_id.0.to_string());
|
||||
set(&object, "trackId", trk_id.0.to_string());
|
||||
set(&object, "segmentIds", uuids(trkseg_ids, |id| id.0));
|
||||
}
|
||||
S::Waypoints { file_id } => {
|
||||
set(&object, "type", "waypoints");
|
||||
set(&object, "fileId", file_id.0.to_string());
|
||||
}
|
||||
S::Waypoint { file_id, wpt_ids } => {
|
||||
set(&object, "type", "waypoint");
|
||||
set(&object, "fileId", file_id.0.to_string());
|
||||
set(&object, "waypointIds", uuids(wpt_ids, |id| id.0));
|
||||
}
|
||||
}
|
||||
object
|
||||
}
|
||||
|
||||
/// The outcome of a call. `error` is what it says about why nothing was done, if that is so.
|
||||
pub(crate) fn outcome_object(outcome: &engine::Outcome, error: Option<&str>) -> Outcome {
|
||||
let object = Object::new();
|
||||
let diff = outcome.diff.clone().unwrap_or_default();
|
||||
set(&object, "changed", outcome.changed);
|
||||
set(&object, "orderChanged", outcome.order_changed);
|
||||
set(&object, "selectionChanged", outcome.selection_changed);
|
||||
set(&object, "clipboardChanged", outcome.clipboard_changed);
|
||||
set(&object, "added", ids(&diff.added));
|
||||
set(&object, "modified", ids(&diff.modified));
|
||||
set(&object, "removed", ids(&diff.removed));
|
||||
if let Some(error) = error {
|
||||
set(&object, "error", error);
|
||||
}
|
||||
object.unchecked_into()
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! The storage of the engine in the browser: IndexedDB.
|
||||
//!
|
||||
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
|
||||
//! store per kind of data. A batch is a single transaction, so that the stored files always are
|
||||
//! ones that the engine had.
|
||||
|
||||
use gpx_engine::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
||||
use idb::TransactionMode;
|
||||
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
|
||||
use js_sys::Uint8Array;
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
const FILES: &str = "files";
|
||||
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
|
||||
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
|
||||
/// The order of the files and the categories of the trackpoints, under their own names.
|
||||
const META: &str = "meta";
|
||||
const SETTINGS: &str = "settings";
|
||||
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
|
||||
|
||||
const ORDER_KEY: &str = "order";
|
||||
const CATEGORIES_KEY: &str = "categories";
|
||||
|
||||
pub struct IdbStorage {
|
||||
database: Database,
|
||||
}
|
||||
|
||||
fn error(error: impl std::fmt::Display) -> StorageError {
|
||||
StorageError(error.to_string())
|
||||
}
|
||||
|
||||
/// The object store of a kind of chunks.
|
||||
fn chunk_store(kind: ChunkKind) -> &'static str {
|
||||
match kind {
|
||||
ChunkKind::Trackpoints => TRACKPOINT_CHUNKS,
|
||||
ChunkKind::Waypoints => WAYPOINT_CHUNKS,
|
||||
}
|
||||
}
|
||||
|
||||
fn key(id: Uuid) -> JsValue {
|
||||
JsValue::from_str(&id.to_string())
|
||||
}
|
||||
|
||||
fn bytes(value: &[u8]) -> JsValue {
|
||||
Uint8Array::from(value).into()
|
||||
}
|
||||
|
||||
fn to_vec(value: &JsValue) -> Vec<u8> {
|
||||
Uint8Array::new(value).to_vec()
|
||||
}
|
||||
|
||||
fn to_uuid(key: &JsValue) -> Result<Uuid, StorageError> {
|
||||
key.as_string()
|
||||
.and_then(|key| Uuid::parse_str(&key).ok())
|
||||
.ok_or_else(|| error("a key is not an id"))
|
||||
}
|
||||
|
||||
impl IdbStorage {
|
||||
/// Opens the database, which is created if it does not exist.
|
||||
pub async fn open(name: &str) -> Result<Self, StorageError> {
|
||||
let factory = Factory::new().map_err(error)?;
|
||||
let mut request = factory.open(name, Some(1)).map_err(error)?;
|
||||
request.on_upgrade_needed(|event| {
|
||||
if let Ok(database) = event.database() {
|
||||
for store in STORES {
|
||||
let _ = database.create_object_store(store, ObjectStoreParams::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
let database = request.await.map_err(error)?;
|
||||
Ok(Self { database })
|
||||
}
|
||||
|
||||
fn transaction(
|
||||
&self,
|
||||
stores: &[&str],
|
||||
mode: TransactionMode,
|
||||
) -> Result<Transaction, StorageError> {
|
||||
self.database.transaction(stores, mode).map_err(error)
|
||||
}
|
||||
|
||||
/// Everything stored in the object store, as `(key, bytes)`.
|
||||
async fn read_all(store: &ObjectStore) -> Result<Vec<(JsValue, Vec<u8>)>, StorageError> {
|
||||
let keys = store.get_all_keys(None, None).map_err(error)?;
|
||||
let values = store.get_all(None, None).map_err(error)?;
|
||||
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
|
||||
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
|
||||
}
|
||||
|
||||
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
|
||||
let files = transaction.object_store(FILES).map_err(error)?;
|
||||
let meta = transaction.object_store(META).map_err(error)?;
|
||||
|
||||
// the requests are all made before waiting for them
|
||||
let mut puts = vec![];
|
||||
let mut deletes = vec![];
|
||||
for (ChunkKey { kind, id }, data) in &batch.put_chunks {
|
||||
let store = transaction
|
||||
.object_store(chunk_store(*kind))
|
||||
.map_err(error)?;
|
||||
puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
||||
}
|
||||
for (FileId(id), data) in &batch.put_files {
|
||||
puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
||||
}
|
||||
if let Some(order) = &batch.order {
|
||||
let value = bytes(order);
|
||||
puts.push(
|
||||
meta.put(&value, Some(&JsValue::from_str(ORDER_KEY)))
|
||||
.map_err(error)?,
|
||||
);
|
||||
}
|
||||
if let Some(categories) = &batch.categories {
|
||||
let value = bytes(categories);
|
||||
puts.push(
|
||||
meta.put(&value, Some(&JsValue::from_str(CATEGORIES_KEY)))
|
||||
.map_err(error)?,
|
||||
);
|
||||
}
|
||||
for FileId(id) in &batch.delete_files {
|
||||
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
|
||||
}
|
||||
for ChunkKey { kind, id } in &batch.delete_chunks {
|
||||
let store = transaction
|
||||
.object_store(chunk_store(*kind))
|
||||
.map_err(error)?;
|
||||
deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?);
|
||||
}
|
||||
for put in puts {
|
||||
put.await.map_err(error)?;
|
||||
}
|
||||
for delete in deletes {
|
||||
delete.await.map_err(error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage for IdbStorage {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
let transaction = self.transaction(
|
||||
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
|
||||
TransactionMode::ReadOnly,
|
||||
)?;
|
||||
let store = |name| transaction.object_store(name).map_err(error);
|
||||
let files = Self::read_all(&store(FILES)?).await?;
|
||||
let trackpoint_chunks = Self::read_all(&store(TRACKPOINT_CHUNKS)?).await?;
|
||||
let waypoint_chunks = Self::read_all(&store(WAYPOINT_CHUNKS)?).await?;
|
||||
let meta = store(META)?;
|
||||
let order = meta
|
||||
.get(JsValue::from_str(ORDER_KEY))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
let categories = meta
|
||||
.get(JsValue::from_str(CATEGORIES_KEY))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
transaction.await.map_err(error)?;
|
||||
|
||||
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
|
||||
records
|
||||
.into_iter()
|
||||
.map(|(k, v)| Ok((to_uuid(&k)?, v)))
|
||||
.collect()
|
||||
};
|
||||
Ok(StoredData {
|
||||
files: ids(files)?
|
||||
.into_iter()
|
||||
.map(|(id, data)| (FileId(id), data))
|
||||
.collect(),
|
||||
chunks: ids(trackpoint_chunks)?
|
||||
.into_iter()
|
||||
.map(|(id, data)| (ChunkKey::trackpoints(id), data))
|
||||
.chain(
|
||||
ids(waypoint_chunks)?
|
||||
.into_iter()
|
||||
.map(|(id, data)| (ChunkKey::waypoints(id), data)),
|
||||
)
|
||||
.collect(),
|
||||
order: order.as_ref().map(to_vec),
|
||||
categories: categories.as_ref().map(to_vec),
|
||||
})
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
||||
let transaction = self.transaction(
|
||||
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
|
||||
TransactionMode::ReadWrite,
|
||||
)?;
|
||||
match self.write(&transaction, batch).await {
|
||||
Ok(()) => {
|
||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
// nothing of the batch is kept
|
||||
let _ = transaction.abort();
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadOnly)?;
|
||||
let store = transaction.object_store(SETTINGS).map_err(error)?;
|
||||
let keys = store.get_all_keys(None, None).map_err(error)?;
|
||||
let values = store.get_all(None, None).map_err(error)?;
|
||||
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
|
||||
transaction.await.map_err(error)?;
|
||||
Ok(keys
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.filter_map(|(key, value)| Some((key.as_string()?, value.as_string()?)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
|
||||
let store = transaction.object_store(SETTINGS).map_err(error)?;
|
||||
store
|
||||
.put(&JsValue::from_str(value), Some(&JsValue::from_str(key)))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
|
||||
let store = transaction.object_store(SETTINGS).map_err(error)?;
|
||||
store
|
||||
.delete(Query::Key(JsValue::from_str(key)))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Ids, which cross as strings (one id) or as concatenated 16-byte UUIDs (several).
|
||||
|
||||
use gpx_engine::{self as engine, FileId};
|
||||
|
||||
pub(crate) fn uuid_list(bytes: &[u8]) -> Option<Vec<uuid::Uuid>> {
|
||||
let (chunks, rest) = bytes.as_chunks::<16>();
|
||||
rest.is_empty()
|
||||
.then(|| chunks.iter().map(|c| uuid::Uuid::from_bytes(*c)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> {
|
||||
uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_file_id(id: &str) -> Option<FileId> {
|
||||
uuid::Uuid::parse_str(id).ok().map(FileId)
|
||||
}
|
||||
|
||||
pub(crate) fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool {
|
||||
a.len() == b.len() && b.len() == c.len()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_waypoint_id(id: &str) -> Option<engine::WaypointId> {
|
||||
uuid::Uuid::parse_str(id).ok().map(engine::WaypointId)
|
||||
}
|
||||
+22
-1204
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
//! Keeping the files and the settings in the browser.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpx_engine::{self as engine, Engine, Persister, Storage};
|
||||
use js_sys::Object;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::{outcome_object, set};
|
||||
use crate::idb::IdbStorage;
|
||||
use crate::session::*;
|
||||
use crate::ts::StorageOpened;
|
||||
|
||||
// Storage
|
||||
//
|
||||
// The files are saved in IndexedDB after each change that touches them, without making the change
|
||||
// wait: `schedule_save` starts a loop that writes what changed since the last write, until
|
||||
// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them.
|
||||
|
||||
thread_local! {
|
||||
static STORAGE: RefCell<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
||||
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
|
||||
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
||||
/// Resolve functions of the promises of `flush_storage`.
|
||||
static WAITERS: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console, js_name = error)]
|
||||
fn console_error(message: &str);
|
||||
}
|
||||
|
||||
pub(crate) fn storage() -> Option<Rc<IdbStorage>> {
|
||||
STORAGE.with(|storage| storage.borrow().clone())
|
||||
}
|
||||
|
||||
pub(crate) fn schedule_save() {
|
||||
if storage().is_none() {
|
||||
return;
|
||||
}
|
||||
DIRTY.set(true);
|
||||
if SAVING.replace(true) {
|
||||
// the running loop will go around again
|
||||
return;
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
while DIRTY.replace(false) {
|
||||
let Some(storage) = storage() else { break };
|
||||
let Some(snapshot) = with_engine(Engine::snapshot) else {
|
||||
break;
|
||||
};
|
||||
let mut persister = PERSISTER
|
||||
.with(|persister| persister.borrow_mut().take())
|
||||
.unwrap_or_default();
|
||||
let result = persister.save(&*storage, snapshot).await;
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
if let Err(error) = result {
|
||||
// what failed is written with the next change
|
||||
console_error(&error.to_string());
|
||||
}
|
||||
}
|
||||
SAVING.set(false);
|
||||
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
||||
let _ = resolve.call0(&JsValue::UNDEFINED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
|
||||
/// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the
|
||||
/// outcome of putting the files in. The changes of the files that follow are saved, unless some of
|
||||
/// what is stored was written by a newer version of the app (`readOnly`): then nothing is, so that
|
||||
/// the newer version finds its data again.
|
||||
#[wasm_bindgen]
|
||||
pub async fn open_storage(name: &str) -> Result<StorageOpened, JsValue> {
|
||||
let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false);
|
||||
if !empty {
|
||||
return Err(JsValue::from_str("the engine already has files"));
|
||||
}
|
||||
let storage = IdbStorage::open(name).await.map_err(to_js_error)?;
|
||||
let mut persister = Persister::default();
|
||||
let restored = persister.restore(&storage).await.map_err(to_js_error)?;
|
||||
let (read_only, unreadable) = (restored.read_only, restored.unreadable);
|
||||
let settings = storage.load_settings().await.map_err(to_js_error)?;
|
||||
|
||||
let outcome = with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default();
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
|
||||
|
||||
let settings_object = Object::new();
|
||||
for (key, value) in settings {
|
||||
set(&settings_object, &key, value);
|
||||
}
|
||||
let opened = Object::new();
|
||||
set(&opened, "settings", settings_object);
|
||||
set(&opened, "outcome", outcome_object(&outcome, None));
|
||||
set(&opened, "readOnly", read_only);
|
||||
set(&opened, "unreadable", unreadable as f64);
|
||||
Ok(opened.unchecked_into())
|
||||
}
|
||||
|
||||
pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
|
||||
JsValue::from_str(&error.to_string())
|
||||
}
|
||||
|
||||
/// Resolves when what was changed has been written to the storage.
|
||||
#[wasm_bindgen]
|
||||
pub async fn flush_storage() -> Result<(), JsValue> {
|
||||
if !SAVING.get() {
|
||||
return Ok(());
|
||||
}
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
|
||||
});
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves a setting, `value` being its JSON.
|
||||
#[wasm_bindgen]
|
||||
pub fn set_setting(key: String, value: String) {
|
||||
if let Some(storage) = storage() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(error) = storage.put_setting(&key, &value).await {
|
||||
console_error(&error.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn delete_setting(key: String) {
|
||||
if let Some(storage) = storage() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(error) = storage.delete_setting(&key).await {
|
||||
console_error(&error.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! The coordinates of the trackpoints and waypoints, as flat typed arrays.
|
||||
|
||||
use gpx_engine::{self as engine, Engine, FileId};
|
||||
use js_sys::Float64Array;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::session::*;
|
||||
|
||||
// Coordinates buffers
|
||||
//
|
||||
// Like the statistics buffers, these are copies. They are flat `[lng, lat, ...]` arrays. Compare `rev` / `waypointsRev` of the file structures with the previous ones to know which
|
||||
// buffers actually changed.
|
||||
|
||||
pub(crate) fn coordinates_array(f: impl FnOnce(&Engine) -> Option<&[f64]>) -> Float64Array {
|
||||
ENGINE.with(|engine| match engine.borrow().as_ref().and_then(f) {
|
||||
Some(coordinates) => Float64Array::from(coordinates),
|
||||
None => Float64Array::new_with_length(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
|
||||
#[wasm_bindgen]
|
||||
pub fn segment_coordinates(segment_id: &str) -> Float64Array {
|
||||
match uuid::Uuid::parse_str(segment_id) {
|
||||
Ok(id) => coordinates_array(|e| Some(e.segment_coordinates(&engine::TrackSegmentId(id)))),
|
||||
Err(_) => Float64Array::new_with_length(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Coordinates of the waypoints of a file (empty if the id is unknown).
|
||||
#[wasm_bindgen]
|
||||
pub fn waypoint_coordinates(file_id: &str) -> Float64Array {
|
||||
match uuid::Uuid::parse_str(file_id) {
|
||||
Ok(id) => coordinates_array(|e| Some(e.waypoint_coordinates(&FileId(id)))),
|
||||
Err(_) => Float64Array::new_with_length(0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! The files, their structure and statistics, the selection and the clipboard.
|
||||
|
||||
// File order and structures
|
||||
//
|
||||
// The files are described by one ordered list of ids and one structure object per file, so that
|
||||
// the UI can react to what changed only: after each action, `last_update` tells which
|
||||
// structures to (re)read or drop and whether the order changed. Ids are UUID strings. The
|
||||
// trackpoints of a segment and the waypoints of a file are in the same order as the coordinates
|
||||
// of their buffers (see below).
|
||||
|
||||
use gpx_engine::{self as engine, FileId};
|
||||
use js_sys::Object;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::ids::*;
|
||||
use crate::queries::statistics::with_stats;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
|
||||
/// Ids of the files in display order.
|
||||
#[wasm_bindgen]
|
||||
pub fn file_order() -> FileOrder {
|
||||
let order = with_engine(|e| ids(e.order())).unwrap_or_default();
|
||||
order.unchecked_into()
|
||||
}
|
||||
|
||||
/// Structure of a file, `undefined` if the id is unknown.
|
||||
#[wasm_bindgen]
|
||||
pub fn file_structure(file_id: &str) -> FileStructure {
|
||||
let structure = uuid::Uuid::parse_str(file_id)
|
||||
.ok()
|
||||
.and_then(|id| {
|
||||
with_engine(|e| e.file_structure(&FileId(id)).map(structure_object)).flatten()
|
||||
})
|
||||
.map_or(JsValue::UNDEFINED, JsValue::from);
|
||||
structure.unchecked_into()
|
||||
}
|
||||
|
||||
/// Global statistics of a file, `undefined` if the id is unknown.
|
||||
#[wasm_bindgen]
|
||||
pub fn file_statistics(file_id: &str) -> GlobalStatistics {
|
||||
uuid::Uuid::parse_str(file_id)
|
||||
.ok()
|
||||
.and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten())
|
||||
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
|
||||
statistics_object(&stats)
|
||||
})
|
||||
}
|
||||
|
||||
/// Global statistics of the whole selection (the arrays of the statistics buffers cover it).
|
||||
#[wasm_bindgen]
|
||||
pub fn selection_statistics() -> GlobalStatistics {
|
||||
with_stats(|buffer| statistics_object(&buffer.global))
|
||||
.unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into())
|
||||
}
|
||||
|
||||
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
|
||||
/// to keep.
|
||||
#[wasm_bindgen]
|
||||
pub fn export_file(
|
||||
file_id: &str,
|
||||
time: bool,
|
||||
hr: bool,
|
||||
cad: bool,
|
||||
atemp: bool,
|
||||
power: bool,
|
||||
osm: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let id = parse_file_id(file_id)?;
|
||||
let options = engine::ExportOptions {
|
||||
time,
|
||||
hr,
|
||||
cad,
|
||||
atemp,
|
||||
power,
|
||||
osm,
|
||||
};
|
||||
with_engine(|e| e.export(&id, options)).flatten()
|
||||
}
|
||||
|
||||
/// The data that some files have, so that the ones that none has are not offered when exporting
|
||||
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
|
||||
/// OpenStreetMap data (32). `file_ids` are concatenated UUIDs.
|
||||
#[wasm_bindgen]
|
||||
pub fn exportable_data(file_ids: &[u8]) -> u8 {
|
||||
let Some(ids) = self::file_ids(file_ids) else {
|
||||
return 0;
|
||||
};
|
||||
let data = with_engine(|e| e.exportable_data(&ids)).unwrap_or(engine::ExportOptions::NONE);
|
||||
[
|
||||
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
|
||||
}
|
||||
|
||||
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
|
||||
/// as indexed by the statistics buffers. `undefined` when the range is not in the selection.
|
||||
#[wasm_bindgen]
|
||||
pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics {
|
||||
with_stats(|buffer| buffer.slice(start, end))
|
||||
.flatten()
|
||||
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
|
||||
statistics_object(&stats)
|
||||
})
|
||||
}
|
||||
|
||||
/// The current selection (ids are UUID strings).
|
||||
#[wasm_bindgen]
|
||||
pub fn selection() -> Selection {
|
||||
with_engine(|e| selection_object(e.selection()))
|
||||
.unwrap_or_default()
|
||||
.unchecked_into()
|
||||
}
|
||||
|
||||
/// All the data of a waypoint of a file, `undefined` if it does not exist.
|
||||
#[wasm_bindgen]
|
||||
pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails {
|
||||
uuid::Uuid::parse_str(file_id)
|
||||
.ok()
|
||||
.zip(uuid::Uuid::parse_str(waypoint_id).ok())
|
||||
.and_then(|(file_id, id)| {
|
||||
with_engine(|e| {
|
||||
e.waypoint(&FileId(file_id), &engine::WaypointId(id))
|
||||
.map(|wpt| {
|
||||
let object = Object::new();
|
||||
set(&object, "id", wpt.id.0.to_string());
|
||||
set(&object, "lng", wpt.coordinates.lng);
|
||||
set(&object, "lat", wpt.coordinates.lat);
|
||||
set(&object, "ele", wpt.ele);
|
||||
if let Some(time) = wpt.time {
|
||||
set(&object, "time", time as f64);
|
||||
}
|
||||
for (key, value) in [
|
||||
("name", &wpt.name),
|
||||
("desc", &wpt.desc),
|
||||
("cmt", &wpt.cmt),
|
||||
("sym", &wpt.sym),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
set(&object, key, value.as_str());
|
||||
}
|
||||
}
|
||||
if !wpt.links.is_empty() {
|
||||
set(
|
||||
&object,
|
||||
"links",
|
||||
array(&wpt.links, |link| {
|
||||
let link_object = Object::new();
|
||||
set(&link_object, "href", link.href.as_str());
|
||||
if let Some(text) = &link.text {
|
||||
set(&link_object, "text", text.as_str());
|
||||
}
|
||||
link_object.into()
|
||||
}),
|
||||
);
|
||||
}
|
||||
JsValue::from(object)
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(JsValue::UNDEFINED)
|
||||
.unchecked_into()
|
||||
}
|
||||
|
||||
/// A trackpoint of a segment of a file, `undefined` if it does not exist.
|
||||
#[wasm_bindgen]
|
||||
pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails {
|
||||
parse_file_id(file_id)
|
||||
.zip(uuid::Uuid::parse_str(segment_id).ok())
|
||||
.and_then(|(file_id, segment_id)| {
|
||||
with_engine(|e| {
|
||||
e.trackpoint(&file_id, &engine::TrackSegmentId(segment_id), index)
|
||||
.map(|pt| {
|
||||
let object = Object::new();
|
||||
set(&object, "lng", pt.coordinates.lng);
|
||||
set(&object, "lat", pt.coordinates.lat);
|
||||
set(&object, "ele", pt.ele);
|
||||
if let Some(time) = pt.time {
|
||||
set(&object, "time", time as f64);
|
||||
}
|
||||
JsValue::from(object)
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(JsValue::UNDEFINED)
|
||||
.unchecked_into()
|
||||
}
|
||||
|
||||
/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the
|
||||
/// elements and their ids (UUID strings).
|
||||
#[wasm_bindgen]
|
||||
pub fn clipboard() -> Clipboard {
|
||||
with_engine(|e| {
|
||||
e.clipboard().map_or(JsValue::UNDEFINED, |clipboard| {
|
||||
let object = Object::new();
|
||||
let (kind, ids) = match clipboard.content.ids() {
|
||||
engine::ClipboardIds::Files(ids) => ("files", uuids(ids, |id| id.0)),
|
||||
engine::ClipboardIds::Tracks(ids) => ("tracks", uuids(ids, |id| id.0)),
|
||||
engine::ClipboardIds::Segments(ids) => ("segments", uuids(ids, |id| id.0)),
|
||||
engine::ClipboardIds::Waypoints(ids) => ("waypoints", uuids(ids, |id| id.0)),
|
||||
};
|
||||
set(&object, "type", kind);
|
||||
set(&object, "ids", ids);
|
||||
set(&object, "cut", clipboard.cut);
|
||||
object.into()
|
||||
})
|
||||
})
|
||||
.unwrap_or(JsValue::UNDEFINED)
|
||||
.unchecked_into()
|
||||
}
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user