36 Commits
Author SHA1 Message Date
vcoppe ce8398f749 renaming 2026-10-14 08:17:51 +02:00
vcoppe 3c2055b50e improve flow 2026-10-14 08:07:58 +02:00
vcoppe 1d13972014 fix 2026-10-11 11:19:57 +02:00
vcoppe 890bcd765a refactoring 2026-10-11 11:18:36 +02:00
vcoppe 4795513344 reorder methods 2026-10-11 10:58:15 +02:00
vcoppe d84ca47745 refactoring 2026-10-11 10:54:06 +02:00
vcoppe 9adb8e7221 outdated state 2026-10-11 10:48:43 +02:00
vcoppe a2b4d89150 cross-tab sync 2026-10-10 11:07:07 +02:00
vcoppe 77929cfa04 per-chunk categories storage 2026-10-09 17:55:07 +02:00
vcoppe dc36de2a48 import and export of routes, ref #192 2026-10-09 09:26:38 +02:00
vcoppe da9abbae5e refactoring and tests 2026-10-09 08:58:00 +02:00
vcoppe 3bb553d6b5 remove unused fields 2026-10-08 21:19:53 +02:00
vcoppe 83b76bb838 fix link fields 2026-10-08 21:13:44 +02:00
vcoppe 42a55e6759 progress 2026-10-08 21:04:29 +02:00
vcoppe b085a1d476 cleaning 2026-10-08 19:04:46 +02:00
vcoppe 840e943626 update readme 2026-10-08 18:41:27 +02:00
vcoppe 16e2e0f679 remove todo 2026-10-08 18:37:46 +02:00
vcoppe 0f9557ea82 remove gpx js package 2026-10-08 18:37:40 +02:00
vcoppe 67a4c090bd progress 2026-10-08 18:34:19 +02:00
vcoppe 606a0e974a export 2026-10-08 09:24:15 +02:00
vcoppe e8c4eb92f9 handle corrupt time data 2026-10-08 08:59:53 +02:00
vcoppe f0851086c0 fix reactivity 2026-10-08 08:47:14 +02:00
vcoppe bec1ce4c9b reduce 2026-10-08 08:36:29 +02:00
vcoppe b29c4cdb08 fix bounds for waypoints 2026-10-08 08:15:22 +02:00
vcoppe 63ba8596a7 time tool 2026-10-08 08:12:44 +02:00
vcoppe 82d2f79da0 progress 2026-10-07 22:20:40 +02:00
vcoppe f642d60f42 merge 2026-10-07 19:57:27 +02:00
vcoppe 24b877c4ee split 2026-10-07 19:38:21 +02:00
vcoppe e07ec97e12 crop 2026-10-07 18:54:00 +02:00
vcoppe 290f4d8c44 reverse 2026-10-07 18:45:56 +02:00
vcoppe a48608840f reverse 2026-10-07 18:36:00 +02:00
vcoppe df07892e76 refactoring 2026-10-07 18:22:30 +02:00
vcoppe 594dd8979a routing 2026-10-07 17:58:17 +02:00
vcoppe 29850c1c8f speed fix 2026-10-07 09:35:27 +02:00
vcoppe adbe4a2693 copy on wasm side to avoid any data corruption 2026-10-07 08:57:37 +02:00
vcoppe b843a18441 reduce data exchanges across boundary 2026-10-07 08:45:50 +02:00
345 changed files with 23983 additions and 23271 deletions

No files matched your search

