25 Commits
Author SHA1 Message Date
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
231 changed files with 18457 additions and 21051 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: 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. - `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 ```bash
cd gpx cd gpx-rs
npm install wasm-pack build wasm --target bundler --out-dir ../pkg
npm run build
``` ```
### Running the website ### 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 - [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts - [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic: - 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 - [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping: - Mapping:
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering - [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", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -23,6 +43,18 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" 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]] [[package]]
name = "cc" name = "cc"
version = "1.4.6" version = "1.4.6"
@@ -52,6 +84,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]] [[package]]
name = "console_error_panic_hook" name = "console_error_panic_hook"
version = "0.1.7" version = "0.1.7"
@@ -68,6 +109,30 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.12"
@@ -114,7 +179,9 @@ name = "gpx-engine"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"postcard",
"quick-xml", "quick-xml",
"serde",
"uuid", "uuid",
] ]
@@ -124,9 +191,41 @@ version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"gpx-engine", "gpx-engine",
"idb",
"js-sys", "js-sys",
"uuid", "uuid",
"wasm-bindgen", "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]] [[package]]
@@ -153,6 +252,37 @@ dependencies = [
"cc", "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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.106" version = "0.3.106"
@@ -170,6 +300,21 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" 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]] [[package]]
name = "log" name = "log"
version = "0.4.34" version = "0.4.34"
@@ -182,6 +327,25 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -189,6 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -197,12 +362,31 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -236,12 +420,85 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" 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]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -254,6 +511,21 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 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]] [[package]]
name = "syn" name = "syn"
version = "2.0.118" version = "2.0.118"
@@ -276,6 +548,35 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -290,9 +591,20 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [ dependencies = [
"getrandom", "getrandom",
"js-sys", "js-sys",
"serde_core",
"wasm-bindgen", "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]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.129" version = "0.2.129"
@@ -306,6 +618,17 @@ dependencies = [
"wasm-bindgen-shared", "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]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.129" version = "0.2.129"
@@ -338,6 +661,64 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -396,3 +777,18 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [ dependencies = [
"windows-link", "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 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] [dependencies]
chrono = "0.4.45" chrono = "0.4.45"
postcard = { version = "1.1.3", features = ["use-std"] }
quick-xml = "0.42.0" 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>
+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 simplify;
mod smooth; mod smooth;
mod timestamps;
pub use anchors::*;
pub use reverse::*;
pub use simplify::*; 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::{ use crate::{
core::gpx::{TrackSegment, TrackSegmentIndex}, core::gpx::{TrackSegment, TrackSegmentIndex},
core::utils::crossarc, core::utils::{crossarc, crossarc_lnglat},
}; };
pub fn ramer_douglas_peucker<F>( pub fn ramer_douglas_peucker<F>(
@@ -10,47 +10,67 @@ pub fn ramer_douglas_peucker<F>(
) -> Vec<TrackSegmentIndex> ) -> Vec<TrackSegmentIndex>
where where
F: Fn(TrackSegmentIndex) -> (f64, f64), 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() { match trkseg.len() {
0 => vec![], 0 => vec![],
1 => vec![trkseg.first_index().unwrap()], 1 => vec![(trkseg.first_index().unwrap(), None)],
2 => vec![trkseg.first_index().unwrap(), trkseg.last_index().unwrap()], 2 => vec![
(trkseg.first_index().unwrap(), None),
(trkseg.last_index().unwrap(), None),
],
_ => { _ => {
let first = trkseg.first_index().unwrap(); let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap(); let last = trkseg.last_index().unwrap();
let mut indices = vec![first]; let mut kept = vec![(first, None)];
ramer_douglas_peucker_helper(trkseg, first, last, mapping, epsilon, &mut indices); ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept);
indices.push(last); kept.push((last, None));
indices kept
} }
} }
} }
fn ramer_douglas_peucker_helper<F>( fn ramer_douglas_peucker_helper<M>(
trkseg: &TrackSegment, trkseg: &TrackSegment,
start: TrackSegmentIndex, start: TrackSegmentIndex,
end: TrackSegmentIndex, end: TrackSegmentIndex,
mapping: &F, measure: &M,
epsilon: f64, epsilon: f64,
indices: &mut Vec<TrackSegmentIndex>, kept: &mut Vec<(TrackSegmentIndex, Option<f64>)>,
) where ) where
F: Fn(TrackSegmentIndex) -> (f64, f64), M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{ {
let mut max_idx = None; let mut max_idx = None;
let mut max_dist = 0.0; let mut max_dist = 0.0;
let start_pt = mapping(start);
let end_pt = mapping(end);
let mut cur = trkseg.next_index(Some(start)); let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur { while let Some(idx) = cur {
if idx == end { if idx == end {
break; break;
} }
let pt = mapping(idx); let dist = measure(start, end, idx);
let dist = crossarc(start_pt.0, start_pt.1, end_pt.0, end_pt.1, pt.0, pt.1);
if dist > max_dist { if dist > max_dist {
max_idx = Some(idx); max_idx = Some(idx);
max_dist = dist; max_dist = dist;
@@ -60,9 +80,88 @@ fn ramer_douglas_peucker_helper<F>(
} }
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) { if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
ramer_douglas_peucker_helper(trkseg, start, idx, mapping, epsilon, indices); ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
indices.push(idx); kept.push((idx, Some(max_dist)));
ramer_douglas_peucker_helper(trkseg, idx, end, mapping, epsilon, indices); ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
}
}
/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`].
pub const MIN_REDUCE_TOLERANCE: f64 = 0.1;
/// The distance (in meters) from a trackpoint to the line between two others of a segment.
fn lnglat_measure(
trkseg: &TrackSegment,
) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ {
move |start, end, idx| {
crossarc_lnglat(
trkseg[start].coordinates,
trkseg[end].coordinates,
trkseg[idx].coordinates,
)
}
}
/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance`
/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept.
pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec<usize> {
ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance)
.into_iter()
.map(|(idx, _)| idx.flat)
.collect()
}
/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by
/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that
/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its
/// value is greater than it.
///
/// A trackpoint is only selected if the ones that split the path before it are, so its value is
/// the smallest distance at which it or one of them was selected.
pub fn reduction_distances(trkseg: &TrackSegment) -> Vec<f64> {
let mut distances = vec![0.0; trkseg.len()];
let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else {
return distances;
};
distances[first.flat] = f64::INFINITY;
distances[last.flat] = f64::INFINITY;
if trkseg.len() > 2 {
let measure = lnglat_measure(trkseg);
reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances);
}
distances
}
fn reduction_distances_helper<M>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
measure: &M,
bound: f64,
distances: &mut [f64],
) where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{
let mut max_idx = None;
let mut max_dist = 0.0;
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let dist = measure(start, end, idx);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) {
let distance = max_dist.min(bound);
distances[idx.flat] = distance;
reduction_distances_helper(trkseg, start, idx, measure, distance, distances);
reduction_distances_helper(trkseg, idx, end, measure, distance, distances);
} }
} }
@@ -0,0 +1,465 @@
use crate::{Statistics, TrackSegment, Trackpoint, distance};
/// The new timestamps of the trackpoints of `segments`, one after the other: each segment goes on
/// from the end of the previous one, the first one starts at `start_time` (in milliseconds).
///
/// A segment that has timestamps keeps its durations multiplied by `ratio`; one that has none
/// (judged by its first trackpoint) follows the previous trackpoint at `speed` (km/h). See
/// [`shifted_and_compressed`] and [`with_timestamps`].
pub fn changed_segment_times(
segments: &[&TrackSegment],
start_time: i64,
speed: f64,
ratio: f64,
) -> Vec<Vec<Option<i64>>> {
let mut last: Option<Trackpoint> = None;
segments
.iter()
.map(|segment| {
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
let Some(first) = points.first() else {
return vec![];
};
let start = last.clone().unwrap_or_else(|| {
let mut start = first.clone();
start.time = Some(start_time);
start
});
let points = if first.time.is_none() {
with_timestamps(points, Some(speed), Some(&start), Some(start_time))
} else {
shifted_and_compressed(points, Some(speed), ratio, &start)
};
last = points.last().cloned();
points.iter().map(|point| point.time).collect()
})
.collect()
}
/// Makes up timestamps for the trackpoints of `segments`, one after the other: they start at
/// `start_time` (in milliseconds) and last `total_time` seconds together, the longer and the
/// steeper an interval the more of it. Each segment goes on from the end of the previous one.
pub fn artificial_segment_times(
segments: &[&TrackSegment],
start_time: i64,
total_time: f64,
) -> Vec<Vec<Option<i64>>> {
let mut weights: Vec<Vec<f64>> = segments
.iter()
.map(|segment| {
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
let slopes: Vec<f64> = Statistics::compute(segment)
.local
.iter()
.map(|l| l.slope)
.collect();
artificial_weights(&points, &slopes)
})
.collect();
let mut total: f64 = weights.iter().flatten().sum();
if total.is_nan() || total <= 0.0 {
// no distance to share the time by: every interval takes as long
for weights in &mut weights {
weights.iter_mut().for_each(|w| *w = 1.0);
}
total = weights.iter().map(Vec::len).sum::<usize>() as f64;
}
let ms_per_weight = if total > 0.0 {
total_time * 1000.0 / total
} else {
0.0
};
let mut start = start_time;
segments
.iter()
.zip(&weights)
.map(|(segment, weights)| {
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
if points.is_empty() {
return vec![];
}
let points = with_artificial_timestamps(points, weights, ms_per_weight, start);
// the next segment goes on from the end of this one
start = points.last().and_then(|point| point.time).unwrap_or(start);
points.iter().map(|point| point.time).collect()
})
.collect()
}
/// Replaces the trackpoints `start..end` of `trkpt` by `points`, keeping the timestamps of the
/// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library.
///
/// The timestamps only matter if the path has some, or if `speed` (in km/h) is given: then the
/// new points that have none get some (a trackpoint after the previous one at `speed`, or one
/// second after it without a speed), and so do the points before or after the range if they
/// had none. `start_time` is the time of the first trackpoint when nothing else gives it.
/// New points that have timestamps are shifted if they are too early to follow the previous
/// trackpoint, and so are the points after the range. With `remove_gaps`, the new points are
/// also brought closer to the previous trackpoint if they come long after it.
///
/// Panics if `start..end` is not inside `trkpt`.
pub fn replace_trackpoints(
trkpt: &mut Vec<Trackpoint>,
start: usize,
end: usize,
mut points: Vec<Trackpoint>,
speed: Option<f64>,
start_time: Option<i64>,
remove_gaps: bool,
) {
assert!(start <= end && end <= trkpt.len());
let has_times = trkpt.first().is_some_and(|first| first.time.is_some());
if speed.is_some() || has_times {
if start > 0 && trkpt[0].time.is_none() {
// the points before the range have no timestamps
let before: Vec<_> = trkpt.drain(..start).collect();
trkpt.splice(0..0, with_timestamps(before, speed, None, start_time));
}
if !points.is_empty() {
let last = start.checked_sub(1).map(|i| &trkpt[i]);
let missing =
points[0].time.is_none() || points.get(1).is_some_and(|p| p.time.is_none());
if missing {
points = with_timestamps(points, speed, last, start_time);
} else if let Some(last) = last {
if is_before(points[0].time, last.time) {
// too early
points = shifted_and_compressed(points, speed, 1.0, last);
} else if remove_gaps {
if same_place(&points[0], last) {
// the same place: the new points start at the previous trackpoint, which
// is the new first one
if is_before(last.time, points[0].time) {
points = shifted_and_compressed(points, speed, 1.0, last);
points.remove(0);
}
} else if matches!((points[0].time, last.time), (Some(new), Some(old)) if new - old > 1000)
{
// another place: the new points start one second after the previous one
let mut artificial_last = points[0].clone();
artificial_last.time = last.time.map(|time| time + 1000);
points = shifted_and_compressed(points, speed, 1.0, &artificial_last);
}
}
}
}
if end < trkpt.len() {
// the points after the range
let last = points
.last()
.or_else(|| start.checked_sub(1).map(|i| &trkpt[i]))
.cloned();
if trkpt[end].time.is_none() {
let after: Vec<_> = trkpt.drain(end..).collect();
let after = with_timestamps(after, speed, last.as_ref(), start_time);
trkpt.extend(after);
} else if let Some(last) = last
&& is_before(trkpt[end].time, last.time)
{
let after: Vec<_> = trkpt.drain(end..).collect();
trkpt.extend(shifted_and_compressed(after, speed, 1.0, &last));
}
}
}
trkpt.splice(start..end, points);
}
/// How long each interval between two consecutive trackpoints of a segment should last, relative
/// to the others, for a made-up pace: the longer the interval, and the steeper it goes up, the
/// longer. `slopes` has the slope (in %) at each trackpoint.
pub fn artificial_weights(points: &[Trackpoint], slopes: &[f64]) -> Vec<f64> {
points
.windows(2)
.zip(slopes)
.map(|(pair, slope)| {
let km = distance(pair[0].coordinates, pair[1].coordinates);
km * (0.5 + 1.0 / (1.0 + (-0.2 * slope).exp()))
})
.collect()
}
/// `points` with made-up timestamps: the first one is at `start`, and each next one follows the
/// previous one after `weights[i]` times `ms_per_weight` milliseconds (see [`artificial_weights`]).
pub fn with_artificial_timestamps(
mut points: Vec<Trackpoint>,
weights: &[f64],
ms_per_weight: f64,
start: i64,
) -> Vec<Trackpoint> {
let mut elapsed = 0.0;
for (i, point) in points.iter_mut().enumerate() {
if i > 0 {
elapsed += weights[i - 1] * ms_per_weight;
}
point.time = Some(start + elapsed as i64);
}
points
}
fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool {
a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat
}
/// Whether both times are known and `a` is before `b`.
fn is_before(a: Option<i64>, b: Option<i64>) -> bool {
matches!((a, b), (Some(a), Some(b)) if a < b)
}
/// The time at which `b` is reached after `a`, at `speed` km/h: one second later without a
/// speed, unknown if `a` has no time.
///
/// The old code gave an invalid date for a null or negative speed: it is as if there was none.
fn timestamp_after(a: &Trackpoint, b: &Trackpoint, speed: Option<f64>) -> Option<i64> {
let time = a.time?;
match speed.filter(|speed| speed.is_finite() && *speed > 0.0) {
None => Some(time + 1000),
Some(speed) => {
let km = distance(a.coordinates, b.coordinates);
Some(time + (3_600_000.0 * km / speed) as i64)
}
}
}
/// `points` with the timestamps that follow `last` (the trackpoint before them), one after the
/// other. Without `last`, the first one is at `start_time`.
///
/// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy
/// used here.
pub fn with_timestamps(
points: Vec<Trackpoint>,
speed: Option<f64>,
last: Option<&Trackpoint>,
start_time: Option<i64>,
) -> Vec<Trackpoint> {
let Some(first) = points.first() else {
return points;
};
let mut last = match last {
Some(last) if last.time.is_some() => last.clone(),
other => {
let mut last = other.unwrap_or(first).clone();
last.time = start_time;
last
}
};
points
.into_iter()
.map(|mut point| {
point.time = timestamp_after(&last, &point, speed);
last = point.clone();
point
})
.collect()
}
/// `points` with their timestamps moved so that the first one follows `last`, and their durations
/// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in
/// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known.
pub fn shifted_and_compressed(
points: Vec<Trackpoint>,
speed: Option<f64>,
ratio: f64,
last: &Trackpoint,
) -> Vec<Trackpoint> {
let Some(first) = points.first() else {
return points;
};
let start = timestamp_after(last, first, speed);
let first_time = first.time;
let mut previous = first.clone();
points
.into_iter()
.map(|point| {
let mut shifted = point.clone();
shifted.time = match point.time {
None => timestamp_after(&previous, &point, speed),
Some(time) => start
.zip(first_time)
.map(|(start, first)| start + (ratio * (time - first) as f64) as i64),
};
previous = shifted.clone();
shifted
})
.collect()
}
#[cfg(test)]
mod tests {
use crate::LngLat;
use super::*;
/// A point every 1 km along the equator... about, at 0.009 degrees of longitude.
fn point(i: usize, time: Option<i64>) -> Trackpoint {
Trackpoint {
coordinates: LngLat {
lng: i as f64 * 0.009,
lat: 0.0,
},
time,
..Default::default()
}
}
fn timed(range: std::ops::Range<usize>, start: i64, step: i64) -> Vec<Trackpoint> {
range
.map(|i| point(i, Some(start + step * i as i64)))
.collect()
}
fn untimed(range: std::ops::Range<usize>) -> Vec<Trackpoint> {
range.map(|i| point(i, None)).collect()
}
fn times(trkpt: &[Trackpoint]) -> Vec<Option<i64>> {
trkpt.iter().map(|p| p.time).collect()
}
#[test]
fn test_without_times_nor_speed_it_is_a_splice() {
let mut trkpt = untimed(0..5);
replace_trackpoints(&mut trkpt, 1, 3, untimed(10..13), None, None, false);
assert_eq!(trkpt.len(), 6);
assert!(trkpt.iter().all(|p| p.time.is_none()));
assert_eq!(trkpt[1].coordinates.lng, point(10, None).coordinates.lng);
assert_eq!(trkpt[4].coordinates.lng, point(3, None).coordinates.lng);
}
#[test]
fn test_new_points_follow_the_previous_one_at_the_speed() {
// 1 km in 10 s is 360 km/h
let mut trkpt = timed(0..4, 0, 10_000);
let km = distance(point(0, None).coordinates, point(1, None).coordinates);
let speed = 36.0 * km / 0.01; // the time of 1 point to the next is 100 s
replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), Some(speed), None, false);
let expected = 10_000 + (3_600_000.0 * km / speed) as i64;
assert_eq!(trkpt[2].time, Some(expected));
assert!(trkpt[2].time > trkpt[1].time);
// the points after follow the new ones, they were not too early
assert_eq!(trkpt.len(), 5);
assert_eq!(trkpt[4].time, Some(30_000));
}
#[test]
fn test_without_a_speed_new_points_are_one_second_apart() {
let mut trkpt = timed(0..3, 5_000, 60_000);
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..13), None, None, false);
assert_eq!(
times(&trkpt[..4]),
[Some(5_000), Some(6_000), Some(7_000), Some(8_000)]
);
// what comes after was after, it is kept
assert_eq!(trkpt[4].time, Some(125_000));
}
#[test]
fn test_the_points_after_are_shifted_if_they_are_too_early() {
let mut trkpt = timed(0..4, 0, 10_000);
// 30 new points, one second apart: the last one is at 30 s, but the next original
// trackpoint is at 20 s
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..40), None, None, false);
assert_eq!(trkpt.len(), 33);
assert_eq!(trkpt[1].time, Some(1_000));
assert_eq!(trkpt[30].time, Some(30_000));
// the following points keep the duration between them, after the new ones
assert_eq!(trkpt[31].time, Some(31_000));
assert_eq!(trkpt[32].time, Some(41_000));
}
#[test]
fn test_a_path_without_times_gets_them_from_the_speed_and_start_time() {
let mut trkpt = untimed(0..4);
replace_trackpoints(
&mut trkpt,
3,
4,
untimed(3..6),
Some(10.0),
Some(1_000),
false,
);
let times: Vec<_> = times(&trkpt);
assert_eq!(times[0], Some(1_000));
assert!(times.iter().all(Option::is_some));
assert!(times.windows(2).all(|w| w[0].unwrap() < w[1].unwrap()));
}
#[test]
fn test_an_unusable_speed_is_as_if_there_was_none() {
for speed in [0.0, -5.0, f64::NAN, f64::INFINITY] {
let mut trkpt = timed(0..2, 0, 10_000);
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(speed), None, false);
assert_eq!(
times(&trkpt),
[Some(0), Some(10_000), Some(11_000), Some(12_000)],
"{speed}"
);
}
}
#[test]
fn test_new_points_with_times_that_are_too_early_are_shifted() {
let mut trkpt = timed(0..3, 100_000, 10_000);
// they are at 0, 5 s: they are moved to follow the trackpoint at 110 s
let new = vec![point(5, Some(0)), point(6, Some(5_000))];
replace_trackpoints(&mut trkpt, 2, 3, new, None, None, false);
// they follow the previous trackpoint after one second, keeping their own durations
assert_eq!(trkpt[2].time, Some(111_000));
assert_eq!(trkpt[3].time, Some(116_000));
}
#[test]
fn test_remove_gaps_at_the_same_place_and_elsewhere() {
// same place: the first new point is the previous trackpoint
let mut trkpt = timed(0..3, 0, 10_000);
let new = vec![point(2, Some(500_000)), point(3, Some(510_000))];
replace_trackpoints(&mut trkpt, 3, 3, new.clone(), None, None, true);
assert_eq!(trkpt.len(), 4);
// without a speed, it is one second after the previous trackpoint, not at its time
assert_eq!(trkpt[3].time, Some(31_000));
// with a speed, the distance is zero
let mut trkpt = timed(0..3, 0, 10_000);
replace_trackpoints(&mut trkpt, 3, 3, new, Some(10.0), None, true);
assert_eq!(trkpt[3].time, Some(30_000));
// another place: one second after the previous one
let mut trkpt = timed(0..3, 0, 10_000);
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, true);
assert_eq!(trkpt.len(), 5);
// the old code adds the second twice with no speed, see `timestamp_after`
assert_eq!(trkpt[3].time, Some(22_000));
assert_eq!(trkpt[4].time, Some(32_000));
// without removing the gaps they stay
let mut trkpt = timed(0..3, 0, 10_000);
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, false);
assert_eq!(trkpt[3].time, Some(500_000));
}
#[test]
fn test_removing_points_adapts_nothing_if_the_next_one_is_after() {
let mut trkpt = timed(0..5, 0, 10_000);
replace_trackpoints(&mut trkpt, 1, 3, vec![], None, None, false);
assert_eq!(times(&trkpt), [Some(0), Some(30_000), Some(40_000)]);
// from the beginning
replace_trackpoints(&mut trkpt, 0, 2, vec![], None, None, false);
assert_eq!(times(&trkpt), [Some(40_000)]);
replace_trackpoints(&mut trkpt, 0, 1, vec![], None, None, false);
assert!(trkpt.is_empty());
}
#[test]
fn test_points_without_a_known_previous_time_get_none() {
// nothing gives a time to start with
let mut trkpt = untimed(0..2);
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(10.0), None, false);
assert!(trkpt.iter().all(|p| p.time.is_none()));
}
}
+7
View File
@@ -7,6 +7,13 @@
pub struct Categories(Vec<String>); pub struct Categories(Vec<String>);
impl Categories { impl Categories {
/// A table with these names, in the order of their codes (at most [`Self::CAPACITY`] of them
/// are kept).
pub fn from_names(mut names: Vec<String>) -> Self {
names.truncate(Self::CAPACITY);
Self(names)
}
/// Maximum number of distinct values. /// Maximum number of distinct values.
pub const CAPACITY: usize = u8::MAX as usize; pub const CAPACITY: usize = u8::MAX as usize;
+77 -44
View File
@@ -1,17 +1,36 @@
use uuid::Uuid;
use crate::{Trackpoint, Waypoint}; 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; const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
#[derive(Debug, PartialEq, Eq)] uuid_id!(TrackpointChunkId);
pub struct TrackpointChunkId(Uuid);
impl Default for TrackpointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct TrackpointChunk { pub struct TrackpointChunk {
@@ -19,26 +38,31 @@ pub struct TrackpointChunk {
pub trkpt: Vec<Trackpoint>, pub trkpt: Vec<Trackpoint>,
} }
impl PartialEq for TrackpointChunk { impl Chunk for TrackpointChunk {
fn eq(&self, other: &Self) -> bool { type Item = Trackpoint;
self.id == other.id const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE;
fn new(trkpt: Vec<Trackpoint>) -> Self {
Self {
trkpt,
..Default::default()
}
}
fn renew(&mut self) {
self.id = Default::default();
}
fn items(&self) -> &Vec<Trackpoint> {
&self.trkpt
}
fn items_mut(&mut self) -> &mut Vec<Trackpoint> {
&mut self.trkpt
} }
} }
impl TrackpointChunk { uuid_id!(WaypointChunkId);
pub fn is_full(&self) -> bool {
self.trkpt.len() == MAX_TRKPT_CHUNK_SIZE
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaypointChunkId(Uuid);
impl Default for WaypointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
const MAX_WPT_CHUNK_SIZE: usize = 128; const MAX_WPT_CHUNK_SIZE: usize = 128;
@@ -48,15 +72,27 @@ pub struct WaypointChunk {
pub wpt: Vec<Waypoint>, pub wpt: Vec<Waypoint>,
} }
impl PartialEq for WaypointChunk { impl Chunk for WaypointChunk {
fn eq(&self, other: &Self) -> bool { type Item = Waypoint;
self.id == other.id const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
}
}
impl WaypointChunk { fn new(wpt: Vec<Waypoint>) -> Self {
pub fn is_full(&self) -> bool { Self {
self.wpt.len() == MAX_WPT_CHUNK_SIZE wpt,
..Default::default()
}
}
fn renew(&mut self) {
self.id = Default::default();
}
fn items(&self) -> &Vec<Waypoint> {
&self.wpt
}
fn items_mut(&mut self) -> &mut Vec<Waypoint> {
&mut self.wpt
} }
} }
@@ -87,14 +123,11 @@ mod tests {
} }
#[test] #[test]
fn test_chunk_equality_is_by_id() { fn test_new_chunks_have_their_own_identity() {
let a = TrackpointChunk::default(); let a = TrackpointChunk::new(vec![Trackpoint::default()]);
let b = TrackpointChunk::default(); let b = TrackpointChunk::new(vec![Trackpoint::default()]);
assert_ne!(a, b); assert_ne!(a.id, b.id);
let c = TrackpointChunk { assert_eq!(a.items().len(), 1);
id: TrackpointChunkId(a.id.0), assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id);
trkpt: vec![Trackpoint::default()],
};
assert_eq!(a, c);
} }
} }
+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)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Link { pub struct Link {
pub href: String, 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)] #[derive(Debug, Default, Clone)]
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)]
pub struct File { pub struct File {
pub id: FileId, pub id: FileId,
pub info: FileInfo, pub info: FileInfo,
pub trk: Vec<Track>, pub trk: Vec<Track>,
pub wpt: Vec<Rc<WaypointChunk>>, pub wpt: Waypoints,
pub wpt_rev_id: FileWaypointsRevisionId,
// TODO routes
} }
#[derive(Debug, Default, Clone, PartialEq)] #[derive(Debug, Default, Clone, PartialEq)]
@@ -37,13 +17,15 @@ pub struct FileInfo {
pub name: String, pub name: String,
pub desc: Option<String>, pub desc: Option<String>,
pub author: Option<Author>, pub author: Option<Author>,
pub link: Option<Link>, pub links: Vec<Link>,
pub time: Option<i64>, pub time: Option<i64>,
} }
#[derive(Debug, Default, Clone, PartialEq)] #[derive(Debug, Default, Clone, PartialEq)]
pub struct Author { pub struct Author {
pub name: Option<String>, pub name: Option<String>,
/// `id@domain`, which the file gives as the two attributes of an `email` element.
pub email: Option<String>, pub email: Option<String>,
/// The author has a single link, unlike the other elements.
pub link: Option<Link>, pub link: Option<Link>,
} }
+4
View File
@@ -1,17 +1,21 @@
mod categories; mod categories;
mod chunk; mod chunk;
mod chunked;
mod common; mod common;
mod file; mod file;
mod segment; mod segment;
mod track; mod track;
mod trackpoint; mod trackpoint;
mod waypoint; mod waypoint;
mod waypoints;
pub use categories::*; pub use categories::*;
pub use chunk::*; pub use chunk::*;
pub use chunked::*;
pub use common::*; pub use common::*;
pub use file::*; pub use file::*;
pub use segment::*; pub use segment::*;
pub use track::*; pub use track::*;
pub use trackpoint::*; pub use trackpoint::*;
pub use waypoint::*; pub use waypoint::*;
pub use waypoints::*;
+183 -226
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)] uuid_id!(TrackSegmentId);
pub struct TrackSegmentId(pub Uuid);
impl Default for TrackSegmentId { uuid_id!(TrackSegmentRevisionId);
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Default, Clone)]
pub struct TrackSegmentRevisionId(pub Uuid);
impl Default for TrackSegmentRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackSegment { pub struct TrackSegment {
pub id: TrackSegmentId, pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId, pub rev_id: TrackSegmentRevisionId,
chunks: Vec<Rc<TrackpointChunk>>, points: Chunked<TrackpointChunk>,
cumul_length: Vec<usize>, }
/// 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 { impl TrackSegment {
/// Adds a chunk after the last trackpoint, which is dropped if it is empty.
pub fn push(&mut self, chunk: TrackpointChunk) { 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>) { /// Adds a chunk that is shared with something else after the last trackpoint.
if chunk.trkpt.is_empty() { pub fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
return; self.points.push_shared(chunk);
} self.rev_id = Default::default();
self.cumul_length
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.trkpt.len());
self.chunks.push(chunk);
} }
/// Replaces the points in `start..end` by `points`. Panics if the range is out of bounds. /// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no
/// /// such trackpoint.
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) {
/// range are copied and refilled. 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>) { pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
assert!( self.points.splice(start, end, points);
start <= end && end <= self.len(), self.rev_id = Default::default();
"splice range out of bounds" self.ensure_end_anchors();
);
let old = std::mem::take(&mut self.chunks);
self.cumul_length.clear();
let mut pending = TrackpointChunk::default();
let mut inserted = false;
let mut offset = 0;
for chunk in old {
let (lo, hi) = (offset, offset + chunk.trkpt.len());
offset = hi;
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
if hi < start || (hi == start && chunk.is_full()) {
self.push_shared(chunk);
continue;
}
if !inserted {
self.fill(&mut pending, chunk.trkpt[..start - lo].iter().cloned());
self.fill(&mut pending, points.iter().cloned());
inserted = true;
}
if hi <= end {
continue;
}
if lo >= end {
self.flush(&mut pending);
self.push_shared(chunk);
} else {
self.fill(&mut pending, chunk.trkpt[end - lo..].iter().cloned());
}
}
if !inserted {
self.fill(&mut pending, points);
}
self.flush(&mut pending);
} }
fn fill( /// The first and the last trackpoints are always anchors, shown at every zoom level.
&mut self, ///
pending: &mut TrackpointChunk, /// It is done by [`TrackSegment::splice`]; commands that build segments in another way need to
points: impl IntoIterator<Item = Trackpoint>, /// call it.
) { pub fn ensure_end_anchors(&mut self) {
for trkpt in points { let Some(last) = self.len().checked_sub(1) else {
pending.trkpt.push(trkpt); return;
if pending.is_full() {
self.flush(pending);
}
}
}
fn flush(&mut self, pending: &mut TrackpointChunk) {
self.push(std::mem::take(pending));
}
pub fn len(&self) -> usize {
self.cumul_length.last().copied().unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
pub fn iter(&self) -> TrackSegmentIterator<'_> {
TrackSegmentIterator::new(self)
}
pub fn first_index(&self) -> Option<TrackSegmentIndex> {
self.next_index(None)
}
pub fn last_index(&self) -> Option<TrackSegmentIndex> {
self.prev_index(None)
}
pub fn next_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut next = cur.map_or_default(|idx| TrackSegmentIndex {
chunk: idx.chunk,
pos: idx.pos + 1,
flat: idx.flat + 1,
});
loop {
if next.chunk >= self.chunks.len() {
return None;
}
if next.pos == self.chunks[next.chunk].trkpt.len() {
next.chunk += 1;
next.pos = 0;
} else {
return Some(next);
}
}
}
pub fn prev_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut prev = cur.unwrap_or(TrackSegmentIndex {
chunk: self.chunks.len(),
pos: 0,
flat: self.cumul_length.last().copied().unwrap_or_default(),
});
if prev.pos == 0 {
while prev.chunk > 0 {
prev.chunk -= 1;
if !self.chunks[prev.chunk].trkpt.is_empty() {
prev.pos = self.chunks[prev.chunk].trkpt.len() - 1;
prev.flat -= 1;
return Some(prev);
}
}
None
} else {
prev.pos -= 1;
prev.flat -= 1;
Some(prev)
}
}
fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> {
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
if chunk >= self.chunks.len() {
return None;
}
let pos = if chunk > 0 {
idx - self.cumul_length[chunk - 1]
} else {
idx
}; };
if pos >= self.chunks[chunk].trkpt.len() { for index in [0, last] {
None if self[index].anchor != Some(0) {
} else { self.set_anchor(index, 0);
Some(TrackSegmentIndex {
chunk,
pos,
flat: idx,
})
} }
} }
}
impl Index<TrackSegmentIndex> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: TrackSegmentIndex) -> &Self::Output {
&self.chunks[idx.chunk].trkpt[idx.pos]
}
}
impl Index<usize> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: usize) -> &Self::Output {
&self[self.locate(idx).unwrap()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct TrackSegmentIndex {
pub chunk: usize,
pub pos: usize,
pub flat: usize,
}
#[derive(Debug, Clone)]
pub struct TrackSegmentIterator<'a> {
trkseg: &'a TrackSegment,
idx: Option<TrackSegmentIndex>,
}
impl<'a> TrackSegmentIterator<'a> {
pub fn new(trkseg: &'a TrackSegment) -> Self {
Self {
trkseg,
idx: Default::default(),
}
}
}
impl<'a> Iterator for TrackSegmentIterator<'a> {
type Item = &'a Trackpoint;
fn next(&mut self) -> Option<Self::Item> {
self.idx = self.trkseg.next_index(self.idx);
self.idx.map(|idx| &self.trkseg[idx])
} }
fn nth(&mut self, n: usize) -> Option<Self::Item> { /// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if
let idx = self.idx.map_or_default(|idx| idx.flat) + n; /// there is no such trackpoint.
self.idx = self.trkseg.locate(idx); pub fn set_anchor(&mut self, index: usize, zoom: u8) {
self.idx.map(|idx| &self.trkseg[idx]) self.update(index, |trkpt| trkpt.anchor = Some(zoom));
}
/// Sets the anchors of the trackpoints from the details of the path of the segment (see
/// [`compute_anchors`]), forgetting the previous ones.
pub fn compute_anchors(&mut self) {
let anchors = compute_anchors(self);
let mut anchors = anchors.into_iter().peekable();
self.update_all(|index, trkpt| {
trkpt.anchor = match anchors.peek() {
Some(&(anchor, zoom)) if anchor == index => {
anchors.next();
Some(zoom)
}
_ => None,
};
});
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::rc::Rc;
use crate::Chunk;
use super::*; use super::*;
fn create_track_segment(nb_chunks: usize) -> TrackSegment { fn create_track_segment(nb_chunks: usize) -> TrackSegment {
@@ -309,17 +172,66 @@ mod tests {
assert!(trkseg.first_index().is_none()); 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] #[test]
fn test_splice_keeps_untouched_chunks_shared() { fn test_splice_keeps_untouched_chunks_shared() {
let mut trkseg = create_track_segment(5); 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 // inside the third chunk only
trkseg.splice(4, 5, points(&[-1.0])); trkseg.splice(4, 5, points(&[-1.0]));
assert!(Rc::ptr_eq(&trkseg.chunks[0], &before[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()[1], &before[1]));
assert!(Rc::ptr_eq(trkseg.chunks.last().unwrap(), &before[4])); assert!(Rc::ptr_eq(trkseg.chunks().last().unwrap(), &before[4]));
assert!(Rc::ptr_eq( assert!(Rc::ptr_eq(
&trkseg.chunks[trkseg.chunks.len() - 2], &trkseg.chunks()[trkseg.chunks().len() - 2],
&before[3] &before[3]
)); ));
} }
@@ -332,10 +244,10 @@ mod tests {
trkseg.splice(len, len, points(&[i as f64])); trkseg.splice(len, len, points(&[i as f64]));
} }
assert_eq!(trkseg.len(), 10_000); assert_eq!(trkseg.len(), 10_000);
assert!(trkseg.chunks.len() <= 3); assert!(trkseg.chunks().len() <= 3);
assert!(trkseg.chunks.iter().all(|c| c.trkpt.len() <= 4096)); assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096));
assert_eq!(trkseg[9_999].ele, 9_999.0); 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] #[test]
@@ -381,7 +293,7 @@ mod tests {
let nb_chunks = 10; let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks); let trkseg = create_track_segment(nb_chunks);
let _ = trkseg[TrackSegmentIndex { let _ = trkseg[TrackSegmentIndex {
chunk: trkseg.chunks.len(), chunk: trkseg.chunks().len(),
pos: 0, pos: 0,
flat: 0, flat: 0,
}]; }];
@@ -451,4 +363,49 @@ mod tests {
idx = next; 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}; use crate::{Link, TrackSegment};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] use super::common::uuid_id;
pub struct TrackId(pub Uuid);
impl Default for TrackId { uuid_id!(TrackId);
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)] #[derive(Debug, Default, Clone)]
pub struct Track { pub struct Track {
pub id: TrackId, pub id: TrackId,
pub info: TrackInfo, pub info: TrackInfo,
@@ -24,7 +17,7 @@ pub struct TrackInfo {
pub cmt: Option<String>, pub cmt: Option<String>,
pub desc: Option<String>, pub desc: Option<String>,
pub src: Option<String>, pub src: Option<String>,
pub link: Option<Link>, pub links: Vec<Link>,
pub type_: Option<String>, pub type_: Option<String>,
pub color: Option<String>, pub color: Option<String>,
pub opacity: Option<f64>, pub opacity: Option<f64>,
+4
View File
@@ -17,4 +17,8 @@ pub struct Trackpoint {
pub sac_scale: Option<u8>, pub sac_scale: Option<u8>,
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`]. /// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
pub mtb_scale: Option<u8>, 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}; use crate::{Link, LngLat};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] use super::common::uuid_id;
pub struct WaypointId(pub Uuid);
impl Default for WaypointId { uuid_id!(WaypointId);
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct Waypoint { pub struct Waypoint {
@@ -20,7 +13,23 @@ pub struct Waypoint {
pub name: Option<String>, pub name: Option<String>,
pub cmt: Option<String>, pub cmt: Option<String>,
pub desc: Option<String>, pub desc: Option<String>,
pub link: Option<Link>, pub links: Vec<Link>,
pub sym: Option<String>, pub sym: Option<String>,
pub type_: 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 parse;
mod write;
pub use parse::*; pub use parse::*;
pub use write::*;
+488 -181
View File
@@ -1,11 +1,10 @@
use std::rc::Rc;
use crate::{ use crate::{
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories, Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
TrackpointChunk, Waypoint, WaypointChunk, TrackpointChunk, Waypoint, WaypointChunk,
}; };
use chrono::DateTime; use chrono::DateTime;
use quick_xml::Error; use quick_xml::Error;
use quick_xml::escape::resolve_predefined_entity;
use quick_xml::events::Event; use quick_xml::events::Event;
use quick_xml::events::attributes::Attributes; use quick_xml::events::attributes::Attributes;
use quick_xml::reader::Reader; use quick_xml::reader::Reader;
@@ -22,6 +21,7 @@ enum GPXElement {
Track(Track), Track(Track),
Segment(TrackSegment), Segment(TrackSegment),
Trackpoint(Trackpoint), Trackpoint(Trackpoint),
RoutePoint(LngLat),
Waypoint(Waypoint), Waypoint(Waypoint),
Elevation, Elevation,
Time, Time,
@@ -52,171 +52,62 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
coordinates coordinates
} }
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of /// Whether the element is a point of the detailed path of a route point (`gpxx:rpt`).
/// `categories`, which learns the values it does not know yet. fn is_route_point_extension(name: &str) -> bool {
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> { name == "rpt" || name.ends_with(":rpt")
let mut reader = Reader::from_reader(data); }
let mut buf = vec![];
let mut gpx = File::default(); /// Whether the element holds a value, which is its text.
let mut stack: Vec<GPXElement> = vec![]; fn holds_text(element: Option<&GPXElement>) -> bool {
let mut trkpt_chunk = TrackpointChunk::default(); matches!(
let mut wpt_chunk = WaypointChunk::default(); element,
loop { Some(
match reader.read_event_into(&mut buf) { GPXElement::Name
Ok(Event::Start(e)) => match e.name().as_ref() { | GPXElement::Comment
"metadata" => stack.push(GPXElement::Metadata), | GPXElement::Description
"name" => stack.push(GPXElement::Name), | GPXElement::Source
"cmt" => stack.push(GPXElement::Comment), | GPXElement::Text
"desc" => stack.push(GPXElement::Description), | GPXElement::Elevation
"src" => stack.push(GPXElement::Source), | GPXElement::Time
"author" => stack.push(GPXElement::Author(Author::default())), | GPXElement::Temperature
"link" => { | GPXElement::Heartrate
let mut link = Link::default(); | GPXElement::Cadence
for attr in e.attributes().flatten() { | GPXElement::Power
if attr.key.as_ref() == "href" { | GPXElement::Surface
link.href = attr.value.to_string(); | GPXElement::Highway
} | GPXElement::SacScale
} | GPXElement::MtbScale
stack.push(GPXElement::Link(link)); | GPXElement::Symbol
} | GPXElement::Type
"text" => stack.push(GPXElement::Text), | GPXElement::Color
"trk" => stack.push(GPXElement::Track(Track::default())), | GPXElement::Opacity
"trkseg" => { | GPXElement::Width
stack.push(GPXElement::Segment(TrackSegment::default())); )
} )
"trkpt" => { }
stack.push(GPXElement::Trackpoint(Trackpoint {
coordinates: parse_coordinates(e.attributes()), /// Gives the text `e` of the element at the top of the stack, which is removed, to what holds it.
..Default::default() fn apply_text(
})); stack: &mut Vec<GPXElement>,
} gpx: &mut File,
"wpt" => { categories: &mut TrackpointCategories,
stack.push(GPXElement::Waypoint(Waypoint { e: &str,
coordinates: parse_coordinates(e.attributes()), ) {
..Default::default()
}));
}
"ele" => stack.push(GPXElement::Elevation),
"time" => stack.push(GPXElement::Time),
e if e.ends_with("atemp") => stack.push(GPXElement::Temperature),
e if e.ends_with("hr") => stack.push(GPXElement::Heartrate),
e if e.ends_with("cad") => stack.push(GPXElement::Cadence),
e if e.ends_with("power") => stack.push(GPXElement::Power),
e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power),
"surface" => stack.push(GPXElement::Surface),
"highway" => stack.push(GPXElement::Highway),
"sac_scale" => stack.push(GPXElement::SacScale),
"mtb_scale" => stack.push(GPXElement::MtbScale),
"sym" => stack.push(GPXElement::Symbol),
"type" => stack.push(GPXElement::Type),
e if e.ends_with("color") => stack.push(GPXElement::Color),
e if e.ends_with("opacity") => stack.push(GPXElement::Opacity),
e if e.ends_with("width") => stack.push(GPXElement::Width),
_ => (),
},
// Self-closing points (`<trkpt lat=".." lon=".."/>`), which have no children
Ok(Event::Empty(e)) => match e.name().as_ref() {
"trkpt" => {
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
trkpt_chunk.trkpt.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"wpt" => {
wpt_chunk.wpt.push(Waypoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if wpt_chunk.is_full() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
_ => (),
},
Ok(Event::End(e)) => match e.name().as_ref() {
"gpx" => {
if !wpt_chunk.wpt.is_empty() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
"metadata" => {
stack.pop();
}
"author" => {
if let Some(GPXElement::Author(author)) = stack.pop() {
gpx.info.author = Some(author);
}
}
"link" => {
if let Some(GPXElement::Link(link)) = stack.pop() {
match stack.last_mut() { match stack.last_mut() {
Some(GPXElement::Author(author)) => {
author.link = Some(link);
}
Some(GPXElement::Track(trk)) => {
trk.info.link = Some(link);
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.link = Some(link);
}
_ => (),
}
}
}
"trk" => {
if let Some(GPXElement::Track(trk)) = stack.pop() {
gpx.trk.push(trk);
}
}
"trkseg" => {
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
{
// `push` ignores the chunk if it is empty
trkseg.push(std::mem::take(&mut trkpt_chunk));
trk.trkseg.push(trkseg);
}
}
"trkpt" => {
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
&& let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
{
trkpt_chunk.trkpt.push(trkpt);
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"wpt" => {
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
wpt_chunk.wpt.push(wpt);
if wpt_chunk.is_full() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
}
_ => (),
},
Ok(Event::Text(e)) => match stack.last_mut() {
Some(GPXElement::Name) => { Some(GPXElement::Name) => {
stack.pop(); stack.pop();
match stack.last_mut() { match stack.last_mut() {
Some(GPXElement::Metadata) => { Some(GPXElement::Metadata) => {
gpx.info.name = e.to_string(); gpx.info.name = e.to_owned();
} }
Some(GPXElement::Author(author)) => { Some(GPXElement::Author(author)) => {
author.name = Some(e.to_string()); author.name = Some(e.to_owned());
} }
Some(GPXElement::Track(trk)) => { Some(GPXElement::Track(trk)) => {
trk.info.name = Some(e.to_string()); trk.info.name = Some(e.to_owned());
} }
Some(GPXElement::Waypoint(wpt)) => { Some(GPXElement::Waypoint(wpt)) => {
wpt.name = Some(e.to_string()); wpt.name = Some(e.to_owned());
} }
_ => (), _ => (),
} }
@@ -225,10 +116,10 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
stack.pop(); stack.pop();
match stack.last_mut() { match stack.last_mut() {
Some(GPXElement::Track(trk)) => { Some(GPXElement::Track(trk)) => {
trk.info.cmt = Some(e.to_string()); trk.info.cmt = Some(e.to_owned());
} }
Some(GPXElement::Waypoint(wpt)) => { Some(GPXElement::Waypoint(wpt)) => {
wpt.cmt = Some(e.to_string()); wpt.cmt = Some(e.to_owned());
} }
_ => (), _ => (),
} }
@@ -237,13 +128,13 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
stack.pop(); stack.pop();
match stack.last_mut() { match stack.last_mut() {
Some(GPXElement::Metadata) => { Some(GPXElement::Metadata) => {
gpx.info.desc = Some(e.to_string()); gpx.info.desc = Some(e.to_owned());
} }
Some(GPXElement::Track(trk)) => { Some(GPXElement::Track(trk)) => {
trk.info.desc = Some(e.to_string()); trk.info.desc = Some(e.to_owned());
} }
Some(GPXElement::Waypoint(wpt)) => { Some(GPXElement::Waypoint(wpt)) => {
wpt.desc = Some(e.to_string()); wpt.desc = Some(e.to_owned());
} }
_ => (), _ => (),
} }
@@ -251,13 +142,13 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
Some(GPXElement::Source) => { Some(GPXElement::Source) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() { if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.src = Some(e.to_string()); trk.info.src = Some(e.to_owned());
} }
} }
Some(GPXElement::Text) => { Some(GPXElement::Text) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Link(link)) = stack.last_mut() { if let Some(GPXElement::Link(link)) = stack.last_mut() {
link.text = Some(e.to_string()); link.text = Some(e.to_owned());
} }
} }
Some(GPXElement::Elevation) => { Some(GPXElement::Elevation) => {
@@ -275,7 +166,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
Some(GPXElement::Time) => { Some(GPXElement::Time) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.time = DateTime::parse_from_rfc3339(e.as_ref()) trkpt.time = DateTime::parse_from_rfc3339(e)
.ok() .ok()
.map(|time| time.timestamp_millis()); .map(|time| time.timestamp_millis());
} }
@@ -307,41 +198,41 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
Some(GPXElement::Surface) => { Some(GPXElement::Surface) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.surface = categories.surface.code(&e); trkpt.surface = categories.surface.code(e);
} }
} }
Some(GPXElement::Highway) => { Some(GPXElement::Highway) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.highway = categories.highway.code(&e); trkpt.highway = categories.highway.code(e);
} }
} }
Some(GPXElement::SacScale) => { Some(GPXElement::SacScale) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.sac_scale = categories.sac_scale.code(&e); trkpt.sac_scale = categories.sac_scale.code(e);
} }
} }
Some(GPXElement::MtbScale) => { Some(GPXElement::MtbScale) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.mtb_scale = categories.mtb_scale.code(&e); trkpt.mtb_scale = categories.mtb_scale.code(e);
} }
} }
Some(GPXElement::Symbol) => { Some(GPXElement::Symbol) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() { if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
wpt.sym = Some(e.to_string()); wpt.sym = Some(e.to_owned());
} }
} }
Some(GPXElement::Type) => { Some(GPXElement::Type) => {
stack.pop(); stack.pop();
match stack.last_mut() { match stack.last_mut() {
Some(GPXElement::Track(trk)) => { Some(GPXElement::Track(trk)) => {
trk.info.type_ = Some(e.to_string()); trk.info.type_ = Some(e.to_owned());
} }
Some(GPXElement::Waypoint(wpt)) => { Some(GPXElement::Waypoint(wpt)) => {
wpt.type_ = Some(e.to_string()); wpt.type_ = Some(e.to_owned());
} }
_ => (), _ => (),
} }
@@ -349,7 +240,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
Some(GPXElement::Color) => { Some(GPXElement::Color) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() { if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(e.to_string()); trk.info.color = Some(e.to_owned());
} }
} }
Some(GPXElement::Opacity) => { Some(GPXElement::Opacity) => {
@@ -365,7 +256,279 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
} }
} }
_ => (), _ => (),
}
}
/// The email of the author, given as the `id` and `domain` attributes of the element.
fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
let Some(GPXElement::Author(author)) = stack.last_mut() else {
return;
};
let (mut id, mut domain) = (None, None);
for attr in attributes.flatten() {
match attr.key.as_ref() {
"id" => id = Some(attr.value.to_string()),
"domain" => domain = Some(attr.value.to_string()),
_ => (),
}
}
if let (Some(id), Some(domain)) = (id, domain) {
author.email = Some(format!("{id}@{domain}"));
}
}
/// The link with the given `href`, if it is an attribute of the element.
fn parse_link(attributes: Attributes<'_>) -> Link {
let mut link = Link::default();
for attr in attributes.flatten() {
if attr.key.as_ref() == "href" {
link.href = attr.value.to_string();
}
}
link
}
/// Gives a link to what holds it, which is at the top of the stack.
fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) {
match stack.last_mut() {
// the author has a single link, the other elements have as many as they want
Some(GPXElement::Author(author)) => author.link = Some(link),
Some(GPXElement::Track(trk)) => trk.info.links.push(link),
Some(GPXElement::Waypoint(wpt)) => wpt.links.push(link),
Some(GPXElement::Metadata) => gpx.info.links.push(link),
_ => (),
}
}
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
/// `categories`, which learns the values it does not know yet.
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
let mut reader = Reader::from_reader(data);
let mut buf = vec![];
let mut gpx = File::default();
let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default();
// Routes are read as tracks of a single segment, which is what `rte_segment` collects the points of. A route
// point that comes with the detailed path of the route to the next one (`gpxx:rpt`) is replaced by it.
let mut rte_segment = TrackSegment::default();
let mut rpt_points: Vec<Trackpoint> = vec![];
// the text of the element at the top of the stack, up to now
let mut text = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => match e.name().as_ref() {
"metadata" => stack.push(GPXElement::Metadata),
"name" => stack.push(GPXElement::Name),
"cmt" => stack.push(GPXElement::Comment),
"desc" => stack.push(GPXElement::Description),
"src" => stack.push(GPXElement::Source),
"author" => stack.push(GPXElement::Author(Author::default())),
"link" => stack.push(GPXElement::Link(parse_link(e.attributes()))),
"email" => set_email(&mut stack, e.attributes()),
"text" => stack.push(GPXElement::Text),
"trk" | "rte" => stack.push(GPXElement::Track(Track::default())),
"trkseg" => {
stack.push(GPXElement::Segment(TrackSegment::default()));
}
"trkpt" | "rtept" => {
stack.push(GPXElement::Trackpoint(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
}));
}
"wpt" => {
stack.push(GPXElement::Waypoint(Waypoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
}));
}
name if is_route_point_extension(name) => {
stack.push(GPXElement::RoutePoint(parse_coordinates(e.attributes())));
}
"ele" => stack.push(GPXElement::Elevation),
"time" => stack.push(GPXElement::Time),
e if e.ends_with("atemp") => stack.push(GPXElement::Temperature),
e if e.ends_with("hr") => stack.push(GPXElement::Heartrate),
e if e.ends_with("cad") => stack.push(GPXElement::Cadence),
e if e.ends_with("power") => stack.push(GPXElement::Power),
e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power),
"surface" => stack.push(GPXElement::Surface),
"highway" => stack.push(GPXElement::Highway),
"sac_scale" => stack.push(GPXElement::SacScale),
"mtb_scale" => stack.push(GPXElement::MtbScale),
"sym" => stack.push(GPXElement::Symbol),
"type" => stack.push(GPXElement::Type),
e if e.ends_with("color") => stack.push(GPXElement::Color),
e if e.ends_with("opacity") => stack.push(GPXElement::Opacity),
e if e.ends_with("width") => stack.push(GPXElement::Width),
_ => (),
}, },
// Self-closing points (`<trkpt lat=".." lon=".."/>`), which have no children
Ok(Event::Empty(e)) => match e.name().as_ref() {
"link" => attach_link(&mut stack, &mut gpx, parse_link(e.attributes())),
"email" => set_email(&mut stack, e.attributes()),
"trkpt" => {
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
trkpt_chunk.trkpt.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"rtept" => {
if let Some(GPXElement::Track(_)) = stack.last() {
trkpt_chunk.trkpt.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if trkpt_chunk.is_full() {
rte_segment.push(std::mem::take(&mut trkpt_chunk));
}
}
}
name if is_route_point_extension(name) => {
if let Some(GPXElement::Trackpoint(_)) = stack.last() {
rpt_points.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
}
}
"wpt" => {
wpt_chunk.wpt.push(Waypoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if wpt_chunk.is_full() {
gpx.wpt.push(std::mem::take(&mut wpt_chunk));
}
}
_ => (),
},
Ok(Event::End(e)) => {
if holds_text(stack.last()) {
let value = std::mem::take(&mut text);
if value.is_empty() {
stack.pop();
} else {
apply_text(&mut stack, &mut gpx, categories, &value);
}
}
match e.name().as_ref() {
"gpx" => {
if !wpt_chunk.wpt.is_empty() {
gpx.wpt.push(std::mem::take(&mut wpt_chunk));
}
}
"metadata" => {
stack.pop();
}
"author" => {
if let Some(GPXElement::Author(author)) = stack.pop() {
gpx.info.author = Some(author);
}
}
"link" => {
if let Some(GPXElement::Link(link)) = stack.pop() {
attach_link(&mut stack, &mut gpx, link);
}
}
"trk" => {
if let Some(GPXElement::Track(trk)) = stack.pop() {
gpx.trk.push(trk);
}
}
"rte" => {
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
rte_segment.push(std::mem::take(&mut trkpt_chunk));
if !rte_segment.is_empty() {
let mut trkseg = std::mem::take(&mut rte_segment);
trkseg.compute_anchors();
trk.trkseg.push(trkseg);
}
gpx.trk.push(trk);
}
}
"rtept" => {
if let Some(GPXElement::Trackpoint(rtept)) = stack.pop()
&& let Some(GPXElement::Track(_)) = stack.last()
{
let points = if rpt_points.is_empty() {
vec![rtept]
} else {
std::mem::take(&mut rpt_points)
};
for point in points {
trkpt_chunk.trkpt.push(point);
if trkpt_chunk.is_full() {
rte_segment.push(std::mem::take(&mut trkpt_chunk));
}
}
}
}
name if is_route_point_extension(name) => {
if let Some(GPXElement::RoutePoint(coordinates)) = stack.pop() {
rpt_points.push(Trackpoint {
coordinates,
..Default::default()
});
}
}
"trkseg" => {
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
{
// `push` ignores the chunk if it is empty
trkseg.push(std::mem::take(&mut trkpt_chunk));
trkseg.compute_anchors();
trk.trkseg.push(trkseg);
}
}
"trkpt" => {
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
&& let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
{
trkpt_chunk.trkpt.push(trkpt);
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"wpt" => {
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
wpt_chunk.wpt.push(wpt);
if wpt_chunk.is_full() {
gpx.wpt.push(std::mem::take(&mut wpt_chunk));
}
}
}
_ => (),
}
}
// The text of an element comes in pieces: the entities (`&amp;`) are events of their own
Ok(Event::Text(e)) => {
if holds_text(stack.last()) {
text.push_str(&e);
}
}
Ok(Event::CData(e)) => {
if holds_text(stack.last()) {
text.push_str(&e);
}
}
Ok(Event::GeneralRef(e)) => {
if holds_text(stack.last()) {
if let Some(c) = e.resolve_char_ref()? {
text.push(c);
} else if let Some(entity) = resolve_predefined_entity(&e) {
text.push_str(entity);
}
}
}
Ok(Event::Eof) => break, Ok(Event::Eof) => break,
Err(e) => return Err(e), Err(e) => return Err(e),
_ => (), _ => (),
@@ -387,12 +550,32 @@ mod tests {
.unwrap() .unwrap()
} }
#[test]
fn test_parse_entities_and_cdata_in_text() {
let data = r#"<gpx><metadata><name>Tom &amp; Jerry &lt;3 &#233;&#x41; &quot;x&quot; &apos;y&apos;</name>
<desc><![CDATA[a <b> & c]]> and &gt; more</desc></metadata>
<wpt lat="1" lon="2"><name></name><desc/><ele>3</ele><cmt>&amp;</cmt></wpt>
<trk><name>t &amp; u</name><trkseg><trkpt lat="1" lon="2"><ele>4</ele></trkpt></trkseg></trk>
</gpx>"#;
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(gpx.info.name, "Tom & Jerry <3 éA \"x\" 'y'");
assert_eq!(gpx.info.desc.as_deref(), Some("a <b> & c and > more"));
let wpt = gpx.wpt.iter().next().unwrap();
// empty elements leave the value unknown, and do not get in the way of the next ones
assert_eq!(wpt.name, None);
assert_eq!(wpt.desc, None);
assert_eq!(wpt.ele, 3.0);
assert_eq!(wpt.cmt.as_deref(), Some("&"));
assert_eq!(gpx.trk[0].info.name.as_deref(), Some("t & u"));
assert_eq!(gpx.trk[0].trkseg[0][0].ele, 4.0);
}
#[test] #[test]
fn test_parse_self_closing_points() { fn test_parse_self_closing_points() {
let gpx = parse_data("self_closing_points"); let gpx = parse_data("self_closing_points");
assert_eq!(gpx.info.name, "self closing points"); assert_eq!(gpx.info.name, "self closing points");
let wpt: Vec<_> = gpx.wpt.iter().flat_map(|chunk| &chunk.wpt).collect(); let wpt: Vec<_> = gpx.wpt.iter().collect();
assert_eq!(wpt.len(), 3); assert_eq!(wpt.len(), 3);
assert_eq!(wpt[0].coordinates.lat, 50.0); assert_eq!(wpt[0].coordinates.lat, 50.0);
assert_eq!(wpt[0].name, None); assert_eq!(wpt[0].name, None);
@@ -436,8 +619,8 @@ mod tests {
.is_some_and(|d| d == "track description") .is_some_and(|d| d == "track description")
); );
assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source")); assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source"));
assert!(trk.info.link.is_some()); assert_eq!(trk.info.links.len(), 1);
let link = trk.info.link.as_ref().unwrap(); let link = &trk.info.links[0];
assert_eq!(link.href, "https://gpx.studio"); assert_eq!(link.href, "https://gpx.studio");
assert!(link.text.as_ref().is_some_and(|t| t == "track link text")); assert!(link.text.as_ref().is_some_and(|t| t == "track link text"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling")); assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
@@ -481,6 +664,132 @@ mod tests {
assert_eq!(trkpt.ele, 115.5); assert_eq!(trkpt.ele, 115.5);
} }
#[test]
fn test_parse_routes_as_tracks() {
let gpx = parse_data("with_routes");
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
assert_eq!(trk.info.name.as_deref(), Some("route 1"));
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 49);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trk = &gpx.trk[1];
assert_eq!(trk.info.name.as_deref(), Some("route 2"));
assert_eq!(trk.trkseg[0].len(), 28);
}
#[test]
fn test_parse_route_point_extensions() {
let gpx = parse_data("with_route_extensions");
// a route without points has no segment
assert_eq!(gpx.trk.len(), 2);
assert!(gpx.trk[1].trkseg.is_empty());
let trk = &gpx.trk[0];
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
assert_eq!(trk.trkseg.len(), 1);
let points: Vec<_> = trk.trkseg[0].iter().collect();
// the detailed path replaces the route point it comes with
let coordinates: Vec<_> = points
.iter()
.map(|p| (p.coordinates.lat, p.coordinates.lng))
.collect();
assert_eq!(
coordinates,
[(1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)]
);
assert_eq!(points[2].ele, 20.0);
assert!(points[2].time.is_some());
}
#[test]
fn test_parse_every_link_and_the_email() {
let gpx = parse_data("with_links");
// the file has as many links as it says, self-closing or not
let hrefs = |links: &[Link]| links.iter().map(|l| l.href.clone()).collect::<Vec<_>>();
assert_eq!(
hrefs(&gpx.info.links),
["https://example.com/file-1", "https://example.com/file-2"]
);
assert_eq!(gpx.info.links[0].text.as_deref(), Some("first"));
assert_eq!(gpx.info.links[1].text, None);
// the author has one link and an email, which is given by two attributes
let author = gpx.info.author.as_ref().unwrap();
assert_eq!(author.name.as_deref(), Some("someone"));
assert_eq!(author.email.as_deref(), Some("someone@example.com"));
assert_eq!(
author.link.as_ref().unwrap().href,
"https://example.com/author"
);
let waypoints: Vec<_> = gpx.wpt.iter().collect();
assert_eq!(
hrefs(&waypoints[0].links),
[
"https://example.com/wpt-1",
"https://example.com/wpt-2",
"https://example.com/wpt-3"
]
);
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
assert!(waypoints[1].links.is_empty());
// tracks, and routes which are read as tracks
assert_eq!(gpx.trk.len(), 2);
assert_eq!(
hrefs(&gpx.trk[0].info.links),
["https://example.com/trk-1", "https://example.com/trk-2"]
);
assert_eq!(
hrefs(&gpx.trk[1].info.links),
["https://example.com/rte-1", "https://example.com/rte-2"]
);
}
#[test]
fn test_parse_email_variants() {
let author = |xml: &str| {
let data = format!("<gpx><metadata><author>{xml}</author></metadata></gpx>");
parse(data.as_bytes(), &mut Default::default())
.unwrap()
.info
.author
.unwrap()
};
assert_eq!(
author(r#"<email id="a" domain="b.c"></email>"#)
.email
.as_deref(),
Some("a@b.c")
);
assert_eq!(
author(r#"<name>n</name><email id="a" domain="b.c"/>"#)
.email
.as_deref(),
Some("a@b.c")
);
// an email that is not complete is not one
assert_eq!(author(r#"<email id="a"/>"#).email, None);
assert_eq!(author("<name>n</name>").email, None);
// outside of an author it means nothing
let gpx = parse(
br#"<gpx><metadata><name>n</name><email id="a" domain="b.c"/></metadata></gpx>"#,
&mut Default::default(),
)
.unwrap();
assert!(gpx.info.author.is_none());
}
#[test] #[test]
fn test_parse_segments() { fn test_parse_segments() {
let gpx = parse_data("with_segments"); let gpx = parse_data("with_segments");
@@ -549,9 +858,7 @@ mod tests {
let gpx = parse_data("with_waypoint"); let gpx = parse_data("with_waypoint");
assert_eq!(gpx.wpt.len(), 1); assert_eq!(gpx.wpt.len(), 1);
let chunk = &gpx.wpt[0]; let wpt = &gpx.wpt[0];
assert_eq!(chunk.wpt.len(), 1);
let wpt = &chunk.wpt[0];
assert_eq!(wpt.coordinates.lat, 50.7836710064975); assert_eq!(wpt.coordinates.lat, 50.7836710064975);
assert_eq!(wpt.coordinates.lng, 4.410764082658738); assert_eq!(wpt.coordinates.lng, 4.410764082658738);
assert!(wpt.name.as_ref().is_some_and(|n| n == "waypoint name")); assert!(wpt.name.as_ref().is_some_and(|n| n == "waypoint name"));
@@ -561,8 +868,8 @@ mod tests {
.as_ref() .as_ref()
.is_some_and(|d| d == "waypoint description") .is_some_and(|d| d == "waypoint description")
); );
assert!(wpt.link.is_some()); assert_eq!(wpt.links.len(), 1);
let link = wpt.link.as_ref().unwrap(); let link = &wpt.links[0];
assert_eq!(link.href, "https://gpx.studio"); assert_eq!(link.href, "https://gpx.studio");
assert!( assert!(
link.text link.text
+521
View File
@@ -0,0 +1,521 @@
use std::io;
use chrono::DateTime;
use quick_xml::Writer;
use quick_xml::events::{BytesDecl, BytesText, Event};
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// What the written file contains: the data of the trackpoints that are `false` are left out.
/// The same type tells which data a file has, see [`File::exportable_data`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions {
pub time: bool,
pub hr: bool,
pub cad: bool,
pub atemp: bool,
pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool,
}
impl ExportOptions {
pub const ALL: Self = Self::of(true);
pub const NONE: Self = Self::of(false);
const fn of(value: bool) -> Self {
Self {
time: value,
hr: value,
cad: value,
atemp: value,
power: value,
osm: value,
}
}
/// The options that are on in `self` or in `other`.
pub fn union(self, other: Self) -> Self {
Self {
time: self.time || other.time,
hr: self.hr || other.hr,
cad: self.cad || other.cad,
atemp: self.atemp || other.atemp,
power: self.power || other.power,
osm: self.osm || other.osm,
}
}
}
impl File {
/// The data that the trackpoints of the file have (and waypoints for the time), which the
/// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE;
for point in self
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter())
{
data.time |= point.time.is_some();
data.hr |= point.hr.is_some();
data.cad |= point.cad.is_some();
data.atemp |= point.atemp.is_some();
data.power |= point.power.is_some();
data.osm |= point.surface.is_some()
|| point.highway.is_some()
|| point.sac_scale.is_some()
|| point.mtb_scale.is_some();
if data == ExportOptions::ALL {
break;
}
}
data
}
}
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
type XmlWriter = Writer<Vec<u8>>;
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
/// the names of their codes in `categories`.
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
// writing to memory does not fail
write_file(&mut writer, file, categories, options).expect("writing to memory");
writer.into_inner()
}
fn write_file(
w: &mut XmlWriter,
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
w.create_element("gpx")
.with_attributes([
("creator", "https://gpx.studio"),
("version", "1.1"),
("xmlns", "http://www.topografix.com/GPX/1/1"),
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
("xsi:schemaLocation", SCHEMA_LOCATION),
(
"xmlns:gpxtpx",
"http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
),
(
"xmlns:gpxx",
"http://www.garmin.com/xmlschemas/GpxExtensions/v3",
),
(
"xmlns:gpxpx",
"http://www.garmin.com/xmlschemas/PowerExtension/v1",
),
(
"xmlns:gpx_style",
"http://www.topografix.com/GPX/gpx_style/0/2",
),
])
.write_inner_content(|w| {
write_metadata(w, file, options)?;
for wpt in file.wpt.iter() {
write_waypoint(w, wpt, options)?;
}
for trk in &file.trk {
// a single unnamed track is named like the file
let name = match (&trk.info.name, file.trk.len()) {
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
(_, 1) => Some(file.info.name.as_str()),
_ => None,
};
write_track(w, trk, name, categories, options)?;
}
Ok(())
})?;
Ok(())
}
fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> {
if !value.is_empty() {
w.create_element(name)
.write_text_content(BytesText::new(value))?;
}
Ok(())
}
fn optional_text(w: &mut XmlWriter, name: &str, value: &Option<String>) -> io::Result<()> {
text(w, name, value.as_deref().unwrap_or_default())
}
fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> {
text(w, name, &value.to_string())
}
fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) {
text(
w,
"time",
&time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
)?;
}
Ok(())
}
fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
if !link.href.is_empty() {
w.create_element("link")
.with_attribute(("href", link.href.as_str()))
.write_inner_content(|w| optional_text(w, "text", &link.text))?;
}
Ok(())
}
fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> {
links.iter().try_for_each(|l| link(w, l))
}
fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
let info = &file.info;
let time = options.time.then_some(info.time).flatten();
if info.name.is_empty()
&& info.desc.is_none()
&& info.author.is_none()
&& info.links.is_empty()
&& time.is_none()
{
return Ok(());
}
w.create_element("metadata").write_inner_content(|w| {
text(w, "name", &info.name)?;
optional_text(w, "desc", &info.desc)?;
if let Some(author) = &info.author {
w.create_element("author").write_inner_content(|w| {
optional_text(w, "name", &author.name)?;
if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@'))
{
w.create_element("email")
.with_attributes([("id", id), ("domain", domain)])
.write_empty()?;
}
author.link.iter().try_for_each(|l| link(w, l))
})?;
}
links(w, &info.links)?;
self::time(w, time)
})?;
Ok(())
}
fn write_coordinates<'a>(
w: &'a mut XmlWriter,
name: &'a str,
coordinates: crate::LngLat,
) -> quick_xml::writer::ElementWriter<'a, Vec<u8>> {
w.create_element(name).with_attributes([
("lat", coordinates.lat.to_string().as_str()),
("lon", coordinates.lng.to_string().as_str()),
])
}
fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> {
write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| {
number(w, "ele", wpt.ele)?;
if options.time {
time(w, wpt.time)?;
}
optional_text(w, "name", &wpt.name)?;
optional_text(w, "cmt", &wpt.cmt)?;
optional_text(w, "desc", &wpt.desc)?;
links(w, &wpt.links)?;
optional_text(w, "sym", &wpt.sym)?;
optional_text(w, "type", &wpt.type_)
})?;
Ok(())
}
fn write_track(
w: &mut XmlWriter,
trk: &Track,
name: Option<&str>,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
let info = &trk.info;
w.create_element("trk").write_inner_content(|w| {
text(w, "name", name.unwrap_or_default())?;
optional_text(w, "cmt", &info.cmt)?;
optional_text(w, "desc", &info.desc)?;
optional_text(w, "src", &info.src)?;
links(w, &info.links)?;
optional_text(w, "type", &info.type_)?;
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
w.create_element("extensions").write_inner_content(|w| {
w.create_element("gpx_style:line")
.write_inner_content(|w| {
optional_text(w, "gpx_style:color", &info.color)?;
if let Some(opacity) = info.opacity {
number(w, "gpx_style:opacity", opacity)?;
}
if let Some(width) = info.width {
number(w, "gpx_style:width", width)?;
}
Ok(())
})?;
Ok(())
})?;
}
for segment in &trk.trkseg {
w.create_element("trkseg").write_inner_content(|w| {
for point in segment.iter() {
write_trackpoint(w, point, categories, options)?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
}
fn write_trackpoint(
w: &mut XmlWriter,
point: &Trackpoint,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
let atemp = point.atemp.filter(|_| options.atemp);
let hr = point.hr.filter(|_| options.hr);
let cad = point.cad.filter(|_| options.cad);
let power = point.power.filter(|_| options.power);
let osm = [
("surface", point.surface, &categories.surface),
("highway", point.highway, &categories.highway),
("sac_scale", point.sac_scale, &categories.sac_scale),
("mtb_scale", point.mtb_scale, &categories.mtb_scale),
]
.map(|(name, code, categories)| {
let value = code
.filter(|_| options.osm)
.and_then(|code| categories.name(code));
(name, value)
});
let has_osm = osm.iter().any(|(_, value)| value.is_some());
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
write_coordinates(w, "trkpt", point.coordinates).write_inner_content(|w| {
number(w, "ele", point.ele)?;
if options.time {
time(w, point.time)?;
}
if !has_track_point_extension && power.is_none() {
return Ok(());
}
w.create_element("extensions").write_inner_content(|w| {
if has_track_point_extension {
w.create_element("gpxtpx:TrackPointExtension")
.write_inner_content(|w| {
if let Some(atemp) = atemp {
number(w, "gpxtpx:atemp", atemp)?;
}
if let Some(hr) = hr {
number(w, "gpxtpx:hr", hr)?;
}
if let Some(cad) = cad {
number(w, "gpxtpx:cad", cad)?;
}
if has_osm {
w.create_element("gpxtpx:Extensions")
.write_inner_content(|w| {
for (name, value) in osm {
text(w, name, value.unwrap_or_default())?;
}
Ok(())
})?;
}
Ok(())
})?;
}
if let Some(power) = power {
w.create_element("gpxpx:PowerExtension")
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn read(name: &str) -> (File, TrackpointCategories) {
let mut categories = TrackpointCategories::default();
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
(parse(&data, &mut categories).unwrap(), categories)
}
fn written_text(
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> String {
String::from_utf8(write(file, categories, options)).unwrap()
}
/// Writes a file and reads it again.
fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) {
let (file, mut categories) = read(name);
let written = write(&file, &categories, options);
let again = parse(&written, &mut categories).unwrap();
(file, again, categories)
}
fn points(file: &File) -> Vec<Trackpoint> {
file.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter().cloned())
.collect()
}
fn same_points(a: &[Trackpoint], b: &[Trackpoint]) {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
assert_eq!(a.coordinates.lat, b.coordinates.lat);
assert_eq!(a.coordinates.lng, b.coordinates.lng);
assert_eq!(a.ele, b.ele);
assert_eq!(a.time, b.time);
assert_eq!(a.atemp, b.atemp);
assert_eq!(a.hr, b.hr);
assert_eq!(a.cad, b.cad);
assert_eq!(a.power, b.power);
assert_eq!(a.surface, b.surface);
assert_eq!(a.highway, b.highway);
assert_eq!(a.sac_scale, b.sac_scale);
assert_eq!(a.mtb_scale, b.mtb_scale);
}
}
#[test]
fn test_links_and_the_email_are_written() {
let data = std::fs::read("data/with_links.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let again = parse(
&write(&file, &Default::default(), ExportOptions::ALL),
&mut Default::default(),
)
.unwrap();
assert_eq!(again.info.links, file.info.links);
assert_eq!(again.info.links.len(), 2);
assert_eq!(again.info.author, file.info.author);
assert_eq!(
again.info.author.as_ref().unwrap().email.as_deref(),
Some("someone@example.com")
);
let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::<Vec<_>>();
assert_eq!(waypoints(&again), waypoints(&file));
assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3);
for (a, b) in again.trk.iter().zip(&file.trk) {
assert_eq!(a.info.links, b.info.links);
assert_eq!(a.info.links.len(), 2);
}
}
#[test]
fn test_files_survive_a_round_trip() {
for name in [
"simple",
"with_links",
"with_time",
"with_hr",
"with_cad",
"with_temp",
"with_power_1",
"with_power_2",
"with_surface",
"with_highway",
"with_style",
"with_waypoint",
"with_tracks_and_segments",
"self_closing_points",
] {
let (file, again, _) = round_trip(name, ExportOptions::ALL);
assert_eq!(file.info.name, again.info.name, "{name}");
assert_eq!(file.info.desc, again.info.desc, "{name}");
assert_eq!(file.info.author, again.info.author, "{name}");
assert_eq!(file.trk.len(), again.trk.len(), "{name}");
for (a, b) in file.trk.iter().zip(&again.trk) {
assert_eq!(a.info, b.info, "{name}");
assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}");
}
same_points(&points(&file), &points(&again));
let waypoints: Vec<_> = file.wpt.iter().collect();
let again_waypoints: Vec<_> = again.wpt.iter().collect();
assert_eq!(waypoints.len(), again_waypoints.len(), "{name}");
for (a, b) in waypoints.iter().zip(&again_waypoints) {
assert!(a.same_content(b), "{name}");
}
}
}
#[test]
fn test_options_leave_data_out() {
let (file, categories) = read("with_hr");
assert!(points(&file).iter().any(|p| p.hr.is_some()));
let options = ExportOptions {
hr: false,
..ExportOptions::ALL
};
let written = written_text(&file, &categories, options);
assert!(!written.contains("hr>"));
let (file, categories) = read("with_time");
let options = ExportOptions {
time: false,
..ExportOptions::ALL
};
assert!(!written_text(&file, &categories, options).contains("<time>"));
assert!(
written_text(&file, &categories, ExportOptions::ALL)
.contains("<time>2023-12-31T23:00:00.000Z</time>")
);
}
#[test]
fn test_text_is_escaped_and_the_single_track_gets_the_name_of_the_file() {
let (mut file, categories) = read("simple");
file.info.name = "a <b> & c".into();
file.trk.truncate(1);
file.trk[0].info.name = None;
let written = write(&file, &categories, ExportOptions::ALL);
let text = String::from_utf8(written.clone()).unwrap();
assert!(text.contains("a &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);
}
}
@@ -3,6 +3,20 @@ use crate::{
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff, 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)] #[derive(Default, Debug)]
pub struct Statistics { pub struct Statistics {
pub global: GlobalStatistics, pub global: GlobalStatistics,
@@ -41,10 +55,6 @@ impl Statistics {
self.accumulate_distance_and_time(prev, cur); self.accumulate_distance_and_time(prev, cur);
self.update_time_bounds(cur.time); self.update_time_bounds(cur.time);
self.update_bounds(cur.coordinates); 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 self.local
.push(TrackpointStatistics::from_partial_stats(self)); .push(TrackpointStatistics::from_partial_stats(self));
} }
@@ -55,9 +65,10 @@ impl Statistics {
self.global.total_distance += dist; self.global.total_distance += dist;
if let Some(time) = time { let moving = time
let speed = speed(dist, time); .zip(time.and_then(|time| speed(dist, time)))
if (0.5..=1500.0).contains(&speed) { .filter(|(_, speed)| (0.5..=1500.0).contains(speed));
if let Some((time, _)) = moving {
self.global.moving_distance = self self.global.moving_distance = self
.global .global
.moving_distance .moving_distance
@@ -68,7 +79,6 @@ impl Statistics {
.map_or(Some(time), |t| Some(t + time)); .map_or(Some(time), |t| Some(t + time));
} }
} }
}
fn update_time_bounds(&mut self, time: Option<i64>) { fn update_time_bounds(&mut self, time: Option<i64>) {
if let Some(time) = time { if let Some(time) = time {
@@ -84,22 +94,35 @@ impl Statistics {
self.global.bounds.extend(coordinates); 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) { fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
for_each_window!( for_each_window!(
trkseg, trkseg,
trkseg.first_index(), trkseg.first_index(),
trkseg.last_index(), trkseg.last_index(),
Some(10_000), TimeSpan::Known(10_000),
|i, j| time_diff(trkseg[j].time, trkseg[i].time), |i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|i, left, right| { |i, left, right| {
self.local[i.flat].speed = if trkseg[i].time.is_some() {
time_diff(trkseg[right].time, trkseg[left].time).map(|t| { 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( speed(
self.local[right.flat].total_distance self.local[right.flat].total_distance
- self.local[left.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)] #[derive(Default, Debug)]
pub struct GlobalStatistics { pub struct GlobalStatistics {
pub total_distance: f64, pub total_distance: f64,
@@ -248,21 +245,20 @@ pub struct GlobalStatistics {
pub start_time: Option<i64>, pub start_time: Option<i64>,
pub end_time: Option<i64>, pub end_time: Option<i64>,
pub bounds: LngLatBounds, pub bounds: LngLatBounds,
pub hr: Average,
pub cad: Average,
pub atemp: Average,
pub power: Average,
} }
impl GlobalStatistics { 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> { 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> { pub fn moving_speed(&self) -> Option<f64> {
self.moving_distance self.moving_distance
.zip(self.moving_time) .zip(self.moving_time)
.map(|(d, t)| speed(d, t)) .and_then(|(d, t)| speed(d, t))
} }
pub fn merge(&mut self, other: &GlobalStatistics) { 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.start_time = min_options(self.start_time, other.start_time);
self.end_time = max_options(self.end_time, other.end_time); self.end_time = max_options(self.end_time, other.end_time);
self.bounds.merge(&other.bounds); 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 { fn load(path: &str) -> crate::File {
let mut f = File::open(path).unwrap(); let mut f = File::open(path).unwrap();
let mut data = String::new(); let mut data = String::new();
@@ -455,50 +524,4 @@ mod tests {
assert_eq!(cumul.total_time, Some(7_000)); assert_eq!(cumul.total_time, Some(7_000));
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0)); 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; mod utils;
pub use statistics::*; pub use compute::*;
pub use utils::*; 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?) Some(a? - b?)
} }
/// Computes the speed for a given distance in kilometers and a time in milliseconds /// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
pub fn speed(distance: f64, time: i64) -> f64 { /// Unknown if the time is not a positive duration.
distance / (time as f64 / 3_600_000.0) 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 { 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(None, Some(1)), None);
assert_eq!(time_diff(Some(1), None), None); assert_eq!(time_diff(Some(1), None), None);
// 1 km in one hour // 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] #[test]
@@ -154,9 +155,13 @@ mod tests {
#[test] #[test]
fn test_speed() { fn test_speed() {
// 1 km in 1 h // 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 // 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] #[test]
@@ -12,8 +12,6 @@ pub trait Apply {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError { pub enum CommandError {
/// The command is not implemented yet.
NotImplemented(&'static str),
/// The command would not change anything. /// The command would not change anything.
NothingToDo, NothingToDo,
/// The input data could not be parsed. /// The input data could not be parsed.
@@ -23,7 +21,6 @@ pub enum CommandError {
impl fmt::Display for CommandError { impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::NotImplemented(name) => write!(f, "{name} is not implemented yet"),
Self::NothingToDo => write!(f, "nothing to do"), Self::NothingToDo => write!(f, "nothing to do"),
Self::InvalidData(err) => write!(f, "invalid data: {err}"), 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] #[test]
fn test_metadata_applies_to_selected_file() { fn test_metadata_applies_to_selected_file() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
New { name: "first" }.apply(&mut fx.state()).unwrap(); New {
New { name: "second" }.apply(&mut fx.state()).unwrap(); // now selected 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(); let selected = *fx.selected_files().iter().next().unwrap();
Metadata { Metadata {
@@ -95,7 +105,12 @@ mod tests {
/// A new file with `nb_tracks` tracks (without name), selected. /// A new file with `nb_tracks` tracks (without name), selected.
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) { 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 { for _ in 0..nb_tracks {
NewTrack.apply(&mut fx.state()).unwrap(); NewTrack.apply(&mut fx.state()).unwrap();
} }
@@ -37,7 +37,12 @@ mod tests {
#[test] #[test]
fn test_new_track() { fn test_new_track() {
let mut fx = Fixture::default(); 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();
NewTrack.apply(&mut fx.state()).unwrap(); NewTrack.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap(); let file = fx.files.values().next().unwrap();
@@ -48,7 +53,12 @@ mod tests {
#[test] #[test]
fn test_new_track_inherits_agreeing_style_only() { fn test_new_track_inherits_agreeing_style_only() {
let mut fx = Fixture::default(); 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();
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()); let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
@@ -26,7 +26,12 @@ mod tests {
#[test] #[test]
fn test_new_track_segment_on_each_track_of_selected_file() { fn test_new_track_segment_on_each_track_of_selected_file() {
let mut fx = Fixture::default(); 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();
NewTrack.apply(&mut fx.state()).unwrap(); NewTrack.apply(&mut fx.state()).unwrap();
NewTrackSegment.apply(&mut fx.state()).unwrap(); NewTrackSegment.apply(&mut fx.state()).unwrap();
+19 -15
View File
@@ -1,8 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{ use crate::{Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint};
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks,
};
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements /// 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. /// are deleted instead, even if only a track or a waypoint is selected.
@@ -83,7 +81,7 @@ pub(crate) fn delete_waypoints(
) -> Result<Selection, CommandError> { ) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?; let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone(); 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)); wpts.retain(|wpt| !filter(wpt));
true true
}); });
@@ -130,7 +128,12 @@ mod tests {
#[test] #[test]
fn test_delete_selected_files() { fn test_delete_selected_files() {
let (mut fx, id) = loaded(); 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]; let keep = fx.order.0[1];
fx.selection = Selection::File { fx.selection = Selection::File {
file_ids: HashSet::from([id]), file_ids: HashSet::from([id]),
@@ -186,10 +189,10 @@ mod tests {
let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect(); let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect();
let ids = wpts.iter().map(|w| w.id).collect(); let ids = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone(); let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk { file.wpt = crate::Waypoints::new([crate::WaypointChunk {
wpt: wpts, wpt: wpts,
..Default::default() ..Default::default()
})]; }]);
fx.files.insert(id, Rc::new(file)); fx.files.insert(id, Rc::new(file));
ids ids
} }
@@ -202,16 +205,12 @@ mod tests {
file_id: id, file_id: id,
wpt_ids: HashSet::from([ids[1]]), 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 } Delete { whole_files: false }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev); assert_ne!(fx.files[&id].wpt.rev_id, rev);
let left: Vec<_> = fx.files[&id] let left: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
assert_eq!(left, vec![ids[0], ids[2]]); assert_eq!(left, vec![ids[0], ids[2]]);
assert_eq!(fx.selected_files(), HashSet::from([id])); assert_eq!(fx.selected_files(), HashSet::from([id]));
} }
@@ -263,7 +262,12 @@ mod tests {
}, },
] { ] {
let (mut fx, id) = loaded(); 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]; let keep = fx.order.0[1];
fx.selection = selection(id, &fx); fx.selection = selection(id, &fx);
Delete { whole_files: true }.apply(&mut fx.state()).unwrap(); Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
@@ -28,8 +28,18 @@ mod tests {
DeleteAll.apply(&mut fx.state()), DeleteAll.apply(&mut fx.state()),
Err(CommandError::NothingToDo) Err(CommandError::NothingToDo)
); );
crate::New { name: "a" }.apply(&mut fx.state()).unwrap(); crate::New {
crate::New { name: "b" }.apply(&mut fx.state()).unwrap(); 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!(DeleteAll.apply(&mut fx.state()).is_ok());
assert!(fx.files.is_empty()); assert!(fx.files.is_empty());
assert!(fx.order.0.is_empty()); assert!(fx.order.0.is_empty());
@@ -37,7 +37,7 @@ mod tests {
fn test_deletes_one_waypoint_without_touching_the_selection() { fn test_deletes_one_waypoint_without_touching_the_selection() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let mut file = File::default(); let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: (0..3) wpt: (0..3)
.map(|i| Waypoint { .map(|i| Waypoint {
name: Some(format!("w{i}")), name: Some(format!("w{i}")),
@@ -45,7 +45,7 @@ mod tests {
}) })
.collect(), .collect(),
..Default::default() ..Default::default()
})); });
let id = file.id; let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect(); let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file)); fx.files.insert(id, Rc::new(file));
@@ -64,7 +64,6 @@ mod tests {
let names: Vec<_> = fx.files[&id] let names: Vec<_> = fx.files[&id]
.wpt .wpt
.iter() .iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.name.clone().unwrap()) .map(|wpt| wpt.name.clone().unwrap())
.collect(); .collect();
assert_eq!(names, ["w0", "w2"]); assert_eq!(names, ["w0", "w2"]);
@@ -80,10 +79,10 @@ mod tests {
fn test_deleting_the_selected_waypoint_selects_its_file() { fn test_deleting_the_selected_waypoint_selects_its_file() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let mut file = File::default(); let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()], wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default() ..Default::default()
})); });
let id = file.id; let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect(); let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file)); fx.files.insert(id, Rc::new(file));
@@ -2,7 +2,7 @@ use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment, Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
copy_track, copy_waypoint, insert_waypoints, copy_track, copy_waypoint,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -112,19 +112,14 @@ fn duplicate_waypoints(
filter: impl Fn(&Waypoint) -> bool, filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> { ) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?; let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<&Waypoint> = file let selected: Vec<&Waypoint> = file.wpt.iter().filter(|wpt| filter(wpt)).collect();
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| filter(wpt))
.collect();
let last = selected.last().ok_or(CommandError::NothingToDo)?.id; let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect(); let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
let copy_ids = copies.iter().map(|wpt| wpt.id).collect(); let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
// The copies go right after the last selected waypoint. // The copies go right after the last selected waypoint.
let mut file = (**file).clone(); 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)); files.insert(file_id, Rc::new(file));
Ok(Selection::Waypoint { Ok(Selection::Waypoint {
file_id, file_id,
@@ -195,7 +190,12 @@ mod tests {
fn test_duplicate_files_go_after_last_selected() { fn test_duplicate_files_go_after_last_selected() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
for name in ["a", "b", "c"] { 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]]; let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]];
fx.selection = Selection::File { fx.selection = Selection::File {
@@ -238,57 +238,44 @@ mod tests {
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect(); let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect(); let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone(); let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk { file.wpt = crate::Waypoints::new([crate::WaypointChunk {
wpt: wpts, wpt: wpts,
..Default::default() ..Default::default()
})]; }]);
fx.files.insert(id, Rc::new(file)); 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 { fx.selection = Selection::Waypoint {
file_id: id, file_id: id,
wpt_ids: HashSet::from([ids[0]]), 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(); Duplicate.apply(&mut fx.state()).unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev); assert_ne!(fx.files[&id].wpt.rev_id, rev);
let all: Vec<_> = fx.files[&id] let all: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
assert_eq!(all.len(), 4); assert_eq!(all.len(), 4);
assert!( assert!(
matches!(&fx.selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids == &HashSet::from([all[1]])) 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!(all[..], [ids[0], all[1], ids[1], ids[2]]);
assert_eq!(fx.files[&id].wpt.len(), 3); assert_eq!(fx.files[&id].wpt.chunks().len(), 1);
assert_ne!(fx.files[&id].wpt[0].id, original_chunk.id); assert_ne!(fx.files[&id].wpt.chunks()[0].id, original_chunk.id);
// selecting the last waypoint of a chunk does not cut it // selecting the last waypoint: the copy goes at the end
let last = fx.files[&id].wpt.last().unwrap().wpt.last().unwrap().id; let last = fx.files[&id].wpt.iter().last().unwrap().id;
let chunks = fx.files[&id].wpt.clone();
fx.selection = Selection::Waypoint { fx.selection = Selection::Waypoint {
file_id: id, file_id: id,
wpt_ids: HashSet::from([last]), wpt_ids: HashSet::from([last]),
}; };
Duplicate.apply(&mut fx.state()).unwrap(); Duplicate.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id]; let all: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
assert_eq!(file.wpt.len(), 4); assert_eq!(all.len(), 5);
assert!( assert_eq!(all[..4], [ids[0], all[1], ids[1], ids[2]]);
file.wpt[..3]
.iter()
.zip(&chunks)
.all(|(a, b)| Rc::ptr_eq(a, b))
);
fx.selection = Selection::Waypoints { file_id: id }; fx.selection = Selection::Waypoints { file_id: id };
Duplicate.apply(&mut fx.state()).unwrap(); Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!( assert_eq!(fx.files[&id].wpt.len(), 10);
fx.files[&id].wpt.iter().map(|c| c.wpt.len()).sum::<usize>(),
10
);
} }
#[test] #[test]
+16 -3
View File
@@ -82,7 +82,12 @@ mod tests {
#[test] #[test]
fn test_load_invalid_file_changes_nothing() { fn test_load_invalid_file_changes_nothing() {
let mut fx = Fixture::default(); 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 selected = fx.selected_files();
let result = Load { let result = Load {
@@ -132,7 +137,10 @@ mod tests {
#[test] #[test]
fn test_load_several_files() { fn test_load_several_files() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
crate::New { name: "before" } crate::New {
name: "before",
trackpoint: None,
}
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
let before = fx.order.0[0]; let before = fx.order.0[0];
@@ -178,7 +186,12 @@ mod tests {
#[test] #[test]
fn test_load_several_invalid_files_changes_nothing() { fn test_load_several_invalid_files_changes_nothing() {
let mut fx = Fixture::default(); 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 selected = fx.selected_files();
let result = LoadFiles { let result = LoadFiles {
@@ -71,7 +71,7 @@ mod tests {
use crate::{ use crate::{
File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId, 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::*; use super::*;
@@ -93,10 +93,10 @@ mod tests {
.collect(), .collect(),
); );
if waypoints > 0 { if waypoints > 0 {
file.wpt = vec![Rc::new(WaypointChunk { file.wpt = Waypoints::new([WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(), wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default() ..Default::default()
})]; }]);
} }
file 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)] #[derive(Debug)]
pub struct New<'a> { pub struct New<'a> {
pub name: &'a str, 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<'_> { impl Apply for New<'_> {
@@ -10,6 +21,26 @@ impl Apply for New<'_> {
produce(state, |_| { produce(state, |_| {
let mut file = File::default(); let mut file = File::default();
file.info.name = self.name.to_owned(); 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] vec![file]
}); });
Ok(()) Ok(())
@@ -25,7 +56,14 @@ mod tests {
#[test] #[test]
fn test_new() { fn test_new() {
let mut fx = Fixture::default(); 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); assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap(); let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "new"); assert_eq!(file.info.name, "new");
@@ -33,4 +71,31 @@ mod tests {
assert_eq!(fx.selected_files(), [file.id].into()); assert_eq!(fx.selected_files(), [file.id].into());
assert_eq!(fx.order.0, vec![file.id]); 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::{ use crate::{
Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment, 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::*; use super::*;
@@ -162,10 +163,10 @@ mod tests {
.collect(), .collect(),
); );
if waypoints > 0 { if waypoints > 0 {
file.wpt = vec![Rc::new(crate::WaypointChunk { file.wpt = Waypoints::new([crate::WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(), wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default() ..Default::default()
})]; }]);
} }
file file
} }
@@ -718,8 +719,6 @@ mod tests {
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2)); let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0)); let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a); 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); copy(&mut fx, tracks(a, &t), false);
fx.selection = files(&[b]); fx.selection = files(&[b]);
@@ -735,7 +734,7 @@ mod tests {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2)); let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0)); 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); copy(&mut fx, segments(a, t[0], &s), false);
fx.selection = files(&[b]); fx.selection = files(&[b]);
delete_file(&mut fx, a); delete_file(&mut fx, a);
@@ -7,7 +7,7 @@ use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection, ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection,
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file, 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. /// 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> + '_ { pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
file.wpt file.wpt.iter().map(|wpt| wpt.id)
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
} }
fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> { 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(); let ids: HashSet<WaypointId> = waypoints.iter().map(|wpt| wpt.id).collect();
for file in state.files.values_mut() { for file in state.files.values_mut() {
if waypoint_ids(file).any(|id| ids.contains(&id)) { if waypoint_ids(file).any(|id| ids.contains(&id)) {
edit_waypoint_chunks( Rc::make_mut(file).wpt.edit(
Rc::make_mut(file),
|wpt| ids.contains(&wpt.id), |wpt| ids.contains(&wpt.id),
|wpts| { |wpts| {
wpts.retain(|wpt| !ids.contains(&wpt.id)); 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)), .is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)),
Destination::Waypoints(file_id, _) => state.files.contains_key(&file_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::Files(_), Destination::Files(_))
| (ClipboardContent::Tracks(_), Destination::Files(_) | Destination::Tracks(..)) | (
ClipboardContent::Tracks(_),
Destination::Files(_) | Destination::Tracks(..)
)
| ( | (
ClipboardContent::Segments(_), ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..), Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
) )
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) => true, | (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
_ => false, );
};
let empty = match content { let empty = match content {
ClipboardContent::Files(files) => files.is_empty(), ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.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 ids = waypoints.iter().map(|wpt| wpt.id).collect();
let file = file_mut(state, file_id)?; let file = file_mut(state, file_id)?;
let existing: Vec<WaypointId> = waypoint_ids(file).collect(); 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 { Ok(Selection::Waypoint {
file_id, file_id,
wpt_ids: ids, wpt_ids: ids,
+76 -2
View File
@@ -1,5 +1,4 @@
mod apply; mod apply;
mod command;
mod edit; mod edit;
mod file; mod file;
#[cfg(test)] #[cfg(test)]
@@ -8,8 +7,83 @@ mod pattern;
mod tools; mod tools;
pub use apply::*; pub use apply::*;
pub use command::*;
pub use edit::*; pub use edit::*;
pub use file::*; pub use file::*;
pub use pattern::*; pub use pattern::*;
pub use tools::*; 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 copy;
mod edit_waypoint_chunks;
mod insert_waypoints;
mod produce; mod produce;
mod update_segments;
mod update_selected; mod update_selected;
mod update_waypoint; mod update_waypoint;
pub use copy::*; pub use copy::*;
pub use edit_waypoint_chunks::*;
pub use insert_waypoints::*;
pub use produce::*; pub use produce::*;
pub use update_segments::*;
pub use update_selected::*; pub use update_selected::*;
pub use update_waypoint::*; 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 std::rc::Rc;
use crate::{ use crate::{File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint};
File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint, edit_waypoint_chunks,
};
/// What an [`Editor`] hook did to the element it was given. /// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -87,7 +85,7 @@ fn edit_waypoints<E: Editor + ?Sized>(
filter: impl Fn(&Waypoint) -> bool, filter: impl Fn(&Waypoint) -> bool,
editor: &mut E, editor: &mut E,
) -> Edit { ) -> 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 edit_where(wpts, &filter, |wpt| editor.waypoint(wpt)) == Edit::Changed
}); });
if changed { if changed {
@@ -287,35 +285,34 @@ mod tests {
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect(); let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect(); let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone(); let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk { file.wpt = crate::Waypoints::new([crate::WaypointChunk {
wpt: wpts, wpt: wpts,
..Default::default() ..Default::default()
})]; }]);
fx.files.insert(id, Rc::new(file)); 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. // A file selection never touches waypoints.
fx.selection = Selection::File { fx.selection = Selection::File {
file_ids: HashSet::from([id]), file_ids: HashSet::from([id]),
}; };
update_selected(&mut fx.state(), &mut Name); 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 { fx.selection = Selection::Waypoint {
file_id: id, file_id: id,
wpt_ids: HashSet::from([ids[1]]), 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); update_selected(&mut fx.state(), &mut Name);
assert_ne!(fx.files[&id].wpt_rev_id, rev); assert_ne!(fx.files[&id].wpt.rev_id, rev);
let chunk = &fx.files[&id].wpt[0]; assert_ne!(fx.files[&id].wpt.chunks()[0].id, chunk_id);
assert_ne!(chunk.id, chunk_id); let named: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.name.is_some()).collect();
let named: Vec<_> = chunk.wpt.iter().map(|w| w.name.is_some()).collect();
assert_eq!(named, [false, true, false]); assert_eq!(named, [false, true, false]);
fx.selection = Selection::Waypoints { file_id: id }; fx.selection = Selection::Waypoints { file_id: id };
update_selected(&mut fx.state(), &mut Name); 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] #[test]
@@ -1,6 +1,6 @@
use std::rc::Rc; 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 /// Changes one waypoint of a file with `f`, whatever is selected. Nothing to do if the file or
/// the waypoint does not exist. /// the waypoint does not exist.
@@ -12,8 +12,7 @@ pub fn update_waypoint(
) -> Result<(), CommandError> { ) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone(); let mut file = (**file).clone();
let changed = edit_waypoint_chunks( let changed = file.wpt.edit(
&mut file,
|wpt| wpt.id == waypoint_id, |wpt| wpt.id == waypoint_id,
|wpts| { |wpts| {
wpts.iter_mut() 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::{ use crate::{
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State, Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, edit_waypoint_chunks, TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected,
update_selected,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -91,8 +90,7 @@ impl Editor for Cleaner<'_> {
} }
} }
if self.clean.wpt if self.clean.wpt
&& edit_waypoint_chunks( && file.wpt.edit(
file,
|wpt| self.clean.removes(wpt.coordinates), |wpt| self.clean.removes(wpt.coordinates),
|wpts| { |wpts| {
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates)); wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
@@ -244,7 +242,7 @@ fn fixed_selection(state: &State) -> Selection {
mod tests { mod tests {
use std::collections::HashSet; use std::collections::HashSet;
use crate::{FileId, Load, engine::command::fixture::Fixture}; use crate::{FileId, Load, WaypointChunk, Waypoints, engine::command::fixture::Fixture};
use super::*; use super::*;
@@ -331,7 +329,8 @@ mod tests {
let result = let result =
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state()); clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo))); 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] #[test]
@@ -363,18 +362,14 @@ mod tests {
fn test_waypoints_flag_and_selection() { fn test_waypoints_flag_and_selection() {
let (mut fx, id) = loaded(); let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone(); 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()], wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::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)); fx.files.insert(id, std::rc::Rc::new(file));
let everywhere = bounds(-180.0, -90.0, 180.0, 90.0); let everywhere = bounds(-180.0, -90.0, 180.0, 90.0);
let wpt_count = |fx: &Fixture| { let wpt_count = |fx: &Fixture| fx.files.get(&id).map_or(0, |f| f.wpt.len());
fx.files
.get(&id)
.map_or(0, |f| f.wpt.iter().map(|c| c.wpt.len()).sum::<usize>())
};
// trackpoints only: waypoints are kept // trackpoints only: waypoints are kept
Clean { 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)] #[derive(Debug)]
pub struct Crop { pub struct Crop {
pub start: u32, pub start: u32,
pub end: 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 { impl Apply for Crop {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let (start, end) = (self.start as usize, self.end as usize);
Err(CommandError::NotImplemented("crop")) 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() { fn test_edit_the_given_waypoint() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let mut file = File::default(); let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: vec![ wpt: vec![
Waypoint { Waypoint {
name: Some("old".into()), name: Some("old".into()),
desc: Some("old description".into()), desc: Some("old description".into()),
cmt: Some("old comment".into()), cmt: Some("old comment".into()),
sym: Some("Flag".into()), sym: Some("Flag".into()),
link: Some(Link { links: vec![
Link {
href: "https://old".into(), href: "https://old".into(),
text: None, text: None,
}), },
Link {
href: "https://second".into(),
text: Some("second".into()),
},
],
..Default::default() ..Default::default()
}, },
Waypoint { Waypoint {
@@ -64,7 +70,7 @@ mod tests {
}, },
], ],
..Default::default() ..Default::default()
})); });
let id = file.id; let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect(); let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file)); fx.files.insert(id, Rc::new(file));
@@ -83,19 +89,16 @@ mod tests {
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
let wpts: Vec<_> = fx.files[&id] let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
.wpt
.iter()
.flat_map(|c| c.wpt.iter())
.collect();
let edited = wpts[0]; let edited = wpts[0];
assert_eq!(edited.id, ids[0]); assert_eq!(edited.id, ids[0]);
assert_eq!(edited.name.as_deref(), Some("new")); assert_eq!(edited.name.as_deref(), Some("new"));
assert_eq!(edited.desc.as_deref(), Some("new description")); assert_eq!(edited.desc.as_deref(), Some("new description"));
assert_eq!(edited.cmt.as_deref(), Some("new description")); assert_eq!(edited.cmt.as_deref(), Some("new description"));
assert_eq!(edited.sym.as_deref(), Some("Summit")); assert_eq!(edited.sym.as_deref(), Some("Summit"));
// an empty link removes it // an empty link removes the first one, the others are not the form's business
assert!(edited.link.is_none()); assert_eq!(edited.links.len(), 1);
assert_eq!(edited.links[0].href, "https://second");
assert_eq!( assert_eq!(
(edited.coordinates.lng, edited.coordinates.lat, edited.ele), (edited.coordinates.lng, edited.coordinates.lat, edited.ele),
(4.5, 50.5, 12.0) (4.5, 50.5, 12.0)
@@ -103,4 +106,57 @@ mod tests {
// the other waypoint is as it was // the other waypoint is as it was
assert_eq!(wpts[1].name.as_deref(), Some("other")); 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)] #[derive(Debug)]
pub struct Elevation<'a> { pub struct Elevation<'a> {
pub ele: &'a [f64], pub ele: &'a [f64],
} }
impl Apply for Elevation<'_> { impl Apply for Elevation<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let locations = state
Err(CommandError::NotImplemented("elevation")) .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)] #[derive(Debug)]
pub struct Extract; pub struct Extract;
impl Apply for Extract { impl Apply for Extract {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let selection = state.selection.clone();
Err(CommandError::NotImplemented("extract")) 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeType { pub enum MergeType {
/// The trackpoints of the elements are connected in a single segment.
Connect, Connect,
/// The elements are put together, in the first one, which keeps them as they are.
Group, 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)] #[derive(Debug)]
pub struct Merge { pub struct Merge {
pub type_: MergeType, pub type_: MergeType,
pub remove_gaps: bool,
} }
impl Apply for Merge { impl Apply for Merge {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let selection = state.selection.clone();
Err(CommandError::NotImplemented("merge")) 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 clean;
mod crop; mod crop;
mod edit_waypoint; mod edit_waypoint;
mod elevation; mod elevation;
mod extract; mod extract;
mod insert_anchor;
mod merge; mod merge;
mod move_waypoint; mod move_waypoint;
mod new_waypoint; mod new_waypoint;
mod reduce;
mod reverse; mod reverse;
mod splice_trackpoints; mod round_trip;
mod route;
mod split; mod split;
mod time; mod time;
pub use change_loop_start::*;
pub use clean::*; pub use clean::*;
pub use crop::*; pub use crop::*;
pub use edit_waypoint::*; pub use edit_waypoint::*;
pub use elevation::*; pub use elevation::*;
pub use extract::*; pub use extract::*;
pub use insert_anchor::*;
pub use merge::*; pub use merge::*;
pub use move_waypoint::*; pub use move_waypoint::*;
pub use new_waypoint::*; pub use new_waypoint::*;
pub use reduce::*;
pub use reverse::*; pub use reverse::*;
pub use splice_trackpoints::*; pub use round_trip::*;
pub use route::*;
pub use split::*; pub use split::*;
pub use time::*; pub use time::*;
@@ -36,7 +36,7 @@ mod tests {
fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) { fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let mut file = File::default(); let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: (0..3) wpt: (0..3)
.map(|i| Waypoint { .map(|i| Waypoint {
name: Some(format!("w{i}")), name: Some(format!("w{i}")),
@@ -44,7 +44,7 @@ mod tests {
}) })
.collect(), .collect(),
..Default::default() ..Default::default()
})); });
let id = file.id; let id = file.id;
let ids = waypoint_ids(&file).collect(); let ids = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file)); fx.files.insert(id, Rc::new(file));
@@ -55,7 +55,7 @@ mod tests {
#[test] #[test]
fn test_only_the_given_waypoint_moves_whatever_is_selected() { fn test_only_the_given_waypoint_moves_whatever_is_selected() {
let (mut fx, id, ids) = fixture(); let (mut fx, id, ids) = fixture();
let rev = fx.files[&id].wpt_rev_id; let rev = fx.files[&id].wpt.rev_id;
MoveWaypoint { MoveWaypoint {
file_id: id, file_id: id,
@@ -67,11 +67,7 @@ mod tests {
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
let wpts: Vec<_> = fx.files[&id] let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
.wpt
.iter()
.flat_map(|c| c.wpt.iter())
.collect();
assert_eq!(wpts.len(), 3); assert_eq!(wpts.len(), 3);
assert_eq!( assert_eq!(
( (
@@ -87,7 +83,7 @@ mod tests {
assert_eq!((wpts[0].coordinates.lng, wpts[0].ele), (0.0, 0.0)); 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)); 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 // 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); assert_eq!(fx.selection, Selection::Empty);
} }
@@ -1,9 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{ use crate::{Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint};
Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint,
insert_waypoints_at,
};
/// Adds a waypoint at the end of the waypoints of each selected file (or of the file of the /// 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. /// 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 // the description is also the comment, as it is what the other applications show
waypoint.cmt = non_empty(desc); waypoint.cmt = non_empty(desc);
waypoint.sym = non_empty(icon); 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<'_> { impl Apply for NewWaypoint<'_> {
@@ -77,7 +82,7 @@ impl Apply for NewWaypoint<'_> {
self.link, self.link,
); );
let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap()); 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(()) Ok(())
} }
@@ -104,20 +109,26 @@ mod tests {
} }
fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> { fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> {
fx.files[&id] fx.files[&id].wpt.iter().cloned().collect()
.wpt
.iter()
.flat_map(|chunk| chunk.wpt.iter().cloned())
.collect()
} }
#[test] #[test]
fn test_new_waypoint_in_the_selected_file() { fn test_new_waypoint_in_the_selected_file() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
New { name: "a" }.apply(&mut fx.state()).unwrap(); New {
New { name: "b" }.apply(&mut fx.state()).unwrap(); 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 (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 // b is selected
new_waypoint("peak").apply(&mut fx.state()).unwrap(); 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.cmt.as_deref(), Some("a description"));
assert_eq!(wpt.sym.as_deref(), Some("Summit")); assert_eq!(wpt.sym.as_deref(), Some("Summit"));
assert_eq!( 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") 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 // added after the others
let mut second = new_waypoint("second"); let mut second = new_waypoint("second");
@@ -155,7 +166,12 @@ mod tests {
#[test] #[test]
fn test_empty_fields_are_not_set() { fn test_empty_fields_are_not_set() {
let mut fx = Fixture::default(); 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]; let a = fx.order.0[0];
NewWaypoint { NewWaypoint {
lng: 1.0, lng: 1.0,
@@ -170,14 +186,19 @@ mod tests {
.unwrap(); .unwrap();
let wpt = &waypoints(&fx, a)[0]; let wpt = &waypoints(&fx, a)[0];
assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none()); 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] #[test]
fn test_each_selected_file_gets_its_own_waypoint() { fn test_each_selected_file_gets_its_own_waypoint() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
for name in ["a", "b", "c"] { 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(); let ids = fx.order.0.clone();
fx.selection = Selection::File { fx.selection = Selection::File {
@@ -210,7 +231,12 @@ mod tests {
}, },
] { ] {
let mut fx = Fixture::default(); 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]; let a = fx.order.0[0];
fx.selection = selection(a); fx.selection = selection(a);
new_waypoint("w").apply(&mut fx.state()).unwrap(); new_waypoint("w").apply(&mut fx.state()).unwrap();
@@ -221,7 +247,12 @@ mod tests {
#[test] #[test]
fn test_nothing_selected_or_unknown_file() { fn test_nothing_selected_or_unknown_file() {
let mut fx = Fixture::default(); 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; fx.selection = Selection::Empty;
assert_eq!( assert_eq!(
new_waypoint("w").apply(&mut fx.state()), 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)] #[derive(Debug)]
pub struct Reverse; pub struct Reverse;
impl Apply for Reverse { impl Apply for Reverse {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let locations = state
Err(CommandError::NotImplemented("reverse")) .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)] #[derive(Debug)]
pub struct Split { pub struct Split {
pub at: u32, pub at: u32,
pub split_type: SplitType,
} }
impl Apply for Split { impl Apply for Split {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let at = self.at as usize;
Err(CommandError::NotImplemented("split")) 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)] #[derive(Debug)]
pub struct Time; pub struct Time {
pub start_time: i64,
pub kind: TimeKind,
}
impl Apply for Time { impl Apply for Time {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO match self.kind {
Err(CommandError::NotImplemented("time")) 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)
));
} }
} }
@@ -23,14 +23,14 @@ impl CoordinatesCache {
if self if self
.waypoints .waypoints
.get(&file.id) .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(); 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]); coordinates.extend([wpt.coordinates.lng, wpt.coordinates.lat]);
} }
self.waypoints 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) { for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
segments.insert(seg.id); segments.insert(seg.id);
@@ -138,17 +138,16 @@ mod tests {
..Default::default() ..Default::default()
}; };
let mut file = (*fx.files[&id]).clone(); let mut file = (*fx.files[&id]).clone();
file.wpt_rev_id = Default::default(); file.wpt = crate::Waypoints::new([
file.wpt = vec![ WaypointChunk {
Rc::new(WaypointChunk {
wpt: vec![wpt(1.0, 2.0)], wpt: vec![wpt(1.0, 2.0)],
..Default::default() ..Default::default()
}), },
Rc::new(WaypointChunk { WaypointChunk {
wpt: vec![wpt(3.0, 4.0)], wpt: vec![wpt(3.0, 4.0)],
..Default::default() ..Default::default()
}), },
]; ]);
fx.files.insert(id, Rc::new(file)); fx.files.insert(id, Rc::new(file));
let mut cache = CoordinatesCache::default(); let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files)); cache.update(Some(&fx.files));
@@ -76,21 +76,19 @@ impl FileStructure {
waypoints: file waypoints: file
.wpt .wpt
.iter() .iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| WaypointNode { .map(|wpt| WaypointNode {
id: wpt.id, id: wpt.id,
name: wpt.name.clone(), name: wpt.name.clone(),
sym: wpt.sym.clone(), sym: wpt.sym.clone(),
}) })
.collect(), .collect(),
wpt_rev_id: file.wpt_rev_id, wpt_rev_id: file.wpt.rev_id,
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::rc::Rc;
use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture}; use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
@@ -133,18 +131,17 @@ mod tests {
name: Some(n.to_string()), name: Some(n.to_string()),
..Default::default() ..Default::default()
}; };
let before = file.wpt_rev_id; let before = file.wpt.rev_id;
file.wpt_rev_id = Default::default(); file.wpt = crate::Waypoints::new([
file.wpt = vec![ WaypointChunk {
Rc::new(WaypointChunk {
wpt: vec![wpt("a"), wpt("b")], wpt: vec![wpt("a"), wpt("b")],
..Default::default() ..Default::default()
}), },
Rc::new(WaypointChunk { WaypointChunk {
wpt: vec![wpt("c")], wpt: vec![wpt("c")],
..Default::default() ..Default::default()
}), },
]; ]);
let node = FileStructure::new(&file); let node = FileStructure::new(&file);
let names: Vec<_> = node let names: Vec<_> = node
.waypoints .waypoints
@@ -153,6 +150,6 @@ mod tests {
.collect(); .collect();
assert_eq!(names, ["a", "b", "c"]); assert_eq!(names, ["a", "b", "c"]);
assert_ne!(node.wpt_rev_id, before); 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);
} }
} }
+2
View File
@@ -1,11 +1,13 @@
mod coordinates_cache; mod coordinates_cache;
mod file_structure; mod file_structure;
mod file_structure_cache; mod file_structure_cache;
mod routing_buffer;
mod statistics_buffer; mod statistics_buffer;
mod statistics_cache; mod statistics_cache;
pub use coordinates_cache::*; pub use coordinates_cache::*;
pub use file_structure::*; pub use file_structure::*;
pub use file_structure_cache::*; pub use file_structure_cache::*;
pub use routing_buffer::*;
pub use statistics_buffer::*; pub use statistics_buffer::*;
pub use statistics_cache::*; pub use statistics_cache::*;
@@ -0,0 +1,162 @@
use crate::{FileId, Selection, StackEntry, TrackSegmentId, TrackSegmentRevisionId};
/// What the routing tool needs about the selected segments (see
/// [`Selection::segment_locations`]): where their anchors are among their trackpoints.
///
/// Trackpoints are numbered like in the [`crate::StatisticsBuffer`], over all the selected
/// segments one after the other, except that the segments that are not covered by the routing
/// tool (waypoints) are left out.
#[derive(Debug, Default)]
pub struct RoutingBuffer {
/// The index of each anchor in the selection, in order. The first and last trackpoints of a
/// segment always are anchors.
pub anchor_indices: Vec<u32>,
/// The lowest map zoom level at which each anchor is shown.
pub anchor_zooms: Vec<u8>,
/// Index in the selection of the first trackpoint of each selected segment that has some,
/// which tells which segment an anchor or a trackpoint belongs to.
pub segment_starts: Vec<u32>,
/// Id of each selected segment that has trackpoints, as `segment_starts`.
pub segment_ids: Vec<TrackSegmentId>,
/// Changes when the selected segments, or their trackpoints, change. Commands that refer to
/// trackpoints by their index in the selection carry it, to be refused if the selection is
/// not the one the indices were taken from anymore.
pub revision: u32,
/// What the revision stands for.
signature: Vec<(TrackSegmentId, TrackSegmentRevisionId)>,
}
impl RoutingBuffer {
pub fn update(&mut self, files: Option<&StackEntry>, selection: &Selection, order: &[FileId]) {
self.anchor_indices.clear();
self.anchor_zooms.clear();
self.segment_starts.clear();
self.segment_ids.clear();
let mut signature = vec![];
let mut index = 0;
for location in files
.map(|files| selection.segment_locations(files, order))
.unwrap_or_default()
{
let segment = &files.unwrap()[&location.file_id].trk[location.trk].trkseg[location.seg];
signature.push((segment.id, segment.rev_id));
if segment.is_empty() {
continue;
}
self.segment_starts.push(index as u32);
self.segment_ids.push(segment.id);
for trkpt in segment.iter() {
if let Some(zoom) = trkpt.anchor {
self.anchor_indices.push(index as u32);
self.anchor_zooms.push(zoom);
}
index += 1;
}
}
if signature != self.signature {
self.revision = self.revision.wrapping_add(1);
self.signature = signature;
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashSet, rc::Rc};
use crate::parse;
use super::*;
fn loaded() -> (StackEntry, FileId) {
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let id = file.id;
let mut files = StackEntry::default();
files.insert(id, Rc::new(file));
(files, id)
}
fn file_selection(id: FileId) -> Selection {
Selection::File {
file_ids: HashSet::from([id]),
}
}
#[test]
fn test_anchors_and_starts_follow_the_selected_segments() {
let (files, id) = loaded();
let lengths: Vec<usize> = files[&id]
.trk
.iter()
.flat_map(|trk| trk.trkseg.iter().map(|segment| segment.len()))
.collect();
let mut buffer = RoutingBuffer::default();
buffer.update(Some(&files), &file_selection(id), &[id]);
// an empty segment has no start
assert_eq!(buffer.segment_ids.len(), buffer.segment_starts.len());
let mut start = 0;
let expected: Vec<u32> = lengths
.iter()
.filter(|len| **len > 0)
.map(|len| {
start += len;
(start - len) as u32
})
.collect();
assert_eq!(buffer.segment_starts, expected);
// the ends of the segments are always anchors, shown at every zoom level
assert_eq!(buffer.anchor_indices.len(), buffer.anchor_zooms.len());
assert!(buffer.anchor_indices.windows(2).all(|w| w[0] < w[1]));
let total: usize = lengths.iter().sum();
let ends = expected
.iter()
.copied()
.chain(expected.iter().skip(1).map(|start| start - 1))
.chain([total as u32 - 1]);
for end in ends {
let position = buffer.anchor_indices.iter().position(|i| *i == end);
assert_eq!(position.map(|p| buffer.anchor_zooms[p]), Some(0), "{end}");
}
}
#[test]
fn test_nothing_without_segments() {
let (files, id) = loaded();
let mut buffer = RoutingBuffer::default();
buffer.update(Some(&files), &Selection::Empty, &[id]);
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
// waypoints are not covered by the routing tool
buffer.update(Some(&files), &Selection::Waypoints { file_id: id }, &[id]);
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
buffer.update(None, &file_selection(id), &[id]);
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
}
#[test]
fn test_revision_changes_with_the_selected_segments() {
let (mut files, id) = loaded();
let mut buffer = RoutingBuffer::default();
buffer.update(Some(&files), &file_selection(id), &[id]);
let first = buffer.revision;
// the same segments: the indices still mean the same trackpoints
buffer.update(Some(&files), &file_selection(id), &[id]);
assert_eq!(buffer.revision, first);
// other trackpoints in the same segment
let mut file = (*files[&id]).clone();
file.trk[0].trkseg[0].rev_id = Default::default();
files.insert(id, Rc::new(file));
buffer.update(Some(&files), &file_selection(id), &[id]);
assert_ne!(buffer.revision, first);
let second = buffer.revision;
// another selection
buffer.update(Some(&files), &Selection::Empty, &[id]);
assert_ne!(buffer.revision, second);
}
}
@@ -10,15 +10,20 @@ pub const NO_TIME: i64 = i64::MIN;
/// trackpoints are the difference between its two ends: see [`StatisticsBuffer::slice`]. /// 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 /// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are
/// [`NO_TIME`], missing measures are NaN. /// [`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)] #[derive(Debug, Default)]
pub struct StatisticsBuffer { pub struct StatisticsBuffer {
/// Statistics of the whole selection. /// Statistics of the whole selection.
pub global: GlobalStatistics, pub global: GlobalStatistics,
pub total_distance: Vec<f64>, pub total_distance: Vec<f64>,
pub moving_distance: Vec<f64>, pub moving_distance: Option<Vec<f64>>,
pub total_time: Vec<i64>, pub total_time: Option<Vec<i64>>,
pub moving_time: Vec<i64>, pub moving_time: Option<Vec<i64>>,
pub speed: Vec<f64>, pub speed: Option<Vec<f64>>,
pub elevation_gain: Vec<f64>, pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>, pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>, pub slope: Vec<f64>,
@@ -28,20 +33,47 @@ pub struct StatisticsBuffer {
pub lat: Vec<f64>, pub lat: Vec<f64>,
pub ele: Vec<f64>, pub ele: Vec<f64>,
/// Timestamps, in milliseconds since the epoch. /// Timestamps, in milliseconds since the epoch.
pub time: Vec<i64>, pub time: Option<Vec<i64>>,
pub hr: Vec<f64>, pub hr: Option<Vec<f64>>,
pub cad: Vec<f64>, pub cad: Option<Vec<f64>>,
pub atemp: Vec<f64>, pub atemp: Option<Vec<f64>>,
pub power: Vec<f64>, pub power: Option<Vec<f64>>,
/// Surface of the trackpoints: 0 when unknown, else 1 + its code in the categories of the /// Surface of the trackpoints, see [`Intervals`].
/// engine (so that it is never 0 for a known surface). pub surface: Intervals,
pub surface: Vec<u8>,
/// Highway of the trackpoints, as the surface. /// Highway of the trackpoints, as the surface.
pub highway: Vec<u8>, pub highway: Intervals,
/// SAC hiking scale of the trackpoints, as the surface. /// 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. /// 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 /// 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) 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 { impl StatisticsBuffer {
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.total_distance.len() self.total_distance.len()
@@ -63,10 +110,10 @@ impl StatisticsBuffer {
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) { pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
self.global = GlobalStatistics::default(); self.global = GlobalStatistics::default();
self.total_distance.clear(); self.total_distance.clear();
self.moving_distance.clear(); self.moving_distance = None;
self.total_time.clear(); self.total_time = None;
self.moving_time.clear(); self.moving_time = None;
self.speed.clear(); self.speed = None;
self.elevation_gain.clear(); self.elevation_gain.clear();
self.elevation_loss.clear(); self.elevation_loss.clear();
self.slope.clear(); self.slope.clear();
@@ -75,35 +122,41 @@ impl StatisticsBuffer {
self.lng.clear(); self.lng.clear();
self.lat.clear(); self.lat.clear();
self.ele.clear(); self.ele.clear();
self.time.clear(); self.time = None;
self.hr.clear(); self.hr = None;
self.cad.clear(); self.cad = None;
self.atemp.clear(); self.atemp = None;
self.power.clear(); self.power = None;
self.surface.clear(); self.surface.clear();
self.highway.clear(); self.highway.clear();
self.sac_scale.clear(); self.sac_scale.clear();
self.mtb_scale.clear(); self.mtb_scale.clear();
let optional = |value: Option<f64>| value.unwrap_or(f64::NAN);
for (segment, stats) in selected { for (segment, stats) in selected {
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) { for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
let index = self.total_distance.len();
let cumul_stats = &self.global; let cumul_stats = &self.global;
self.total_distance self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance); .push(cumul_stats.total_distance + trkpt_stats.total_distance);
self.moving_distance.push( push_optional(
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance) &mut self.moving_distance,
.unwrap_or_default(), index,
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance),
0.0,
); );
self.total_time.push( push_optional(
sum_options(cumul_stats.total_time, trkpt_stats.total_time).unwrap_or_default(), &mut self.total_time,
index,
sum_options(cumul_stats.total_time, trkpt_stats.total_time),
0,
); );
self.moving_time.push( push_optional(
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time) &mut self.moving_time,
.unwrap_or_default(), 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 self.elevation_gain
.push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain); .push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain);
self.elevation_loss self.elevation_loss
@@ -116,15 +169,15 @@ impl StatisticsBuffer {
self.lng.push(trkpt.coordinates.lng); self.lng.push(trkpt.coordinates.lng);
self.lat.push(trkpt.coordinates.lat); self.lat.push(trkpt.coordinates.lat);
self.ele.push(trkpt.ele); self.ele.push(trkpt.ele);
self.time.push(trkpt.time.unwrap_or(NO_TIME)); push_optional(&mut self.time, index, trkpt.time, NO_TIME);
self.hr.push(optional(trkpt.hr.map(f64::from))); push_optional(&mut self.hr, index, trkpt.hr.map(f64::from), f64::NAN);
self.cad.push(optional(trkpt.cad.map(f64::from))); push_optional(&mut self.cad, index, trkpt.cad.map(f64::from), f64::NAN);
self.atemp.push(optional(trkpt.atemp.map(f64::from))); push_optional(&mut self.atemp, index, trkpt.atemp.map(f64::from), f64::NAN);
self.power.push(optional(trkpt.power.map(f64::from))); push_optional(&mut self.power, index, trkpt.power.map(f64::from), f64::NAN);
self.surface.push(unknown_or_next(trkpt.surface)); self.surface.push(index, unknown_or_next(trkpt.surface));
self.highway.push(unknown_or_next(trkpt.highway)); self.highway.push(index, unknown_or_next(trkpt.highway));
self.sac_scale.push(unknown_or_next(trkpt.sac_scale)); self.sac_scale.push(index, unknown_or_next(trkpt.sac_scale));
self.mtb_scale.push(unknown_or_next(trkpt.mtb_scale)); self.mtb_scale.push(index, unknown_or_next(trkpt.mtb_scale));
} }
self.global.merge(&stats.global); self.global.merge(&stats.global);
} }
@@ -142,28 +195,22 @@ impl StatisticsBuffer {
} }
let delta = |values: &[f64]| values[end] - values[start]; let delta = |values: &[f64]| values[end] - values[start];
let delta_time = |values: &[i64]| values[end] - values[start]; // the total time is the span of the timestamps, which is negative where they go backwards
let time = |i: usize| (self.time[i] != NO_TIME).then_some(self.time[i]); 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 { Some(GlobalStatistics {
total_distance: delta(&self.total_distance), total_distance: delta(&self.total_distance),
moving_distance: self moving_distance: self.moving_distance.as_deref().map(delta),
.global total_time: self.total_time.as_deref().map(delta_time),
.moving_distance moving_time: self.moving_time.as_deref().map(delta_time),
.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)),
elevation_gain: delta(&self.elevation_gain), elevation_gain: delta(&self.elevation_gain),
elevation_loss: delta(&self.elevation_loss), elevation_loss: delta(&self.elevation_loss),
start_time: time(start), start_time: time(start),
end_time: time(end), end_time: time(end),
bounds: self.global.bounds, 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) (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> { fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
vec![ vec![
buffer.total_distance.len(), 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_gain.len(),
buffer.elevation_loss.len(), buffer.elevation_loss.len(),
buffer.slope.len(), buffer.slope.len(),
@@ -197,15 +251,6 @@ mod tests {
buffer.lng.len(), buffer.lng.len(),
buffer.lat.len(), buffer.lat.len(),
buffer.ele.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(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[]); buffer.update(&[]);
assert!(buffer.is_empty()); assert!(buffer.is_empty());
assert!(buffer.speed.is_empty()); assert!(buffer.speed.is_none());
assert_eq!(buffer.global.total_distance, 0.0); assert_eq!(buffer.global.total_distance, 0.0);
assert!(buffer.slice(0, 0).is_none()); assert!(buffer.slice(0, 0).is_none());
} }
@@ -286,37 +331,153 @@ mod tests {
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]); buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.total_time[n - 1], duration); let total_time = buffer.total_time.as_ref().unwrap();
assert_eq!(buffer.total_time[n], duration); assert_eq!(total_time[n - 1], duration);
assert_eq!(buffer.total_time[2 * n - 1], 2 * duration); assert_eq!(total_time[n], duration);
assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1])); assert_eq!(total_time[2 * n - 1], 2 * duration);
assert!(buffer.time.iter().all(|t| *t != NO_TIME)); assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
assert!(buffer.time.unwrap().iter().all(|t| *t != NO_TIME));
} }
#[test] #[test]
fn test_missing_times_and_measures_are_nan() { fn test_absent_optional_values_have_no_buffer() {
let (segment, s) = computed("data/simple.gpx"); let (segment, s) = computed("data/simple.gpx");
assert!(segment.iter().all(|trkpt| trkpt.time.is_none())); assert!(segment.iter().all(|trkpt| trkpt.time.is_none()));
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]); buffer.update(&[(&segment, &s)]);
assert!(buffer.time.iter().all(|t| *t == NO_TIME)); assert!(buffer.time.is_none());
assert!(buffer.hr.iter().all(|v| v.is_nan())); assert!(buffer.hr.is_none());
assert_eq!(buffer.global.hr.count, 0); 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.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] #[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 (segment, s) = computed("data/with_highway.gpx");
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]); buffer.update(&[(&segment, &s)]);
// 0 for the trackpoints that have none, else the code of the engine plus one // 0 for the trackpoints that have none, else the code of the engine plus one
assert_eq!(buffer.surface, [1, 1, 0, 2, 2]); let surface = &buffer.surface;
assert_eq!(buffer.highway, [1, 1, 0, 2, 0]); assert_eq!(surface.starts, [0, 2, 3]);
assert_eq!(buffer.sac_scale, [0, 0, 0, 1, 2]); assert_eq!(surface.values, [1, 0, 2]);
assert_eq!(buffer.mtb_scale, [0, 0, 0, 1, 1]); 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)); 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] #[test]
@@ -331,26 +492,37 @@ mod tests {
let s = Statistics::compute(&segment); let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]); buffer.update(&[(&segment, &s)]);
assert_eq!((buffer.surface[0], buffer.highway[0]), (8, 255)); let at = |i: usize| {
assert_eq!((buffer.sac_scale[0], buffer.mtb_scale[0]), (1, 4)); (
assert_eq!((buffer.surface[1], buffer.highway[1]), (0, 0)); value_at(&buffer.surface, i),
assert_eq!((buffer.sac_scale[1], buffer.mtb_scale[1]), (0, 0)); 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] #[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 (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]); buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.surface.len(), s.local.len()); // a single interval of unknown values
assert!(buffer.surface.iter().all(|c| *c == 0)); assert_eq!(buffer.surface.starts, [0]);
assert!(buffer.highway.iter().all(|c| *c == 0)); assert_eq!(buffer.surface.values, [0]);
assert!(buffer.sac_scale.iter().all(|c| *c == 0)); assert_eq!(buffer.highway.starts, [0]);
assert!(buffer.mtb_scale.iter().all(|c| *c == 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 // and they are reset by the next update
buffer.update(&[]); buffer.update(&[]);
assert!(buffer.surface.is_empty() && buffer.highway.is_empty()); assert_eq!(buffer.surface, Intervals::default());
assert!(buffer.sac_scale.is_empty() && buffer.mtb_scale.is_empty()); 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] #[test]
@@ -383,6 +555,44 @@ mod tests {
assert_eq!(slice.end_time, global.end_time); assert_eq!(slice.end_time, global.end_time);
} }
#[test]
fn test_slices_of_corrupt_timestamps_are_never_negative_nor_infinite() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
assert!(n > 40);
// timestamps that go back, then forward, and some that are missing
let times: Vec<_> = segment.iter().map(|p| p.time.unwrap()).collect();
segment.update_all(|i, pt| {
pt.time = match i {
10..=19 => Some(times[29 - i]),
20..=24 => None,
25..=29 => Some(times[0] - 1_000 * i as i64),
_ => pt.time,
};
});
let stats = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &stats)]);
for start in 0..n {
for end in start..n {
let slice = buffer.slice(start, end).unwrap();
for time in [slice.total_time, slice.moving_time].into_iter().flatten() {
assert!(time >= 0, "time of {start}..={end}");
}
for speed in [slice.total_speed(), slice.moving_speed()]
.into_iter()
.flatten()
{
assert!(
speed.is_finite() && speed >= 0.0,
"speed of {start}..={end}"
);
}
}
}
}
#[test] #[test]
fn test_slice_of_a_range() { fn test_slice_of_a_range() {
let (segment, s) = computed("data/with_time.gpx"); let (segment, s) = computed("data/with_time.gpx");
@@ -399,14 +609,20 @@ mod tests {
); );
assert_eq!( assert_eq!(
slice.total_time, 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!( assert_eq!(
slice.moving_time, 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.start_time, Some(buffer.time.as_ref().unwrap()[start]));
assert_eq!(slice.end_time, Some(buffer.time[end])); assert_eq!(slice.end_time, Some(buffer.time.as_ref().unwrap()[end]));
assert!(slice.total_distance < buffer.global.total_distance); assert!(slice.total_distance < buffer.global.total_distance);
assert!(slice.total_speed().is_some()); assert!(slice.total_speed().is_some());
@@ -417,21 +633,16 @@ mod tests {
} }
#[test] #[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 data = std::fs::read("data/with_hr.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap(); let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone(); let segment = file.trk[0].trkseg[0].clone();
let s = Statistics::compute(&segment); let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]); buffer.update(&[(&segment, &s)]);
assert!(buffer.global.hr.count > 0);
let slice = buffer.slice(1, 3).unwrap(); let slice = buffer.slice(1, 3).unwrap();
let (global, bounds) = (&buffer.global, &slice.bounds); 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!( assert_eq!(
(bounds.sw.lng, bounds.sw.lat), (bounds.sw.lng, bounds.sw.lat),
(global.bounds.sw.lng, global.bounds.sw.lat) (global.bounds.sw.lng, global.bounds.sw.lat)
File diff suppressed because it is too large. Load diff
+363 -2
View File
@@ -1,11 +1,372 @@
mod action; mod action;
mod command; mod command;
mod derived; mod derived;
mod engine; mod outcome;
mod state; mod state;
pub use action::*; pub use action::*;
pub use command::*; pub use command::*;
pub use derived::*; pub use derived::*;
pub use engine::*; pub use outcome::*;
pub use state::*; pub use state::*;
use crate::{
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint,
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
};
#[derive(Debug, Default)]
pub struct Engine {
stack: Stack,
selection: Selection,
order: FileOrder,
statistics_cache: StatisticsCache,
coordinates_cache: CoordinatesCache,
structure_cache: FileStructureCache,
statistics_buffer: StatisticsBuffer,
routing_buffer: RoutingBuffer,
categories: TrackpointCategories,
clipboard: Option<Clipboard>,
/// What the tests of the last action read, see `Engine::run`.
#[cfg(test)]
last: Outcome,
}
impl Engine {
pub fn statistics(&self) -> &StatisticsBuffer {
&self.statistics_buffer
}
/// The anchors of the routing tool among the trackpoints of the selection.
pub fn routing(&self) -> &RoutingBuffer {
&self.routing_buffer
}
/// The names of the surfaces and highways that the trackpoints refer to by code.
pub fn categories(&self) -> &TrackpointCategories {
&self.categories
}
/// Files in display order.
pub fn order(&self) -> &[FileId] {
&self.order.0
}
/// Name, tracks, segments and waypoints (with their ids) of a file.
pub fn file_structure(&self, id: &FileId) -> Option<&FileStructure> {
self.structure_cache.get(id)
}
/// Global statistics (distance, time, elevation, bounds...) of a file.
pub fn file_statistics(&self, id: &FileId) -> Option<GlobalStatistics> {
let file = self.stack.current()?.get(id)?;
Some(self.statistics_cache.file_global(file))
}
/// What was copied or cut and is waiting to be pasted.
pub fn clipboard(&self) -> Option<&Clipboard> {
self.clipboard.as_ref()
}
/// Whether the clipboard can be pasted with the current selection.
pub fn can_paste(&self) -> bool {
self.clipboard
.as_ref()
.is_some_and(|clipboard| clipboard.can_paste(&self.selection))
}
/// A waypoint of a file, with all its data. `None` if it does not exist.
pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> {
self.stack
.current()?
.get(file_id)?
.wpt
.iter()
.find(|wpt| wpt.id == *id)
}
/// A trackpoint of a segment of a file. `None` if it does not exist.
pub fn trackpoint(
&self,
file_id: &FileId,
segment_id: &TrackSegmentId,
index: usize,
) -> Option<&Trackpoint> {
let segment = self
.stack
.current()?
.get(file_id)?
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.find(|seg| seg.id == *segment_id)?;
(index < segment.len()).then(|| &segment[index])
}
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
/// which `Reduce` keeps it, see [`reduction_distances`].
pub fn reduction_distances(&self) -> Vec<f64> {
let Some(files) = self.stack.current() else {
return vec![];
};
self.selection
.segment_locations(files, &self.order.0)
.iter()
.flat_map(|location| {
reduction_distances(
&files[&location.file_id].trk[location.trk].trkseg[location.seg],
)
})
.collect()
}
/// What there is to keep of the engine, see [`crate::Persister`].
pub fn snapshot(&self) -> Snapshot {
Snapshot {
files: self.stack.current().cloned().unwrap_or_default(),
order: self.order.0.clone(),
categories: self.categories.clone(),
}
}
/// Puts files in an engine that has none, typically the ones read from the storage. They are
/// the state to start from: they cannot be undone. The files are reported as added, and the
/// order, the selection and the clipboard as changed.
pub fn restore(&mut self, restored: Restored) -> Outcome {
let files: StackEntry = restored
.files
.into_iter()
.map(|file| (file.id, file))
.collect();
self.categories = restored.categories;
self.order = FileOrder(restored.order);
self.selection = Selection::Empty;
self.clipboard = None;
let mut diff = Some(Diff {
added: files.keys().copied().collect(),
..Default::default()
});
self.stack = Stack::restored(files);
self.refresh(diff.as_ref());
sort_diff(&mut diff, &self.order.0);
let outcome = Outcome {
changed: true,
diff,
order_changed: true,
selection_changed: true,
clipboard_changed: true,
error: None,
};
#[cfg(test)]
{
self.last = outcome.clone();
}
outcome
}
/// A file as GPX (UTF-8), `None` if it does not exist.
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
let file = self.stack.current()?.get(id)?;
Some(write(file, &self.categories, options))
}
/// The data that the given files have, which the options of `export` can leave out.
pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions {
let Some(files) = self.stack.current() else {
return ExportOptions::NONE;
};
ids.iter()
.filter_map(|id| files.get(id))
.fold(ExportOptions::NONE, |data, file| {
data.union(file.exportable_data())
})
}
/// Whether there is something to undo.
pub fn can_undo(&self) -> bool {
self.stack.can_undo()
}
/// Whether there is something to redo.
pub fn can_redo(&self) -> bool {
self.stack.can_redo()
}
/// Currently selected elements.
pub fn selection(&self) -> &Selection {
&self.selection
}
/// Coordinates (`[lng, lat, ...]`) of the trackpoints of a segment.
pub fn segment_coordinates(&self, id: &TrackSegmentId) -> &[f64] {
self.coordinates_cache.segment(id)
}
/// Coordinates (`[lng, lat, ...]`) of the waypoints of a file.
pub fn waypoint_coordinates(&self, id: &FileId) -> &[f64] {
self.coordinates_cache.waypoints(id)
}
/// Single entry point for every action coming from the frontend. Says what it changed.
pub fn execute(&mut self, action: Action) -> Outcome {
let selection_before = self.selection.clone();
let order_before = self.order.0.clone();
let clipboard_before = self.clipboard.clone();
let is_edit = matches!(action, Action::Edit(_));
let mut error = None;
// the files change iff there is a diff, but selection and order actions have none
let mut diff = match action {
Action::Edit(command) => self.edit(command).unwrap_or_else(|err| {
error = Some(err);
None
}),
Action::Undo => self.stack.undo(),
Action::Redo => self.stack.redo(),
Action::Select { selection, mode } => {
self.selection.select(self.stack.current(), selection, mode);
None
}
Action::SelectAll => {
if let Some(files) = self.stack.current() {
self.selection.select_all(files, &self.order.0);
}
None
}
Action::ArrowSelect { down, add } => {
if let Some(files) = self.stack.current() {
self.selection
.select_neighbour(files, &self.order.0, down, add);
}
None
}
Action::Copy => {
self.copy(false);
None
}
Action::Cut => {
self.copy(true);
None
}
Action::Reorder { file_ids, index } => {
self.order.move_files(&file_ids, index);
None
}
};
let changed = diff.is_some()
|| self.selection != selection_before
|| self.order.0 != order_before
|| self.clipboard != clipboard_before;
if is_edit && !changed && error.is_none() {
error = Some(CommandError::NothingToDo);
}
if changed {
self.refresh(diff.as_ref());
sort_diff(&mut diff, &self.order.0);
}
// after the refresh, which also syncs the order and selection with the files (undo, redo)
let outcome = Outcome {
changed,
diff,
order_changed: self.order.0 != order_before,
selection_changed: self.selection != selection_before,
clipboard_changed: self.clipboard != clipboard_before,
error,
};
#[cfg(test)]
{
self.last = outcome.clone();
}
outcome
}
/// Puts the selection in the clipboard, if there is one.
fn copy(&mut self, cut: bool) {
if let Some(files) = self.stack.current()
&& let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut)
{
self.clipboard = Some(clipboard);
}
}
fn edit(&mut self, command: Command) -> Result<Option<Diff>, CommandError> {
self.stack.create_and_push_next(|files| {
let mut state = State {
categories: &mut self.categories,
files,
selection: &mut self.selection,
order: &mut self.order,
clipboard: &mut self.clipboard,
};
command.apply(&mut state)
})
}
/// Brings everything derived from the files back in line with the current stack entry.
fn refresh(&mut self, diff: Option<&Diff>) {
let current = self.stack.current();
match current {
Some(files) => {
// files coming back after an undo/redo are added at the end of the order
self.order.sync(files);
self.selection.retain_existing(files);
}
None => {
self.order.0.clear();
self.selection = Selection::Empty;
}
}
self.statistics_cache.update(current);
self.coordinates_cache.update(current);
self.structure_cache.update(current, diff);
self.routing_buffer
.update(current, &self.selection, &self.order.0);
self.statistics_buffer
.update(
&self
.statistics_cache
.selected(current, &self.selection, &self.order.0),
);
}
}
/// Puts the files of a diff in the order of the files (the stack does not know it), so that
/// whatever is done with them one after the other, like giving them colors, follows it.
fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
let position = |id: &FileId| order.iter().position(|other| other == id);
if let Some(diff) = diff {
diff.added.sort_by_key(position);
diff.modified.sort_by_key(position);
}
}
#[cfg(test)]
impl Engine {
/// Executes an action and tells whether it changed anything. What it did is kept for the
/// accessors below, which the tests read right after.
pub(crate) fn run(&mut self, action: Action) -> bool {
self.execute(action).changed
}
pub(crate) fn last_diff(&self) -> Option<&Diff> {
self.last.diff.as_ref()
}
pub(crate) fn last_error(&self) -> Option<&CommandError> {
self.last.error.as_ref()
}
pub(crate) fn order_changed(&self) -> bool {
self.last.order_changed
}
pub(crate) fn selection_changed(&self) -> bool {
self.last.selection_changed
}
pub(crate) fn clipboard_changed(&self) -> bool {
self.last.clipboard_changed
}
}
#[cfg(test)]
mod tests;
+21
View File
@@ -0,0 +1,21 @@
use crate::{CommandError, Diff};
/// What an action did, returned by [`crate::Engine::execute`] and [`crate::Engine::restore`].
///
/// It describes that action only: it is not kept by the engine, so reading it never depends on
/// what was done since.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Outcome {
/// Whether anything changed: the files, the selection, the order of the files or the
/// clipboard.
pub changed: bool,
/// Which files the action added, removed or modified, in the order of the files. `None` when
/// the files are as they were.
pub diff: Option<Diff>,
pub order_changed: bool,
pub selection_changed: bool,
pub clipboard_changed: bool,
/// Why an edit did nothing: the command failed, or it had nothing to change. `None` if it
/// succeeded, and for the actions that do not edit the files.
pub error: Option<CommandError>,
}
+9 -19
View File
@@ -120,20 +120,14 @@ impl Clipboard {
.collect(), .collect(),
) )
} }
Selection::Waypoints { file_id } => ClipboardContent::Waypoints( Selection::Waypoints { file_id } => {
files ClipboardContent::Waypoints(files.get(file_id)?.wpt.iter().cloned().collect())
.get(file_id)? }
.wpt
.iter()
.flat_map(|chunk| chunk.wpt.iter().cloned())
.collect(),
),
Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints( Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints(
files files
.get(file_id)? .get(file_id)?
.wpt .wpt
.iter() .iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| wpt_ids.contains(&wpt.id)) .filter(|wpt| wpt_ids.contains(&wpt.id))
.cloned() .cloned()
.collect(), .collect(),
@@ -233,14 +227,14 @@ mod tests {
..Default::default() ..Default::default()
}); });
} }
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()], wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default() ..Default::default()
})); });
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()], wpt: vec![Waypoint::default()],
..Default::default() ..Default::default()
})); });
file file
} }
@@ -267,11 +261,7 @@ mod tests {
let file = file("source"); let file = file("source");
let (t0, t2) = (file.trk[0].id, file.trk[2].id); 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 (s0, s1) = (file.trk[1].trkseg[0].id, file.trk[1].trkseg[1].id);
let wpt: Vec<_> = file let wpt: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
let files = files_of(vec![file.clone()]); let files = files_of(vec![file.clone()]);
// tracks, in the order of the file, with the name of their file // 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 waypoints = Selection::Waypoints { file_id: file.id };
let waypoint = Selection::Waypoint { let waypoint = Selection::Waypoint {
file_id: file.id, file_id: file.id,
wpt_ids: [file.wpt[0].wpt[0].id].into(), wpt_ids: [file.wpt[0].id].into(),
}; };
let targets = [ let targets = [
Selection::Empty, Selection::Empty,
+118 -41
View File
@@ -40,7 +40,109 @@ pub enum Selection {
}, },
} }
/// Where a track segment is: its file, then its track and its own position in it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentLocation {
pub file_id: FileId,
pub trk: usize,
pub seg: usize,
}
impl Selection { impl Selection {
/// Combines `selection` with this one according to `mode`, ignoring the elements of
/// `selection` that do not exist in `files`. Nothing to add or toggle leaves the selection
/// as it is, while replacing it with nothing deselects everything.
pub fn select(
&mut self,
files: Option<&StackEntry>,
mut selection: Selection,
mode: SelectMode,
) {
match files {
Some(files) => selection.retain_existing(files),
None => selection = Selection::Empty,
}
match mode {
SelectMode::Replace => *self = selection,
_ if selection == Selection::Empty => {}
SelectMode::Add => self.extend(selection),
SelectMode::Toggle => self.toggle(selection),
}
}
/// Selects all the elements of the same kind as the selected ones, see
/// [`Selection::all_at_level`]. Does nothing when there is nothing more to select.
pub fn select_all(&mut self, files: &StackEntry, order: &[FileId]) {
if let Some(all) = self.all_at_level(files, order) {
*self = all;
}
}
/// Moves the selection to the next (`down`) or previous element, see
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
/// replacing it.
pub fn select_neighbour(
&mut self,
files: &StackEntry,
order: &[FileId],
down: bool,
add: bool,
) {
if let Some(next) = self.neighbour(files, order, down) {
if add {
self.extend(next);
} else {
*self = next;
}
}
}
}
impl Selection {
/// The track segments covered by the selection, in order: the segments of several selected
/// files follow `order` (the files missing from it come last), then the tracks and segments
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
pub fn segment_locations(&self, files: &StackEntry, order: &[FileId]) -> Vec<SegmentLocation> {
let mut locations = vec![];
let mut add = |file_id: FileId,
trk_filter: &dyn Fn(&crate::Track) -> bool,
seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| {
let Some(file) = files.get(&file_id) else {
return;
};
for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) {
for (seg, segment) in track.trkseg.iter().enumerate() {
if seg_filter(segment) {
locations.push(SegmentLocation { file_id, trk, seg });
}
}
}
};
match self {
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => (),
Selection::File { file_ids } => {
let ordered = order
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
add(*id, &|_| true, &|_| true);
}
}
Selection::Track { file_id, trk_ids } => {
add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => add(*file_id, &|trk| trk.id == *trk_id, &|seg| {
trkseg_ids.contains(&seg.id)
}),
}
locations
}
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments /// 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. /// 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: &StackEntry) {
@@ -74,12 +176,7 @@ impl Selection {
Selection::Waypoints { file_id } => !files.contains_key(file_id), Selection::Waypoints { file_id } => !files.contains_key(file_id),
Selection::Waypoint { file_id, wpt_ids } => match files.get(file_id) { Selection::Waypoint { file_id, wpt_ids } => match files.get(file_id) {
Some(file) => { Some(file) => {
wpt_ids.retain(|id| { wpt_ids.retain(|id| file.wpt.iter().any(|wpt| wpt.id == *id));
file.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.any(|wpt| wpt.id == *id)
});
wpt_ids.is_empty() wpt_ids.is_empty()
} }
None => true, None => true,
@@ -197,13 +294,10 @@ impl Selection {
} }
/// Position of the first and last of the `selected` ids among the `all` ids. /// 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>, all: impl Iterator<Item = T>,
selected: &HashSet<T>, selected: &HashSet<T>,
) -> Option<(usize, usize)> ) -> Option<(usize, usize)> {
where
T: std::hash::Hash + Eq,
{
let mut range: Option<(usize, usize)> = None; let mut range: Option<(usize, usize)> = None;
for (i, id) in all.enumerate() { for (i, id) in all.enumerate() {
if selected.contains(&id) { if selected.contains(&id) {
@@ -272,13 +366,7 @@ impl Selection {
Selection::Waypoints { .. } => None, Selection::Waypoints { .. } => None,
Selection::Waypoint { file_id, .. } => Some(Selection::Waypoint { Selection::Waypoint { file_id, .. } => Some(Selection::Waypoint {
file_id: *file_id, file_id: *file_id,
wpt_ids: files wpt_ids: files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect(),
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
.collect(),
}), }),
} }
} }
@@ -332,13 +420,8 @@ impl Selection {
}) })
} }
Selection::Waypoint { file_id, wpt_ids } => { Selection::Waypoint { file_id, wpt_ids } => {
let ids: Vec<WaypointId> = files let ids: Vec<WaypointId> =
.get(file_id)? files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect();
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
.collect();
Some(Selection::Waypoint { Some(Selection::Waypoint {
file_id: *file_id, file_id: *file_id,
wpt_ids: [neighbour(&ids, wpt_ids, down)?].into(), wpt_ids: [neighbour(&ids, wpt_ids, down)?].into(),
@@ -392,12 +475,12 @@ mod tests {
trkseg: vec![TrackSegment::default()], trkseg: vec![TrackSegment::default()],
..Default::default() ..Default::default()
}); });
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()], wpt: vec![Waypoint::default()],
..Default::default() ..Default::default()
})); });
let (file_id, trk_id) = (file.id, file.trk[0].id); let (file_id, trk_id) = (file.id, file.trk[0].id);
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].wpt[0].id); let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
let mut files = StackEntry::default(); let mut files = StackEntry::default();
files.insert(file_id, Rc::new(file)); files.insert(file_id, Rc::new(file));
@@ -543,15 +626,15 @@ mod tests {
..Default::default() ..Default::default()
}); });
} }
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()], wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default() ..Default::default()
})); });
file.wpt.push(Rc::new(WaypointChunk { file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()], wpt: vec![Waypoint::default()],
..Default::default() ..Default::default()
})); });
let tree = Tree { Tree {
file: file.id, file: file.id,
tracks: file.trk.iter().map(|t| t.id).collect(), tracks: file.trk.iter().map(|t| t.id).collect(),
segments: file segments: file
@@ -559,16 +642,10 @@ mod tests {
.iter() .iter()
.map(|t| t.trkseg.iter().map(|s| s.id).collect()) .map(|t| t.trkseg.iter().map(|s| s.id).collect())
.collect(), .collect(),
waypoints: file waypoints: file.wpt.iter().map(|w| w.id).collect(),
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|w| w.id)
.collect(),
order: vec![file.id], order: vec![file.id],
files: StackEntry::from([(file.id, Rc::new(file))]), files: StackEntry::from([(file.id, Rc::new(file))]),
}; }
tree
} }
#[test] #[test]
+195 -18
View File
@@ -3,28 +3,57 @@ use std::{
rc::Rc, rc::Rc,
}; };
use crate::{File, FileId}; use crate::{CommandError, File, FileId};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct Stack { pub struct Stack {
entries: Vec<StackEntry>, entries: Vec<StackEntry>,
index: Option<usize>, index: Option<usize>,
/// Whether the first entry is the state the stack starts from, which is not undone: files
/// restored from the storage.
floor: bool,
} }
impl Stack { impl Stack {
/// A stack whose first entry is `entry`, from which nothing can be undone.
pub fn restored(entry: StackEntry) -> Self {
Self {
entries: vec![entry],
index: Some(0),
floor: true,
}
}
pub fn current(&self) -> Option<&StackEntry> { pub fn current(&self) -> Option<&StackEntry> {
self.index.map_or_default(|i| self.get(i)) self.index.map_or_default(|i| self.get(i))
} }
pub fn create_and_push_next<F>(&mut self, f: F) -> Option<Diff> /// Applies `f` to a copy of the current entry and makes the copy the new current entry.
///
/// Nothing is pushed, and `None` is returned, if `f` fails (the error is returned) or if it
/// leaves every file as it was: a step that undoes nothing is not worth a place in the
/// history. Files are told apart by their identity, not their content.
pub fn create_and_push_next<F>(&mut self, f: F) -> Result<Option<Diff>, CommandError>
where where
F: FnOnce(&mut StackEntry) -> Result<(), String>, F: FnOnce(&mut StackEntry) -> Result<(), CommandError>,
{ {
self.record_diff(|stack| { let mut next = self.current().map_or_default(|c| c.clone());
let mut next = stack.current().map_or_default(|c| c.clone()); f(&mut next)?;
if f(&mut next).is_ok() { if self.is_current(&next) {
stack.push(next); return Ok(None);
} }
Ok(self.record_diff(|stack| stack.push(next)))
}
/// Whether `entry` holds exactly the files of the current entry.
fn is_current(&self, entry: &StackEntry) -> bool {
let current = self.current();
let len = current.map_or(0, |current| current.len());
entry.len() == len
&& current.is_none_or(|current| {
entry
.iter()
.all(|(id, file)| current.get(id).is_some_and(|c| Rc::ptr_eq(c, file)))
}) })
} }
@@ -32,7 +61,9 @@ impl Stack {
self.record_diff(|stack| { self.record_diff(|stack| {
if let Some(i) = stack.index { if let Some(i) = stack.index {
if i == 0 { if i == 0 {
if !stack.floor {
stack.index = None; stack.index = None;
}
} else { } else {
stack.index = Some(i - 1); stack.index = Some(i - 1);
} }
@@ -56,7 +87,7 @@ impl Stack {
} }
pub fn can_undo(&self) -> bool { pub fn can_undo(&self) -> bool {
self.index.is_some() self.index.is_some_and(|i| i > 0 || !self.floor)
} }
pub fn can_redo(&self) -> bool { pub fn can_redo(&self) -> bool {
@@ -97,7 +128,7 @@ impl Stack {
for id in prev_ids.intersection(&cur_ids) { for id in prev_ids.intersection(&cur_ids) {
let before = prev.map_or_default(|e| e.get(id)); let before = prev.map_or_default(|e| e.get(id));
let after = cur.map_or_default(|e| e.get(id)); let after = cur.map_or_default(|e| e.get(id));
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) && before != after { if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) {
modified.push(*id); modified.push(*id);
} }
} }
@@ -123,6 +154,16 @@ pub struct Diff {
mod tests { mod tests {
use super::*; use super::*;
impl Stack {
/// A step that is expected not to fail.
fn push_ok(
&mut self,
f: impl FnOnce(&mut StackEntry) -> Result<(), CommandError>,
) -> Option<Diff> {
self.create_and_push_next(f).unwrap()
}
}
#[test] #[test]
fn test_new_file() { fn test_new_file() {
let mut stack = Stack::default(); let mut stack = Stack::default();
@@ -131,7 +172,7 @@ mod tests {
assert!(!stack.can_redo()); assert!(!stack.can_redo());
assert!(stack.current().is_none()); assert!(stack.current().is_none());
let diff = stack.create_and_push_next(|e| { let diff = stack.push_ok(|e| {
let file = Rc::new(File::default()); let file = Rc::new(File::default());
e.insert(file.id, file); e.insert(file.id, file);
Ok(()) Ok(())
@@ -153,7 +194,7 @@ mod tests {
let file = Rc::new(File::default()); let file = Rc::new(File::default());
let id = file.id; let id = file.id;
stack stack
.create_and_push_next(|e| { .push_ok(|e| {
e.insert(id, file); e.insert(id, file);
Ok(()) Ok(())
}) })
@@ -166,9 +207,9 @@ mod tests {
let mut stack = Stack::default(); let mut stack = Stack::default();
let diff = stack.create_and_push_next(|e| { let diff = stack.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default())); e.insert(FileId::default(), Rc::new(File::default()));
Err("nope".to_string()) Err(CommandError::InvalidData("nope".to_string()))
}); });
assert!(diff.is_none()); assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
assert!(!stack.can_undo()); assert!(!stack.can_undo());
assert!(stack.current().is_none()); assert!(stack.current().is_none());
} }
@@ -203,7 +244,7 @@ mod tests {
let id = add_file(&mut stack); let id = add_file(&mut stack);
let diff = stack let diff = stack
.create_and_push_next(|e| { .push_ok(|e| {
let mut file = (**e.get(&id).unwrap()).clone(); let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string(); file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file)); e.insert(id, Rc::new(file));
@@ -213,12 +254,20 @@ mod tests {
assert_eq!(diff.modified, vec![id]); assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty()); assert!(diff.added.is_empty() && diff.removed.is_empty());
// an unchanged file is not reported as modified // a step that changes nothing is not a step
let diff = stack.create_and_push_next(|_| Ok(())).unwrap(); assert!(stack.push_ok(|_| Ok(())).is_none());
assert!(diff.modified.is_empty()); assert!(
stack
.push_ok(|e| {
let file = e[&id].clone();
e.insert(id, file);
Ok(())
})
.is_none()
);
let diff = stack let diff = stack
.create_and_push_next(|e| { .push_ok(|e| {
e.remove(&id); e.remove(&id);
Ok(()) Ok(())
}) })
@@ -231,6 +280,34 @@ mod tests {
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed"); assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
} }
#[test]
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
// a copy, even if it has the same content, is another file for the stack: files are
// told apart by their identity, comparing them would read all of them
let diff = stack
.push_ok(|e| {
let copy = (**e.get(&id).unwrap()).clone();
e.insert(id, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
// and the files that were not replaced are not
let other = add_file(&mut stack);
let diff = stack
.push_ok(|e| {
let copy = (**e.get(&other).unwrap()).clone();
e.insert(other, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![other]);
}
#[test] #[test]
fn test_push_after_undo_drops_redo_branch() { fn test_push_after_undo_drops_redo_branch() {
let mut stack = Stack::default(); let mut stack = Stack::default();
@@ -249,4 +326,104 @@ mod tests {
assert!(current.contains_key(&third)); assert!(current.contains_key(&third));
assert!(!current.contains_key(&second)); assert!(!current.contains_key(&second));
} }
fn restored_entry() -> (StackEntry, FileId) {
let file = Rc::new(File::default());
let id = file.id;
(StackEntry::from([(id, file)]), id)
}
#[test]
fn test_restored_stack_starts_from_its_entry() {
let (entry, id) = restored_entry();
let mut stack = Stack::restored(entry);
assert!(stack.current().unwrap().contains_key(&id));
// there is nothing to go back to, nor forward to
assert!(!stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.undo().is_none());
assert!(stack.redo().is_none());
assert!(stack.current().unwrap().contains_key(&id));
}
#[test]
fn test_restored_stack_cannot_undo_an_empty_entry_either() {
let mut stack = Stack::restored(StackEntry::new());
assert!(stack.current().is_some_and(|files| files.is_empty()));
assert!(!stack.can_undo());
assert!(stack.undo().is_none());
// the stack is not the one of an engine that has done nothing
assert!(stack.current().is_some());
}
#[test]
fn test_restored_stack_undoes_what_comes_after_it_and_no_further() {
let (entry, restored) = restored_entry();
let mut stack = Stack::restored(entry);
let added = add_file(&mut stack);
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert_eq!(stack.current().unwrap().len(), 2);
// back to the restored files
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![added]);
assert!(diff.added.is_empty() && diff.modified.is_empty());
assert_eq!(stack.current().unwrap().len(), 1);
assert!(stack.current().unwrap().contains_key(&restored));
assert!(!stack.can_undo());
assert!(stack.can_redo());
assert!(stack.undo().is_none());
assert!(stack.current().unwrap().contains_key(&restored));
let diff = stack.redo().unwrap();
assert_eq!(diff.added, vec![added]);
assert!(stack.can_undo());
assert!(!stack.can_redo());
// and again
assert!(stack.undo().is_some());
assert!(stack.undo().is_none());
assert!(stack.current().unwrap().contains_key(&restored));
}
#[test]
fn test_restored_stack_removing_its_files_can_be_undone() {
let (entry, restored) = restored_entry();
let mut stack = Stack::restored(entry);
let diff = stack
.push_ok(|e| {
e.remove(&restored);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![restored]);
assert!(stack.current().unwrap().is_empty());
let diff = stack.undo().unwrap();
assert_eq!(diff.added, vec![restored]);
assert!(stack.current().unwrap().contains_key(&restored));
assert!(!stack.can_undo());
}
#[test]
fn test_restored_stack_push_after_undo_drops_redo_branch() {
let (entry, restored) = restored_entry();
let mut stack = Stack::restored(entry);
add_file(&mut stack);
stack.undo().unwrap();
assert!(stack.can_redo());
let other = add_file(&mut stack);
assert!(!stack.can_redo());
assert!(stack.can_undo());
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![other]);
assert!(stack.current().unwrap().contains_key(&restored));
assert!(!stack.can_undo());
}
} }
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1,5 +1,7 @@
mod core; mod core;
mod engine; mod engine;
mod storage;
pub use core::*; pub use core::*;
pub use engine::*; pub use engine::*;
pub use storage::*;
+593
View File
@@ -0,0 +1,593 @@
//! The binary form of what is stored.
//!
//! Every blob starts with a version byte, then the data (postcard, which does not name the fields:
//! the form is exactly the types that wrote it). So a form never changes once it has been
//! written: the types of each version live in their own module (see [`v1`]) and are frozen, and
//! a change is a new version next to them. Blobs of any known version can be read; they are
//! written again in the latest version when they are written again. Chunks are never rewritten
//! unless they change, so blobs of several versions live together in a storage.
//!
//! To change the form: add `vN`, make `LATEST` its number, convert from it in the decoders, and
//! keep the golden bytes of the older versions in the tests.
mod v1;
use std::rc::Rc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Trackpoint,
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
WaypointChunkId, Waypoints,
};
/// The version that is written.
pub const LATEST: u8 = 1;
/// Why a blob could not be read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecodeError {
/// It was written by a version that this one does not know: a newer one, or not a blob.
UnknownVersion(u8),
/// It has a known version, but not what it should have.
Corrupt,
}
fn encode<T: Serialize + ?Sized>(value: &T) -> Vec<u8> {
postcard::to_extend(value, vec![LATEST]).expect("serializing to memory")
}
/// The version of a blob, and its data.
fn split(bytes: &[u8]) -> Result<(u8, &[u8]), DecodeError> {
bytes
.split_first()
.map(|(v, data)| (*v, data))
.ok_or(DecodeError::Corrupt)
}
fn parse<'a, T: Deserialize<'a>>(data: &'a [u8]) -> Result<T, DecodeError> {
postcard::from_bytes(data).map_err(|_| DecodeError::Corrupt)
}
/// A file without its trackpoints and waypoints, which are in chunks.
pub struct FileRecord {
info: FileInfo,
tracks: Vec<TrackRecord>,
waypoints: Vec<WaypointChunkId>,
}
struct TrackRecord {
id: TrackId,
info: TrackInfo,
segments: Vec<SegmentRecord>,
}
struct SegmentRecord {
id: TrackSegmentId,
chunks: Vec<TrackpointChunkId>,
}
impl From<v1::FileRecord1> for FileRecord {
fn from(record: v1::FileRecord1) -> Self {
Self {
info: record.info.into(),
tracks: record
.tracks
.into_iter()
.map(|track| TrackRecord {
id: TrackId(track.id),
info: track.info.into(),
segments: track
.segments
.into_iter()
.map(|segment| SegmentRecord {
id: TrackSegmentId(segment.id),
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
})
.collect(),
})
.collect(),
waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(),
}
}
}
impl FileRecord {
/// The file made of the record and its chunks, `None` if one is missing. Chunks that several
/// files use stay shared.
pub fn build(
self,
id: FileId,
trackpoint_chunks: &impl Fn(&TrackpointChunkId) -> Option<Rc<TrackpointChunk>>,
waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option<Rc<WaypointChunk>>,
) -> Option<File> {
let mut file = File {
id,
info: self.info,
..Default::default()
};
for track in self.tracks {
let mut trkseg = Vec::with_capacity(track.segments.len());
for segment in track.segments {
let mut built = TrackSegment::default();
built.id = segment.id;
for chunk in &segment.chunks {
built.push_shared(trackpoint_chunks(chunk)?);
}
trkseg.push(built);
}
file.trk.push(Track {
id: track.id,
info: track.info,
trkseg,
});
}
let mut wpt = Waypoints::default();
for chunk in &self.waypoints {
wpt.push_shared(waypoint_chunks(chunk)?);
}
file.wpt = wpt;
Some(file)
}
}
pub fn encode_file(file: &File) -> Vec<u8> {
encode(&v1::FileRecord1::new(file))
}
pub fn decode_file(bytes: &[u8]) -> Result<FileRecord, DecodeError> {
match split(bytes)? {
(1, data) => Ok(parse::<v1::FileRecord1>(data)?.into()),
(version, _) => Err(DecodeError::UnknownVersion(version)),
}
}
pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec<u8> {
encode(
&chunk
.trkpt
.iter()
.map(v1::Trackpoint1::from)
.collect::<Vec<_>>(),
)
}
pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Result<TrackpointChunk, DecodeError> {
let trkpt = match split(bytes)? {
(1, data) => parse::<Vec<v1::Trackpoint1>>(data)?
.into_iter()
.map(Trackpoint::from)
.collect(),
(version, _) => return Err(DecodeError::UnknownVersion(version)),
};
Ok(TrackpointChunk {
id: TrackpointChunkId(id),
trkpt,
})
}
pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec<u8> {
encode(
&chunk
.wpt
.iter()
.map(v1::Waypoint1::from)
.collect::<Vec<_>>(),
)
}
pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Result<WaypointChunk, DecodeError> {
let wpt = match split(bytes)? {
(1, data) => parse::<Vec<v1::Waypoint1>>(data)?
.into_iter()
.map(Waypoint::from)
.collect(),
(version, _) => return Err(DecodeError::UnknownVersion(version)),
};
Ok(WaypointChunk {
id: WaypointChunkId(id),
wpt,
})
}
pub fn encode_order(order: &[FileId]) -> Vec<u8> {
encode(&order.iter().map(|id| id.0).collect::<Vec<_>>())
}
pub fn decode_order(bytes: &[u8]) -> Result<Vec<FileId>, DecodeError> {
match split(bytes)? {
(1, data) => Ok(parse::<Vec<Uuid>>(data)?.into_iter().map(FileId).collect()),
(version, _) => Err(DecodeError::UnknownVersion(version)),
}
}
pub fn encode_categories(categories: &TrackpointCategories) -> Vec<u8> {
encode(&v1::Categories1::from(categories))
}
pub fn decode_categories(bytes: &[u8]) -> Result<TrackpointCategories, DecodeError> {
match split(bytes)? {
(1, data) => Ok(parse::<v1::Categories1>(data)?.into()),
(version, _) => Err(DecodeError::UnknownVersion(version)),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::{Author, Link, LngLat};
use super::*;
fn id(n: u128) -> Uuid {
Uuid::from_u128(n)
}
fn link(href: &str) -> Link {
Link {
href: href.into(),
text: Some(format!("text of {href}")),
}
}
fn trackpoint(n: u8) -> Trackpoint {
let f = f64::from(n);
Trackpoint {
coordinates: LngLat {
lng: 4.0 + f,
lat: 50.0 + f,
},
ele: 100.5 + f,
time: Some(1_700_000_000_000 + i64::from(n)),
atemp: Some(-3),
hr: Some(140 + u16::from(n)),
cad: Some(80),
power: Some(250),
surface: Some(1),
highway: Some(2),
sac_scale: Some(3),
mtb_scale: Some(4),
anchor: Some(n),
}
}
fn waypoint() -> Waypoint {
Waypoint {
id: crate::WaypointId(id(7)),
coordinates: LngLat {
lng: 5.5,
lat: 51.5,
},
ele: 321.0,
time: Some(1_700_000_000_000),
name: Some("summit".into()),
cmt: Some("comment".into()),
desc: Some("description".into()),
links: vec![
link("https://example.com/summit"),
link("https://example.com/summit-2"),
],
sym: Some("Summit".into()),
type_: Some("peak".into()),
}
}
/// A file that has a value for every field, with fixed ids.
fn sample() -> File {
let mut file = File {
id: FileId(id(1)),
info: FileInfo {
name: "sample".into(),
desc: Some("about".into()),
author: Some(Author {
name: Some("someone".into()),
email: Some("someone@example.com".into()),
link: Some(link("https://example.com/someone")),
}),
links: vec![
link("https://example.com/file"),
link("https://example.com/file-2"),
],
time: Some(1_700_000_000_000),
},
..Default::default()
};
let mut segment = TrackSegment::default();
segment.id = TrackSegmentId(id(3));
segment.push(TrackpointChunk {
id: TrackpointChunkId(id(4)),
trkpt: vec![trackpoint(0), trackpoint(1), Trackpoint::default()],
});
let mut empty = TrackSegment::default();
empty.id = TrackSegmentId(id(6));
file.trk.push(Track {
id: TrackId(id(2)),
info: TrackInfo {
name: Some("track".into()),
cmt: Some("cmt".into()),
desc: Some("desc".into()),
src: Some("src".into()),
links: vec![
link("https://example.com/track"),
link("https://example.com/track-2"),
],
type_: Some("Cycling".into()),
color: Some("ff0000".into()),
opacity: Some(0.5),
width: Some(6.0),
},
trkseg: vec![segment, empty],
});
file.wpt.push(WaypointChunk {
id: WaypointChunkId(id(5)),
wpt: vec![
waypoint(),
Waypoint {
id: crate::WaypointId(id(8)),
..Default::default()
},
],
});
file
}
fn categories() -> TrackpointCategories {
let mut categories = TrackpointCategories::default();
categories.surface.code("asphalt");
categories.surface.code("gravel");
categories.highway.code("path");
categories.sac_scale.code("hiking");
categories.mtb_scale.code("mtb0");
categories
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(text: &str) -> Vec<u8> {
(0..text.len())
.step_by(2)
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap())
.collect()
}
/// The chunks of a file, by identity.
fn chunks_of(
file: &File,
) -> (
HashMap<TrackpointChunkId, Rc<TrackpointChunk>>,
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
) {
let trackpoints = file
.trk
.iter()
.flat_map(|track| &track.trkseg)
.flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone())))
.collect();
let waypoints = file
.wpt
.chunks()
.iter()
.map(|c| (c.id, c.clone()))
.collect();
(trackpoints, waypoints)
}
fn rebuilt(file: &File, record: FileRecord) -> Option<File> {
let (trackpoints, waypoints) = chunks_of(file);
record.build(file.id, &|id| trackpoints.get(id).cloned(), &|id| {
waypoints.get(id).cloned()
})
}
fn assert_same_file(a: &File, b: &File) {
assert_eq!(a.id, b.id);
assert_eq!(a.info, b.info);
assert_eq!(a.trk.len(), b.trk.len());
for (a, b) in a.trk.iter().zip(&b.trk) {
assert_eq!((a.id, &a.info), (b.id, &b.info));
assert_eq!(a.trkseg.len(), b.trkseg.len());
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_eq!(a.id, b.id);
assert_eq!(a.len(), b.len());
for (a, b) in a.chunks().iter().zip(b.chunks()) {
assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given");
}
}
}
assert_eq!(a.wpt.len(), b.wpt.len());
for (a, b) in a.wpt.chunks().iter().zip(b.wpt.chunks()) {
assert!(Rc::ptr_eq(a, b));
}
}
#[test]
fn test_a_file_record_rebuilds_the_file_with_the_same_chunks() {
let file = sample();
let record = decode_file(&encode_file(&file)).unwrap();
assert_same_file(&rebuilt(&file, record).unwrap(), &file);
}
#[test]
fn test_a_file_record_of_a_missing_chunk_builds_nothing() {
let file = sample();
let (trackpoints, waypoints) = chunks_of(&file);
let record = || decode_file(&encode_file(&file)).unwrap();
assert!(
record()
.build(file.id, &|_| None, &|id| waypoints.get(id).cloned())
.is_none()
);
assert!(
record()
.build(file.id, &|id| trackpoints.get(id).cloned(), &|_| None)
.is_none()
);
}
#[test]
fn test_a_file_without_data_builds_an_empty_file() {
let file = File::default();
let built = decode_file(&encode_file(&file))
.unwrap()
.build(file.id, &|_| None, &|_| None)
.unwrap();
assert!(built.trk.is_empty() && built.wpt.is_empty());
}
#[test]
fn test_chunks_keep_their_identity_and_items() {
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
let decoded = decode_trackpoints(chunk.id.0, &encode_trackpoints(chunk)).unwrap();
assert_eq!(decoded.id, chunk.id);
assert_eq!(decoded.trkpt.len(), 3);
for (a, b) in decoded.trkpt.iter().zip(&chunk.trkpt) {
assert_eq!(format!("{a:?}"), format!("{b:?}"));
}
let chunk = &sample().wpt.chunks()[0].clone();
let decoded = decode_waypoints(chunk.id.0, &encode_waypoints(chunk)).unwrap();
assert_eq!(decoded.id, chunk.id);
for (a, b) in decoded.wpt.iter().zip(&chunk.wpt) {
assert_eq!(a.id, b.id);
assert!(a.same_content(b));
}
}
#[test]
fn test_order_and_categories_round_trip() {
let order = vec![FileId(id(9)), FileId(id(8))];
assert_eq!(decode_order(&encode_order(&order)).unwrap(), order);
let categories = categories();
assert_eq!(
decode_categories(&encode_categories(&categories)).unwrap(),
categories
);
}
#[test]
fn test_blobs_say_their_version_and_unknown_ones_are_told_apart_from_corrupt_ones() {
assert_eq!(encode_file(&sample())[0], LATEST);
assert_eq!(encode_order(&[])[0], LATEST);
let mut bytes = encode_order(&[FileId(id(1))]);
bytes[0] = LATEST + 1;
assert_eq!(
decode_order(&bytes),
Err(DecodeError::UnknownVersion(LATEST + 1))
);
assert_eq!(decode_order(&[0]), Err(DecodeError::UnknownVersion(0)));
assert!(matches!(
decode_file(&bytes),
Err(DecodeError::UnknownVersion(_))
));
assert!(matches!(
decode_trackpoints(id(1), &bytes),
Err(DecodeError::UnknownVersion(_))
));
assert!(matches!(
decode_waypoints(id(1), &bytes),
Err(DecodeError::UnknownVersion(_))
));
assert!(matches!(
decode_categories(&bytes),
Err(DecodeError::UnknownVersion(_))
));
// nothing at all, or a known version with something else
assert_eq!(decode_order(&[]), Err(DecodeError::Corrupt));
assert_eq!(
decode_file(&[LATEST, 0xff, 0xff]).err(),
Some(DecodeError::Corrupt)
);
assert_eq!(
decode_trackpoints(id(1), &[LATEST, 9, 9, 9]).err(),
Some(DecodeError::Corrupt)
);
assert_eq!(decode_categories(&[LATEST]), Err(DecodeError::Corrupt));
}
// The stored forms of version 1, as they were written when the version was made. They must
// stay readable, whatever happens to the engine types: if a test below fails, the form was
// changed by mistake, or a version was not added when it should have been.
const FILE_V1: &str = concat!(
"010673616d706c65010561626f7574010107736f6d656f6e650113736f6d656f6e65406578616d706c652e63",
"6f6d011b68747470733a2f2f6578616d706c652e636f6d2f736f6d656f6e65012374657874206f6620687474",
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
"70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
"6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472",
"61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261",
"636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a",
"2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70",
"6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000",
"0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000",
"000000000000000000000000000600011000000000000000000000000000000005",
);
const TRACKPOINTS_V1: &str = concat!(
"01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101",
"0101020103010401000000000000001440000000000080494000000000006059400182a0abfef9620105018d",
"01015001fa010101010201030104010100000000000000000000000000000000000000000000000000000000",
"000000000000",
);
const WAYPOINTS_V1: &str = concat!(
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
"80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e021a68747470733a",
"2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c",
"652e636f6d2f73756d6d69741c68747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32012474",
"657874206f662068747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32010653756d6d697401",
"047065616b100000000000000000000000000000000800000000000000000000000000000000000000000000",
"000000000000000000",
);
const ORDER_V1: &str =
"010210000000000000000000000000000000091000000000000000000000000000000008";
const CATEGORIES_V1: &str =
"01020761737068616c740667726176656c010470617468010668696b696e6701046d746230";
#[test]
fn test_version_1_is_still_written_as_it_was() {
let file = sample();
assert_eq!(hex(&encode_file(&file)), FILE_V1);
assert_eq!(
hex(&encode_trackpoints(&file.trk[0].trkseg[0].chunks()[0])),
TRACKPOINTS_V1
);
assert_eq!(hex(&encode_waypoints(&file.wpt.chunks()[0])), WAYPOINTS_V1);
assert_eq!(
hex(&encode_order(&[FileId(id(9)), FileId(id(8))])),
ORDER_V1
);
assert_eq!(hex(&encode_categories(&categories())), CATEGORIES_V1);
}
#[test]
fn test_version_1_is_still_read_as_it_was() {
let file = sample();
assert_same_file(
&rebuilt(&file, decode_file(&unhex(FILE_V1)).unwrap()).unwrap(),
&file,
);
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1)).unwrap();
let expected = &file.trk[0].trkseg[0].chunks()[0];
assert_eq!(
format!("{:?}", chunk.trkpt),
format!("{:?}", expected.trkpt)
);
let chunk = decode_waypoints(id(5), &unhex(WAYPOINTS_V1)).unwrap();
assert_eq!(chunk.wpt.len(), 2);
assert!(chunk.wpt[0].same_content(&waypoint()));
assert_eq!(chunk.wpt[0].id, waypoint().id);
assert_eq!(
decode_order(&unhex(ORDER_V1)).unwrap(),
vec![FileId(id(9)), FileId(id(8))]
);
assert_eq!(
decode_categories(&unhex(CATEGORIES_V1)).unwrap(),
categories()
);
}
}
+318
View File
@@ -0,0 +1,318 @@
//! Version 1 of the stored form. It is frozen: the engine types may change, these may not (a test
//! compares the bytes they give with the ones of a fixture). A change of the form is a new
//! version, next to this one, that the decoders convert from.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, Trackpoint, TrackpointCategories,
Waypoint, WaypointId,
};
#[derive(Serialize, Deserialize)]
pub struct Link1 {
href: String,
text: Option<String>,
}
impl From<&Link> for Link1 {
fn from(link: &Link) -> Self {
Self {
href: link.href.clone(),
text: link.text.clone(),
}
}
}
impl From<Link1> for Link {
fn from(link: Link1) -> Self {
Self {
href: link.href,
text: link.text,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct Author1 {
name: Option<String>,
email: Option<String>,
link: Option<Link1>,
}
#[derive(Serialize, Deserialize)]
pub struct FileInfo1 {
name: String,
desc: Option<String>,
author: Option<Author1>,
links: Vec<Link1>,
time: Option<i64>,
}
impl From<&FileInfo> for FileInfo1 {
fn from(info: &FileInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
author: info.author.as_ref().map(|author| Author1 {
name: author.name.clone(),
email: author.email.clone(),
link: author.link.as_ref().map(Into::into),
}),
links: info.links.iter().map(Into::into).collect(),
time: info.time,
}
}
}
impl From<FileInfo1> for FileInfo {
fn from(info: FileInfo1) -> Self {
Self {
name: info.name,
desc: info.desc,
author: info.author.map(|author| Author {
name: author.name,
email: author.email,
link: author.link.map(Into::into),
}),
links: info.links.into_iter().map(Into::into).collect(),
time: info.time,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct TrackInfo1 {
name: Option<String>,
cmt: Option<String>,
desc: Option<String>,
src: Option<String>,
links: Vec<Link1>,
type_: Option<String>,
color: Option<String>,
opacity: Option<f64>,
width: Option<f64>,
}
impl From<&TrackInfo> for TrackInfo1 {
fn from(info: &TrackInfo) -> Self {
Self {
name: info.name.clone(),
cmt: info.cmt.clone(),
desc: info.desc.clone(),
src: info.src.clone(),
links: info.links.iter().map(Into::into).collect(),
type_: info.type_.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
impl From<TrackInfo1> for TrackInfo {
fn from(info: TrackInfo1) -> Self {
Self {
name: info.name,
cmt: info.cmt,
desc: info.desc,
src: info.src,
links: info.links.into_iter().map(Into::into).collect(),
type_: info.type_,
color: info.color,
opacity: info.opacity,
width: info.width,
}
}
}
/// A file without its trackpoints and waypoints, which are in chunks.
#[derive(Serialize, Deserialize)]
pub struct FileRecord1 {
pub info: FileInfo1,
pub tracks: Vec<TrackRecord1>,
pub waypoints: Vec<Uuid>,
}
#[derive(Serialize, Deserialize)]
pub struct TrackRecord1 {
pub id: Uuid,
pub info: TrackInfo1,
pub segments: Vec<SegmentRecord1>,
}
#[derive(Serialize, Deserialize)]
pub struct SegmentRecord1 {
pub id: Uuid,
pub chunks: Vec<Uuid>,
}
impl FileRecord1 {
pub fn new(file: &File) -> Self {
Self {
info: (&file.info).into(),
tracks: file
.trk
.iter()
.map(|track| TrackRecord1 {
id: track.id.0,
info: (&track.info).into(),
segments: track
.trkseg
.iter()
.map(|segment| SegmentRecord1 {
id: segment.id.0,
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
})
.collect(),
})
.collect(),
waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(),
}
}
}
#[derive(Serialize, Deserialize)]
pub struct Trackpoint1 {
lng: f64,
lat: f64,
ele: f64,
time: Option<i64>,
atemp: Option<i16>,
hr: Option<u16>,
cad: Option<u16>,
power: Option<u16>,
surface: Option<u8>,
highway: Option<u8>,
sac_scale: Option<u8>,
mtb_scale: Option<u8>,
anchor: Option<u8>,
}
impl From<&Trackpoint> for Trackpoint1 {
fn from(point: &Trackpoint) -> Self {
Self {
lng: point.coordinates.lng,
lat: point.coordinates.lat,
ele: point.ele,
time: point.time,
atemp: point.atemp,
hr: point.hr,
cad: point.cad,
power: point.power,
surface: point.surface,
highway: point.highway,
sac_scale: point.sac_scale,
mtb_scale: point.mtb_scale,
anchor: point.anchor,
}
}
}
impl From<Trackpoint1> for Trackpoint {
fn from(point: Trackpoint1) -> Self {
Self {
coordinates: LngLat {
lng: point.lng,
lat: point.lat,
},
ele: point.ele,
time: point.time,
atemp: point.atemp,
hr: point.hr,
cad: point.cad,
power: point.power,
surface: point.surface,
highway: point.highway,
sac_scale: point.sac_scale,
mtb_scale: point.mtb_scale,
anchor: point.anchor,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct Waypoint1 {
id: Uuid,
lng: f64,
lat: f64,
ele: f64,
time: Option<i64>,
name: Option<String>,
cmt: Option<String>,
desc: Option<String>,
links: Vec<Link1>,
sym: Option<String>,
type_: Option<String>,
}
impl From<&Waypoint> for Waypoint1 {
fn from(waypoint: &Waypoint) -> Self {
Self {
id: waypoint.id.0,
lng: waypoint.coordinates.lng,
lat: waypoint.coordinates.lat,
ele: waypoint.ele,
time: waypoint.time,
name: waypoint.name.clone(),
cmt: waypoint.cmt.clone(),
desc: waypoint.desc.clone(),
links: waypoint.links.iter().map(Into::into).collect(),
sym: waypoint.sym.clone(),
type_: waypoint.type_.clone(),
}
}
}
impl From<Waypoint1> for Waypoint {
fn from(waypoint: Waypoint1) -> Self {
Self {
id: WaypointId(waypoint.id),
coordinates: LngLat {
lng: waypoint.lng,
lat: waypoint.lat,
},
ele: waypoint.ele,
time: waypoint.time,
name: waypoint.name,
cmt: waypoint.cmt,
desc: waypoint.desc,
links: waypoint.links.into_iter().map(Into::into).collect(),
sym: waypoint.sym,
type_: waypoint.type_,
}
}
}
/// The names of each table of categories, in the order of their codes.
#[derive(Serialize, Deserialize)]
pub struct Categories1 {
surface: Vec<String>,
highway: Vec<String>,
sac_scale: Vec<String>,
mtb_scale: Vec<String>,
}
impl From<&TrackpointCategories> for Categories1 {
fn from(categories: &TrackpointCategories) -> Self {
let names = |table: &Categories| table.names().to_vec();
Self {
surface: names(&categories.surface),
highway: names(&categories.highway),
sac_scale: names(&categories.sac_scale),
mtb_scale: names(&categories.mtb_scale),
}
}
}
impl From<Categories1> for TrackpointCategories {
fn from(categories: Categories1) -> Self {
Self {
surface: Categories::from_names(categories.surface),
highway: Categories::from_names(categories.highway),
sac_scale: Categories::from_names(categories.sac_scale),
mtb_scale: Categories::from_names(categories.mtb_scale),
}
}
}
+219
View File
@@ -0,0 +1,219 @@
use std::cell::RefCell;
use std::collections::HashMap;
use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
/// and what the tests use.
#[derive(Debug, Default)]
pub struct MemoryStorage {
state: RefCell<State>,
}
#[derive(Debug, Default)]
struct State {
files: HashMap<FileId, Vec<u8>>,
chunks: HashMap<ChunkKey, Vec<u8>>,
order: Option<Vec<u8>>,
categories: Option<Vec<u8>>,
settings: HashMap<String, String>,
}
/// How much there is in a storage.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StorageCounts {
pub files: usize,
pub trackpoint_chunks: usize,
pub waypoint_chunks: usize,
}
impl MemoryStorage {
/// How many files and chunks are stored.
pub fn counts(&self) -> StorageCounts {
let state = self.state.borrow();
let chunks = |kind| state.chunks.keys().filter(|key| key.kind == kind).count();
StorageCounts {
files: state.files.len(),
trackpoint_chunks: chunks(ChunkKind::Trackpoints),
waypoint_chunks: chunks(ChunkKind::Waypoints),
}
}
}
impl Storage for MemoryStorage {
async fn load(&self) -> Result<StoredData, StorageError> {
let state = self.state.borrow();
Ok(StoredData {
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
order: state.order.clone(),
categories: state.categories.clone(),
})
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
let mut state = self.state.borrow_mut();
state.files.extend(batch.put_files.iter().cloned());
for id in &batch.delete_files {
state.files.remove(id);
}
state.chunks.extend(batch.put_chunks.iter().cloned());
for key in &batch.delete_chunks {
state.chunks.remove(key);
}
if let Some(order) = &batch.order {
state.order = Some(order.clone());
}
if let Some(categories) = &batch.categories {
state.categories = Some(categories.clone());
}
Ok(())
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
let state = self.state.borrow();
Ok(state
.settings
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect())
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
self.state
.borrow_mut()
.settings
.insert(key.to_owned(), value.to_owned());
Ok(())
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
self.state.borrow_mut().settings.remove(key);
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::task::{Context, Poll, Waker};
use uuid::Uuid;
use super::*;
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let mut context = Context::from_waker(Waker::noop());
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => value,
Poll::Pending => panic!("the memory storage does not wait"),
}
}
fn counts(files: usize, trackpoint_chunks: usize, waypoint_chunks: usize) -> StorageCounts {
StorageCounts {
files,
trackpoint_chunks,
waypoint_chunks,
}
}
#[test]
fn test_empty_storage_has_nothing() {
let storage = MemoryStorage::default();
let data = block_on(storage.load()).unwrap();
assert_eq!(data, StoredData::default());
assert_eq!(storage.counts(), counts(0, 0, 0));
assert!(block_on(storage.load_settings()).unwrap().is_empty());
}
#[test]
fn test_commit_puts_and_deletes() {
let storage = MemoryStorage::default();
let (a, b) = (FileId::default(), FileId::default());
let (t, w) = (Uuid::new_v4(), Uuid::new_v4());
block_on(storage.commit(&Batch {
put_files: vec![(a, vec![1]), (b, vec![2])],
put_chunks: vec![
(ChunkKey::trackpoints(t), vec![3]),
(ChunkKey::waypoints(w), vec![4]),
],
order: Some(vec![5]),
categories: Some(vec![6]),
..Default::default()
}))
.unwrap();
assert_eq!(storage.counts(), counts(2, 1, 1));
let mut data = block_on(storage.load()).unwrap();
data.files.sort_by_key(|(_, bytes)| bytes.clone());
assert_eq!(data.files, vec![(a, vec![1]), (b, vec![2])]);
data.chunks.sort_by_key(|(_, bytes)| bytes.clone());
assert_eq!(
data.chunks,
vec![
(ChunkKey::trackpoints(t), vec![3]),
(ChunkKey::waypoints(w), vec![4])
]
);
assert_eq!(
(data.order, data.categories),
(Some(vec![5]), Some(vec![6]))
);
// a put replaces, a delete removes, what is not mentioned stays
block_on(storage.commit(&Batch {
put_files: vec![(a, vec![10])],
delete_files: vec![b],
delete_chunks: vec![ChunkKey::trackpoints(t)],
..Default::default()
}))
.unwrap();
assert_eq!(storage.counts(), counts(1, 0, 1));
let data = block_on(storage.load()).unwrap();
assert_eq!(data.files, vec![(a, vec![10])]);
assert_eq!(
(data.order, data.categories),
(Some(vec![5]), Some(vec![6]))
);
block_on(storage.commit(&Batch {
delete_chunks: vec![ChunkKey::waypoints(w)],
order: Some(vec![]),
..Default::default()
}))
.unwrap();
assert_eq!(storage.counts(), counts(1, 0, 0));
assert_eq!(block_on(storage.load()).unwrap().order, Some(vec![]));
}
#[test]
fn test_settings() {
let storage = MemoryStorage::default();
block_on(storage.put_setting("a", "1")).unwrap();
block_on(storage.put_setting("b", "2")).unwrap();
block_on(storage.put_setting("a", "3")).unwrap();
let mut settings = block_on(storage.load_settings()).unwrap();
settings.sort();
assert_eq!(
settings,
vec![("a".into(), "3".into()), ("b".into(), "2".into())]
);
block_on(storage.delete_setting("a")).unwrap();
// deleting what is not there is fine
block_on(storage.delete_setting("missing")).unwrap();
assert_eq!(
block_on(storage.load_settings()).unwrap(),
vec![("b".into(), "2".into())]
);
}
#[test]
fn test_settings_are_not_files() {
let storage = MemoryStorage::default();
block_on(storage.put_setting("a", "1")).unwrap();
assert_eq!(block_on(storage.load()).unwrap(), StoredData::default());
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Keeping the files and the settings between sessions.
//!
//! The engine does not know where the data goes: the host (the browser, a desktop app...)
//! implements [`Storage`], and a [`Persister`] works out what to write after each change.
//!
//! The files are not stored as a whole. Their trackpoints and waypoints are stored in chunks (see
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
mod codec;
mod memory;
mod persister;
pub use memory::*;
pub use persister::*;
use uuid::Uuid;
use crate::FileId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageError(pub String);
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "storage error: {}", self.0)
}
}
impl std::error::Error for StorageError {}
/// What a chunk holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChunkKind {
Trackpoints,
Waypoints,
}
/// Where a chunk is stored: chunks are told apart by their identity within their kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ChunkKey {
pub kind: ChunkKind,
pub id: Uuid,
}
impl ChunkKey {
pub fn trackpoints(id: Uuid) -> Self {
Self {
kind: ChunkKind::Trackpoints,
id,
}
}
pub fn waypoints(id: Uuid) -> Self {
Self {
kind: ChunkKind::Waypoints,
id,
}
}
}
/// Everything that is stored about the files, as opaque bytes (see the module documentation).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct StoredData {
/// The record of each file.
pub files: Vec<(FileId, Vec<u8>)>,
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
/// The order of the files.
pub order: Option<Vec<u8>>,
/// The categories of the trackpoints, which their chunks refer to.
pub categories: Option<Vec<u8>>,
}
/// A change of the stored data, to be applied all at once: a stored state is always one that the
/// engine had.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Batch {
pub put_files: Vec<(FileId, Vec<u8>)>,
pub delete_files: Vec<FileId>,
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
pub delete_chunks: Vec<ChunkKey>,
pub order: Option<Vec<u8>>,
pub categories: Option<Vec<u8>>,
}
impl Batch {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
/// How many chunks of the kind the batch writes.
pub fn puts(&self, kind: ChunkKind) -> usize {
self.put_chunks
.iter()
.filter(|(key, _)| key.kind == kind)
.count()
}
/// How many chunks of the kind the batch deletes.
pub fn deletes(&self, kind: ChunkKind) -> usize {
self.delete_chunks
.iter()
.filter(|key| key.kind == kind)
.count()
}
}
/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that
/// keeps nothing after the session.
///
/// The methods are async because storage often is (IndexedDB), the engine itself is not: it
/// changes its files at once and the changes are written afterwards, see [`Persister`].
#[allow(async_fn_in_trait)]
pub trait Storage {
/// Reads the files.
async fn load(&self) -> Result<StoredData, StorageError>;
/// Applies a batch: either all of it is stored, or none.
async fn commit(&self, batch: &Batch) -> Result<(), StorageError>;
/// Reads the settings, as `(key, value)`. The engine does not look into them.
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError>;
async fn delete_setting(&self, key: &str) -> Result<(), StorageError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_batch_is_empty_until_it_holds_something() {
assert!(Batch::default().is_empty());
let holds = [
Batch {
put_files: vec![(FileId::default(), vec![])],
..Default::default()
},
Batch {
delete_files: vec![FileId::default()],
..Default::default()
},
Batch {
put_chunks: vec![(ChunkKey::waypoints(Uuid::new_v4()), vec![])],
..Default::default()
},
Batch {
delete_chunks: vec![ChunkKey::trackpoints(Uuid::new_v4())],
..Default::default()
},
Batch {
order: Some(vec![]),
..Default::default()
},
Batch {
categories: Some(vec![]),
..Default::default()
},
];
assert!(holds.iter().all(|batch| !batch.is_empty()));
}
#[test]
fn test_storage_error_is_readable() {
let error = StorageError("disk full".into());
assert_eq!(error.to_string(), "storage error: disk full");
let _: &dyn std::error::Error = &error;
}
}
+575
View File
@@ -0,0 +1,575 @@
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use uuid::Uuid;
use super::codec::{
DecodeError, decode_categories, decode_file, decode_order, decode_trackpoints,
decode_waypoints, encode_categories, encode_file, encode_order, encode_trackpoints,
encode_waypoints,
};
use crate::{
Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories,
TrackpointChunk, WaypointChunk,
};
/// What there is to keep of the engine: the files of the current state, their order, and the
/// categories their trackpoints refer to. See [`crate::Engine::snapshot`].
#[derive(Debug, Default, Clone)]
pub struct Snapshot {
pub files: StackEntry,
pub order: Vec<FileId>,
pub categories: TrackpointCategories,
}
/// What was read from the storage, to start an engine with (see [`crate::Engine::restore`]).
#[derive(Debug, Default)]
pub struct Restored {
pub files: Vec<Rc<File>>,
pub order: Vec<FileId>,
pub categories: TrackpointCategories,
/// The files that could not be read: they are dropped at the next save.
pub unreadable: usize,
/// Whether some of what is stored was written by a newer version, which this one cannot read.
/// Nothing is written then, so that the newer version finds its data again: the files that
/// could be read are only kept in memory, and the persister does not save.
pub read_only: bool,
}
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
/// already holds.
///
/// A file whose `Rc` is the saved one has not changed, which is what makes a save cheap: only the
/// files that changed are looked at to find the chunks to write, and the others are only counted
/// to find the chunks that nothing uses anymore.
#[derive(Debug, Default)]
pub struct Persister {
/// The files as they are saved.
files: HashMap<FileId, Rc<File>>,
/// The files that have a record, including the ones that could not be read.
stored_files: HashSet<FileId>,
chunks: HashSet<ChunkKey>,
order: Option<Vec<FileId>>,
categories: Option<TrackpointCategories>,
/// Set by `restore`, see [`Restored::read_only`].
read_only: bool,
}
/// Remembers whether something that was read comes from a newer version.
#[derive(Default)]
struct Newer(Cell<bool>);
impl Newer {
fn read<T>(&self, result: Result<T, DecodeError>) -> Option<T> {
match result {
Ok(value) => Some(value),
Err(error) => {
self.0
.set(self.0.get() || matches!(error, DecodeError::UnknownVersion(_)));
None
}
}
}
}
/// What a save writes, and what is stored once it is written.
struct Plan {
batch: Batch,
snapshot: Snapshot,
chunks: HashSet<ChunkKey>,
}
impl Persister {
/// Reads the files of the storage. The persister then knows that they are saved.
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
let data = storage.load().await?;
*self = Self::default();
// what comes from a newer version is not corrupt: it must not be overwritten
let newer = Newer::default();
let categories: Option<TrackpointCategories> = data
.categories
.as_deref()
.and_then(|bytes| newer.read(decode_categories(bytes)));
let stored_order: Option<Vec<FileId>> = data
.order
.as_deref()
.and_then(|bytes| newer.read(decode_order(bytes)));
let chunk_bytes: HashMap<ChunkKey, Vec<u8>> = data.chunks.into_iter().collect();
// chunks are decoded once, whatever the number of files that use them
let trackpoint_cache: RefCell<HashMap<Uuid, Option<Rc<TrackpointChunk>>>> =
Default::default();
let waypoint_cache: RefCell<HashMap<Uuid, Option<Rc<WaypointChunk>>>> = Default::default();
let trackpoint_chunk = |id: &crate::TrackpointChunkId| {
trackpoint_cache
.borrow_mut()
.entry(id.0)
.or_insert_with(|| {
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
newer.read(decode_trackpoints(id.0, bytes)).map(Rc::new)
})
.clone()
};
let waypoint_chunk = |id: &crate::WaypointChunkId| {
waypoint_cache
.borrow_mut()
.entry(id.0)
.or_insert_with(|| {
let bytes = chunk_bytes.get(&ChunkKey::waypoints(id.0))?;
newer.read(decode_waypoints(id.0, bytes)).map(Rc::new)
})
.clone()
};
let mut restored = Restored {
categories: categories.clone().unwrap_or_default(),
..Default::default()
};
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
for (id, bytes) in &data.files {
self.stored_files.insert(*id);
let file = newer
.read(decode_file(bytes))
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
match file {
Some(file) => {
files.insert(*id, Rc::new(file));
}
None => restored.unreadable += 1,
}
}
restored.order = stored_order
.iter()
.flatten()
.filter(|id| files.contains_key(id))
.copied()
.collect();
let ordered: HashSet<FileId> = restored.order.iter().copied().collect();
restored
.order
.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
self.files = files;
self.chunks = chunk_bytes.into_keys().collect();
self.order = stored_order;
self.categories = categories;
self.read_only = newer.0.get();
restored.read_only = self.read_only;
Ok(restored)
}
/// Writes what changed since the last save. Nothing is written if nothing changed.
pub async fn save<S: Storage>(
&mut self,
storage: &S,
snapshot: Snapshot,
) -> Result<(), StorageError> {
if self.read_only {
return Ok(());
}
let plan = self.plan(snapshot);
if plan.batch.is_empty() {
self.files = plan.snapshot.files;
return Ok(());
}
storage.commit(&plan.batch).await?;
self.stored_files = plan.snapshot.files.keys().copied().collect();
self.files = plan.snapshot.files;
self.chunks = plan.chunks;
self.order = Some(plan.snapshot.order);
self.categories = Some(plan.snapshot.categories);
Ok(())
}
fn plan(&self, snapshot: Snapshot) -> Plan {
let mut batch = Batch::default();
// the chunks that the files use, which the storage has afterwards
let mut chunks = HashSet::new();
for (id, file) in &snapshot.files {
let changed = self
.files
.get(id)
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
for chunk in segments.flat_map(|segment| segment.chunks()) {
let key = ChunkKey::trackpoints(chunk.id.0);
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
batch.put_chunks.push((key, encode_trackpoints(chunk)));
}
}
for chunk in file.wpt.chunks() {
let key = ChunkKey::waypoints(chunk.id.0);
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
batch.put_chunks.push((key, encode_waypoints(chunk)));
}
}
if changed || !self.stored_files.contains(id) {
batch.put_files.push((*id, encode_file(file)));
}
}
batch.delete_files = self
.stored_files
.iter()
.filter(|id| !snapshot.files.contains_key(id))
.copied()
.collect();
batch.delete_chunks = self.chunks.difference(&chunks).copied().collect();
if self.order.as_ref() != Some(&snapshot.order) {
batch.order = Some(encode_order(&snapshot.order));
}
if self.categories.as_ref() != Some(&snapshot.categories) {
batch.categories = Some(encode_categories(&snapshot.categories));
}
Plan {
batch,
snapshot,
chunks,
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::future::Future;
use std::task::{Context, Poll, Waker};
use crate::{
Action, ChunkKind, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse,
write,
};
use super::*;
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let mut context = Context::from_waker(Waker::noop());
loop {
if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
return value;
}
}
}
/// A memory storage that remembers the batches it was given.
#[derive(Default)]
struct Recording {
inner: MemoryStorage,
batches: RefCell<Vec<Batch>>,
}
impl Storage for Recording {
async fn load(&self) -> Result<StoredData, StorageError> {
self.inner.load().await
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
self.batches.borrow_mut().push(batch.clone());
self.inner.commit(batch).await
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
self.inner.load_settings().await
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
self.inner.put_setting(key, value).await
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
self.inner.delete_setting(key).await
}
}
fn snapshot_of(paths: &[&str]) -> Snapshot {
let mut snapshot = Snapshot::default();
for path in paths {
let data = std::fs::read(path).unwrap();
let file = parse(&data, &mut snapshot.categories).unwrap();
snapshot.order.push(file.id);
snapshot.files.insert(file.id, Rc::new(file));
}
snapshot
}
fn gpx(file: &File, categories: &TrackpointCategories) -> Vec<u8> {
write(file, categories, ExportOptions::ALL)
}
const FILES: [&str; 3] = [
"data/with_tracks_and_segments.gpx",
"data/with_waypoint.gpx",
"data/with_surface.gpx",
];
#[test]
fn test_saved_files_are_restored() {
let storage = Recording::default();
let snapshot = snapshot_of(&FILES);
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
let mut persister = Persister::default();
let restored = block_on(persister.restore(&storage)).unwrap();
assert_eq!(restored.unreadable, 0);
assert_eq!(restored.order, snapshot.order);
assert_eq!(restored.categories, snapshot.categories);
for file in &restored.files {
let original = &snapshot.files[&file.id];
assert_eq!(
gpx(file, &restored.categories),
gpx(original, &snapshot.categories)
);
// identities that the rest of the engine refers to are kept
assert_eq!(file.trk[0].id, original.trk[0].id);
assert_eq!(file.trk[0].trkseg[0].id, original.trk[0].trkseg[0].id);
assert_eq!(
file.trk[0].trkseg[0].chunks()[0].id,
original.trk[0].trkseg[0].chunks()[0].id
);
}
// what was just read is saved
let again = Snapshot {
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
order: restored.order,
categories: restored.categories,
};
block_on(persister.save(&storage, again)).unwrap();
assert_eq!(storage.batches.borrow().len(), 1);
}
#[test]
fn test_nothing_is_written_when_nothing_changed() {
let storage = Recording::default();
let snapshot = snapshot_of(&FILES);
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot.clone())).unwrap();
block_on(persister.save(&storage, snapshot)).unwrap();
assert_eq!(storage.batches.borrow().len(), 1);
}
#[test]
fn test_an_edit_writes_what_it_changed_only() {
let storage = Recording::default();
let mut snapshot = snapshot_of(&FILES);
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot.clone())).unwrap();
let counts = storage.inner.counts();
// renaming a file writes its record, not its chunks
let id = snapshot.order[0];
let mut file = (*snapshot.files[&id]).clone();
file.info.name = "renamed".into();
snapshot.files.insert(id, Rc::new(file));
block_on(persister.save(&storage, snapshot.clone())).unwrap();
{
let batches = storage.batches.borrow();
let batch = batches.last().unwrap();
assert_eq!(batch.put_files.len(), 1);
assert!(batch.put_chunks.is_empty());
assert!(batch.delete_chunks.is_empty());
assert!(batch.order.is_none() && batch.categories.is_none());
}
assert_eq!(storage.inner.counts(), counts);
// changing a trackpoint replaces the chunk that holds it only
let mut file = (*snapshot.files[&id]).clone();
let chunks_before = file
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.chunks().len())
.sum::<usize>();
file.trk[0].trkseg[0].update(0, |point| point.ele += 1.0);
snapshot.files.insert(id, Rc::new(file));
block_on(persister.save(&storage, snapshot.clone())).unwrap();
{
let batches = storage.batches.borrow();
let batch = batches.last().unwrap();
assert_eq!(batch.put_files.len(), 1);
assert_eq!(batch.puts(ChunkKind::Trackpoints), 1);
assert_eq!(batch.deletes(ChunkKind::Trackpoints), 1);
assert_eq!(batch.puts(ChunkKind::Waypoints), 0);
}
assert_eq!(storage.inner.counts(), counts);
assert!(chunks_before >= 1);
// a deleted file takes its chunks and its record away
snapshot.files.remove(&id);
snapshot.order.retain(|other| *other != id);
block_on(persister.save(&storage, snapshot)).unwrap();
let after = storage.inner.counts();
assert_eq!(after.files, counts.files - 1);
assert!(after.trackpoint_chunks < counts.trackpoint_chunks);
}
#[test]
fn test_chunks_shared_by_files_are_stored_once_and_stay_shared() {
let storage = Recording::default();
let mut snapshot = snapshot_of(&["data/with_time.gpx"]);
let original = snapshot.files[&snapshot.order[0]].clone();
let mut copy = (*original).clone();
copy.id = FileId::default();
snapshot.order.push(copy.id);
snapshot.files.insert(copy.id, Rc::new(copy));
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot)).unwrap();
let chunks = original.trk[0].trkseg[0].chunks().len();
assert_eq!(storage.inner.counts().trackpoint_chunks, chunks);
let restored = block_on(Persister::default().restore(&storage)).unwrap();
assert_eq!(restored.files.len(), 2);
assert!(Rc::ptr_eq(
&restored.files[0].trk[0].trkseg[0].chunks()[0],
&restored.files[1].trk[0].trkseg[0].chunks()[0]
));
}
#[test]
fn test_unreadable_files_are_skipped_and_dropped_at_the_next_save() {
let storage = Recording::default();
let snapshot = snapshot_of(&FILES);
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot.clone())).unwrap();
// damage a record, and lose a chunk of another file
let broken = snapshot.order[0];
block_on(storage.inner.commit(&Batch {
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
..Default::default()
}))
.unwrap();
let mut persister = Persister::default();
let restored = block_on(persister.restore(&storage)).unwrap();
assert_eq!(restored.unreadable, 1);
assert!(!restored.read_only);
assert_eq!(restored.files.len(), FILES.len() - 1);
assert!(restored.files.iter().all(|file| file.id != broken));
let kept = Snapshot {
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
order: restored.order,
categories: restored.categories,
};
block_on(persister.save(&storage, kept)).unwrap();
assert_eq!(storage.inner.counts().files, FILES.len() - 1);
}
/// A storage holding the files, with one blob replaced by `damage`.
fn stored_with(damage: impl FnOnce(&Snapshot, &mut Batch)) -> (Recording, Snapshot) {
let storage = Recording::default();
let snapshot = snapshot_of(&FILES);
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
let mut batch = Batch::default();
damage(&snapshot, &mut batch);
block_on(storage.inner.commit(&batch)).unwrap();
storage.batches.borrow_mut().clear();
(storage, snapshot)
}
fn from_the_future() -> Vec<u8> {
vec![super::super::codec::LATEST + 1, 1, 2, 3]
}
#[test]
fn test_what_a_newer_version_wrote_is_left_alone() {
type Damage = (&'static str, fn(&Snapshot, &mut Batch));
let damages: [Damage; 4] = [
("a file", |snapshot, batch| {
batch.put_files.push((snapshot.order[0], from_the_future()));
}),
("a chunk", |snapshot, batch| {
let file = &snapshot.files[&snapshot.order[0]];
let chunk = &file.trk[0].trkseg[0].chunks()[0];
batch
.put_chunks
.push((ChunkKey::trackpoints(chunk.id.0), from_the_future()));
}),
("the order", |_, batch| {
batch.order = Some(from_the_future())
}),
("the categories", |_, batch| {
batch.categories = Some(from_the_future())
}),
];
for (what, damage) in damages {
let (storage, snapshot) = stored_with(damage);
let before = storage.inner.counts();
let mut persister = Persister::default();
let restored = block_on(persister.restore(&storage)).unwrap();
assert!(restored.read_only, "{what}");
// whatever is saved afterwards, nothing is written: the newer version needs its data
let mut changed = snapshot.clone();
changed.order.clear();
changed.files.clear();
block_on(persister.save(&storage, changed)).unwrap();
assert!(storage.batches.borrow().is_empty(), "{what}");
assert_eq!(storage.inner.counts(), before, "{what}");
}
}
#[test]
fn test_restoring_again_forgets_that_the_storage_was_newer() {
let (storage, _) = stored_with(|snapshot, batch| {
batch.put_files.push((snapshot.order[0], from_the_future()));
});
let mut persister = Persister::default();
assert!(block_on(persister.restore(&storage)).unwrap().read_only);
// the same persister, on a storage that is not newer
let clean = Recording::default();
let restored = block_on(persister.restore(&clean)).unwrap();
assert!(!restored.read_only);
block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap();
assert_eq!(clean.inner.counts().files, FILES.len());
}
#[test]
fn test_an_engine_starts_from_what_is_restored() {
let storage = Recording::default();
let mut engine = Engine::default();
for path in FILES {
let data = std::fs::read(path).unwrap();
assert!(engine.run(Action::Edit(Command::Load(Load {
data: &data,
name: "file",
}))));
}
let snapshot = engine.snapshot();
assert_eq!(snapshot.files.len(), FILES.len());
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
let restored = block_on(Persister::default().restore(&storage)).unwrap();
let mut engine = Engine::default();
engine.restore(restored);
assert_eq!(engine.snapshot().order, snapshot.order);
let diff = engine.last_diff().unwrap();
assert_eq!(diff.added.len(), FILES.len());
assert!(engine.order_changed());
for id in &snapshot.order {
assert!(engine.file_structure(id).is_some());
}
// the files that were restored are not undone
assert!(!engine.can_undo());
assert!(!engine.run(Action::Undo));
let data = std::fs::read(FILES[0]).unwrap();
assert!(engine.run(Action::Edit(Command::Load(Load {
data: &data,
name: "file",
}))));
assert!(engine.can_undo());
assert!(engine.run(Action::Undo));
assert_eq!(engine.snapshot().files.len(), FILES.len());
assert!(!engine.can_undo());
}
}
+5
View File
@@ -9,6 +9,11 @@ crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
gpx-engine = { path = "../engine" } gpx-engine = { path = "../engine" }
idb = "0.6.5"
js-sys = "0.3.106" js-sys = "0.3.106"
uuid = { version = "1.26.1", features = ["js"] } uuid = { version = "1.26.1", features = ["js"] }
wasm-bindgen = "0.2" 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()
}
+243
View File
@@ -0,0 +1,243 @@
//! The storage of the engine in the browser: IndexedDB.
//!
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
//! store per kind of data. A batch is a single transaction, so that the stored files always are
//! ones that the engine had.
use gpx_engine::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
use idb::TransactionMode;
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
use js_sys::Uint8Array;
use uuid::Uuid;
use wasm_bindgen::JsValue;
const FILES: &str = "files";
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
/// The order of the files and the categories of the trackpoints, under their own names.
const META: &str = "meta";
const SETTINGS: &str = "settings";
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
const ORDER_KEY: &str = "order";
const CATEGORIES_KEY: &str = "categories";
pub struct IdbStorage {
database: Database,
}
fn error(error: impl std::fmt::Display) -> StorageError {
StorageError(error.to_string())
}
/// The object store of a kind of chunks.
fn chunk_store(kind: ChunkKind) -> &'static str {
match kind {
ChunkKind::Trackpoints => TRACKPOINT_CHUNKS,
ChunkKind::Waypoints => WAYPOINT_CHUNKS,
}
}
fn key(id: Uuid) -> JsValue {
JsValue::from_str(&id.to_string())
}
fn bytes(value: &[u8]) -> JsValue {
Uint8Array::from(value).into()
}
fn to_vec(value: &JsValue) -> Vec<u8> {
Uint8Array::new(value).to_vec()
}
fn to_uuid(key: &JsValue) -> Result<Uuid, StorageError> {
key.as_string()
.and_then(|key| Uuid::parse_str(&key).ok())
.ok_or_else(|| error("a key is not an id"))
}
impl IdbStorage {
/// Opens the database, which is created if it does not exist.
pub async fn open(name: &str) -> Result<Self, StorageError> {
let factory = Factory::new().map_err(error)?;
let mut request = factory.open(name, Some(1)).map_err(error)?;
request.on_upgrade_needed(|event| {
if let Ok(database) = event.database() {
for store in STORES {
let _ = database.create_object_store(store, ObjectStoreParams::new());
}
}
});
let database = request.await.map_err(error)?;
Ok(Self { database })
}
fn transaction(
&self,
stores: &[&str],
mode: TransactionMode,
) -> Result<Transaction, StorageError> {
self.database.transaction(stores, mode).map_err(error)
}
/// Everything stored in the object store, as `(key, bytes)`.
async fn read_all(store: &ObjectStore) -> Result<Vec<(JsValue, Vec<u8>)>, StorageError> {
let keys = store.get_all_keys(None, None).map_err(error)?;
let values = store.get_all(None, None).map_err(error)?;
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
}
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
let files = transaction.object_store(FILES).map_err(error)?;
let meta = transaction.object_store(META).map_err(error)?;
// the requests are all made before waiting for them
let mut puts = vec![];
let mut deletes = vec![];
for (ChunkKey { kind, id }, data) in &batch.put_chunks {
let store = transaction
.object_store(chunk_store(*kind))
.map_err(error)?;
puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?);
}
for (FileId(id), data) in &batch.put_files {
puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?);
}
if let Some(order) = &batch.order {
let value = bytes(order);
puts.push(
meta.put(&value, Some(&JsValue::from_str(ORDER_KEY)))
.map_err(error)?,
);
}
if let Some(categories) = &batch.categories {
let value = bytes(categories);
puts.push(
meta.put(&value, Some(&JsValue::from_str(CATEGORIES_KEY)))
.map_err(error)?,
);
}
for FileId(id) in &batch.delete_files {
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
}
for ChunkKey { kind, id } in &batch.delete_chunks {
let store = transaction
.object_store(chunk_store(*kind))
.map_err(error)?;
deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?);
}
for put in puts {
put.await.map_err(error)?;
}
for delete in deletes {
delete.await.map_err(error)?;
}
Ok(())
}
}
impl Storage for IdbStorage {
async fn load(&self) -> Result<StoredData, StorageError> {
let transaction = self.transaction(
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
TransactionMode::ReadOnly,
)?;
let store = |name| transaction.object_store(name).map_err(error);
let files = Self::read_all(&store(FILES)?).await?;
let trackpoint_chunks = Self::read_all(&store(TRACKPOINT_CHUNKS)?).await?;
let waypoint_chunks = Self::read_all(&store(WAYPOINT_CHUNKS)?).await?;
let meta = store(META)?;
let order = meta
.get(JsValue::from_str(ORDER_KEY))
.map_err(error)?
.await
.map_err(error)?;
let categories = meta
.get(JsValue::from_str(CATEGORIES_KEY))
.map_err(error)?
.await
.map_err(error)?;
transaction.await.map_err(error)?;
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
records
.into_iter()
.map(|(k, v)| Ok((to_uuid(&k)?, v)))
.collect()
};
Ok(StoredData {
files: ids(files)?
.into_iter()
.map(|(id, data)| (FileId(id), data))
.collect(),
chunks: ids(trackpoint_chunks)?
.into_iter()
.map(|(id, data)| (ChunkKey::trackpoints(id), data))
.chain(
ids(waypoint_chunks)?
.into_iter()
.map(|(id, data)| (ChunkKey::waypoints(id), data)),
)
.collect(),
order: order.as_ref().map(to_vec),
categories: categories.as_ref().map(to_vec),
})
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
let transaction = self.transaction(
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
TransactionMode::ReadWrite,
)?;
match self.write(&transaction, batch).await {
Ok(()) => {
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
Err(err) => {
// nothing of the batch is kept
let _ = transaction.abort();
Err(err)
}
}
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadOnly)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
let keys = store.get_all_keys(None, None).map_err(error)?;
let values = store.get_all(None, None).map_err(error)?;
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
transaction.await.map_err(error)?;
Ok(keys
.into_iter()
.zip(values)
.filter_map(|(key, value)| Some((key.as_string()?, value.as_string()?)))
.collect())
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
store
.put(&JsValue::from_str(value), Some(&JsValue::from_str(key)))
.map_err(error)?
.await
.map_err(error)?;
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
store
.delete(Query::Key(JsValue::from_str(key)))
.map_err(error)?
.await
.map_err(error)?;
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Ids, which cross as strings (one id) or as concatenated 16-byte UUIDs (several).
use gpx_engine::{self as engine, FileId};
pub(crate) fn uuid_list(bytes: &[u8]) -> Option<Vec<uuid::Uuid>> {
let (chunks, rest) = bytes.as_chunks::<16>();
rest.is_empty()
.then(|| chunks.iter().map(|c| uuid::Uuid::from_bytes(*c)).collect())
}
pub(crate) fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> {
uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect())
}
pub(crate) fn parse_file_id(id: &str) -> Option<FileId> {
uuid::Uuid::parse_str(id).ok().map(FileId)
}
pub(crate) fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool {
a.len() == b.len() && b.len() == c.len()
}
pub(crate) fn parse_waypoint_id(id: &str) -> Option<engine::WaypointId> {
uuid::Uuid::parse_str(id).ok().map(engine::WaypointId)
}
+22 -1204
View File
File diff suppressed because it is too large. Load diff
+143
View File
@@ -0,0 +1,143 @@
//! Keeping the files and the settings in the browser.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gpx_engine::{self as engine, Engine, Persister, Storage};
use js_sys::Object;
use wasm_bindgen::prelude::*;
use crate::convert::{outcome_object, set};
use crate::idb::IdbStorage;
use crate::session::*;
use crate::ts::StorageOpened;
// Storage
//
// The files are saved in IndexedDB after each change that touches them, without making the change
// wait: `schedule_save` starts a loop that writes what changed since the last write, until
// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them.
thread_local! {
static STORAGE: RefCell<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
static SAVING: Cell<bool> = const { Cell::new(false) };
static DIRTY: Cell<bool> = const { Cell::new(false) };
/// Resolve functions of the promises of `flush_storage`.
static WAITERS: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console, js_name = error)]
fn console_error(message: &str);
}
pub(crate) fn storage() -> Option<Rc<IdbStorage>> {
STORAGE.with(|storage| storage.borrow().clone())
}
pub(crate) fn schedule_save() {
if storage().is_none() {
return;
}
DIRTY.set(true);
if SAVING.replace(true) {
// the running loop will go around again
return;
}
wasm_bindgen_futures::spawn_local(async {
while DIRTY.replace(false) {
let Some(storage) = storage() else { break };
let Some(snapshot) = with_engine(Engine::snapshot) else {
break;
};
let mut persister = PERSISTER
.with(|persister| persister.borrow_mut().take())
.unwrap_or_default();
let result = persister.save(&*storage, snapshot).await;
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
if let Err(error) = result {
// what failed is written with the next change
console_error(&error.to_string());
}
}
SAVING.set(false);
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
let _ = resolve.call0(&JsValue::UNDEFINED);
}
});
}
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
/// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the
/// outcome of putting the files in. The changes of the files that follow are saved, unless some of
/// what is stored was written by a newer version of the app (`readOnly`): then nothing is, so that
/// the newer version finds its data again.
#[wasm_bindgen]
pub async fn open_storage(name: &str) -> Result<StorageOpened, JsValue> {
let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false);
if !empty {
return Err(JsValue::from_str("the engine already has files"));
}
let storage = IdbStorage::open(name).await.map_err(to_js_error)?;
let mut persister = Persister::default();
let restored = persister.restore(&storage).await.map_err(to_js_error)?;
let (read_only, unreadable) = (restored.read_only, restored.unreadable);
let settings = storage.load_settings().await.map_err(to_js_error)?;
let outcome = with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default();
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
let settings_object = Object::new();
for (key, value) in settings {
set(&settings_object, &key, value);
}
let opened = Object::new();
set(&opened, "settings", settings_object);
set(&opened, "outcome", outcome_object(&outcome, None));
set(&opened, "readOnly", read_only);
set(&opened, "unreadable", unreadable as f64);
Ok(opened.unchecked_into())
}
pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
JsValue::from_str(&error.to_string())
}
/// Resolves when what was changed has been written to the storage.
#[wasm_bindgen]
pub async fn flush_storage() -> Result<(), JsValue> {
if !SAVING.get() {
return Ok(());
}
let promise = js_sys::Promise::new(&mut |resolve, _| {
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
});
wasm_bindgen_futures::JsFuture::from(promise).await?;
Ok(())
}
/// Saves a setting, `value` being its JSON.
#[wasm_bindgen]
pub fn set_setting(key: String, value: String) {
if let Some(storage) = storage() {
wasm_bindgen_futures::spawn_local(async move {
if let Err(error) = storage.put_setting(&key, &value).await {
console_error(&error.to_string());
}
});
}
}
#[wasm_bindgen]
pub fn delete_setting(key: String) {
if let Some(storage) = storage() {
wasm_bindgen_futures::spawn_local(async move {
if let Err(error) = storage.delete_setting(&key).await {
console_error(&error.to_string());
}
});
}
}
+37
View File
@@ -0,0 +1,37 @@
//! The coordinates of the trackpoints and waypoints, as flat typed arrays.
use gpx_engine::{self as engine, Engine, FileId};
use js_sys::Float64Array;
use wasm_bindgen::prelude::*;
use crate::session::*;
// Coordinates buffers
//
// Like the statistics buffers, these are copies. They are flat `[lng, lat, ...]` arrays. Compare `rev` / `waypointsRev` of the file structures with the previous ones to know which
// buffers actually changed.
pub(crate) fn coordinates_array(f: impl FnOnce(&Engine) -> Option<&[f64]>) -> Float64Array {
ENGINE.with(|engine| match engine.borrow().as_ref().and_then(f) {
Some(coordinates) => Float64Array::from(coordinates),
None => Float64Array::new_with_length(0),
})
}
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
#[wasm_bindgen]
pub fn segment_coordinates(segment_id: &str) -> Float64Array {
match uuid::Uuid::parse_str(segment_id) {
Ok(id) => coordinates_array(|e| Some(e.segment_coordinates(&engine::TrackSegmentId(id)))),
Err(_) => Float64Array::new_with_length(0),
}
}
/// Coordinates of the waypoints of a file (empty if the id is unknown).
#[wasm_bindgen]
pub fn waypoint_coordinates(file_id: &str) -> Float64Array {
match uuid::Uuid::parse_str(file_id) {
Ok(id) => coordinates_array(|e| Some(e.waypoint_coordinates(&FileId(id)))),
Err(_) => Float64Array::new_with_length(0),
}
}
+215
View File
@@ -0,0 +1,215 @@
//! The files, their structure and statistics, the selection and the clipboard.
// File order and structures
//
// The files are described by one ordered list of ids and one structure object per file, so that
// the UI can react to what changed only: after each action, `last_update` tells which
// structures to (re)read or drop and whether the order changed. Ids are UUID strings. The
// trackpoints of a segment and the waypoints of a file are in the same order as the coordinates
// of their buffers (see below).
use gpx_engine::{self as engine, FileId};
use js_sys::Object;
use wasm_bindgen::prelude::*;
use crate::convert::*;
use crate::ids::*;
use crate::queries::statistics::with_stats;
use crate::session::*;
use crate::ts::*;
/// Ids of the files in display order.
#[wasm_bindgen]
pub fn file_order() -> FileOrder {
let order = with_engine(|e| ids(e.order())).unwrap_or_default();
order.unchecked_into()
}
/// Structure of a file, `undefined` if the id is unknown.
#[wasm_bindgen]
pub fn file_structure(file_id: &str) -> FileStructure {
let structure = uuid::Uuid::parse_str(file_id)
.ok()
.and_then(|id| {
with_engine(|e| e.file_structure(&FileId(id)).map(structure_object)).flatten()
})
.map_or(JsValue::UNDEFINED, JsValue::from);
structure.unchecked_into()
}
/// Global statistics of a file, `undefined` if the id is unknown.
#[wasm_bindgen]
pub fn file_statistics(file_id: &str) -> GlobalStatistics {
uuid::Uuid::parse_str(file_id)
.ok()
.and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten())
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
statistics_object(&stats)
})
}
/// Global statistics of the whole selection (the arrays of the statistics buffers cover it).
#[wasm_bindgen]
pub fn selection_statistics() -> GlobalStatistics {
with_stats(|buffer| statistics_object(&buffer.global))
.unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into())
}
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
/// to keep.
#[wasm_bindgen]
pub fn export_file(
file_id: &str,
time: bool,
hr: bool,
cad: bool,
atemp: bool,
power: bool,
osm: bool,
) -> Option<Vec<u8>> {
let id = parse_file_id(file_id)?;
let options = engine::ExportOptions {
time,
hr,
cad,
atemp,
power,
osm,
};
with_engine(|e| e.export(&id, options)).flatten()
}
/// The data that some files have, so that the ones that none has are not offered when exporting
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
/// OpenStreetMap data (32). `file_ids` are concatenated UUIDs.
#[wasm_bindgen]
pub fn exportable_data(file_ids: &[u8]) -> u8 {
let Some(ids) = self::file_ids(file_ids) else {
return 0;
};
let data = with_engine(|e| e.exportable_data(&ids)).unwrap_or(engine::ExportOptions::NONE);
[
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
]
.iter()
.enumerate()
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
}
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
/// as indexed by the statistics buffers. `undefined` when the range is not in the selection.
#[wasm_bindgen]
pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics {
with_stats(|buffer| buffer.slice(start, end))
.flatten()
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
statistics_object(&stats)
})
}
/// The current selection (ids are UUID strings).
#[wasm_bindgen]
pub fn selection() -> Selection {
with_engine(|e| selection_object(e.selection()))
.unwrap_or_default()
.unchecked_into()
}
/// All the data of a waypoint of a file, `undefined` if it does not exist.
#[wasm_bindgen]
pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails {
uuid::Uuid::parse_str(file_id)
.ok()
.zip(uuid::Uuid::parse_str(waypoint_id).ok())
.and_then(|(file_id, id)| {
with_engine(|e| {
e.waypoint(&FileId(file_id), &engine::WaypointId(id))
.map(|wpt| {
let object = Object::new();
set(&object, "id", wpt.id.0.to_string());
set(&object, "lng", wpt.coordinates.lng);
set(&object, "lat", wpt.coordinates.lat);
set(&object, "ele", wpt.ele);
if let Some(time) = wpt.time {
set(&object, "time", time as f64);
}
for (key, value) in [
("name", &wpt.name),
("desc", &wpt.desc),
("cmt", &wpt.cmt),
("sym", &wpt.sym),
] {
if let Some(value) = value {
set(&object, key, value.as_str());
}
}
if !wpt.links.is_empty() {
set(
&object,
"links",
array(&wpt.links, |link| {
let link_object = Object::new();
set(&link_object, "href", link.href.as_str());
if let Some(text) = &link.text {
set(&link_object, "text", text.as_str());
}
link_object.into()
}),
);
}
JsValue::from(object)
})
})
.flatten()
})
.unwrap_or(JsValue::UNDEFINED)
.unchecked_into()
}
/// A trackpoint of a segment of a file, `undefined` if it does not exist.
#[wasm_bindgen]
pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails {
parse_file_id(file_id)
.zip(uuid::Uuid::parse_str(segment_id).ok())
.and_then(|(file_id, segment_id)| {
with_engine(|e| {
e.trackpoint(&file_id, &engine::TrackSegmentId(segment_id), index)
.map(|pt| {
let object = Object::new();
set(&object, "lng", pt.coordinates.lng);
set(&object, "lat", pt.coordinates.lat);
set(&object, "ele", pt.ele);
if let Some(time) = pt.time {
set(&object, "time", time as f64);
}
JsValue::from(object)
})
})
.flatten()
})
.unwrap_or(JsValue::UNDEFINED)
.unchecked_into()
}
/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the
/// elements and their ids (UUID strings).
#[wasm_bindgen]
pub fn clipboard() -> Clipboard {
with_engine(|e| {
e.clipboard().map_or(JsValue::UNDEFINED, |clipboard| {
let object = Object::new();
let (kind, ids) = match clipboard.content.ids() {
engine::ClipboardIds::Files(ids) => ("files", uuids(ids, |id| id.0)),
engine::ClipboardIds::Tracks(ids) => ("tracks", uuids(ids, |id| id.0)),
engine::ClipboardIds::Segments(ids) => ("segments", uuids(ids, |id| id.0)),
engine::ClipboardIds::Waypoints(ids) => ("waypoints", uuids(ids, |id| id.0)),
};
set(&object, "type", kind);
set(&object, "ids", ids);
set(&object, "cut", clipboard.cut);
object.into()
})
})
.unwrap_or(JsValue::UNDEFINED)
.unchecked_into()
}
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more