+5 -9
View File
@@ -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
+396
View File
@@ -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"
+11
View File
@@ -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
```
+3 -1
View File
@@ -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"] }
+38
View File
@@ -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>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
<rte>
<name>Lausanne</name>
<cmt>comment</cmt>
<desc>description</desc>
<type>Cycling</type>
<rtept lat="46.544628096744418" lon="6.658079791814089">
<ele>500</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="46.544483928009868" lon="6.658018603920937"/>
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="46.544100000000000" lon="6.659000000000000">
<ele>510</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="46.543900000000000" lon="6.659500000000000"/>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="46.543000000000000" lon="6.660000000000000">
<ele>505</ele>
</rtept>
</rte>
</gpx>
+112
View File
@@ -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)]
);
}
}
+6
View File
@@ -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::*;
+232
View File
@@ -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)]);
}
}
+118 -19
View File
@@ -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()));
}
}
+72 -39
View File
@@ -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()
}
}
impl TrackpointChunk {
pub fn is_full(&self) -> bool {
self.trkpt.len() == MAX_TRKPT_CHUNK_SIZE
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
}
}
#[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;
fn new(wpt: Vec<Waypoint>) -> Self {
Self {
wpt,
..Default::default()
}
}
impl WaypointChunk {
pub fn is_full(&self) -> bool {
self.wpt.len() == MAX_WPT_CHUNK_SIZE
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);
}
}
+619
View File
@@ -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]);
}
}
+17
View File
@@ -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,
+8 -26
View File
@@ -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>,
}
+4
View File
@@ -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::*;
+180 -223
View File
@@ -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]
}
/// 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));
}
impl Index<usize> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: usize) -> &Self::Output {
&self[self.locate(idx).unwrap()]
/// 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)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct TrackSegmentIndex {
pub chunk: usize,
pub pos: usize,
pub flat: usize,
}
#[derive(Debug, Clone)]
pub struct TrackSegmentIterator<'a> {
trkseg: &'a TrackSegment,
idx: Option<TrackSegmentIndex>,
}
impl<'a> TrackSegmentIterator<'a> {
pub fn new(trkseg: &'a TrackSegment) -> Self {
Self {
trkseg,
idx: Default::default(),
}
}
}
impl<'a> Iterator for TrackSegmentIterator<'a> {
type Item = &'a Trackpoint;
fn next(&mut self) -> Option<Self::Item> {
self.idx = self.trkseg.next_index(self.idx);
self.idx.map(|idx| &self.trkseg[idx])
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let idx = self.idx.map_or_default(|idx| idx.flat) + n;
self.idx = self.trkseg.locate(idx);
self.idx.map(|idx| &self.trkseg[idx])
_ => 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());
}
}
+4 -11
View File
@@ -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>,
+4
View File
@@ -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>,
}
+19 -10
View File
@@ -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_
}
}
+315
View File
@@ -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));
}
}
+2
View File
@@ -1,3 +1,5 @@
mod parse;
mod write;
pub use parse::*;
pub use write::*;
File diff suppressed because it is too large. Load diff
+766
View File
@@ -0,0 +1,766 @@
use std::io;
use chrono::DateTime;
use quick_xml::Writer;
use quick_xml::events::{BytesDecl, BytesText, Event};
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// What the written file contains: the data of the trackpoints that are `false` are left out.
/// The same type tells which data a file has, see [`File::exportable_data`].
///
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions {
pub time: bool,
pub hr: bool,
pub cad: bool,
pub atemp: bool,
pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool,
/// Writes every segment as a route (`rte`) instead of a track.
pub as_route: bool,
}
impl ExportOptions {
pub const ALL: Self = Self::of(true);
pub const NONE: Self = Self::of(false);
const fn of(value: bool) -> Self {
Self {
time: value,
hr: value,
cad: value,
atemp: value,
power: value,
osm: value,
as_route: false,
}
}
/// The options that are on in `self` or in `other`.
pub fn union(self, other: Self) -> Self {
Self {
time: self.time || other.time,
hr: self.hr || other.hr,
cad: self.cad || other.cad,
atemp: self.atemp || other.atemp,
power: self.power || other.power,
osm: self.osm || other.osm,
as_route: self.as_route || other.as_route,
}
}
}
impl File {
/// The data that the trackpoints of the file have (and waypoints for the time), which the
/// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE;
for point in self
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter())
{
data.time |= point.time.is_some();
data.hr |= point.hr.is_some();
data.cad |= point.cad.is_some();
data.atemp |= point.atemp.is_some();
data.power |= point.power.is_some();
data.osm |= point.surface.is_some()
|| point.highway.is_some()
|| point.sac_scale.is_some()
|| point.mtb_scale.is_some();
if data == ExportOptions::ALL {
break;
}
}
data
}
}
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
type XmlWriter = Writer<Vec<u8>>;
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
/// the names of their codes in `categories`.
///
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`.
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
// writing to memory does not fail
write_file(&mut writer, file, categories, options).expect("writing to memory");
writer.into_inner()
}
fn write_file(
w: &mut XmlWriter,
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
w.create_element("gpx")
.with_attributes([
("creator", "https://gpx.studio"),
("version", "1.1"),
("xmlns", "http://www.topografix.com/GPX/1/1"),
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
("xsi:schemaLocation", SCHEMA_LOCATION),
(
"xmlns:gpxtpx",
"http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
),
(
"xmlns:gpxx",
"http://www.garmin.com/xmlschemas/GpxExtensions/v3",
),
(
"xmlns:gpxpx",
"http://www.garmin.com/xmlschemas/PowerExtension/v1",
),
(
"xmlns:gpx_style",
"http://www.topografix.com/GPX/gpx_style/0/2",
),
])
.write_inner_content(|w| {
write_metadata(w, file, options)?;
for wpt in file.wpt.iter() {
write_waypoint(w, wpt, options)?;
}
for trk in &file.trk {
// a single unnamed track is named like the file
let name = match (&trk.info.name, file.trk.len()) {
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
(_, 1) => Some(file.info.name.as_str()),
_ => None,
};
if options.as_route {
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) {
write_route(w, trk, segment, name, categories, options)?;
}
} else {
write_track(w, trk, name, categories, options)?;
}
}
Ok(())
})?;
Ok(())
}
fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> {
if !value.is_empty() {
w.create_element(name)
.write_text_content(BytesText::new(value))?;
}
Ok(())
}
fn optional_text(w: &mut XmlWriter, name: &str, value: &Option<String>) -> io::Result<()> {
text(w, name, value.as_deref().unwrap_or_default())
}
fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> {
text(w, name, &value.to_string())
}
fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) {
text(
w,
"time",
&time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
)?;
}
Ok(())
}
fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
if !link.href.is_empty() {
w.create_element("link")
.with_attribute(("href", link.href.as_str()))
.write_inner_content(|w| optional_text(w, "text", &link.text))?;
}
Ok(())
}
fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> {
links.iter().try_for_each(|l| link(w, l))
}
fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
let info = &file.info;
let time = options.time.then_some(info.time).flatten();
if info.name.is_empty()
&& info.desc.is_none()
&& info.author.is_none()
&& info.links.is_empty()
&& time.is_none()
{
return Ok(());
}
w.create_element("metadata").write_inner_content(|w| {
text(w, "name", &info.name)?;
optional_text(w, "desc", &info.desc)?;
if let Some(author) = &info.author {
w.create_element("author").write_inner_content(|w| {
optional_text(w, "name", &author.name)?;
if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@'))
{
w.create_element("email")
.with_attributes([("id", id), ("domain", domain)])
.write_empty()?;
}
author.link.iter().try_for_each(|l| link(w, l))
})?;
}
links(w, &info.links)?;
self::time(w, time)
})?;
Ok(())
}
fn write_coordinates<'a>(
w: &'a mut XmlWriter,
name: &'a str,
coordinates: crate::LngLat,
) -> quick_xml::writer::ElementWriter<'a, Vec<u8>> {
w.create_element(name).with_attributes([
("lat", coordinates.lat.to_string().as_str()),
("lon", coordinates.lng.to_string().as_str()),
])
}
fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> {
write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| {
number(w, "ele", wpt.ele)?;
if options.time {
time(w, wpt.time)?;
}
optional_text(w, "name", &wpt.name)?;
optional_text(w, "cmt", &wpt.cmt)?;
optional_text(w, "desc", &wpt.desc)?;
links(w, &wpt.links)?;
optional_text(w, "sym", &wpt.sym)?;
optional_text(w, "type", &wpt.type_)
})?;
Ok(())
}
/// The information of a track that its routes have too: name, comment, description, source,
/// links, type and style.
fn write_track_info(
w: &mut XmlWriter,
name: Option<&str>,
info: &crate::TrackInfo,
) -> io::Result<()> {
text(w, "name", name.unwrap_or_default())?;
optional_text(w, "cmt", &info.cmt)?;
optional_text(w, "desc", &info.desc)?;
optional_text(w, "src", &info.src)?;
links(w, &info.links)?;
optional_text(w, "type", &info.type_)?;
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
w.create_element("extensions").write_inner_content(|w| {
w.create_element("gpx_style:line")
.write_inner_content(|w| {
optional_text(w, "gpx_style:color", &info.color)?;
if let Some(opacity) = info.opacity {
number(w, "gpx_style:opacity", opacity)?;
}
if let Some(width) = info.width {
number(w, "gpx_style:width", width)?;
}
Ok(())
})?;
Ok(())
})?;
}
Ok(())
}
fn write_track(
w: &mut XmlWriter,
trk: &Track,
name: Option<&str>,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.create_element("trk").write_inner_content(|w| {
write_track_info(w, name, &trk.info)?;
for segment in &trk.trkseg {
w.create_element("trkseg").write_inner_content(|w| {
for point in segment.iter() {
write_point(w, "trkpt", point, &[], categories, options)?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
}
fn write_route(
w: &mut XmlWriter,
trk: &Track,
segment: &crate::TrackSegment,
name: Option<&str>,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.create_element("rte").write_inner_content(|w| {
write_track_info(w, name, &trk.info)?;
let points: Vec<&Trackpoint> = segment.iter().collect();
let last = points.len() - 1;
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some();
let mut i = 0;
while i < points.len() {
// the trackpoints up to the next anchor
let mut next = i + 1;
while next < points.len() && !is_anchor(next) {
next += 1;
}
write_point(
w,
"rtept",
points[i],
&points[i + 1..next],
categories,
options,
)?;
i = next;
}
Ok(())
})?;
Ok(())
}
/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one,
/// of which only the positions are written.
fn write_point(
w: &mut XmlWriter,
tag: &str,
point: &Trackpoint,
path: &[&Trackpoint],
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
let atemp = point.atemp.filter(|_| options.atemp);
let hr = point.hr.filter(|_| options.hr);
let cad = point.cad.filter(|_| options.cad);
let power = point.power.filter(|_| options.power);
let osm = [
("surface", point.surface, &categories.surface),
("highway", point.highway, &categories.highway),
("sac_scale", point.sac_scale, &categories.sac_scale),
("mtb_scale", point.mtb_scale, &categories.mtb_scale),
]
.map(|(name, code, categories)| {
let value = code
.filter(|_| options.osm)
.and_then(|code| categories.name(code));
(name, value)
});
let has_osm = osm.iter().any(|(_, value)| value.is_some());
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
write_coordinates(w, tag, point.coordinates).write_inner_content(|w| {
number(w, "ele", point.ele)?;
if options.time {
time(w, point.time)?;
}
if !has_track_point_extension && power.is_none() && path.is_empty() {
return Ok(());
}
w.create_element("extensions").write_inner_content(|w| {
if has_track_point_extension {
w.create_element("gpxtpx:TrackPointExtension")
.write_inner_content(|w| {
if let Some(atemp) = atemp {
number(w, "gpxtpx:atemp", atemp)?;
}
if let Some(hr) = hr {
number(w, "gpxtpx:hr", hr)?;
}
if let Some(cad) = cad {
number(w, "gpxtpx:cad", cad)?;
}
if has_osm {
w.create_element("gpxtpx:Extensions")
.write_inner_content(|w| {
for (name, value) in osm {
text(w, name, value.unwrap_or_default())?;
}
Ok(())
})?;
}
Ok(())
})?;
}
if let Some(power) = power {
w.create_element("gpxpx:PowerExtension")
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
}
if !path.is_empty() {
w.create_element("gpxx:RoutePointExtension")
.write_inner_content(|w| {
for point in path {
write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn read(name: &str) -> (File, TrackpointCategories) {
let mut categories = TrackpointCategories::default();
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
(parse(&data, &mut categories).unwrap(), categories)
}
fn written_text(
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> String {
String::from_utf8(write(file, categories, options)).unwrap()
}
/// Writes a file and reads it again.
fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) {
let (file, mut categories) = read(name);
let written = write(&file, &categories, options);
let again = parse(&written, &mut categories).unwrap();
(file, again, categories)
}
fn points(file: &File) -> Vec<Trackpoint> {
file.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter().cloned())
.collect()
}
fn same_points(a: &[Trackpoint], b: &[Trackpoint]) {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
assert_eq!(a.coordinates.lat, b.coordinates.lat);
assert_eq!(a.coordinates.lng, b.coordinates.lng);
assert_eq!(a.ele, b.ele);
assert_eq!(a.time, b.time);
assert_eq!(a.atemp, b.atemp);
assert_eq!(a.hr, b.hr);
assert_eq!(a.cad, b.cad);
assert_eq!(a.power, b.power);
assert_eq!(a.surface, b.surface);
assert_eq!(a.highway, b.highway);
assert_eq!(a.sac_scale, b.sac_scale);
assert_eq!(a.mtb_scale, b.mtb_scale);
}
}
#[test]
fn test_links_and_the_email_are_written() {
let data = std::fs::read("data/with_links.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let again = parse(
&write(&file, &Default::default(), ExportOptions::ALL),
&mut Default::default(),
)
.unwrap();
assert_eq!(again.info.links, file.info.links);
assert_eq!(again.info.links.len(), 2);
assert_eq!(again.info.author, file.info.author);
assert_eq!(
again.info.author.as_ref().unwrap().email.as_deref(),
Some("someone@example.com")
);
let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::<Vec<_>>();
assert_eq!(waypoints(&again), waypoints(&file));
assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3);
for (a, b) in again.trk.iter().zip(&file.trk) {
assert_eq!(a.info.links, b.info.links);
assert_eq!(a.info.links.len(), 2);
}
}
#[test]
fn test_files_survive_a_round_trip() {
for name in [
"simple",
"with_links",
"with_time",
"with_hr",
"with_cad",
"with_temp",
"with_power_1",
"with_power_2",
"with_surface",
"with_highway",
"with_style",
"with_waypoint",
"with_tracks_and_segments",
"self_closing_points",
] {
let (file, again, _) = round_trip(name, ExportOptions::ALL);
assert_eq!(file.info.name, again.info.name, "{name}");
assert_eq!(file.info.desc, again.info.desc, "{name}");
assert_eq!(file.info.author, again.info.author, "{name}");
assert_eq!(file.trk.len(), again.trk.len(), "{name}");
for (a, b) in file.trk.iter().zip(&again.trk) {
assert_eq!(a.info, b.info, "{name}");
assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}");
}
same_points(&points(&file), &points(&again));
let waypoints: Vec<_> = file.wpt.iter().collect();
let again_waypoints: Vec<_> = again.wpt.iter().collect();
assert_eq!(waypoints.len(), again_waypoints.len(), "{name}");
for (a, b) in waypoints.iter().zip(&again_waypoints) {
assert!(a.same_content(b), "{name}");
}
}
}
#[test]
fn test_options_leave_data_out() {
let (file, categories) = read("with_hr");
assert!(points(&file).iter().any(|p| p.hr.is_some()));
let options = ExportOptions {
hr: false,
..ExportOptions::ALL
};
let written = written_text(&file, &categories, options);
assert!(!written.contains("hr>"));
let (file, categories) = read("with_time");
let options = ExportOptions {
time: false,
..ExportOptions::ALL
};
assert!(!written_text(&file, &categories, options).contains("<time>"));
assert!(
written_text(&file, &categories, ExportOptions::ALL)
.contains("<time>2023-12-31T23:00:00.000Z</time>")
);
}
#[test]
fn test_text_is_escaped_and_the_single_track_gets_the_name_of_the_file() {
let (mut file, categories) = read("simple");
file.info.name = "a <b> & c".into();
file.trk.truncate(1);
file.trk[0].info.name = None;
let written = write(&file, &categories, ExportOptions::ALL);
let text = String::from_utf8(written.clone()).unwrap();
assert!(text.contains("a &lt;b&gt; &amp; c"));
// the track has the name of the file
assert_eq!(text.matches("a &lt;b&gt; &amp; c").count(), 2);
// and it is read back as it was
let mut categories = categories;
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.info.name, "a <b> & c");
assert_eq!(again.trk[0].info.name.as_deref(), Some("a <b> & c"));
}
#[test]
fn test_exportable_data() {
let (file, _) = read("with_time");
let data = file.exportable_data();
assert!(data.time && !data.hr && !data.osm);
let (file, _) = read("with_surface");
assert!(file.exportable_data().osm);
let (file, _) = read("with_power_2");
assert!(file.exportable_data().power);
}
fn as_route() -> ExportOptions {
ExportOptions {
as_route: true,
..ExportOptions::ALL
}
}
fn count(text: &str, pattern: &str) -> usize {
text.matches(pattern).count()
}
#[test]
fn test_export_as_route_writes_routes_instead_of_tracks() {
let (file, categories) = read("with_route_points");
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<trk>"), 0);
assert_eq!(count(&text, "<trkseg>"), 0);
assert_eq!(count(&text, "<trkpt"), 0);
// the anchors are the route points, the trackpoints between them the points of their path
assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), 4);
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
// a route has the information of its track
assert!(text.contains("<name>Lausanne</name>"));
assert!(text.contains("<cmt>comment</cmt>"));
assert!(text.contains("<desc>description</desc>"));
assert!(text.contains("<type>Cycling</type>"));
// and its points only have a position in the path, and an elevation as route points
let first_rtept = &text[text.find("<rtept").unwrap()..];
let (lat, lng) = (46.54462809674442_f64, 6.658079791814089_f64);
assert!(first_rtept.starts_with(&format!("<rtept lat=\"{lat}\" lon=\"{lng}\">")));
assert!(first_rtept.contains("<ele>500</ele>"));
let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64);
assert!(text.contains(&format!("<gpxx:rpt lat=\"{lat}\" lon=\"{lng}\"/>")));
}
#[test]
fn test_a_route_survives_being_exported_as_a_route() {
let (file, mut categories) = read("with_route_points");
let written = write(&file, &categories, as_route());
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), 1);
assert_eq!(again.trk[0].info, file.trk[0].info);
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(a.coordinates.lat, b.coordinates.lat);
assert_eq!(a.coordinates.lng, b.coordinates.lng);
assert_eq!(a.anchor, b.anchor);
}
// the elevation of the route points is kept, the other ones are worked out again
let anchors = |s: &crate::TrackSegment| {
s.iter()
.filter(|p| p.anchor.is_some())
.map(|p| p.ele)
.collect::<Vec<_>>()
};
assert_eq!(anchors(a), anchors(b));
}
#[test]
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
let (file, mut categories) = read("with_tracks_and_segments");
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
assert!(segments > 2);
let written = write(&file, &categories, as_route());
let text = String::from_utf8(written.clone()).unwrap();
assert_eq!(count(&text, "<rte>"), segments);
assert_eq!(count(&text, "<trk>"), 0);
// every route starts and ends with a route point
assert_eq!(count(&text, "</rte>"), segments);
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), segments);
let mut routes = again.trk.iter();
for track in &file.trk {
for segment in &track.trkseg {
let route = routes.next().unwrap();
// the information of the track, repeated for each of its segments
assert_eq!(route.info.name, track.info.name);
assert_eq!(route.info.type_, track.info.type_);
let (a, b) = (segment, &route.trkseg[0]);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(
(a.coordinates.lat, a.coordinates.lng),
(b.coordinates.lat, b.coordinates.lng)
);
}
// the anchors of the segment are the ones of its route
let anchors = |s: &crate::TrackSegment| {
s.iter()
.enumerate()
.filter(|(_, p)| p.anchor.is_some())
.map(|(i, _)| i)
.collect::<Vec<_>>()
};
assert_eq!(anchors(a), anchors(b));
}
}
}
#[test]
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
let (mut file, categories) = read("simple");
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None);
let len = file.trk[0].trkseg[0].len();
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rtept"), 2);
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
// and a single trackpoint is a route of one point
file.trk[0].trkseg[0].splice(1, len, vec![]);
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rtept"), 1);
assert_eq!(count(&text, "<gpxx:rpt"), 0);
}
#[test]
fn test_empty_segments_are_not_routes() {
let (mut file, categories) = read("simple");
file.trk[0].trkseg.push(crate::TrackSegment::default());
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rte>"), 1);
}
#[test]
fn test_routes_have_the_style_of_their_track() {
let (file, categories) = read("with_style");
let text = written_text(&file, &categories, as_route());
assert!(count(&text, "<gpx_style:line>") >= 1);
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.trk[0].info.color, file.trk[0].info.color);
assert_eq!(again.trk[0].info.width, file.trk[0].info.width);
}
#[test]
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
let (file, categories) = read("with_hr");
let with_all = written_text(&file, &categories, as_route());
assert!(with_all.contains("<gpxtpx:hr>"));
let without = written_text(
&file,
&categories,
ExportOptions {
hr: false,
time: false,
as_route: true,
..ExportOptions::ALL
},
);
assert!(!without.contains("<gpxtpx:hr>"));
assert!(!without.contains("<time>"));
}
#[test]
fn test_the_route_option_is_not_data() {
let (file, _) = read("with_route_points");
assert!(!file.exportable_data().as_route);
// it is a way to write the data, never part of them
let all = std::hint::black_box(ExportOptions::ALL);
assert!(!all.as_route && !ExportOptions::NONE.as_route);
assert!(as_route().union(ExportOptions::NONE).as_route);
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
}
}
@@ -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,9 +65,10 @@ impl Statistics {
self.global.total_distance += dist;
if let Some(time) = time {
let speed = speed(dist, time);
if (0.5..=1500.0).contains(&speed) {
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
@@ -68,7 +79,6 @@ impl Statistics {
.map_or(Some(time), |t| Some(t + time));
}
}
}
fn update_time_bounds(&mut self, time: Option<i64>) {
if let Some(time) = 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| {
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,
t,
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}");
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
mod statistics;
mod compute;
mod utils;
pub use statistics::*;
pub use compute::*;
pub use utils::*;
+11 -6
View File
@@ -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();
+20 -16
View File
@@ -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, Files, Selection, 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.
@@ -77,13 +75,13 @@ impl Apply for Delete {
}
pub(crate) fn delete_waypoints(
files: &mut StackEntry,
files: &mut Files,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = edit_waypoint_chunks(&mut file, &filter, |wpts| {
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));
@@ -1,8 +1,8 @@
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,
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment,
copy_track, copy_waypoint,
};
#[derive(Debug)]
@@ -107,24 +107,19 @@ impl Apply for Duplicate {
}
fn duplicate_waypoints(
files: &mut StackEntry,
files: &mut Files,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<&Waypoint> = file
.wpt
.iter()
.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]
+16 -3
View File
@@ -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,7 +137,10 @@ mod tests {
#[test]
fn test_load_several_files() {
let mut fx = Fixture::default();
crate::New { name: "before" }
crate::New {
name: "before",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let before = fx.order.0[0];
@@ -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
}
+67 -2
View 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::Tracks(_),
Destination::Files(_) | Destination::Tracks(..)
)
| (
ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
)
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) => true,
_ => false,
};
| (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,
+2 -2
View File
@@ -2,11 +2,11 @@
use std::collections::HashSet;
use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
use crate::{Clipboard, FileId, FileOrder, Files, Selection, State, TrackpointCategories};
#[derive(Default)]
pub struct Fixture {
pub files: StackEntry,
pub files: Files,
pub selection: Selection,
pub order: FileOrder,
pub clipboard: Option<Clipboard>,
+76 -2
View File
@@ -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, Files, Selection, State, Track, TrackSegment, Waypoint};
/// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -72,7 +70,7 @@ fn edit_where<T>(
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
/// new snapshot only if something changed.
fn update_file(files: &mut StackEntry, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
fn update_file(files: &mut Files, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
let Some(current) = files.get(&id) else {
return;
};
@@ -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 {
+325 -4
View File
@@ -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 {
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)
);
}
}
+571 -4
View File
@@ -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));
}
}
+10 -2
View File
@@ -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);
}
}
+330 -4
View File
@@ -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));
}
}
+187 -5
View File
@@ -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 = &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)
));
}
}
@@ -1,8 +1,6 @@
use std::collections::{HashMap, HashSet};
use crate::{
FileId, FileWaypointsRevisionId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId,
};
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
@@ -23,14 +21,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 +136,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,6 +1,6 @@
use std::collections::HashMap;
use crate::{Diff, FileId, FileStructure, StackEntry as Files};
use crate::{Diff, FileId, FileStructure, Files};
/// The structure of each file, only recomputed for the files that changed according to the
/// [`Diff`] of the stack.
+2
View File
@@ -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, Files, Selection, 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<&Files>, 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() -> (Files, 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 = Files::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)
@@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet, hash_map::Entry};
use crate::{
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
File, FileId, Files, GlobalStatistics, Selection, Statistics, Track, TrackSegment,
TrackSegmentRevisionId,
};
@@ -14,7 +14,7 @@ pub struct StatisticsCache {
}
impl StatisticsCache {
pub fn update(&mut self, state: Option<&StackEntry>) {
pub fn update(&mut self, state: Option<&Files>) {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
@@ -40,7 +40,7 @@ impl StatisticsCache {
/// Statistics of the segments of the selection.
pub fn get<'a>(
&'a self,
state: Option<&'a StackEntry>,
state: Option<&'a Files>,
selection: &Selection,
) -> Vec<&'a Statistics> {
self.selected(state, selection, &[])
@@ -53,7 +53,7 @@ impl StatisticsCache {
/// files follow `order` (the files missing from it come last).
pub fn selected<'a>(
&'a self,
state: Option<&'a StackEntry>,
state: Option<&'a Files>,
selection: &Selection,
order: &[FileId],
) -> Vec<SelectedSegment<'a>> {
@@ -159,10 +159,10 @@ mod tests {
use super::*;
fn state(path: &str) -> (StackEntry, Rc<File>) {
fn state(path: &str) -> (Files, Rc<File>) {
let data = std::fs::read(path).unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
let mut state = StackEntry::default();
let mut state = Files::default();
state.insert(file.id, file.clone());
(state, file)
}
@@ -235,7 +235,7 @@ mod tests {
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
// a new snapshot of the file that shares its segments (same revision ids)
let mut next = StackEntry::default();
let mut next = Files::default();
let renamed = {
let mut f = (*file).clone();
f.info.name = "other".to_string();
@@ -258,7 +258,7 @@ mod tests {
let mut f = (*file).clone();
f.trk[0].trkseg[0].rev_id = Default::default();
let new_rev = f.trk[0].trkseg[0].rev_id;
let mut next = StackEntry::default();
let mut next = Files::default();
next.insert(f.id, Rc::new(f));
cache.update(Some(&next));
assert!(cache.map.contains_key(&new_rev));
File diff suppressed because it is too large. Load diff
+325 -2
View File
@@ -1,11 +1,334 @@
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 {
history: History,
selection: Selection,
order: FileOrder,
statistics_cache: StatisticsCache,
coordinates_cache: CoordinatesCache,
structure_cache: FileStructureCache,
statistics_buffer: StatisticsBuffer,
routing_buffer: RoutingBuffer,
categories: TrackpointCategories,
clipboard: Option<Clipboard>,
}
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.history.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.history
.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
.history
.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.history.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.history.current().cloned().unwrap_or_default(),
order: self.order.0.clone(),
categories: self.categories.clone(),
}
}
/// Replaces what the engine holds by the files read from the storage: at the start, or when
/// another writer (a tab) committed since, which makes what was held here out of date. What
/// was here is gone, edits that were not saved included, and the files are the state to start
/// from: they cannot be undone, and nothing from before can be.
///
/// The selection keeps what still exists, and so does the clipboard. The files that differ
/// from the ones that were here are reported as added, removed or modified (all the files
/// that were here, as they are not the same objects), and the order as changed if it is.
pub fn restore(&mut self, restored: Restored) -> Outcome {
let selection_before = self.selection.clone();
let order_before = self.order.0.clone();
let before = self.history.current().cloned();
let files: Files = restored
.files
.into_iter()
.map(|file| (file.id, file))
.collect();
let diff = History::diff_between(before.as_ref(), Some(&files));
let mut diff =
(!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty()))
.then_some(diff);
self.categories = restored.categories;
self.order = FileOrder(restored.order);
self.history = History::restored(files);
self.refresh(diff.as_ref());
sort_diff(&mut diff, &self.order.0);
Outcome {
changed: diff.is_some()
|| self.order.0 != order_before
|| self.selection != selection_before,
diff,
order_changed: self.order.0 != order_before,
selection_changed: self.selection != selection_before,
clipboard_changed: false,
}
}
/// 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.history.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.history.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.history.can_undo()
}
/// Whether there is something to redo.
pub fn can_redo(&self) -> bool {
self.history.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, or why
/// it did nothing: an edit that fails or has nothing to do.
pub fn execute(&mut self, action: Action) -> Result<Outcome, CommandError> {
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(_));
// 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)?,
Action::Undo => self.history.undo(),
Action::Redo => self.history.redo(),
Action::Select { selection, mode } => {
self.selection
.select(self.history.current(), selection, mode);
None
}
Action::SelectAll => {
if let Some(files) = self.history.current() {
self.selection.select_all(files, &self.order.0);
}
None
}
Action::ArrowSelect { down, add } => {
if let Some(files) = self.history.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 {
return Err(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)
Ok(Outcome {
changed,
diff,
order_changed: self.order.0 != order_before,
selection_changed: self.selection != selection_before,
clipboard_changed: self.clipboard != clipboard_before,
})
}
/// Puts the selection in the clipboard, if there is one.
fn copy(&mut self, cut: bool) {
if let Some(files) = self.history.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.history.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 history entry.
fn refresh(&mut self, diff: Option<&Diff>) {
let current = self.history.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 history 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)]
mod tests;
+19
View File
@@ -0,0 +1,19 @@
use crate::Diff;
/// What an action did, returned by [`crate::Engine::execute`] (unless it was refused) 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,
}
+13 -23
View File
@@ -3,7 +3,7 @@ use std::rc::Rc;
use uuid::Uuid;
use crate::{
File, FileId, Selection, StackEntry, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
File, FileId, Files, Selection, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
WaypointId,
};
@@ -71,7 +71,7 @@ impl Clipboard {
/// selected does not exist).
pub fn new(
selection: &Selection,
files: &StackEntry,
files: &Files,
order: &[FileId],
cut: bool,
) -> Option<Clipboard> {
@@ -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,25 +227,25 @@ 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
}
fn files_of(files: Vec<File>) -> StackEntry {
fn files_of(files: Vec<File>) -> Files {
files
.into_iter()
.map(|file| (file.id, Rc::new(file)))
.collect()
}
fn order_of(files: &StackEntry) -> Vec<FileId> {
fn order_of(files: &Files) -> Vec<FileId> {
files.keys().copied().collect()
}
@@ -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,
+435
View File
@@ -0,0 +1,435 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{CommandError, File, FileId};
#[derive(Debug, Default)]
pub struct History {
entries: Vec<Files>,
index: Option<usize>,
/// Whether the first entry is the state the history starts from, which is not undone: files
/// restored from the storage.
floor: bool,
}
impl History {
/// A history whose first entry is `entry`, from which nothing can be undone.
pub fn restored(entry: Files) -> Self {
Self {
entries: vec![entry],
index: Some(0),
floor: true,
}
}
pub fn current(&self) -> Option<&Files> {
self.index.map_or_default(|i| self.get(i))
}
/// 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 Files) -> Result<(), CommandError>,
{
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(|history| history.push(next)))
}
/// Whether `entry` holds exactly the files of the current entry.
fn is_current(&self, entry: &Files) -> 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(|history| {
if let Some(i) = history.index {
if i == 0 {
if !history.floor {
history.index = None;
}
} else {
history.index = Some(i - 1);
}
}
})
}
pub fn redo(&mut self) -> Option<Diff> {
self.record_diff(|history| match history.index {
Some(i) => {
if i + 1 < history.entries.len() {
history.index = Some(i + 1);
}
}
None => {
if !history.entries.is_empty() {
history.index = Some(0);
}
}
})
}
pub fn can_undo(&self) -> bool {
self.index.is_some_and(|i| i > 0 || !self.floor)
}
pub fn can_redo(&self) -> bool {
match self.index {
Some(i) => i + 1 < self.entries.len(),
None => !self.entries.is_empty(),
}
}
fn get(&self, index: usize) -> Option<&Files> {
self.entries.get(index)
}
fn push(&mut self, entry: Files) {
if let Some(i) = self.index {
self.entries.truncate(i + 1);
}
self.entries.push(entry);
self.index = Some(self.entries.len() - 1);
}
fn record_diff<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut Self),
{
let prev = self.index;
f(self);
let cur = self.index;
if prev == cur {
return None;
}
Some(Self::diff_between(
prev.map_or_default(|i| self.get(i)),
cur.map_or_default(|i| self.get(i)),
))
}
/// Which files differ between two states.
pub fn diff_between(prev: Option<&Files>, cur: Option<&Files>) -> Diff {
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
let mut modified = vec![];
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)) {
modified.push(*id);
}
}
Diff {
added: cur_ids.difference(&prev_ids).copied().collect(),
removed: prev_ids.difference(&cur_ids).copied().collect(),
modified,
}
}
}
pub type Files = HashMap<FileId, Rc<File>>;
/// Which files were added, removed or modified by a change of the current history entry.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Diff {
pub added: Vec<FileId>,
pub removed: Vec<FileId>,
pub modified: Vec<FileId>,
}
#[cfg(test)]
mod tests {
use super::*;
impl History {
/// A step that is expected not to fail.
fn push_ok(
&mut self,
f: impl FnOnce(&mut Files) -> Result<(), CommandError>,
) -> Option<Diff> {
self.create_and_push_next(f).unwrap()
}
}
#[test]
fn test_new_file() {
let mut history = History::default();
assert!(!history.can_undo());
assert!(!history.can_redo());
assert!(history.current().is_none());
let diff = history.push_ok(|e| {
let file = Rc::new(File::default());
e.insert(file.id, file);
Ok(())
});
assert!(history.can_undo());
assert!(!history.can_redo());
assert!(history.current().is_some());
assert!(diff.is_some());
let diff = diff.unwrap();
assert_eq!(diff.added.len(), 1);
assert!(diff.removed.is_empty());
assert!(diff.modified.is_empty());
}
fn add_file(history: &mut History) -> FileId {
let file = Rc::new(File::default());
let id = file.id;
history
.push_ok(|e| {
e.insert(id, file);
Ok(())
})
.unwrap();
id
}
#[test]
fn test_failed_command_pushes_nothing() {
let mut history = History::default();
let diff = history.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default()));
Err(CommandError::InvalidData("nope".to_string()))
});
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
assert!(!history.can_undo());
assert!(history.current().is_none());
}
#[test]
fn test_undo_redo() {
let mut history = History::default();
assert!(history.undo().is_none());
assert!(history.redo().is_none());
let id = add_file(&mut history);
let diff = history.undo().unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty());
assert!(history.current().is_none());
assert!(!history.can_undo());
assert!(history.can_redo());
assert!(history.undo().is_none());
let diff = history.redo().unwrap();
assert_eq!(diff.added, vec![id]);
assert!(history.current().unwrap().contains_key(&id));
assert!(history.can_undo());
assert!(!history.can_redo());
assert!(history.redo().is_none());
}
#[test]
fn test_modified_and_removed() {
let mut history = History::default();
let id = add_file(&mut history);
let diff = history
.push_ok(|e| {
let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty());
// a step that changes nothing is not a step
assert!(history.push_ok(|_| Ok(())).is_none());
assert!(
history
.push_ok(|e| {
let file = e[&id].clone();
e.insert(id, file);
Ok(())
})
.is_none()
);
let diff = history
.push_ok(|e| {
e.remove(&id);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(history.current().unwrap().is_empty());
// going back restores the renamed file
history.undo();
assert_eq!(history.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
let mut history = History::default();
let id = add_file(&mut history);
// a copy, even if it has the same content, is another file for the history: files are
// told apart by their identity, comparing them would read all of them
let diff = history
.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 history);
let diff = history
.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 history = History::default();
let first = add_file(&mut history);
let second = add_file(&mut history);
history.undo();
assert!(history.can_redo());
assert!(!history.current().unwrap().contains_key(&second));
let third = add_file(&mut history);
assert!(!history.can_redo());
assert!(history.redo().is_none());
let current = history.current().unwrap();
assert!(current.contains_key(&first));
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
fn restored_entry() -> (Files, FileId) {
let file = Rc::new(File::default());
let id = file.id;
(Files::from([(id, file)]), id)
}
#[test]
fn test_restored_history_starts_from_its_entry() {
let (entry, id) = restored_entry();
let mut history = History::restored(entry);
assert!(history.current().unwrap().contains_key(&id));
// there is nothing to go back to, nor forward to
assert!(!history.can_undo());
assert!(!history.can_redo());
assert!(history.undo().is_none());
assert!(history.redo().is_none());
assert!(history.current().unwrap().contains_key(&id));
}
#[test]
fn test_restored_history_cannot_undo_an_empty_entry_either() {
let mut history = History::restored(Files::new());
assert!(history.current().is_some_and(|files| files.is_empty()));
assert!(!history.can_undo());
assert!(history.undo().is_none());
// the history is not the one of an engine that has done nothing
assert!(history.current().is_some());
}
#[test]
fn test_restored_history_undoes_what_comes_after_it_and_no_further() {
let (entry, restored) = restored_entry();
let mut history = History::restored(entry);
let added = add_file(&mut history);
assert!(history.can_undo());
assert!(!history.can_redo());
assert_eq!(history.current().unwrap().len(), 2);
// back to the restored files
let diff = history.undo().unwrap();
assert_eq!(diff.removed, vec![added]);
assert!(diff.added.is_empty() && diff.modified.is_empty());
assert_eq!(history.current().unwrap().len(), 1);
assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo());
assert!(history.can_redo());
assert!(history.undo().is_none());
assert!(history.current().unwrap().contains_key(&restored));
let diff = history.redo().unwrap();
assert_eq!(diff.added, vec![added]);
assert!(history.can_undo());
assert!(!history.can_redo());
// and again
assert!(history.undo().is_some());
assert!(history.undo().is_none());
assert!(history.current().unwrap().contains_key(&restored));
}
#[test]
fn test_restored_history_removing_its_files_can_be_undone() {
let (entry, restored) = restored_entry();
let mut history = History::restored(entry);
let diff = history
.push_ok(|e| {
e.remove(&restored);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![restored]);
assert!(history.current().unwrap().is_empty());
let diff = history.undo().unwrap();
assert_eq!(diff.added, vec![restored]);
assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo());
}
#[test]
fn test_restored_history_push_after_undo_drops_redo_branch() {
let (entry, restored) = restored_entry();
let mut history = History::restored(entry);
add_file(&mut history);
history.undo().unwrap();
assert!(history.can_redo());
let other = add_file(&mut history);
assert!(!history.can_redo());
assert!(history.can_undo());
let diff = history.undo().unwrap();
assert_eq!(diff.removed, vec![other]);
assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo());
}
}
+3 -3
View File
@@ -1,16 +1,16 @@
mod clipboard;
mod history;
mod order;
mod selection;
mod stack;
pub use clipboard::*;
pub use history::*;
pub use order::*;
pub use selection::*;
pub use stack::*;
pub struct State<'a> {
pub categories: &'a mut crate::TrackpointCategories,
pub files: &'a mut StackEntry,
pub files: &'a mut Files,
pub selection: &'a mut Selection,
pub order: &'a mut FileOrder,
pub clipboard: &'a mut Option<Clipboard>,
+1 -1
View File
@@ -1,6 +1,6 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry as Files};
use crate::{FileId, Files};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileOrder(pub Vec<FileId>);
+116 -50
View File
@@ -1,6 +1,6 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
use crate::{FileId, Files, TrackId, TrackSegmentId, WaypointId};
/// How a new selection combines with the current one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -40,10 +40,101 @@ 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<&Files>, 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: &Files, 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: &Files, 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: &Files, 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) {
pub fn retain_existing(&mut self, files: &Files) {
let empty = match self {
Selection::Empty => false,
Selection::File { file_ids } => {
@@ -74,12 +165,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 +283,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) {
@@ -246,7 +329,7 @@ impl Selection {
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
/// of the file. All the files when nothing is selected. `None` when there is nothing to
/// select (the waypoints node, or a file that does not exist).
pub fn all_at_level(&self, files: &StackEntry, order: &[FileId]) -> Option<Selection> {
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
match self {
Selection::Empty | Selection::File { .. } => Some(Selection::File {
file_ids: order.iter().copied().collect(),
@@ -272,13 +355,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(),
}),
}
}
@@ -287,7 +364,7 @@ impl Selection {
/// (`down`) or the previous one among the elements of the same kind, in the same place.
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
/// the ones that are already selected. `None` when there is no such element.
pub fn neighbour(&self, files: &StackEntry, order: &[FileId], down: bool) -> Option<Selection> {
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
match self {
Selection::Empty | Selection::Waypoints { .. } => None,
Selection::File { file_ids } => {
@@ -332,13 +409,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(),
@@ -360,7 +432,7 @@ mod tests {
fn test_retain_existing() {
let file = File::default();
let (kept, gone) = (file.id, FileId::default());
let mut files = StackEntry::default();
let mut files = Files::default();
files.insert(kept, Rc::new(file));
let mut selection = Selection::File {
@@ -392,13 +464,13 @@ 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 mut files = StackEntry::default();
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
let mut files = Files::default();
files.insert(file_id, Rc::new(file));
let mut selection = Selection::Track {
@@ -527,7 +599,7 @@ mod tests {
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
struct Tree {
files: StackEntry,
files: Files,
order: Vec<FileId>,
file: FileId,
tracks: Vec<TrackId>,
@@ -543,15 +615,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 +631,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
files: Files::from([(file.id, Rc::new(file))]),
}
}
#[test]
@@ -695,7 +761,7 @@ mod tests {
#[test]
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
let files = StackEntry::default();
let files = Files::default();
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
let order = [a, b, c];
let select = |ids: &[FileId]| Selection::File {
-252
View File
@@ -1,252 +0,0 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{File, FileId};
#[derive(Debug, Default)]
pub struct Stack {
entries: Vec<StackEntry>,
index: Option<usize>,
}
impl Stack {
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>
where
F: FnOnce(&mut StackEntry) -> Result<(), String>,
{
self.record_diff(|stack| {
let mut next = stack.current().map_or_default(|c| c.clone());
if f(&mut next).is_ok() {
stack.push(next);
}
})
}
pub fn undo(&mut self) -> Option<Diff> {
self.record_diff(|stack| {
if let Some(i) = stack.index {
if i == 0 {
stack.index = None;
} else {
stack.index = Some(i - 1);
}
}
})
}
pub fn redo(&mut self) -> Option<Diff> {
self.record_diff(|stack| match stack.index {
Some(i) => {
if i + 1 < stack.entries.len() {
stack.index = Some(i + 1);
}
}
None => {
if !stack.entries.is_empty() {
stack.index = Some(0);
}
}
})
}
pub fn can_undo(&self) -> bool {
self.index.is_some()
}
pub fn can_redo(&self) -> bool {
match self.index {
Some(i) => i + 1 < self.entries.len(),
None => !self.entries.is_empty(),
}
}
fn get(&self, index: usize) -> Option<&StackEntry> {
self.entries.get(index)
}
fn push(&mut self, entry: StackEntry) {
if let Some(i) = self.index {
self.entries.truncate(i + 1);
}
self.entries.push(entry);
self.index = Some(self.entries.len() - 1);
}
fn record_diff<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut Self),
{
let prev = self.index;
f(self);
let cur = self.index;
if prev == cur {
return None;
}
let prev = prev.map_or_default(|i| self.get(i));
let cur = cur.map_or_default(|i| self.get(i));
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
let mut modified = vec![];
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 {
modified.push(*id);
}
}
Some(Diff {
added: cur_ids.difference(&prev_ids).copied().collect(),
removed: prev_ids.difference(&cur_ids).copied().collect(),
modified,
})
}
}
pub type StackEntry = HashMap<FileId, Rc<File>>;
/// Which files were added, removed or modified by a change of the current stack entry.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Diff {
pub added: Vec<FileId>,
pub removed: Vec<FileId>,
pub modified: Vec<FileId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_file() {
let mut stack = Stack::default();
assert!(!stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_none());
let diff = stack.create_and_push_next(|e| {
let file = Rc::new(File::default());
e.insert(file.id, file);
Ok(())
});
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_some());
assert!(diff.is_some());
let diff = diff.unwrap();
assert_eq!(diff.added.len(), 1);
assert!(diff.removed.is_empty());
assert!(diff.modified.is_empty());
}
fn add_file(stack: &mut Stack) -> FileId {
let file = Rc::new(File::default());
let id = file.id;
stack
.create_and_push_next(|e| {
e.insert(id, file);
Ok(())
})
.unwrap();
id
}
#[test]
fn test_failed_command_pushes_nothing() {
let mut stack = Stack::default();
let diff = stack.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default()));
Err("nope".to_string())
});
assert!(diff.is_none());
assert!(!stack.can_undo());
assert!(stack.current().is_none());
}
#[test]
fn test_undo_redo() {
let mut stack = Stack::default();
assert!(stack.undo().is_none());
assert!(stack.redo().is_none());
let id = add_file(&mut stack);
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty());
assert!(stack.current().is_none());
assert!(!stack.can_undo());
assert!(stack.can_redo());
assert!(stack.undo().is_none());
let diff = stack.redo().unwrap();
assert_eq!(diff.added, vec![id]);
assert!(stack.current().unwrap().contains_key(&id));
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
}
#[test]
fn test_modified_and_removed() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
let diff = stack
.create_and_push_next(|e| {
let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty());
// an unchanged file is not reported as modified
let diff = stack.create_and_push_next(|_| Ok(())).unwrap();
assert!(diff.modified.is_empty());
let diff = stack
.create_and_push_next(|e| {
e.remove(&id);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(stack.current().unwrap().is_empty());
// going back restores the renamed file
stack.undo();
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_push_after_undo_drops_redo_branch() {
let mut stack = Stack::default();
let first = add_file(&mut stack);
let second = add_file(&mut stack);
stack.undo();
assert!(stack.can_redo());
assert!(!stack.current().unwrap().contains_key(&second));
let third = add_file(&mut stack);
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
let current = stack.current().unwrap();
assert!(current.contains_key(&first));
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1,5 +1,7 @@
mod core;
mod engine;
mod storage;
pub use core::*;
pub use engine::*;
pub use storage::*;
+683
View File
@@ -0,0 +1,683 @@
//! 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,
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)),
}
}
/// The bytes of a chunk of trackpoints. Its surfaces, highways and scales are written as names,
/// the codes of `categories` being the ones of the engine that has the chunk.
pub fn encode_trackpoints(chunk: &TrackpointChunk, categories: &TrackpointCategories) -> Vec<u8> {
encode(&v1::TrackpointChunk1::new(&chunk.trkpt, categories))
}
/// A chunk of trackpoints from its bytes. The names it holds are added to `categories` if it does
/// not know them yet, and the trackpoints refer to them by the codes of `categories`.
pub fn decode_trackpoints(
id: Uuid,
bytes: &[u8],
categories: &mut TrackpointCategories,
) -> Result<TrackpointChunk, DecodeError> {
let trkpt = match split(bytes)? {
(1, data) => parse::<v1::TrackpointChunk1>(data)?.into_points(categories),
(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)),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::{Author, Chunk, Link, LngLat, Trackpoint};
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),
// the first has the second name of the table, which comes first in the chunk
surface: Some(if n == 0 { 1 } else { 0 }),
highway: Some(0),
sac_scale: Some(0),
mtb_scale: Some(0),
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());
}
/// The names of the categories of a trackpoint.
fn names(point: &Trackpoint, categories: &TrackpointCategories) -> [Option<String>; 4] {
let name = |table: &crate::Categories, code: Option<u8>| {
code.and_then(|code| table.name(code)).map(str::to_owned)
};
[
name(&categories.surface, point.surface),
name(&categories.highway, point.highway),
name(&categories.sac_scale, point.sac_scale),
name(&categories.mtb_scale, point.mtb_scale),
]
}
#[test]
fn test_chunks_keep_their_identity_and_items() {
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
let categories = categories();
let mut reader = categories.clone();
let decoded = decode_trackpoints(
chunk.id.0,
&encode_trackpoints(chunk, &categories),
&mut reader,
)
.unwrap();
assert_eq!(decoded.id, chunk.id);
assert_eq!(decoded.trkpt.len(), 3);
// read by an engine that has the same table, the trackpoints are the same
assert_eq!(reader, categories);
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_a_chunk_means_the_same_to_an_engine_with_another_table() {
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
let writer = categories();
let bytes = encode_trackpoints(chunk, &writer);
// an engine that learned other names first, and the same ones in another order
let mut reader = TrackpointCategories::default();
reader.surface.code("sand");
reader.surface.code("gravel");
reader.surface.code("asphalt");
reader.highway.code("track");
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
for (read, written) in decoded.trkpt.iter().zip(&chunk.trkpt) {
assert_eq!(names(read, &reader), names(written, &writer));
}
// the codes are the ones of the reader, which learned what it did not know
assert_eq!(decoded.trkpt[0].surface, Some(1));
assert_eq!(decoded.trkpt[1].surface, Some(2));
assert_eq!(reader.highway.names(), ["track", "path"]);
assert_eq!(decoded.trkpt[0].highway, Some(1));
assert_eq!(reader.sac_scale.names(), ["hiking"]);
// and an empty engine learns them in the order they come
let mut empty = TrackpointCategories::default();
decode_trackpoints(chunk.id.0, &bytes, &mut empty).unwrap();
assert_eq!(empty.surface.names(), ["gravel", "asphalt"]);
}
#[test]
fn test_a_chunk_only_carries_the_names_it_uses() {
let mut writer = TrackpointCategories::default();
for i in 0..10 {
writer.surface.code(&format!("surface-{i}"));
}
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
chunk.iter_mut().for_each(|p| p.surface = Some(7));
let chunk = TrackpointChunk::new(chunk);
let bytes = encode_trackpoints(&chunk, &writer);
let mut reader = TrackpointCategories::default();
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
assert_eq!(reader.surface.names(), ["surface-7"]);
assert!(decoded.trkpt.iter().all(|p| p.surface == Some(0)));
}
#[test]
fn test_codes_without_a_name_are_not_stored_and_names_without_room_are_lost() {
let mut writer = categories();
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
// a code that the table does not know
chunk[1].surface = Some(200);
let chunk = TrackpointChunk::new(chunk);
let bytes = encode_trackpoints(&chunk, &writer);
let decoded =
decode_trackpoints(chunk.id.0, &bytes, &mut TrackpointCategories::default()).unwrap();
assert_eq!(decoded.trkpt[1].surface, None);
assert_eq!(decoded.trkpt[0].surface, Some(0));
// an engine whose table is full keeps the names it has and loses the other ones
let mut full = TrackpointCategories::default();
for i in 0..crate::Categories::CAPACITY {
full.surface.code(&format!("other-{i}"));
}
writer.surface.code("other-3");
let mut chunk = chunk.trkpt.clone();
chunk[0].surface = writer.surface.code("other-3");
chunk[1].surface = writer.surface.code("gravel");
let chunk = TrackpointChunk::new(chunk);
let decoded =
decode_trackpoints(chunk.id.0, &encode_trackpoints(&chunk, &writer), &mut full)
.unwrap();
assert_eq!(decoded.trkpt[0].surface, Some(3));
assert_eq!(decoded.trkpt[1].surface, None);
}
#[test]
fn test_chunks_read_one_after_the_other_share_the_table() {
let writer = categories();
let chunk = sample().trk[0].trkseg[0].chunks()[0].clone();
let bytes = encode_trackpoints(&chunk, &writer);
let mut reader = TrackpointCategories::default();
let a = decode_trackpoints(id(1), &bytes, &mut reader).unwrap();
let b = decode_trackpoints(id(2), &bytes, &mut reader).unwrap();
for (a, b) in a.trkpt.iter().zip(&b.trkpt) {
assert_eq!((a.surface, a.highway), (b.surface, b.highway));
}
assert_eq!(reader.surface.names().len(), 2);
}
#[test]
fn test_order_round_trips() {
let order = vec![FileId(id(9)), FileId(id(8))];
assert_eq!(decode_order(&encode_order(&order)).unwrap(), order);
}
#[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, &mut Default::default()),
Err(DecodeError::UnknownVersion(_))
));
assert!(matches!(
decode_waypoints(id(1), &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], &mut Default::default()).err(),
Some(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!(
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
"1040000000000000494000000000002059400180a0abfef9620105018c01015001fa01010001000100010001",
"000000000000001440000000000080494000000000006059400182a0abfef9620105018d01015001fa010101",
"010001000100010100000000000000000000000000000000000000000000000000000000000000000000",
);
const WAYPOINTS_V1: &str = concat!(
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
"80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e021a68747470733a",
"2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c",
"652e636f6d2f73756d6d69741c68747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32012474",
"657874206f662068747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32010653756d6d697401",
"047065616b100000000000000000000000000000000800000000000000000000000000000000000000000000",
"000000000000000000",
);
const ORDER_V1: &str =
"010210000000000000000000000000000000091000000000000000000000000000000008";
#[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],
&categories()
)),
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
);
}
#[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 mut reader = TrackpointCategories::default();
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1), &mut reader).unwrap();
let expected = &file.trk[0].trkseg[0].chunks()[0];
assert_eq!(chunk.trkpt.len(), expected.trkpt.len());
for (read, written) in chunk.trkpt.iter().zip(&expected.trkpt) {
assert_eq!(names(read, &reader), names(written, &categories()));
assert_eq!(read.hr, written.hr);
assert_eq!(read.anchor, written.anchor);
}
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))]
);
}
}
+394
View File
@@ -0,0 +1,394 @@
//! 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 std::collections::HashMap;
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>,
/// The position of the name in the list of the chunk (see [`TrackpointChunk1`]).
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_,
}
}
}
/// A chunk of trackpoints. It carries the names its categories refer to: the surface, highway,
/// SAC scale and MTB scale of its trackpoints are the positions of the names in its own lists,
/// which only hold the names it uses. So it means the same whatever the table of categories of
/// the engine that reads it is, and it is not changed by what other chunks learn.
#[derive(Serialize, Deserialize)]
pub struct TrackpointChunk1 {
surface: Vec<String>,
highway: Vec<String>,
sac_scale: Vec<String>,
mtb_scale: Vec<String>,
points: Vec<Trackpoint1>,
}
/// The names of a category used by a chunk, with their position in the list of the chunk.
struct LocalNames<'a> {
table: &'a Categories,
names: Vec<String>,
local: HashMap<u8, u8>,
}
impl<'a> LocalNames<'a> {
fn new(table: &'a Categories) -> Self {
Self {
table,
names: vec![],
local: HashMap::new(),
}
}
/// The position in the list of the chunk of the name of a code of the table. A code that
/// has no name has no position.
fn position(&mut self, code: Option<u8>) -> Option<u8> {
let code = code?;
if let Some(position) = self.local.get(&code) {
return Some(*position);
}
let name = self.table.name(code)?;
let position = u8::try_from(self.names.len()).ok()?;
self.names.push(name.to_owned());
self.local.insert(code, position);
Some(position)
}
}
/// The codes of the table for the names of a chunk, which are added to it if it does not know
/// them. A name that does not fit in a full table has no code.
fn codes_of(names: &[String], table: &mut Categories) -> Vec<Option<u8>> {
names.iter().map(|name| table.code(name)).collect()
}
impl TrackpointChunk1 {
pub fn new(points: &[Trackpoint], categories: &TrackpointCategories) -> Self {
let mut surface = LocalNames::new(&categories.surface);
let mut highway = LocalNames::new(&categories.highway);
let mut sac_scale = LocalNames::new(&categories.sac_scale);
let mut mtb_scale = LocalNames::new(&categories.mtb_scale);
let points = points
.iter()
.map(|point| {
let mut stored = Trackpoint1::from(point);
stored.surface = surface.position(point.surface);
stored.highway = highway.position(point.highway);
stored.sac_scale = sac_scale.position(point.sac_scale);
stored.mtb_scale = mtb_scale.position(point.mtb_scale);
stored
})
.collect();
Self {
surface: surface.names,
highway: highway.names,
sac_scale: sac_scale.names,
mtb_scale: mtb_scale.names,
points,
}
}
/// The trackpoints, with the codes of `categories` for the names of the chunk.
pub fn into_points(self, categories: &mut TrackpointCategories) -> Vec<Trackpoint> {
let surface = codes_of(&self.surface, &mut categories.surface);
let highway = codes_of(&self.highway, &mut categories.highway);
let sac_scale = codes_of(&self.sac_scale, &mut categories.sac_scale);
let mtb_scale = codes_of(&self.mtb_scale, &mut categories.mtb_scale);
let global = |codes: &[Option<u8>], local: Option<u8>| {
local.and_then(|local| codes.get(usize::from(local)).copied().flatten())
};
self.points
.into_iter()
.map(|stored| {
let (s, h, sac, mtb) = (
global(&surface, stored.surface),
global(&highway, stored.highway),
global(&sac_scale, stored.sac_scale),
global(&mtb_scale, stored.mtb_scale),
);
let mut point = Trackpoint::from(stored);
point.surface = s;
point.highway = h;
point.sac_scale = sac;
point.mtb_scale = mtb;
point
})
.collect()
}
}
+268
View File
@@ -0,0 +1,268 @@
use std::cell::RefCell;
use std::collections::HashMap;
use crate::{
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, 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 {
head: Revision,
files: HashMap<FileId, Vec<u8>>,
chunks: HashMap<ChunkKey, Vec<u8>>,
order: 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(),
head: state.head,
})
}
async fn head(&self) -> Result<Revision, StorageError> {
Ok(self.state.borrow().head)
}
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
let mut state = self.state.borrow_mut();
if state.head != batch.expect_head {
return Err(CommitError::Stale { head: state.head });
}
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());
}
state.head = state.head.next();
Ok(state.head)
}
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]),
expect_head: Revision(0),
..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, Some(vec![5]));
// 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)],
expect_head: Revision(1),
..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, Some(vec![5]));
block_on(storage.commit(&Batch {
delete_chunks: vec![ChunkKey::waypoints(w)],
order: Some(vec![]),
expect_head: Revision(2),
..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());
}
fn batch(expect_head: u64, entry: u8) -> Batch {
Batch {
put_files: vec![(FileId::default(), vec![entry])],
expect_head: Revision(expect_head),
..Default::default()
}
}
#[test]
fn test_every_commit_advances_the_head() {
let storage = MemoryStorage::default();
assert_eq!(block_on(storage.head()).unwrap(), Revision(0));
assert_eq!(block_on(storage.load()).unwrap().head, Revision(0));
assert_eq!(block_on(storage.commit(&batch(0, 1))), Ok(Revision(1)));
assert_eq!(block_on(storage.commit(&batch(1, 2))), Ok(Revision(2)));
assert_eq!(block_on(storage.head()).unwrap(), Revision(2));
assert_eq!(block_on(storage.load()).unwrap().head, Revision(2));
}
#[test]
fn test_a_commit_based_on_another_head_changes_nothing() {
let storage = MemoryStorage::default();
block_on(storage.commit(&batch(0, 1))).unwrap();
block_on(storage.commit(&batch(1, 2))).unwrap();
let before = block_on(storage.load()).unwrap();
// behind, or ahead: the head is not the one it is based on
for expect in [0, 1, 3] {
let stale = Batch {
delete_files: vec![before.files[0].0],
order: Some(vec![9]),
..batch(expect, 3)
};
assert_eq!(
block_on(storage.commit(&stale)),
Err(CommitError::Stale { head: Revision(2) })
);
}
let after = block_on(storage.load()).unwrap();
assert_eq!(after.head, Revision(2));
assert_eq!(after.files.len(), before.files.len());
assert_eq!(after.order, before.order);
// and it can be done again from the right head
assert_eq!(block_on(storage.commit(&batch(2, 3))), Ok(Revision(3)));
}
}
+231
View File
@@ -0,0 +1,231 @@
//! 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.
//!
//! Several engines can write to a storage (tabs, a server). It has a head, a revision that each
//! commit advances, and a commit only applies if it is based on the current head: an engine that
//! is behind is refused, and has to read the storage again, which replaces what it holds, before
//! it writes. So what an engine knows of the storage is exact whenever it writes, which is what
//! lets it write only what changed and delete what it does not use anymore.
//!
//! The surface, highway and scales of the trackpoints are stored as names, in the chunk that has
//! them: the codes that the engine gives them (see [`crate::TrackpointCategories`]) depend on the
//! order in which it learned the names, so they mean nothing outside of it. A storage that two
//! engines (tabs, a server) write to therefore has nothing to agree on but the chunks.
mod codec;
mod memory;
mod persister;
pub use memory::*;
pub use persister::*;
use uuid::Uuid;
use crate::FileId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageError {
/// The storage could not be read or written.
Failed(String),
/// Some of what is stored was written by a newer version of the app, which this one cannot
/// read. Nothing is written then, so that the newer version finds its data again.
Newer,
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed(message) => write!(f, "storage error: {message}"),
Self::Newer => write!(f, "stored by a newer version"),
}
}
}
impl std::error::Error for StorageError {}
/// A state of the storage: the head is the revision of the last commit, 0 for a storage that was
/// never written to.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Revision(pub u64);
impl Revision {
pub fn next(self) -> Self {
Self(self.0 + 1)
}
}
/// Why a commit did not happen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitError {
/// The head is not the one the commit is based on: another engine wrote since.
Stale {
head: Revision,
},
Failed(StorageError),
}
impl std::fmt::Display for CommitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stale { head } => write!(f, "the storage changed (now at revision {})", head.0),
Self::Failed(error) => error.fmt(f),
}
}
}
impl std::error::Error for CommitError {}
impl From<StorageError> for CommitError {
fn from(error: StorageError) -> Self {
Self::Failed(error)
}
}
/// 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 revision of the storage that this is the state of.
pub head: Revision,
}
/// 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>>,
/// The head that the batch is based on: it is only applied if the head still is that one.
pub expect_head: Revision,
}
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>;
/// The head of the storage.
async fn head(&self) -> Result<Revision, StorageError>;
/// Applies a batch if the head is the one it is based on (`Stale` otherwise): either all of it
/// is stored, or none. The head advances. Gives the new head.
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError>;
/// 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()
},
];
assert!(holds.iter().all(|batch| !batch.is_empty()));
}
#[test]
fn test_storage_error_is_readable() {
let error = StorageError::Failed("disk full".into());
assert_eq!(error.to_string(), "storage error: disk full");
let _: &dyn std::error::Error = &error;
}
}
+757
View File
@@ -0,0 +1,757 @@
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use uuid::Uuid;
use super::codec::{
DecodeError, decode_file, decode_order, decode_trackpoints, decode_waypoints, encode_file,
encode_order, encode_trackpoints, encode_waypoints,
};
use crate::{
Batch, ChunkKey, CommitError, File, FileId, Files, Revision, 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: Files,
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,
}
/// 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>>,
/// The head of the storage that all of this is the state of.
head: Revision,
/// 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.
///
/// If some of what is stored was written by a newer version, nothing is read (`Newer`), and the
/// persister does not save until it reads a storage that is not newer: the newer version
/// needs its data.
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();
// the names of the chunks are added to the categories of the engine as they are read
let categories: RefCell<TrackpointCategories> = Default::default();
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,
&mut categories.borrow_mut(),
))
.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 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));
// a file that cannot be read is left out, and dropped at the next save
if let Some(file) = file {
files.insert(*id, Rc::new(file));
}
}
if newer.0.get() {
// `*self` was reset: only what is needed to not write, and to know the storage
self.read_only = true;
self.head = data.head;
return Err(StorageError::Newer);
}
let mut order: Vec<FileId> = stored_order
.iter()
.flatten()
.filter(|id| files.contains_key(id))
.copied()
.collect();
let ordered: HashSet<FileId> = order.iter().copied().collect();
order.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
let restored = Restored {
files: order.iter().map(|id| files[id].clone()).collect(),
order,
categories: categories.into_inner(),
};
self.files = files;
self.chunks = chunk_bytes.into_keys().collect();
self.order = stored_order;
self.head = data.head;
Ok(restored)
}
/// The revision of the storage that the persister knows.
pub fn head(&self) -> Revision {
self.head
}
/// Writes what changed since the last save. Nothing is written if nothing changed.
///
/// Nothing is written while what is stored comes from a newer version (`Failed(Newer)`, see
/// [`Persister::restore`]).
///
/// The write only happens if nothing else was committed since the persister read or wrote
/// last: otherwise it is refused (`Stale`), nothing is written, and the persister has to read
/// the storage again ([`Persister::restore`]) before it can write.
pub async fn save<S: Storage>(
&mut self,
storage: &S,
snapshot: Snapshot,
) -> Result<(), CommitError> {
if self.read_only {
return Err(StorageError::Newer.into());
}
let mut plan = self.plan(snapshot);
if plan.batch.is_empty() {
self.files = plan.snapshot.files;
return Ok(());
}
plan.batch.expect_head = self.head;
self.head = 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);
Ok(())
}
/// Whether something else was committed to the storage since the persister read or wrote it
/// last: what it knows of the storage is out of date, and it cannot write until it reads
/// again ([`Persister::restore`]).
pub async fn is_behind<S: Storage>(&self, storage: &S) -> Result<bool, StorageError> {
Ok(storage.head().await? != self.head)
}
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, &snapshot.categories)));
}
}
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));
}
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 head(&self) -> Result<Revision, StorageError> {
self.inner.head().await
}
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
let revision = self.inner.commit(batch).await?;
self.batches.borrow_mut().push(batch.clone());
Ok(revision)
}
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.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());
}
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])],
expect_head: persister.head(),
..Default::default()
}))
.unwrap();
let mut persister = Persister::default();
let restored = block_on(persister.restore(&storage)).unwrap();
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);
batch.expect_head = block_on(storage.inner.head()).unwrap();
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; 3] = [
("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())
}),
];
for (what, damage) in damages {
let (storage, snapshot) = stored_with(damage);
let before = storage.inner.counts();
let mut persister = Persister::default();
let result = block_on(persister.restore(&storage));
assert_eq!(result.unwrap_err(), StorageError::Newer, "{what}");
// whatever is saved afterwards is refused: the newer version needs its data
let mut changed = snapshot.clone();
changed.order.clear();
changed.files.clear();
let result = block_on(persister.save(&storage, changed));
assert_eq!(
result,
Err(CommitError::Failed(StorageError::Newer)),
"{what}"
);
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)).is_err());
// the same persister, on a storage that is not newer
let clean = Recording::default();
block_on(persister.restore(&clean)).unwrap();
block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap();
assert_eq!(clean.inner.counts().files, FILES.len());
}
/// Opens a storage as a tab does: the persister, and the state of the engine it gives.
fn open(storage: &Recording) -> (Persister, Snapshot) {
let mut persister = Persister::default();
let restored = block_on(persister.restore(storage)).unwrap();
let snapshot = Snapshot {
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
order: restored.order,
categories: restored.categories,
};
(persister, snapshot)
}
/// Adds a file that has just been read to the state of an engine.
fn add_file(snapshot: &mut Snapshot, path: &str) -> FileId {
let data = std::fs::read(path).unwrap();
let file = parse(&data, &mut snapshot.categories).unwrap();
let id = file.id;
snapshot.order.push(id);
snapshot.files.insert(id, Rc::new(file));
id
}
#[test]
fn test_files_saved_by_engines_with_other_categories_read_the_same() {
// a second engine opens the storage, so its categories come from what it read, in the
// order it read them; it adds a file that has names of its own
let storage = Recording::default();
let first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]);
block_on(Persister::default().save(&storage, first.clone())).unwrap();
let (mut persister, mut second) = open(&storage);
let added = add_file(&mut second, "data/with_surface.gpx");
block_on(persister.save(&storage, second.clone())).unwrap();
// the first engine reads it all again, and every file means what it did for its engine
let (_, reader) = open(&storage);
assert_eq!(reader.files.len(), 3);
for (id, file) in &reader.files {
let (original, categories) = match first.files.get(id) {
Some(original) => (original, &first.categories),
None => (&second.files[id], &second.categories),
};
assert_eq!(gpx(file, &reader.categories), gpx(original, categories));
}
assert!(reader.files.contains_key(&added));
}
#[test]
fn test_the_head_follows_the_commits_of_the_persister() {
let storage = Recording::default();
let mut persister = Persister::default();
assert_eq!(persister.head(), Revision(0));
let mut snapshot = snapshot_of(&FILES);
block_on(persister.save(&storage, snapshot.clone())).unwrap();
assert_eq!(persister.head(), Revision(1));
assert_eq!(block_on(storage.head()).unwrap(), Revision(1));
// nothing to write is no commit
block_on(persister.save(&storage, snapshot.clone())).unwrap();
assert_eq!(persister.head(), Revision(1));
assert_eq!(storage.batches.borrow().len(), 1);
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)).unwrap();
assert_eq!(persister.head(), Revision(2));
// a persister that opens the storage starts from its head
assert_eq!(open(&storage).0.head(), Revision(2));
assert_eq!(storage.batches.borrow()[1].expect_head, Revision(1));
}
#[test]
fn test_a_tab_that_is_behind_cannot_write() {
// two tabs open the same file; one edits a point, the other renames the file
let storage = Recording::default();
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
let ((mut a, mut snap_a), (mut b, mut snap_b)) = (open(&storage), open(&storage));
let id = snap_a.order[0];
let mut file = (*snap_a.files[&id]).clone();
file.trk[0].trkseg[0].update(0, |p| p.ele += 100.0);
snap_a.files.insert(id, Rc::new(file));
block_on(a.save(&storage, snap_a.clone())).unwrap();
let stored = block_on(storage.load()).unwrap();
let mut file = (*snap_b.files[&id]).clone();
file.info.name = "renamed by B".into();
snap_b.files.insert(id, Rc::new(file));
assert_eq!(
block_on(b.save(&storage, snap_b)),
Err(CommitError::Stale { head: Revision(2) })
);
// nothing was written, the file is still readable, and the persister did not move
assert_eq!(block_on(storage.load()).unwrap(), stored);
assert_eq!(b.head(), Revision(1));
let (_, reader) = open(&storage);
assert_eq!(reader.files.len(), 1);
assert_eq!(reader.files[&id].info.name, snap_a.files[&id].info.name);
}
#[test]
fn test_a_tab_that_is_behind_reads_the_storage_again_and_writes_again() {
let storage = Recording::default();
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
let ((mut a, mut snap_a), (mut b, snap_b)) = (open(&storage), open(&storage));
let id = snap_a.order[0];
assert!(!block_on(b.is_behind(&storage)).unwrap());
let mut file = (*snap_a.files[&id]).clone();
file.info.name = "renamed by A".into();
snap_a.files.insert(id, Rc::new(file));
block_on(a.save(&storage, snap_a)).unwrap();
assert!(block_on(b.is_behind(&storage)).unwrap());
assert!(!block_on(a.is_behind(&storage)).unwrap());
let restored = block_on(b.restore(&storage)).unwrap();
assert_eq!(restored.files[0].info.name, "renamed by A");
assert!(!block_on(b.is_behind(&storage)).unwrap());
let mut snapshot = snap_b;
snapshot.files.insert(id, restored.files[0].clone());
assert!(block_on(b.save(&storage, snapshot)).is_ok());
}
#[test]
fn test_chunks_shared_by_files_still_are_after_the_names_are_read() {
let storage = Recording::default();
let mut snapshot = snapshot_of(&["data/with_surface.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));
block_on(Persister::default().save(&storage, snapshot)).unwrap();
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]
));
assert!(!restored.categories.surface.names().is_empty());
}
#[test]
fn test_nothing_but_the_files_and_the_order_is_stored() {
let storage = Recording::default();
block_on(Persister::default().save(&storage, snapshot_of(&FILES))).unwrap();
// the categories are in the chunks: a storage with no more than these is complete
let data = block_on(storage.load()).unwrap();
assert_eq!(data.files.len(), FILES.len());
assert!(data.order.is_some());
}
#[test]
fn test_an_engine_starts_from_what_is_restored() {
let storage = Recording::default();
let mut engine = Engine::default();
let load = |engine: &mut Engine, path: &str| {
let data = std::fs::read(path).unwrap();
engine
.execute(Action::Edit(Command::Load(Load {
data: &data,
name: "file",
})))
.unwrap()
.changed
};
for path in FILES {
assert!(load(&mut engine, path));
}
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();
let outcome = engine.restore(restored);
assert_eq!(engine.snapshot().order, snapshot.order);
assert_eq!(outcome.diff.unwrap().added.len(), FILES.len());
assert!(outcome.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.execute(Action::Undo).unwrap().changed);
assert!(load(&mut engine, FILES[0]));
assert!(engine.can_undo());
assert!(engine.execute(Action::Undo).unwrap().changed);
assert_eq!(engine.snapshot().files.len(), FILES.len());
assert!(!engine.can_undo());
}
}
+5
View File
@@ -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"
+34
View File
@@ -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))
}
+120
View File
@@ -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"),
}
}
+30
View File
@@ -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)
}
+6
View File
@@ -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;
+144
View File
@@ -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(),
}))
})
}
+109
View File
@@ -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,
}))
}
+90
View File
@@ -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"),
}
}
+254
View File
@@ -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()
}
Loaded 100 of 345 files, more files were not shown because too many files have changed in this diff. Show more