32 Commits
Author SHA1 Message Date
vcoppe ef9a5247f0 New translations toolbar.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:29 +02:00
vcoppe 2418f9bf4d New translations menu.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:28 +02:00
vcoppe 5263b88fb7 New translations map-controls.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:27 +02:00
vcoppe b5eb617fbc New translations integration.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:25 +02:00
vcoppe f15ee2c6cf New translations gpx.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:24 +02:00
vcoppe 30369cc952 New translations getting-started.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:23 +02:00
vcoppe 8a089708dc New translations files-and-stats.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:21 +02:00
vcoppe e3da56e455 New translations faq.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:20 +02:00
vcoppe bab4910ee0 New translations view.mdx (Ukrainian)
[ci skip]
2026-06-28 16:41:18 +02:00
vcoppe 8301d610cb New translations en.json (Ukrainian)
[ci skip]
2026-06-28 16:41:13 +02:00
vcoppe 6887b7de7e New translations faq.mdx (Ukrainian)
[ci skip]
2026-06-28 15:19:38 +02:00
vcoppe ab4667546a New translations time.mdx (Ukrainian)
[ci skip]
2026-06-28 15:19:37 +02:00
vcoppe 08233c9e25 New translations scissors.mdx (Ukrainian)
[ci skip]
2026-06-28 15:19:35 +02:00
vcoppe be7bdc14b6 New translations routing.mdx (Ukrainian)
[ci skip]
2026-06-28 15:19:34 +02:00
vcoppe 11d488e999 New translations en.json (Ukrainian)
[ci skip]
2026-06-28 15:19:33 +02:00
vcoppe 5f023281b0 New translations elevation.mdx (Ukrainian)
[ci skip]
2026-06-28 14:24:35 +02:00
vcoppe a224ac3dcd New translations poi.mdx (Ukrainian)
[ci skip]
2026-06-28 14:24:34 +02:00
vcoppe 598b2a30fe New translations minify.mdx (Ukrainian)
[ci skip]
2026-06-28 14:24:33 +02:00
vcoppe eb5a71a6f1 New translations merge.mdx (Ukrainian)
[ci skip]
2026-06-28 14:24:31 +02:00
vcoppe e1198d0dc9 New translations extract.mdx (Ukrainian)
[ci skip]
2026-06-28 14:24:30 +02:00
vcoppe 08828d609d New translations routing.mdx (Ukrainian)
[ci skip]
2026-06-28 14:24:29 +02:00
vcoppe 281d5f87c1 New translations clean.mdx (Ukrainian)
[ci skip]
2026-06-28 14:24:28 +02:00
vcoppe 282cfe4dbd New translations en.json (Ukrainian)
[ci skip]
2026-06-28 14:24:27 +02:00
vcoppe 59536ee63a New translations routing.mdx (Ukrainian)
[ci skip]
2026-06-28 13:10:01 +02:00
vcoppe 2dda05f22c New translations clean.mdx (Ukrainian)
[ci skip]
2026-06-28 13:09:59 +02:00
vcoppe 1354ca1d2a New translations view.mdx (Ukrainian)
[ci skip]
2026-06-28 13:09:58 +02:00
vcoppe 94e69c9774 New translations settings.mdx (Ukrainian)
[ci skip]
2026-06-28 13:09:57 +02:00
vcoppe 5a85bc41ea New translations file.mdx (Ukrainian)
[ci skip]
2026-06-28 13:09:55 +02:00
vcoppe bd9e7ea726 New translations file.mdx (Ukrainian)
[ci skip]
2026-06-28 12:04:05 +02:00
vcoppe aa861be53d New translations en.json (Ukrainian)
[ci skip]
2026-06-28 12:04:04 +02:00
vcoppe 52387a40e6 New translations en.json (Hungarian)
[ci skip]
2026-06-25 22:06:50 +02:00
vcoppe dd4c5e17ff New translations en.json (Hungarian)
[ci skip]
2026-06-25 20:51:48 +02:00
324 changed files with 13185 additions and 34508 deletions

No files matched your search

+9 -5
View File
@@ -27,16 +27,17 @@ Any help is greatly appreciated!
The code is split into two parts: The code is split into two parts:
- `gpx-rs`: a Rust library, compiled to WebAssembly, for parsing and manipulating GPX files, - `gpx`: a Typescript library 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/), [Rust](https://rustup.rs/) and [wasm-pack](https://rustwasm.github.io/wasm-pack/) to build and run these two parts. You will need [Node.js](https://nodejs.org/) to build and run these two parts.
### Building the `gpx-rs` library ### Building the `gpx` library
```bash ```bash
cd gpx-rs cd gpx
wasm-pack build wasm --target bundler --out-dir ../pkg npm install
npm run build
``` ```
### Running the website ### Running the website
@@ -63,6 +64,9 @@ 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
-2
View File
@@ -1,2 +0,0 @@
target/
pkg/
-794
View File
@@ -1,794 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "gpx-engine"
version = "0.1.0"
dependencies = [
"chrono",
"postcard",
"quick-xml",
"serde",
"uuid",
]
[[package]]
name = "gpx-rs"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"gpx-engine",
"idb",
"js-sys",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "idb"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828"
dependencies = [
"indexmap",
"js-sys",
"num-traits",
"thiserror",
"tokio",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-6
View File
@@ -1,6 +0,0 @@
[workspace]
members = ["engine", "wasm"]
resolver = "3"
[profile.release]
debug = true
-14
View File
@@ -1,14 +0,0 @@
# gpx-rs
- `engine`: the GPX engine, plain Rust. Tested natively: `cargo test`.
- `wasm`: the interface to the web page, compiled to WebAssembly.
```
wasm-pack build wasm --target bundler --out-dir ../pkg
```
The interface is tested in Node (it needs `wasm-pack`, which fetches the test runner the first time):
```
wasm-pack test --node wasm
```
-11
View File
@@ -1,11 +0,0 @@
[package]
name = "gpx-engine"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.45"
postcard = { version = "1.1.3", features = ["use-std"] }
quick-xml = "0.42.0"
serde = { version = "1.0.229", features = ["derive"] }
uuid = { version = "1.26.1", features = ["serde", "v4"] }
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="gpx.studio" xmlns="http://www.topografix.com/GPX/1/1"
xmlns:ext="https://example.com/ext/0.1">
<metadata>
<name>self closing points</name>
<desc>Points without children are self-closing elements.</desc>
<extensions>
<ext:version>2.0</ext:version>
<ext:paths/>
</extensions>
</metadata>
<wpt lat="50.0" lon="4.0"/>
<wpt lat="50.1" lon="4.1"><name>with children</name></wpt>
<wpt lat="50.2" lon="4.2"/>
<trk>
<name>track</name>
<trkseg>
<trkpt lat="50.00" lon="4.00"/>
<trkpt lat="50.01" lon="4.01"><ele>12.5</ele></trkpt>
<trkpt lat="50.02" lon="4.02"/>
</trkseg>
<trkseg>
<trkpt lat="51.00" lon="5.00"/>
</trkseg>
</trk>
</gpx>
-62
View File
@@ -1,62 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_highway</name>
</metadata>
<trk>
<name>with_highway</name>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791000" lon="4.405100">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791200" lon="4.405300">
<ele>111.0</ele>
</trkpt>
<trkpt lat="50.791400" lon="4.405500">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<highway>track</highway>
<sac_scale>mountain_hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791600" lon="4.405700">
<ele>113.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<sac_scale>hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
-38
View File
@@ -1,38 +0,0 @@
<?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>
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
<rte>
<name>with extensions</name>
<rtept lat="1" lon="1">
<ele>10</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="1.1" lon="1.1"/>
<gpxx:rpt lat="1.2" lon="1.2"></gpxx:rpt>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="2" lon="2">
<ele>20</ele>
<time>2024-01-01T00:00:00Z</time>
</rtept>
<rtept lat="3" lon="3"/>
</rte>
<rte>
<name>empty</name>
</rte>
</gpx>
-30
View File
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
<rte>
<name>Lausanne</name>
<cmt>comment</cmt>
<desc>description</desc>
<type>Cycling</type>
<rtept lat="46.544628096744418" lon="6.658079791814089">
<ele>500</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="46.544483928009868" lon="6.658018603920937"/>
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="46.544100000000000" lon="6.659000000000000">
<ele>510</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="46.543900000000000" lon="6.659500000000000"/>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="46.543000000000000" lon="6.660000000000000">
<ele>505</ele>
</rtept>
</rte>
</gpx>
-112
View File
@@ -1,112 +0,0 @@
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)]
);
}
}
-10
View File
@@ -1,10 +0,0 @@
mod anchors;
mod reverse;
mod simplify;
mod smooth;
mod timestamps;
pub use anchors::*;
pub use reverse::*;
pub use simplify::*;
pub use timestamps::*;
-232
View File
@@ -1,232 +0,0 @@
//! Reversing a path: the order of its trackpoints, and of the segments and tracks that hold them.
//!
//! The timestamps are mirrored, so that the reversed path goes through the same period of time:
//! a trackpoint at time `t` of a part that spans `start..end` ends up at `start + end - t`. When
//! the part is made of several ones (the segments of a track, the tracks of a file), the gaps
//! between them are kept: every part comes after the previous one in the new order, as late as the
//! original one came after the part that followed it.
use crate::{Track, TrackSegment, Trackpoint};
/// The time of the first trackpoint of a segment.
pub fn start_time(segment: &TrackSegment) -> Option<i64> {
segment.iter().next().and_then(|trkpt| trkpt.time)
}
/// The time of the last trackpoint of a segment.
pub fn end_time(segment: &TrackSegment) -> Option<i64> {
segment.last_index().and_then(|idx| segment[idx].time)
}
/// The time of the first trackpoint of a track.
pub fn track_start_time(track: &Track) -> Option<i64> {
track.trkseg.first().and_then(start_time)
}
/// The time of the last trackpoint of a track.
pub fn track_end_time(track: &Track) -> Option<i64> {
track.trkseg.last().and_then(end_time)
}
/// Reverses the trackpoints of a segment.
///
/// `original_next` is the time at which what came after the segment started, and `new_previous`
/// the time at which what comes before it now ends: the segment is mirrored to start there. Both
/// are those of the segment itself if the segment is reversed on its own, which are the ones
/// used when they are missing (`new_previous` is the start of the segment, and `original_next`
/// follows from the duration of the segment). Without timestamps, the trackpoints are only put
/// in the opposite order.
pub fn reverse_segment(
segment: &mut TrackSegment,
original_next: Option<i64>,
new_previous: Option<i64>,
) {
let (original_start, original_end) = (start_time(segment), end_time(segment));
let new_previous = new_previous.or(original_start);
let original_next = original_next.or_else(|| {
// the segment lasts as long as before
Some(new_previous? + original_end? - original_start?)
});
let len = segment.len();
let mut points: Vec<Trackpoint> = segment.iter().cloned().collect();
if let (Some(next), Some(previous), Some(end)) = (original_next, new_previous, original_end) {
let new_start = previous + next - end;
for point in points.iter_mut() {
point.time = point.time.map(|time| new_start + (end - time));
}
}
points.reverse();
segment.splice(0, len, points);
segment.rev_id = Default::default();
}
/// Makes a segment go back where it started: a reversed copy of it is added after its last
/// trackpoint, without the first trackpoint of the copy, which is the last one of the segment.
///
/// The timestamps of the copy go on from the end of the segment: it would start at the time of the
/// last trackpoint, and it lasts as long as the segment. Only the end of the segment changes, so
/// its other chunks stay shared.
pub fn round_trip(segment: &mut TrackSegment) {
let len = segment.len();
let end = end_time(segment);
let mut back = segment.clone();
reverse_segment(&mut back, end, end);
segment.splice(len, len, back.iter().skip(1).cloned().collect());
segment.rev_id = Default::default();
}
/// Reverses the segments of a track, and their trackpoints: see [`reverse_segment`] for the
/// meaning of the times, which are the ones of the track as a whole.
pub fn reverse_segments(
segments: &mut [TrackSegment],
original_next: Option<i64>,
new_previous: Option<i64>,
) {
segments.reverse();
let (mut next, mut previous) = (original_next, new_previous);
for segment in segments.iter_mut() {
// the next one is the one that was before, which started here
let original_start = start_time(segment);
reverse_segment(segment, next, previous);
next = original_start;
previous = end_time(segment);
}
}
/// Reverses the tracks of a file, and their segments and trackpoints.
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
tracks.reverse();
let (mut next, mut previous) = (original_next, new_previous);
for track in tracks.iter_mut() {
let original_start = track_start_time(track);
reverse_segments(&mut track.trkseg, next, previous);
next = original_start;
previous = track_end_time(track);
}
}
#[cfg(test)]
mod tests {
use crate::LngLat;
use super::*;
fn segment(points: &[(f64, Option<i64>)]) -> TrackSegment {
let mut segment = TrackSegment::default();
let points = points
.iter()
.map(|(lng, time)| Trackpoint {
coordinates: LngLat {
lng: *lng,
lat: 0.0,
},
time: *time,
..Default::default()
})
.collect();
segment.splice(0, 0, points);
segment
}
fn lngs(segment: &TrackSegment) -> Vec<f64> {
segment.iter().map(|p| p.coordinates.lng).collect()
}
fn times(segment: &TrackSegment) -> Vec<Option<i64>> {
segment.iter().map(|p| p.time).collect()
}
#[test]
fn test_a_segment_is_mirrored_in_its_own_time_span() {
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
let (start, end) = (start_time(&s), end_time(&s));
reverse_segment(&mut s, end, start);
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
// same span, the durations between points follow the points
assert_eq!(times(&s), [Some(0), Some(30), Some(40)]);
}
#[test]
fn test_reverse_segment_defaults() {
// without the times around, the segment is mirrored where it is
let mut s = segment(&[(0.0, Some(100)), (1.0, Some(130))]);
reverse_segment(&mut s, None, None);
assert_eq!(times(&s), [Some(100), Some(130)]);
assert_eq!(lngs(&s), [1.0, 0.0]);
}
#[test]
fn test_a_segment_without_times_is_only_reversed() {
let mut s = segment(&[(0.0, None), (1.0, None), (2.0, None)]);
reverse_segment(&mut s, None, None);
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
assert_eq!(times(&s), [None, None, None]);
}
#[test]
fn test_round_trip_goes_back_in_time_from_the_end() {
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
let first = s.rev_id;
round_trip(&mut s);
assert_ne!(s.rev_id, first);
// the last trackpoint is the turning point, it is not repeated
assert_eq!(lngs(&s), [0.0, 1.0, 2.0, 1.0, 0.0]);
// the way back lasts as long as the way there, and starts when it ended
assert_eq!(times(&s), [Some(0), Some(10), Some(40), Some(70), Some(80)]);
// and it is a segment whose ends are anchors
assert_eq!(s[0].anchor, Some(0));
assert_eq!(s[4].anchor, Some(0));
}
#[test]
fn test_round_trip_without_times() {
let mut s = segment(&[(0.0, None), (1.0, None)]);
round_trip(&mut s);
assert_eq!(lngs(&s), [0.0, 1.0, 0.0]);
assert_eq!(times(&s), [None; 3]);
}
#[test]
fn test_segments_keep_the_gaps_between_them() {
// 0..20, then 100..120: a gap of 80
let mut segments = vec![
segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(20))]),
segment(&[(3.0, Some(100)), (4.0, Some(110)), (5.0, Some(120))]),
];
let (end, start) = (end_time(&segments[1]), start_time(&segments[0]));
reverse_segments(&mut segments, end, start);
assert_eq!(lngs(&segments[0]), [5.0, 4.0, 3.0]);
assert_eq!(lngs(&segments[1]), [2.0, 1.0, 0.0]);
assert_eq!(times(&segments[0]), [Some(0), Some(10), Some(20)]);
// the gap is still 80
assert_eq!(times(&segments[1]), [Some(100), Some(110), Some(120)]);
}
#[test]
fn test_tracks_are_reversed_like_segments() {
let track = |segments: Vec<TrackSegment>| Track {
trkseg: segments,
..Default::default()
};
let mut tracks = vec![
track(vec![
segment(&[(0.0, Some(0)), (1.0, Some(10))]),
segment(&[(2.0, Some(50)), (3.0, Some(60))]),
]),
track(vec![segment(&[(4.0, Some(200)), (5.0, Some(230))])]),
];
let (end, start) = (track_end_time(&tracks[1]), track_start_time(&tracks[0]));
reverse_tracks(&mut tracks, end, start);
let all: Vec<f64> = tracks
.iter()
.flat_map(|t| t.trkseg.iter().flat_map(lngs))
.collect();
assert_eq!(all, [5.0, 4.0, 3.0, 2.0, 1.0, 0.0]);
// 3 segments: durations 10, 10, 30 and gaps 40 and 140 are reversed to 30, 10, 10 and
// gaps 140 and 40 (the first part of the time is the longest)
assert_eq!(times(&tracks[0].trkseg[0]), [Some(0), Some(30)]);
assert_eq!(times(&tracks[1].trkseg[0]), [Some(170), Some(180)]);
assert_eq!(times(&tracks[1].trkseg[1]), [Some(220), Some(230)]);
}
}
@@ -1,232 +0,0 @@
use crate::{
core::gpx::{TrackSegment, TrackSegmentIndex},
core::utils::{crossarc, crossarc_lnglat},
};
pub fn ramer_douglas_peucker<F>(
trkseg: &TrackSegment,
mapping: &F,
epsilon: f64,
) -> Vec<TrackSegmentIndex>
where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
let measure = |start, end, idx| {
let (start, end, pt) = (mapping(start), mapping(end), mapping(idx));
crossarc(start.0, start.1, end.0, end.1, pt.0, pt.1)
};
ramer_douglas_peucker_by(trkseg, &measure, epsilon)
.into_iter()
.map(|(idx, _)| idx)
.collect()
}
/// Ramer-Douglas-Peucker with any distance from a point (the last argument) to the line made of
/// two others (the first ones). Gives the kept points, with the distance at which each of them
/// was selected (`None` for the ends of the segment, which are always kept).
pub fn ramer_douglas_peucker_by<M>(
trkseg: &TrackSegment,
measure: &M,
epsilon: f64,
) -> Vec<(TrackSegmentIndex, Option<f64>)>
where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{
match trkseg.len() {
0 => vec![],
1 => vec![(trkseg.first_index().unwrap(), None)],
2 => vec![
(trkseg.first_index().unwrap(), None),
(trkseg.last_index().unwrap(), None),
],
_ => {
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
let mut kept = vec![(first, None)];
ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept);
kept.push((last, None));
kept
}
}
}
fn ramer_douglas_peucker_helper<M>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
measure: &M,
epsilon: f64,
kept: &mut Vec<(TrackSegmentIndex, Option<f64>)>,
) where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{
let mut max_idx = None;
let mut max_dist = 0.0;
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let dist = measure(start, end, idx);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
kept.push((idx, Some(max_dist)));
ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
}
}
/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`].
pub const MIN_REDUCE_TOLERANCE: f64 = 0.1;
/// The distance (in meters) from a trackpoint to the line between two others of a segment.
fn lnglat_measure(
trkseg: &TrackSegment,
) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ {
move |start, end, idx| {
crossarc_lnglat(
trkseg[start].coordinates,
trkseg[end].coordinates,
trkseg[idx].coordinates,
)
}
}
/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance`
/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept.
pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec<usize> {
ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance)
.into_iter()
.map(|(idx, _)| idx.flat)
.collect()
}
/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by
/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that
/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its
/// value is greater than it.
///
/// A trackpoint is only selected if the ones that split the path before it are, so its value is
/// the smallest distance at which it or one of them was selected.
pub fn reduction_distances(trkseg: &TrackSegment) -> Vec<f64> {
let mut distances = vec![0.0; trkseg.len()];
let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else {
return distances;
};
distances[first.flat] = f64::INFINITY;
distances[last.flat] = f64::INFINITY;
if trkseg.len() > 2 {
let measure = lnglat_measure(trkseg);
reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances);
}
distances
}
fn reduction_distances_helper<M>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
measure: &M,
bound: f64,
distances: &mut [f64],
) where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{
let mut max_idx = None;
let mut max_dist = 0.0;
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let dist = measure(start, end, idx);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) {
let distance = max_dist.min(bound);
distances[idx.flat] = distance;
reduction_distances_helper(trkseg, start, idx, measure, distance, distances);
reduction_distances_helper(trkseg, idx, end, measure, distance, distances);
}
}
#[cfg(test)]
mod tests {
use crate::{Trackpoint, TrackpointChunk};
use super::*;
fn segment(eles: &[f64]) -> TrackSegment {
let mut trkseg = TrackSegment::default();
// several small chunks, to also exercise the chunk boundaries
for part in eles.chunks(3) {
let mut chunk = TrackpointChunk::default();
for ele in part {
chunk.trkpt.push(Trackpoint {
ele: *ele,
..Default::default()
});
}
trkseg.push(chunk);
}
trkseg
}
fn simplify(eles: &[f64], epsilon: f64) -> Vec<usize> {
let trkseg = segment(eles);
ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon)
.iter()
.map(|idx| idx.flat)
.collect()
}
#[test]
fn test_empty_and_single_point() {
assert!(simplify(&[], 1.0).is_empty());
assert_eq!(simplify(&[5.0], 1.0), vec![0]);
}
#[test]
fn test_collinear_points_are_removed() {
let eles: Vec<f64> = (0..20).map(|i| i as f64 * 2.0).collect();
assert_eq!(simplify(&eles, 0.1), vec![0, 19]);
}
#[test]
fn test_peak_is_kept_when_above_epsilon() {
let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0];
assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]);
}
#[test]
fn test_peak_is_dropped_when_below_epsilon() {
let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0];
assert_eq!(simplify(&eles, 1.0), vec![0, 6]);
}
#[test]
fn test_indices_are_sorted_and_keep_extremities() {
let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0];
let kept = simplify(&eles, 0.5);
assert_eq!(kept.first(), Some(&0));
assert_eq!(kept.last(), Some(&9));
assert!(kept.windows(2).all(|w| w[0] < w[1]));
// a larger epsilon never keeps more points
assert!(simplify(&eles, 5.0).len() <= kept.len());
}
}
-141
View File
@@ -1,141 +0,0 @@
#[macro_export]
macro_rules! for_each_window {
(
$trkseg:expr,
$left:expr,
$right:expr,
$window:expr,
|$a:ident, $b:ident| $distance:expr,
|$i:ident, $l:ident, $r:ident| $body:block,
) => {{
let mut window_start = $left.unwrap_or_default();
let mut window_center = $left;
let mut window_end = $left.unwrap_or_default();
while let Some(idx) = window_center {
// advance left if needed
while window_start != idx && {
let $a = window_start;
let $b = idx;
$distance
} > $window {
if let Some(next) = $trkseg.next_index(Some(window_start)) {
if next == idx {
break;
}
window_start = next;
} else {
break;
}
}
// advance right if needed
window_end = window_end.max(idx);
while let Some(next) = $trkseg.next_index(Some(window_end)) {
if window_end != idx && {
let $a = idx;
let $b = next;
$distance
} > $window {
break;
}
window_end = next;
}
// apply body
let $i = idx;
let $l = window_start;
let $r = window_end;
$body
// go next
if window_center == $right {
break;
}
window_center = $trkseg.next_index(window_center);
}
}};
}
#[cfg(test)]
mod tests {
use crate::{TrackSegment, Trackpoint, TrackpointChunk};
fn segment(eles: &[f64]) -> TrackSegment {
let mut trkseg = TrackSegment::default();
// several small chunks, to also exercise the chunk boundaries
for part in eles.chunks(3) {
let mut chunk = TrackpointChunk::default();
for ele in part {
chunk.trkpt.push(Trackpoint {
ele: *ele,
..Default::default()
});
}
trkseg.push(chunk);
}
trkseg
}
/// Returns (center, left, right) flat indices for each visited center.
fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> {
let trkseg = segment(&vec![0.0; len]);
let mut visited = vec![];
for_each_window!(
trkseg,
trkseg.first_index(),
trkseg.last_index(),
window,
|a, b| (b.flat - a.flat) as f64,
|i, l, r| {
visited.push((i.flat, l.flat, r.flat));
},
);
visited
}
#[test]
fn test_window_bounds() {
let visited = windows(10, 2.0);
assert_eq!(visited.len(), 10);
for (i, l, r) in visited {
assert_eq!(l, i.saturating_sub(2), "left of {i}");
assert_eq!(r, (i + 2).min(9), "right of {i}");
}
}
#[test]
fn test_window_always_includes_neighbours() {
for (i, l, r) in windows(6, 0.0) {
assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5)));
}
}
#[test]
fn test_window_larger_than_segment() {
for (_, l, r) in windows(5, 100.0) {
assert_eq!((l, r), (0, 4));
}
}
#[test]
fn test_window_sub_range() {
let trkseg = segment(&[0.0; 10]);
let mut centers = vec![];
let mut first = trkseg.first_index();
for _ in 0..3 {
first = trkseg.next_index(first);
}
let mut last = first;
for _ in 0..2 {
last = trkseg.next_index(last);
}
for_each_window!(
trkseg,
first,
last,
1.0,
|a, b| (b.flat - a.flat) as f64,
|i, _l, _r| {
centers.push(i.flat);
},
);
assert_eq!(centers, vec![3, 4, 5]);
}
}
@@ -1,465 +0,0 @@
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()));
}
}
-103
View File
@@ -1,103 +0,0 @@
/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes,
/// so that the trackpoints only hold a `u8` instead of a string.
///
/// The codes are the positions of the names in the order of first appearance. The table only
/// grows: a code never changes meaning, so it stays valid in every state of the history.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Categories(Vec<String>);
impl Categories {
/// A table with these names, in the order of their codes (at most [`Self::CAPACITY`] of them
/// are kept).
pub fn from_names(mut names: Vec<String>) -> Self {
names.truncate(Self::CAPACITY);
Self(names)
}
/// Maximum number of distinct values.
pub const CAPACITY: usize = u8::MAX as usize;
/// The code of `name`, which is added to the table if it is new. `None` if the table is full.
pub fn code(&mut self, name: &str) -> Option<u8> {
let index = match self.0.iter().position(|known| known == name) {
Some(index) => index,
None if self.0.len() < Self::CAPACITY => {
self.0.push(name.to_owned());
self.0.len() - 1
}
None => return None,
};
u8::try_from(index).ok()
}
/// The name of a code, `None` if it is unknown.
pub fn name(&self, code: u8) -> Option<&str> {
self.0.get(usize::from(code)).map(String::as_str)
}
/// All the names, in the order of their codes.
pub fn names(&self) -> &[String] {
&self.0
}
}
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
/// its files.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackpointCategories {
pub surface: Categories,
pub highway: Categories,
pub sac_scale: Categories,
pub mtb_scale: Categories,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_codes_follow_the_order_of_appearance() {
let mut categories = Categories::default();
assert_eq!(categories.code("asphalt"), Some(0));
assert_eq!(categories.code("gravel"), Some(1));
// a known name keeps its code
assert_eq!(categories.code("asphalt"), Some(0));
assert_eq!(categories.names(), ["asphalt", "gravel"]);
}
#[test]
fn test_names_of_codes() {
let mut categories = Categories::default();
let code = categories.code("asphalt").unwrap();
assert_eq!(categories.name(code), Some("asphalt"));
assert_eq!(categories.name(code + 1), None);
assert_eq!(Categories::default().name(0), None);
}
#[test]
fn test_a_full_table_gives_no_code_to_new_names() {
let mut categories = Categories::default();
for i in 0..Categories::CAPACITY {
assert_eq!(categories.code(&i.to_string()), Some(i as u8));
}
assert_eq!(categories.code("one too many"), None);
assert_eq!(categories.names().len(), Categories::CAPACITY);
// the known ones still have their code, the last one included
assert_eq!(categories.code("0"), Some(0));
assert_eq!(categories.code("254"), Some(254));
assert_eq!(categories.name(254), Some("254"));
}
#[test]
fn test_the_categories_are_independent() {
let mut categories = TrackpointCategories::default();
categories.surface.code("asphalt");
assert_eq!(categories.highway.code("path"), Some(0));
assert_eq!(categories.sac_scale.code("hiking"), Some(0));
assert_eq!(categories.mtb_scale.code("1"), Some(0));
assert_eq!(categories.surface.names(), ["asphalt"]);
assert_eq!(categories.highway.names(), ["path"]);
assert_eq!(categories.sac_scale.names(), ["hiking"]);
assert_eq!(categories.mtb_scale.names(), ["1"]);
}
}
-133
View File
@@ -1,133 +0,0 @@
use crate::{Trackpoint, Waypoint};
use super::common::uuid_id;
/// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk
/// that is not touched by an edit is not copied.
///
/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new
/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is.
pub trait Chunk {
type Item: Clone;
/// Maximum number of items of a chunk.
const MAX_SIZE: usize;
/// A chunk with a new identity, holding `items`.
fn new(items: Vec<Self::Item>) -> Self;
/// Gives the chunk a new identity, after its items were changed in place.
fn renew(&mut self);
fn items(&self) -> &Vec<Self::Item>;
fn items_mut(&mut self) -> &mut Vec<Self::Item>;
fn is_full(&self) -> bool {
self.items().len() >= Self::MAX_SIZE
}
}
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
uuid_id!(TrackpointChunkId);
#[derive(Debug, Default)]
pub struct TrackpointChunk {
pub id: TrackpointChunkId,
pub trkpt: Vec<Trackpoint>,
}
impl Chunk for TrackpointChunk {
type Item = Trackpoint;
const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE;
fn new(trkpt: Vec<Trackpoint>) -> Self {
Self {
trkpt,
..Default::default()
}
}
fn renew(&mut self) {
self.id = Default::default();
}
fn items(&self) -> &Vec<Trackpoint> {
&self.trkpt
}
fn items_mut(&mut self) -> &mut Vec<Trackpoint> {
&mut self.trkpt
}
}
uuid_id!(WaypointChunkId);
const MAX_WPT_CHUNK_SIZE: usize = 128;
#[derive(Debug, Default)]
pub struct WaypointChunk {
pub id: WaypointChunkId,
pub wpt: Vec<Waypoint>,
}
impl Chunk for WaypointChunk {
type Item = Waypoint;
const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
fn new(wpt: Vec<Waypoint>) -> Self {
Self {
wpt,
..Default::default()
}
}
fn renew(&mut self) {
self.id = Default::default();
}
fn items(&self) -> &Vec<Waypoint> {
&self.wpt
}
fn items_mut(&mut self) -> &mut Vec<Waypoint> {
&mut self.wpt
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trackpoint_chunk_full() {
let mut chunk = TrackpointChunk::default();
assert!(!chunk.is_full());
for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 {
chunk.trkpt.push(Trackpoint::default());
}
assert!(!chunk.is_full());
chunk.trkpt.push(Trackpoint::default());
assert!(chunk.is_full());
}
#[test]
fn test_waypoint_chunk_full() {
let mut chunk = WaypointChunk::default();
assert!(!chunk.is_full());
for _ in 0..MAX_WPT_CHUNK_SIZE {
chunk.wpt.push(Waypoint::default());
}
assert!(chunk.is_full());
}
#[test]
fn test_new_chunks_have_their_own_identity() {
let a = TrackpointChunk::new(vec![Trackpoint::default()]);
let b = TrackpointChunk::new(vec![Trackpoint::default()]);
assert_ne!(a.id, b.id);
assert_eq!(a.items().len(), 1);
assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id);
}
}
-619
View File
@@ -1,619 +0,0 @@
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]);
}
}
-114
View File
@@ -1,114 +0,0 @@
/// Defines an identifier made of a UUID, which is a new one by default.
macro_rules! uuid_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct $name(pub uuid::Uuid);
impl Default for $name {
fn default() -> Self {
Self(uuid::Uuid::new_v4())
}
}
};
}
pub(crate) use uuid_id;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Link {
pub href: String,
pub text: Option<String>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LngLat {
pub lng: f64,
pub lat: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct LngLatBounds {
pub sw: LngLat,
pub ne: LngLat,
}
impl Default for LngLatBounds {
fn default() -> Self {
Self {
sw: LngLat {
lng: 180.0,
lat: 90.0,
},
ne: LngLat {
lng: -180.0,
lat: -90.0,
},
}
}
}
impl LngLatBounds {
pub fn extend(&mut self, coordinates: LngLat) {
self.sw.lng = self.sw.lng.min(coordinates.lng);
self.sw.lat = self.sw.lat.min(coordinates.lat);
self.ne.lng = self.ne.lng.max(coordinates.lng);
self.ne.lat = self.ne.lat.max(coordinates.lat);
}
pub fn merge(&mut self, other: &LngLatBounds) {
self.sw.lng = self.sw.lng.min(other.sw.lng);
self.sw.lat = self.sw.lat.min(other.sw.lat);
self.ne.lng = self.ne.lng.max(other.ne.lng);
self.ne.lat = self.ne.lat.max(other.ne.lat);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bounds_extend() {
let mut bounds = LngLatBounds::default();
bounds.extend(LngLat {
lng: 4.0,
lat: 50.0,
});
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0));
assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0));
bounds.extend(LngLat {
lng: 6.0,
lat: 45.0,
});
bounds.extend(LngLat {
lng: 5.0,
lat: 48.0,
});
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0));
assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0));
}
#[test]
fn test_bounds_merge() {
let mut a = LngLatBounds::default();
a.extend(LngLat { lng: 0.0, lat: 0.0 });
a.extend(LngLat { lng: 1.0, lat: 1.0 });
let mut b = LngLatBounds::default();
b.extend(LngLat {
lng: -2.0,
lat: 0.5,
});
b.extend(LngLat { lng: 0.5, lat: 3.0 });
a.merge(&b);
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
// merging an empty bounds changes nothing
a.merge(&LngLatBounds::default());
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
}
}
-31
View File
@@ -1,31 +0,0 @@
use crate::{Link, Track, Waypoints};
use super::common::uuid_id;
uuid_id!(FileId);
#[derive(Debug, Default, Clone)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
pub trk: Vec<Track>,
pub wpt: Waypoints,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
pub author: Option<Author>,
pub links: Vec<Link>,
pub time: Option<i64>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Author {
pub name: Option<String>,
/// `id@domain`, which the file gives as the two attributes of an `email` element.
pub email: Option<String>,
/// The author has a single link, unlike the other elements.
pub link: Option<Link>,
}
-21
View File
@@ -1,21 +0,0 @@
mod categories;
mod chunk;
mod chunked;
mod common;
mod file;
mod segment;
mod track;
mod trackpoint;
mod waypoint;
mod waypoints;
pub use categories::*;
pub use chunk::*;
pub use chunked::*;
pub use common::*;
pub use file::*;
pub use segment::*;
pub use track::*;
pub use trackpoint::*;
pub use waypoint::*;
pub use waypoints::*;
-411
View File
@@ -1,411 +0,0 @@
use std::ops::Deref;
use std::rc::Rc;
use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors};
use super::common::uuid_id;
uuid_id!(TrackSegmentId);
uuid_id!(TrackSegmentRevisionId);
#[derive(Debug, Default, Clone)]
pub struct TrackSegment {
pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId,
points: Chunked<TrackpointChunk>,
}
/// The position of a trackpoint in a segment.
pub type TrackSegmentIndex = ChunkIndex;
pub type TrackSegmentIterator<'a> = ChunkedIter<'a, TrackpointChunk>;
impl Deref for TrackSegment {
type Target = Chunked<TrackpointChunk>;
fn deref(&self) -> &Self::Target {
&self.points
}
}
impl TrackSegment {
/// Adds a chunk after the last trackpoint, which is dropped if it is empty.
pub fn push(&mut self, chunk: TrackpointChunk) {
self.points.push(chunk);
self.rev_id = Default::default();
}
/// Adds a chunk that is shared with something else after the last trackpoint.
pub fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
self.points.push_shared(chunk);
self.rev_id = Default::default();
}
/// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no
/// such trackpoint.
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) {
self.points.update(index, f);
self.rev_id = Default::default();
}
/// Applies `f` to every trackpoint, with its index, see [`Chunked::update_all`].
pub fn update_all(&mut self, f: impl FnMut(usize, &mut Trackpoint)) {
self.points.update_all(f);
self.rev_id = Default::default();
}
/// Replaces the points in `start..end` by `points`, see [`Chunked::splice`]. The first and
/// last trackpoints are anchors afterwards.
pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
self.points.splice(start, end, points);
self.rev_id = Default::default();
self.ensure_end_anchors();
}
/// The first and the last trackpoints are always anchors, shown at every zoom level.
///
/// It is done by [`TrackSegment::splice`]; commands that build segments in another way need to
/// call it.
pub fn ensure_end_anchors(&mut self) {
let Some(last) = self.len().checked_sub(1) else {
return;
};
for index in [0, last] {
if self[index].anchor != Some(0) {
self.set_anchor(index, 0);
}
}
}
/// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if
/// there is no such trackpoint.
pub fn set_anchor(&mut self, index: usize, zoom: u8) {
self.update(index, |trkpt| trkpt.anchor = Some(zoom));
}
/// Sets the anchors of the trackpoints from the details of the path of the segment (see
/// [`compute_anchors`]), forgetting the previous ones.
pub fn compute_anchors(&mut self) {
let anchors = compute_anchors(self);
let mut anchors = anchors.into_iter().peekable();
self.update_all(|index, trkpt| {
trkpt.anchor = match anchors.peek() {
Some(&(anchor, zoom)) if anchor == index => {
anchors.next();
Some(zoom)
}
_ => None,
};
});
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::Chunk;
use super::*;
fn create_track_segment(nb_chunks: usize) -> TrackSegment {
let mut trkseg = TrackSegment::default();
let mut count = 0;
for n in 1..=nb_chunks {
let mut chunk = TrackpointChunk::default();
for _ in 0..n {
chunk.trkpt.push(Trackpoint {
ele: count as f64,
..Default::default()
});
count += 1;
}
trkseg.push(chunk);
}
trkseg
}
fn eles(trkseg: &TrackSegment) -> Vec<f64> {
trkseg.iter().map(|p| p.ele).collect()
}
fn points(eles: &[f64]) -> Vec<Trackpoint> {
eles.iter()
.map(|&ele| Trackpoint {
ele,
..Default::default()
})
.collect()
}
#[test]
fn test_splice_replace_insert_delete_append() {
// chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14]
let mut trkseg = create_track_segment(5);
trkseg.splice(4, 8, points(&[-1.0, -2.0]));
let mut expected: Vec<f64> = (0..4).map(f64::from).collect();
expected.extend([-1.0, -2.0]);
expected.extend((8..15).map(f64::from));
assert_eq!(eles(&trkseg), expected);
assert_eq!(trkseg.len(), expected.len());
for i in 0..trkseg.len() {
assert_eq!(trkseg[i].ele, expected[i]);
}
trkseg.splice(2, 2, points(&[100.0]));
expected.insert(2, 100.0);
assert_eq!(eles(&trkseg), expected);
trkseg.splice(0, 3, vec![]);
expected.drain(..3);
assert_eq!(eles(&trkseg), expected);
let len = trkseg.len();
trkseg.splice(len, len, points(&[7.0, 8.0]));
expected.extend([7.0, 8.0]);
assert_eq!(eles(&trkseg), expected);
let len = trkseg.len();
trkseg.splice(0, len, vec![]);
assert_eq!(trkseg.len(), 0);
assert!(trkseg.first_index().is_none());
}
fn anchors(trkseg: &TrackSegment) -> Vec<Option<u8>> {
trkseg.iter().map(|p| p.anchor).collect()
}
#[test]
fn test_splice_keeps_the_ends_anchors() {
let mut trkseg = TrackSegment::default();
assert_eq!(anchors(&trkseg), vec![]);
trkseg.splice(0, 0, points(&[0.0, 1.0, 2.0]));
assert_eq!(anchors(&trkseg), [Some(0), None, Some(0)]);
// new ends: the previous ones stay anchors
trkseg.splice(3, 3, points(&[3.0]));
trkseg.splice(0, 0, points(&[-1.0]));
assert_eq!(anchors(&trkseg), [Some(0), Some(0), None, Some(0), Some(0)]);
// removing the ends: the new ones become anchors
trkseg.splice(4, 5, vec![]);
trkseg.splice(0, 2, vec![]);
assert_eq!(anchors(&trkseg), [Some(0), Some(0)]);
// a single point is both ends
trkseg.splice(1, 2, vec![]);
assert_eq!(anchors(&trkseg), [Some(0)]);
}
#[test]
fn test_compute_anchors_replaces_the_previous_ones() {
let mut trkseg = TrackSegment::default();
let line: Vec<_> = (0..10)
.map(|i| Trackpoint {
coordinates: crate::LngLat {
lng: i as f64 * 0.001,
lat: 0.0,
},
anchor: Some(5),
..Default::default()
})
.collect();
trkseg.splice(0, 0, line);
trkseg.compute_anchors();
let mut expected = vec![None; 10];
expected[0] = Some(0);
expected[9] = Some(0);
assert_eq!(anchors(&trkseg), expected);
}
#[test]
fn test_splice_keeps_untouched_chunks_shared() {
let mut trkseg = create_track_segment(5);
// the ends are anchors already, or their chunks would be copied
trkseg.ensure_end_anchors();
let before = trkseg.chunks().to_vec();
// inside the third chunk only
trkseg.splice(4, 5, points(&[-1.0]));
assert!(Rc::ptr_eq(&trkseg.chunks()[0], &before[0]));
assert!(Rc::ptr_eq(&trkseg.chunks()[1], &before[1]));
assert!(Rc::ptr_eq(trkseg.chunks().last().unwrap(), &before[4]));
assert!(Rc::ptr_eq(
&trkseg.chunks()[trkseg.chunks().len() - 2],
&before[3]
));
}
#[test]
fn test_splice_append_extends_last_chunk_and_respects_max_size() {
let mut trkseg = TrackSegment::default();
for i in 0..10_000 {
let len = trkseg.len();
trkseg.splice(len, len, points(&[i as f64]));
}
assert_eq!(trkseg.len(), 10_000);
assert!(trkseg.chunks().len() <= 3);
assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096));
assert_eq!(trkseg[9_999].ele, 9_999.0);
assert_eq!(trkseg.len(), 10_000);
}
#[test]
fn test_len() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
assert_eq!(trkseg.len(), nb_chunks * (nb_chunks + 1) / 2);
}
#[test]
fn test_locate() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for i in 0..trkseg.len() {
let idx = trkseg.locate(i);
assert!(idx.is_some());
assert_eq!(trkseg[idx.unwrap()].ele, i as f64);
}
assert!(trkseg.locate(trkseg.len()).is_none());
}
#[test]
fn test_index() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for i in 0..trkseg.len() {
assert_eq!(trkseg[i].ele, i as f64);
}
}
#[test]
#[should_panic]
fn test_index_out_of_bounds_1() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let _ = trkseg[trkseg.len()];
}
#[test]
#[should_panic]
fn test_index_out_of_bounds_2() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let _ = trkseg[TrackSegmentIndex {
chunk: trkseg.chunks().len(),
pos: 0,
flat: 0,
}];
}
#[test]
fn test_iter() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for (i, trkpt) in trkseg.iter().enumerate() {
assert!(std::ptr::eq(&trkseg[i], trkpt));
}
}
#[test]
fn test_iter_nth() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let tenth = trkseg.iter().nth(10);
assert!(tenth.is_some());
assert!(std::ptr::eq(&trkseg[10], tenth.unwrap()));
}
#[test]
fn test_iter_skip() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for (i, trkpt) in trkseg.iter().enumerate().skip(5) {
assert!(std::ptr::eq(&trkseg[i], trkpt));
}
}
#[test]
fn test_first_last_index() {
assert!(TrackSegment::default().first_index().is_none());
assert!(TrackSegment::default().last_index().is_none());
let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0));
assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14));
assert_eq!(trkseg[last].ele, 14.0);
assert_eq!(trkseg.locate(14), Some(last));
assert!(trkseg.next_index(Some(last)).is_none());
assert!(trkseg.prev_index(Some(first)).is_none());
}
#[test]
fn test_prev_next_are_inverse() {
let trkseg = create_track_segment(6);
let mut idx = trkseg.last_index();
let mut visited = vec![];
while let Some(i) = idx {
assert_eq!(trkseg.locate(i.flat), Some(i));
visited.push(i.flat);
idx = trkseg.prev_index(Some(i));
}
assert_eq!(visited, (0..trkseg.len()).rev().collect::<Vec<_>>());
let mut idx = trkseg.first_index();
while let Some(i) = idx {
let next = trkseg.next_index(Some(i));
if let Some(n) = next {
assert_eq!(trkseg.prev_index(Some(n)), Some(i));
}
idx = next;
}
}
fn points_of(n: usize) -> Vec<Trackpoint> {
(0..n).map(|_| Trackpoint::default()).collect()
}
#[test]
fn test_every_change_gives_a_new_revision() {
type Change = (&'static str, fn(&mut TrackSegment));
let changes: [Change; 7] = [
("push", |s| s.push(TrackpointChunk::new(points_of(1)))),
("push_shared", |s| {
s.push_shared(Rc::new(TrackpointChunk::new(points_of(1))))
}),
("update", |s| s.update(0, |p| p.ele += 1.0)),
("update_all", |s| s.update_all(|_, p| p.ele += 1.0)),
("splice", |s| s.splice(1, 2, points_of(2))),
("set_anchor", |s| s.set_anchor(1, 5)),
("compute_anchors", |s| s.compute_anchors()),
];
for (name, change) in changes {
let mut trkseg = create_track_segment(3);
let before = trkseg.rev_id;
change(&mut trkseg);
assert_ne!(trkseg.rev_id, before, "{name}");
}
// reading does not
let trkseg = create_track_segment(3);
let before = trkseg.rev_id;
let _ = trkseg.iter().count();
assert_eq!(trkseg.rev_id, before);
}
#[test]
fn test_ends_are_anchors_after_a_splice() {
let mut trkseg = create_track_segment(3);
trkseg.splice(0, 1, points_of(2));
let last = trkseg.len() - 1;
assert_eq!(trkseg[0].anchor, Some(0));
assert_eq!(trkseg[last].anchor, Some(0));
// nothing to anchor in an empty segment
let mut empty = TrackSegment::default();
empty.ensure_end_anchors();
assert!(empty.is_empty());
}
}
-25
View File
@@ -1,25 +0,0 @@
use crate::{Link, TrackSegment};
use super::common::uuid_id;
uuid_id!(TrackId);
#[derive(Debug, Default, Clone)]
pub struct Track {
pub id: TrackId,
pub info: TrackInfo,
pub trkseg: Vec<TrackSegment>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackInfo {
pub name: Option<String>,
pub cmt: Option<String>,
pub desc: Option<String>,
pub src: Option<String>,
pub links: Vec<Link>,
pub type_: Option<String>,
pub color: Option<String>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
-24
View File
@@ -1,24 +0,0 @@
use crate::LngLat;
#[derive(Debug, Default, Clone)]
pub struct Trackpoint {
pub coordinates: LngLat,
pub ele: f64,
pub time: Option<i64>,
pub atemp: Option<i16>,
pub hr: Option<u16>,
pub cad: Option<u16>,
pub power: Option<u16>,
/// Code of the surface, see [`crate::TrackpointCategories`].
pub surface: Option<u8>,
/// Code of the highway, see [`crate::TrackpointCategories`].
pub highway: Option<u8>,
/// Code of the SAC hiking scale, see [`crate::TrackpointCategories`].
pub sac_scale: Option<u8>,
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
pub mtb_scale: Option<u8>,
/// Whether the trackpoint is an anchor of the routing tool (a point the user can drag to
/// reroute the segment): the lowest map zoom level at which it is shown. The first and last
/// trackpoints of a segment are always anchors that are always shown (zoom 0).
pub anchor: Option<u8>,
}
-35
View File
@@ -1,35 +0,0 @@
use crate::{Link, LngLat};
use super::common::uuid_id;
uuid_id!(WaypointId);
#[derive(Debug, Default, Clone)]
pub struct Waypoint {
pub id: WaypointId,
pub coordinates: LngLat,
pub ele: f64,
pub time: Option<i64>,
pub name: Option<String>,
pub cmt: Option<String>,
pub desc: Option<String>,
pub links: Vec<Link>,
pub sym: Option<String>,
pub type_: Option<String>,
}
impl Waypoint {
/// Whether the waypoints are the same, whatever their ids.
pub fn same_content(&self, other: &Waypoint) -> bool {
self.coordinates.lng == other.coordinates.lng
&& self.coordinates.lat == other.coordinates.lat
&& self.ele == other.ele
&& self.time == other.time
&& self.name == other.name
&& self.cmt == other.cmt
&& self.desc == other.desc
&& self.links == other.links
&& self.sym == other.sym
&& self.type_ == other.type_
}
}
-315
View File
@@ -1,315 +0,0 @@
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));
}
}
-5
View File
@@ -1,5 +0,0 @@
mod parse;
mod write;
pub use parse::*;
pub use write::*;
File diff suppressed because it is too large. Load diff
-766
View File
@@ -1,766 +0,0 @@
use std::io;
use chrono::DateTime;
use quick_xml::Writer;
use quick_xml::events::{BytesDecl, BytesText, Event};
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// What the written file contains: the data of the trackpoints that are `false` are left out.
/// The same type tells which data a file has, see [`File::exportable_data`].
///
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions {
pub time: bool,
pub hr: bool,
pub cad: bool,
pub atemp: bool,
pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool,
/// Writes every segment as a route (`rte`) instead of a track.
pub as_route: bool,
}
impl ExportOptions {
pub const ALL: Self = Self::of(true);
pub const NONE: Self = Self::of(false);
const fn of(value: bool) -> Self {
Self {
time: value,
hr: value,
cad: value,
atemp: value,
power: value,
osm: value,
as_route: false,
}
}
/// The options that are on in `self` or in `other`.
pub fn union(self, other: Self) -> Self {
Self {
time: self.time || other.time,
hr: self.hr || other.hr,
cad: self.cad || other.cad,
atemp: self.atemp || other.atemp,
power: self.power || other.power,
osm: self.osm || other.osm,
as_route: self.as_route || other.as_route,
}
}
}
impl File {
/// The data that the trackpoints of the file have (and waypoints for the time), which the
/// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE;
for point in self
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter())
{
data.time |= point.time.is_some();
data.hr |= point.hr.is_some();
data.cad |= point.cad.is_some();
data.atemp |= point.atemp.is_some();
data.power |= point.power.is_some();
data.osm |= point.surface.is_some()
|| point.highway.is_some()
|| point.sac_scale.is_some()
|| point.mtb_scale.is_some();
if data == ExportOptions::ALL {
break;
}
}
data
}
}
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
type XmlWriter = Writer<Vec<u8>>;
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
/// the names of their codes in `categories`.
///
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`.
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
// writing to memory does not fail
write_file(&mut writer, file, categories, options).expect("writing to memory");
writer.into_inner()
}
fn write_file(
w: &mut XmlWriter,
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
w.create_element("gpx")
.with_attributes([
("creator", "https://gpx.studio"),
("version", "1.1"),
("xmlns", "http://www.topografix.com/GPX/1/1"),
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
("xsi:schemaLocation", SCHEMA_LOCATION),
(
"xmlns:gpxtpx",
"http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
),
(
"xmlns:gpxx",
"http://www.garmin.com/xmlschemas/GpxExtensions/v3",
),
(
"xmlns:gpxpx",
"http://www.garmin.com/xmlschemas/PowerExtension/v1",
),
(
"xmlns:gpx_style",
"http://www.topografix.com/GPX/gpx_style/0/2",
),
])
.write_inner_content(|w| {
write_metadata(w, file, options)?;
for wpt in file.wpt.iter() {
write_waypoint(w, wpt, options)?;
}
for trk in &file.trk {
// a single unnamed track is named like the file
let name = match (&trk.info.name, file.trk.len()) {
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
(_, 1) => Some(file.info.name.as_str()),
_ => None,
};
if options.as_route {
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) {
write_route(w, trk, segment, name, categories, options)?;
}
} else {
write_track(w, trk, name, categories, options)?;
}
}
Ok(())
})?;
Ok(())
}
fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> {
if !value.is_empty() {
w.create_element(name)
.write_text_content(BytesText::new(value))?;
}
Ok(())
}
fn optional_text(w: &mut XmlWriter, name: &str, value: &Option<String>) -> io::Result<()> {
text(w, name, value.as_deref().unwrap_or_default())
}
fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> {
text(w, name, &value.to_string())
}
fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) {
text(
w,
"time",
&time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
)?;
}
Ok(())
}
fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
if !link.href.is_empty() {
w.create_element("link")
.with_attribute(("href", link.href.as_str()))
.write_inner_content(|w| optional_text(w, "text", &link.text))?;
}
Ok(())
}
fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> {
links.iter().try_for_each(|l| link(w, l))
}
fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
let info = &file.info;
let time = options.time.then_some(info.time).flatten();
if info.name.is_empty()
&& info.desc.is_none()
&& info.author.is_none()
&& info.links.is_empty()
&& time.is_none()
{
return Ok(());
}
w.create_element("metadata").write_inner_content(|w| {
text(w, "name", &info.name)?;
optional_text(w, "desc", &info.desc)?;
if let Some(author) = &info.author {
w.create_element("author").write_inner_content(|w| {
optional_text(w, "name", &author.name)?;
if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@'))
{
w.create_element("email")
.with_attributes([("id", id), ("domain", domain)])
.write_empty()?;
}
author.link.iter().try_for_each(|l| link(w, l))
})?;
}
links(w, &info.links)?;
self::time(w, time)
})?;
Ok(())
}
fn write_coordinates<'a>(
w: &'a mut XmlWriter,
name: &'a str,
coordinates: crate::LngLat,
) -> quick_xml::writer::ElementWriter<'a, Vec<u8>> {
w.create_element(name).with_attributes([
("lat", coordinates.lat.to_string().as_str()),
("lon", coordinates.lng.to_string().as_str()),
])
}
fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> {
write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| {
number(w, "ele", wpt.ele)?;
if options.time {
time(w, wpt.time)?;
}
optional_text(w, "name", &wpt.name)?;
optional_text(w, "cmt", &wpt.cmt)?;
optional_text(w, "desc", &wpt.desc)?;
links(w, &wpt.links)?;
optional_text(w, "sym", &wpt.sym)?;
optional_text(w, "type", &wpt.type_)
})?;
Ok(())
}
/// The information of a track that its routes have too: name, comment, description, source,
/// links, type and style.
fn write_track_info(
w: &mut XmlWriter,
name: Option<&str>,
info: &crate::TrackInfo,
) -> io::Result<()> {
text(w, "name", name.unwrap_or_default())?;
optional_text(w, "cmt", &info.cmt)?;
optional_text(w, "desc", &info.desc)?;
optional_text(w, "src", &info.src)?;
links(w, &info.links)?;
optional_text(w, "type", &info.type_)?;
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
w.create_element("extensions").write_inner_content(|w| {
w.create_element("gpx_style:line")
.write_inner_content(|w| {
optional_text(w, "gpx_style:color", &info.color)?;
if let Some(opacity) = info.opacity {
number(w, "gpx_style:opacity", opacity)?;
}
if let Some(width) = info.width {
number(w, "gpx_style:width", width)?;
}
Ok(())
})?;
Ok(())
})?;
}
Ok(())
}
fn write_track(
w: &mut XmlWriter,
trk: &Track,
name: Option<&str>,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.create_element("trk").write_inner_content(|w| {
write_track_info(w, name, &trk.info)?;
for segment in &trk.trkseg {
w.create_element("trkseg").write_inner_content(|w| {
for point in segment.iter() {
write_point(w, "trkpt", point, &[], categories, options)?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
}
fn write_route(
w: &mut XmlWriter,
trk: &Track,
segment: &crate::TrackSegment,
name: Option<&str>,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.create_element("rte").write_inner_content(|w| {
write_track_info(w, name, &trk.info)?;
let points: Vec<&Trackpoint> = segment.iter().collect();
let last = points.len() - 1;
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some();
let mut i = 0;
while i < points.len() {
// the trackpoints up to the next anchor
let mut next = i + 1;
while next < points.len() && !is_anchor(next) {
next += 1;
}
write_point(
w,
"rtept",
points[i],
&points[i + 1..next],
categories,
options,
)?;
i = next;
}
Ok(())
})?;
Ok(())
}
/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one,
/// of which only the positions are written.
fn write_point(
w: &mut XmlWriter,
tag: &str,
point: &Trackpoint,
path: &[&Trackpoint],
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
let atemp = point.atemp.filter(|_| options.atemp);
let hr = point.hr.filter(|_| options.hr);
let cad = point.cad.filter(|_| options.cad);
let power = point.power.filter(|_| options.power);
let osm = [
("surface", point.surface, &categories.surface),
("highway", point.highway, &categories.highway),
("sac_scale", point.sac_scale, &categories.sac_scale),
("mtb_scale", point.mtb_scale, &categories.mtb_scale),
]
.map(|(name, code, categories)| {
let value = code
.filter(|_| options.osm)
.and_then(|code| categories.name(code));
(name, value)
});
let has_osm = osm.iter().any(|(_, value)| value.is_some());
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
write_coordinates(w, tag, point.coordinates).write_inner_content(|w| {
number(w, "ele", point.ele)?;
if options.time {
time(w, point.time)?;
}
if !has_track_point_extension && power.is_none() && path.is_empty() {
return Ok(());
}
w.create_element("extensions").write_inner_content(|w| {
if has_track_point_extension {
w.create_element("gpxtpx:TrackPointExtension")
.write_inner_content(|w| {
if let Some(atemp) = atemp {
number(w, "gpxtpx:atemp", atemp)?;
}
if let Some(hr) = hr {
number(w, "gpxtpx:hr", hr)?;
}
if let Some(cad) = cad {
number(w, "gpxtpx:cad", cad)?;
}
if has_osm {
w.create_element("gpxtpx:Extensions")
.write_inner_content(|w| {
for (name, value) in osm {
text(w, name, value.unwrap_or_default())?;
}
Ok(())
})?;
}
Ok(())
})?;
}
if let Some(power) = power {
w.create_element("gpxpx:PowerExtension")
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
}
if !path.is_empty() {
w.create_element("gpxx:RoutePointExtension")
.write_inner_content(|w| {
for point in path {
write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn read(name: &str) -> (File, TrackpointCategories) {
let mut categories = TrackpointCategories::default();
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
(parse(&data, &mut categories).unwrap(), categories)
}
fn written_text(
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> String {
String::from_utf8(write(file, categories, options)).unwrap()
}
/// Writes a file and reads it again.
fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) {
let (file, mut categories) = read(name);
let written = write(&file, &categories, options);
let again = parse(&written, &mut categories).unwrap();
(file, again, categories)
}
fn points(file: &File) -> Vec<Trackpoint> {
file.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter().cloned())
.collect()
}
fn same_points(a: &[Trackpoint], b: &[Trackpoint]) {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
assert_eq!(a.coordinates.lat, b.coordinates.lat);
assert_eq!(a.coordinates.lng, b.coordinates.lng);
assert_eq!(a.ele, b.ele);
assert_eq!(a.time, b.time);
assert_eq!(a.atemp, b.atemp);
assert_eq!(a.hr, b.hr);
assert_eq!(a.cad, b.cad);
assert_eq!(a.power, b.power);
assert_eq!(a.surface, b.surface);
assert_eq!(a.highway, b.highway);
assert_eq!(a.sac_scale, b.sac_scale);
assert_eq!(a.mtb_scale, b.mtb_scale);
}
}
#[test]
fn test_links_and_the_email_are_written() {
let data = std::fs::read("data/with_links.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let again = parse(
&write(&file, &Default::default(), ExportOptions::ALL),
&mut Default::default(),
)
.unwrap();
assert_eq!(again.info.links, file.info.links);
assert_eq!(again.info.links.len(), 2);
assert_eq!(again.info.author, file.info.author);
assert_eq!(
again.info.author.as_ref().unwrap().email.as_deref(),
Some("someone@example.com")
);
let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::<Vec<_>>();
assert_eq!(waypoints(&again), waypoints(&file));
assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3);
for (a, b) in again.trk.iter().zip(&file.trk) {
assert_eq!(a.info.links, b.info.links);
assert_eq!(a.info.links.len(), 2);
}
}
#[test]
fn test_files_survive_a_round_trip() {
for name in [
"simple",
"with_links",
"with_time",
"with_hr",
"with_cad",
"with_temp",
"with_power_1",
"with_power_2",
"with_surface",
"with_highway",
"with_style",
"with_waypoint",
"with_tracks_and_segments",
"self_closing_points",
] {
let (file, again, _) = round_trip(name, ExportOptions::ALL);
assert_eq!(file.info.name, again.info.name, "{name}");
assert_eq!(file.info.desc, again.info.desc, "{name}");
assert_eq!(file.info.author, again.info.author, "{name}");
assert_eq!(file.trk.len(), again.trk.len(), "{name}");
for (a, b) in file.trk.iter().zip(&again.trk) {
assert_eq!(a.info, b.info, "{name}");
assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}");
}
same_points(&points(&file), &points(&again));
let waypoints: Vec<_> = file.wpt.iter().collect();
let again_waypoints: Vec<_> = again.wpt.iter().collect();
assert_eq!(waypoints.len(), again_waypoints.len(), "{name}");
for (a, b) in waypoints.iter().zip(&again_waypoints) {
assert!(a.same_content(b), "{name}");
}
}
}
#[test]
fn test_options_leave_data_out() {
let (file, categories) = read("with_hr");
assert!(points(&file).iter().any(|p| p.hr.is_some()));
let options = ExportOptions {
hr: false,
..ExportOptions::ALL
};
let written = written_text(&file, &categories, options);
assert!(!written.contains("hr>"));
let (file, categories) = read("with_time");
let options = ExportOptions {
time: false,
..ExportOptions::ALL
};
assert!(!written_text(&file, &categories, options).contains("<time>"));
assert!(
written_text(&file, &categories, ExportOptions::ALL)
.contains("<time>2023-12-31T23:00:00.000Z</time>")
);
}
#[test]
fn test_text_is_escaped_and_the_single_track_gets_the_name_of_the_file() {
let (mut file, categories) = read("simple");
file.info.name = "a <b> & c".into();
file.trk.truncate(1);
file.trk[0].info.name = None;
let written = write(&file, &categories, ExportOptions::ALL);
let text = String::from_utf8(written.clone()).unwrap();
assert!(text.contains("a &lt;b&gt; &amp; c"));
// the track has the name of the file
assert_eq!(text.matches("a &lt;b&gt; &amp; c").count(), 2);
// and it is read back as it was
let mut categories = categories;
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.info.name, "a <b> & c");
assert_eq!(again.trk[0].info.name.as_deref(), Some("a <b> & c"));
}
#[test]
fn test_exportable_data() {
let (file, _) = read("with_time");
let data = file.exportable_data();
assert!(data.time && !data.hr && !data.osm);
let (file, _) = read("with_surface");
assert!(file.exportable_data().osm);
let (file, _) = read("with_power_2");
assert!(file.exportable_data().power);
}
fn as_route() -> ExportOptions {
ExportOptions {
as_route: true,
..ExportOptions::ALL
}
}
fn count(text: &str, pattern: &str) -> usize {
text.matches(pattern).count()
}
#[test]
fn test_export_as_route_writes_routes_instead_of_tracks() {
let (file, categories) = read("with_route_points");
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<trk>"), 0);
assert_eq!(count(&text, "<trkseg>"), 0);
assert_eq!(count(&text, "<trkpt"), 0);
// the anchors are the route points, the trackpoints between them the points of their path
assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), 4);
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
// a route has the information of its track
assert!(text.contains("<name>Lausanne</name>"));
assert!(text.contains("<cmt>comment</cmt>"));
assert!(text.contains("<desc>description</desc>"));
assert!(text.contains("<type>Cycling</type>"));
// and its points only have a position in the path, and an elevation as route points
let first_rtept = &text[text.find("<rtept").unwrap()..];
let (lat, lng) = (46.54462809674442_f64, 6.658079791814089_f64);
assert!(first_rtept.starts_with(&format!("<rtept lat=\"{lat}\" lon=\"{lng}\">")));
assert!(first_rtept.contains("<ele>500</ele>"));
let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64);
assert!(text.contains(&format!("<gpxx:rpt lat=\"{lat}\" lon=\"{lng}\"/>")));
}
#[test]
fn test_a_route_survives_being_exported_as_a_route() {
let (file, mut categories) = read("with_route_points");
let written = write(&file, &categories, as_route());
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), 1);
assert_eq!(again.trk[0].info, file.trk[0].info);
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(a.coordinates.lat, b.coordinates.lat);
assert_eq!(a.coordinates.lng, b.coordinates.lng);
assert_eq!(a.anchor, b.anchor);
}
// the elevation of the route points is kept, the other ones are worked out again
let anchors = |s: &crate::TrackSegment| {
s.iter()
.filter(|p| p.anchor.is_some())
.map(|p| p.ele)
.collect::<Vec<_>>()
};
assert_eq!(anchors(a), anchors(b));
}
#[test]
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
let (file, mut categories) = read("with_tracks_and_segments");
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
assert!(segments > 2);
let written = write(&file, &categories, as_route());
let text = String::from_utf8(written.clone()).unwrap();
assert_eq!(count(&text, "<rte>"), segments);
assert_eq!(count(&text, "<trk>"), 0);
// every route starts and ends with a route point
assert_eq!(count(&text, "</rte>"), segments);
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), segments);
let mut routes = again.trk.iter();
for track in &file.trk {
for segment in &track.trkseg {
let route = routes.next().unwrap();
// the information of the track, repeated for each of its segments
assert_eq!(route.info.name, track.info.name);
assert_eq!(route.info.type_, track.info.type_);
let (a, b) = (segment, &route.trkseg[0]);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(
(a.coordinates.lat, a.coordinates.lng),
(b.coordinates.lat, b.coordinates.lng)
);
}
// the anchors of the segment are the ones of its route
let anchors = |s: &crate::TrackSegment| {
s.iter()
.enumerate()
.filter(|(_, p)| p.anchor.is_some())
.map(|(i, _)| i)
.collect::<Vec<_>>()
};
assert_eq!(anchors(a), anchors(b));
}
}
}
#[test]
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
let (mut file, categories) = read("simple");
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None);
let len = file.trk[0].trkseg[0].len();
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rtept"), 2);
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
// and a single trackpoint is a route of one point
file.trk[0].trkseg[0].splice(1, len, vec![]);
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rtept"), 1);
assert_eq!(count(&text, "<gpxx:rpt"), 0);
}
#[test]
fn test_empty_segments_are_not_routes() {
let (mut file, categories) = read("simple");
file.trk[0].trkseg.push(crate::TrackSegment::default());
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rte>"), 1);
}
#[test]
fn test_routes_have_the_style_of_their_track() {
let (file, categories) = read("with_style");
let text = written_text(&file, &categories, as_route());
assert!(count(&text, "<gpx_style:line>") >= 1);
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.trk[0].info.color, file.trk[0].info.color);
assert_eq!(again.trk[0].info.width, file.trk[0].info.width);
}
#[test]
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
let (file, categories) = read("with_hr");
let with_all = written_text(&file, &categories, as_route());
assert!(with_all.contains("<gpxtpx:hr>"));
let without = written_text(
&file,
&categories,
ExportOptions {
hr: false,
time: false,
as_route: true,
..ExportOptions::ALL
},
);
assert!(!without.contains("<gpxtpx:hr>"));
assert!(!without.contains("<time>"));
}
#[test]
fn test_the_route_option_is_not_data() {
let (file, _) = read("with_route_points");
assert!(!file.exportable_data().as_route);
// it is a way to write the data, never part of them
let all = std::hint::black_box(ExportOptions::ALL);
assert!(!all.as_route && !ExportOptions::NONE.as_route);
assert!(as_route().union(ExportOptions::NONE).as_route);
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
}
}
-11
View File
@@ -1,11 +0,0 @@
mod algorithm;
pub(crate) mod gpx;
mod io;
pub(crate) mod statistics;
mod utils;
pub use algorithm::*;
pub use gpx::*;
pub use io::*;
pub use statistics::*;
pub use utils::*;
@@ -1,527 +0,0 @@
use crate::{
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
};
/// Time between two trackpoints, to size the windows of a smoothing. It is unknown when one of
/// them has no time, which is longer than any known time span: the window does not go there.
#[derive(Clone, Copy, PartialEq, PartialOrd)]
enum TimeSpan {
Known(i64),
Unknown,
}
impl TimeSpan {
fn between(end: Option<i64>, start: Option<i64>) -> Self {
time_diff(end, start).map_or(Self::Unknown, Self::Known)
}
}
#[derive(Default, Debug)]
pub struct Statistics {
pub global: GlobalStatistics,
pub local: Vec<TrackpointStatistics>,
}
impl Statistics {
pub fn total_speed(&self) -> Option<f64> {
self.global.total_speed()
}
pub fn moving_speed(&self) -> Option<f64> {
self.global.moving_speed()
}
pub fn compute(trkseg: &TrackSegment) -> Self {
let mut stats = Self::default();
if trkseg.is_empty() {
return stats;
}
let mut prev = &trkseg[0];
for cur in trkseg.iter() {
stats.accumulate(prev, cur);
prev = cur;
}
stats.compute_smoothed_speed(trkseg);
stats.compute_smoothed_elevation_segments(trkseg);
stats.compute_smoothed_slope(trkseg);
stats
}
fn accumulate(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
self.accumulate_distance_and_time(prev, cur);
self.update_time_bounds(cur.time);
self.update_bounds(cur.coordinates);
self.local
.push(TrackpointStatistics::from_partial_stats(self));
}
fn accumulate_distance_and_time(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
let dist = distance(prev.coordinates, cur.coordinates);
let time = time_diff(cur.time, prev.time);
self.global.total_distance += dist;
let moving = time
.zip(time.and_then(|time| speed(dist, time)))
.filter(|(_, speed)| (0.5..=1500.0).contains(speed));
if let Some((time, _)) = moving {
self.global.moving_distance = self
.global
.moving_distance
.map_or(Some(dist), |d| Some(d + dist));
self.global.moving_time = self
.global
.moving_time
.map_or(Some(time), |t| Some(t + time));
}
}
fn update_time_bounds(&mut self, time: Option<i64>) {
if let Some(time) = time {
if self.global.start_time.is_none() {
self.global.start_time = Some(time);
}
self.global.end_time = Some(time);
self.global.total_time = time_diff(self.global.end_time, self.global.start_time);
}
}
fn update_bounds(&mut self, coordinates: LngLat) {
self.global.bounds.extend(coordinates);
}
/// Speed over a window of 10 seconds on each side of the trackpoints that have a time (the
/// others have no speed). The windows do not extend to the trackpoints without time: if the
/// previous or next trackpoint has none, the window ends at the trackpoint itself on that side.
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
for_each_window!(
trkseg,
trkseg.first_index(),
trkseg.last_index(),
TimeSpan::Known(10_000),
|i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|i, left, right| {
if trkseg[i].time.is_some() {
let timed = |index: TrackSegmentIndex| {
if trkseg[index].time.is_some() {
index
} else {
i
}
};
let (left, right) = (timed(left), timed(right));
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
.and_then(|time| {
speed(
self.local[right.flat].total_distance
- self.local[left.flat].total_distance,
time,
)
});
}
},
);
}
fn compute_smoothed_elevation_segments(&mut self, trkseg: &TrackSegment) {
let simplified = self.get_elevation_extremas(trkseg);
for i in 0..(simplified.len() - 1) {
let start = simplified[i];
let end = simplified[i + 1];
let last = i + 1 == simplified.len() - 1;
self.compute_smoothed_elevation_gain(trkseg, start, end, last);
let segment_dist =
self.local[end.flat].total_distance - self.local[start.flat].total_distance;
let segment_ele = trkseg[end].ele - trkseg[start].ele;
let segment_slope = slope(segment_ele, segment_dist);
for k in start.flat..(end.flat + last as usize) {
self.local[k].slope_segment = SlopeSegment {
slope: segment_slope,
distance: segment_dist,
};
}
}
}
fn compute_smoothed_elevation_gain(
&mut self,
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
last: bool,
) {
let mut cumul_ele = 0.0;
let mut current_left = start;
let mut current_right = Some(start);
let mut prev_smoothed_ele = trkseg[start].ele;
for_each_window!(
trkseg,
Some(start),
Some(end),
0.1,
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|i, left, right| {
while current_left != left {
cumul_ele -= trkseg[current_left].ele;
current_left = trkseg.next_index(Some(current_left)).unwrap();
}
while let Some(current) = current_right {
if current > right {
break;
}
cumul_ele += trkseg[current].ele;
current_right = trkseg.next_index(current_right);
}
let smoothed_ele = if i == start || i == end {
trkseg[i].ele
} else {
cumul_ele / (right.flat - left.flat + 1) as f64
};
let delta = smoothed_ele - prev_smoothed_ele;
if delta > 0.0 {
self.global.elevation_gain += delta;
} else if delta < 0.0 {
self.global.elevation_loss -= delta;
}
if i < end || last {
self.local[i.flat].elevation_gain = self.global.elevation_gain;
self.local[i.flat].elevation_loss = self.global.elevation_loss;
}
prev_smoothed_ele = smoothed_ele;
},
);
}
fn compute_smoothed_slope(&mut self, trkseg: &TrackSegment) {
for_each_window!(
trkseg,
trkseg.first_index(),
trkseg.last_index(),
0.05,
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|i, left, right| {
let dist =
self.local[right.flat].total_distance - self.local[left.flat].total_distance;
let ele = trkseg[right].ele - trkseg[left].ele;
self.local[i.flat].slope = slope(ele, dist);
},
);
}
fn get_elevation_extremas(&self, trkseg: &TrackSegment) -> Vec<TrackSegmentIndex> {
ramer_douglas_peucker(
trkseg,
&|idx| {
(
self.local[idx.flat].total_distance * 1000.0,
trkseg[idx].ele,
)
},
20.0,
)
}
}
#[derive(Default, Debug)]
pub struct GlobalStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
pub total_time: Option<i64>,
pub moving_time: Option<i64>,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub bounds: LngLatBounds,
}
impl GlobalStatistics {
/// Average speed over the total time, unknown if that is not a positive duration (for
/// example a range with a single timestamp, or timestamps going backwards).
pub fn total_speed(&self) -> Option<f64> {
self.total_time.and_then(|t| speed(self.total_distance, t))
}
/// Average speed over the moving time, unknown if there is no moving time.
pub fn moving_speed(&self) -> Option<f64> {
self.moving_distance
.zip(self.moving_time)
.and_then(|(d, t)| speed(d, t))
}
pub fn merge(&mut self, other: &GlobalStatistics) {
self.total_distance += other.total_distance;
self.moving_distance = sum_options(self.moving_distance, other.moving_distance);
self.total_time = sum_options(self.total_time, other.total_time);
self.moving_time = sum_options(self.moving_time, other.moving_time);
self.elevation_gain += other.elevation_gain;
self.elevation_loss += other.elevation_loss;
self.start_time = min_options(self.start_time, other.start_time);
self.end_time = max_options(self.end_time, other.end_time);
self.bounds.merge(&other.bounds);
}
}
#[derive(Default, Debug)]
pub struct TrackpointStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
pub total_time: Option<i64>,
pub moving_time: Option<i64>,
pub speed: Option<f64>,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub slope: f64,
pub slope_segment: SlopeSegment,
}
impl TrackpointStatistics {
fn from_partial_stats(stats: &Statistics) -> Self {
Self {
total_distance: stats.global.total_distance,
moving_distance: stats.global.moving_distance,
total_time: stats.global.total_time,
moving_time: stats.global.moving_time,
// stats below are computed later
speed: None,
elevation_gain: Default::default(),
elevation_loss: Default::default(),
slope: Default::default(),
slope_segment: Default::default(),
}
}
}
#[derive(Default, Debug)]
pub struct SlopeSegment {
pub slope: f64,
pub distance: f64,
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::Read};
use crate::parse;
use super::*;
#[test]
fn test_compute_smoothed_speed() {
let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg);
assert_eq!(stats.local.len(), trkseg.len());
for trkpt_stats in stats.local.iter() {
assert!(trkpt_stats.speed.is_some());
let speed = trkpt_stats.speed.unwrap();
assert_ne!(speed, f64::NAN);
assert!((speed - 20.0).abs() < 0.1);
}
}
/// The speeds of the segment of `data/with_time.gpx` (80 trackpoints at 20 km/h) once the
/// trackpoints at `holes` have lost their time.
fn speeds_with_holes(holes: &[usize]) -> Vec<Option<f64>> {
let gpx = load("data/with_time.gpx");
let mut segment = gpx.trk[0].trkseg[0].clone();
let points: Vec<_> = segment
.iter()
.enumerate()
.map(|(i, trkpt)| {
let mut trkpt = trkpt.clone();
if holes.contains(&i) {
trkpt.time = None;
}
trkpt
})
.collect();
segment.splice(0, points.len(), points);
Statistics::compute(&segment)
.local
.iter()
.map(|local| local.speed)
.collect()
}
/// Without time, there is no speed. With one, the speed is the one of the trackpoints around.
fn assert_speeds_with_holes(holes: &[usize]) {
let speeds = speeds_with_holes(holes);
assert_eq!(speeds.len(), 80);
for (i, speed) in speeds.iter().enumerate() {
if holes.contains(&i) {
assert_eq!(*speed, None, "trackpoint {i}");
} else {
let speed = speed.unwrap_or_else(|| panic!("no speed for trackpoint {i}"));
assert!((speed - 20.0).abs() < 0.1, "trackpoint {i}: {speed}");
}
}
}
#[test]
fn test_speed_without_holes() {
assert_speeds_with_holes(&[]);
}
#[test]
fn test_speed_with_holes_at_the_beginning() {
assert_speeds_with_holes(&[0, 1, 2, 3, 4]);
}
#[test]
fn test_speed_with_holes_at_the_end() {
assert_speeds_with_holes(&[75, 76, 77, 78, 79]);
}
#[test]
fn test_speed_with_holes_at_both_ends() {
assert_speeds_with_holes(&[0, 1, 2, 77, 78, 79]);
}
#[test]
fn test_speed_with_holes_in_the_middle() {
assert_speeds_with_holes(&[40]);
assert_speeds_with_holes(&[30, 31, 32, 33, 34]);
assert_speeds_with_holes(&(20..60).collect::<Vec<_>>());
}
#[test]
fn test_speed_with_scattered_holes() {
let holes: Vec<_> = (0..80).step_by(7).collect();
assert_speeds_with_holes(&holes);
}
#[test]
fn test_speed_without_any_time() {
let holes: Vec<_> = (0..80).collect();
assert!(speeds_with_holes(&holes).iter().all(Option::is_none));
}
fn load(path: &str) -> crate::File {
let mut f = File::open(path).unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
parse(data.as_bytes(), &mut Default::default()).unwrap()
}
#[test]
fn test_compute_empty_segment() {
let stats = Statistics::compute(&TrackSegment::default());
assert!(stats.local.is_empty());
assert_eq!(stats.global.total_distance, 0.0);
}
#[test]
fn test_compute_distance_and_bounds() {
let gpx = load("data/simple.gpx");
let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg);
assert_eq!(stats.local.len(), trkseg.len());
assert_eq!(stats.local[0].total_distance, 0.0);
assert!(stats.global.total_distance > 0.0);
// cumulative distance never decreases and ends at the global distance
assert!(
stats
.local
.windows(2)
.all(|w| w[0].total_distance <= w[1].total_distance)
);
let last = stats.local.last().unwrap();
assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9);
assert!(stats.global.elevation_gain >= 0.0);
assert!(stats.global.elevation_loss >= 0.0);
// every point lies inside the bounds
let b = &stats.global.bounds;
for trkpt in trkseg.iter() {
assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng);
assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat);
}
}
#[test]
fn test_compute_time() {
let gpx = load("data/with_time.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
let total_time = stats.global.total_time.unwrap();
assert!(total_time > 0);
let speed = stats.global.total_speed().unwrap();
assert!((speed - 20.0).abs() < 0.5, "{speed}");
}
#[test]
fn test_no_time_without_timestamps() {
let gpx = load("data/simple.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
assert!(stats.global.total_time.is_none());
assert!(stats.global.total_speed().is_none());
assert!(stats.local.iter().all(|s| s.speed.is_none()));
}
#[test]
fn test_global_merge() {
let mut a = GlobalStatistics {
total_distance: 1.0,
elevation_gain: 10.0,
moving_time: Some(5),
..Default::default()
};
a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 });
let mut b = GlobalStatistics {
total_distance: 2.0,
elevation_loss: 4.0,
moving_distance: Some(1.5),
moving_time: Some(7),
..Default::default()
};
b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 });
a.merge(&b);
assert_eq!(a.total_distance, 3.0);
assert_eq!(a.elevation_gain, 10.0);
assert_eq!(a.elevation_loss, 4.0);
assert_eq!(a.moving_distance, Some(1.5));
assert_eq!(a.moving_time, Some(12));
assert_eq!(a.total_time, None);
let mut c = GlobalStatistics {
start_time: Some(1_000),
end_time: Some(4_000),
total_time: Some(3_000),
..Default::default()
};
let d = GlobalStatistics {
start_time: Some(10_000),
end_time: Some(12_000),
total_time: Some(2_000),
..Default::default()
};
c.merge(&d);
// 3 s + 2 s, the gap between the two is ignored
assert_eq!(c.total_time, Some(5_000));
let mut cumul = GlobalStatistics::default();
cumul.merge(&c);
cumul.merge(&d);
assert_eq!(cumul.start_time, Some(1_000));
assert_eq!(cumul.end_time, Some(12_000));
assert_eq!(cumul.total_time, Some(7_000));
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
}
}
-5
View File
@@ -1,5 +0,0 @@
mod compute;
mod utils;
pub use compute::*;
pub use utils::*;
@@ -1,44 +0,0 @@
use std::ops::Add;
pub fn combine_options<T>(a: Option<T>, b: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
match (a, b) {
(Some(a), Some(b)) => Some(f(a, b)),
(Some(v), None) | (None, Some(v)) => Some(v),
(None, None) => None,
}
}
pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Add<Output = T>,
{
combine_options(a, b, |a, b| a + b)
}
pub fn min_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Ord,
{
combine_options(a, b, |a, b| a.min(b))
}
pub fn max_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Ord,
{
combine_options(a, b, |a, b| a.max(b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sum_options() {
assert_eq!(sum_options(Some(1), Some(2)), Some(3));
assert_eq!(sum_options(Some(1), None), Some(1));
assert_eq!(sum_options(None, Some(2)), Some(2));
assert_eq!(sum_options::<i32>(None, None), None);
assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75));
}
}
-208
View File
@@ -1,208 +0,0 @@
use std::f64::consts::PI;
use crate::LngLat;
const TO_RADIANS: f64 = PI / 180.0;
const EARTH_RADIUS: f64 = 6371.0088;
/// Computes the distance in kilometers between two coordinates using the Haversine formula
pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
let lat1 = p1.lat * TO_RADIANS;
let lat2 = p2.lat * TO_RADIANS;
let delta_lat = lat2 - lat1;
let delta_lng = (p2.lng - p1.lng) * TO_RADIANS;
let a =
(delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lng / 2.0).sin().powi(2);
let c = 2.0 * a.min(1.0).sqrt().asin();
EARTH_RADIUS * c
}
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
Some(a? - b?)
}
/// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
/// Unknown if the time is not a positive duration.
pub fn speed(distance: f64, time: i64) -> Option<f64> {
(time > 0).then(|| distance / (time as f64 / 3_600_000.0))
}
pub fn slope(ele: f64, distance: f64) -> f64 {
if distance == 0.0 {
100.0
} else {
0.1 * ele / distance
}
}
const METERS_PER_LATITUDE_DEGREE: f64 = 111320.0;
/// Approximate planar projection, in meters, around the latitude of `origin` (ignores the
/// curvature of the earth).
struct Planar {
meters_per_longitude_degree: f64,
}
impl Planar {
fn around(origin: LngLat) -> Self {
Self {
meters_per_longitude_degree: (origin.lat * TO_RADIANS).cos()
* METERS_PER_LATITUDE_DEGREE,
}
}
fn to_meters(&self, p: LngLat) -> (f64, f64) {
(
p.lng * self.meters_per_longitude_degree,
p.lat * METERS_PER_LATITUDE_DEGREE,
)
}
fn to_degrees(&self, (x, y): (f64, f64)) -> LngLat {
LngLat {
lng: x / self.meters_per_longitude_degree,
lat: y / METERS_PER_LATITUDE_DEGREE,
}
}
}
/// Position, between 0 (at `(x1, y1)`) and 1 (at `(x2, y2)`), of the point of the segment that is
/// the closest to `(x3, y3)`. A degenerate segment (same ends) is a point.
fn closest_on_segment(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
let dx = x2 - x1;
let dy = y2 - y1;
let segment_length_squared = dx * dx + dy * dy;
if segment_length_squared == 0.0 {
0.0
} else {
(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared).clamp(0.0, 1.0)
}
}
/// Calculates the point on the line segment defined by p1 and p2 that is closest to the third
/// point, p3. Uses simple planar geometry (ignores earth curvature).
pub fn projected(p1: LngLat, p2: LngLat, p3: LngLat) -> LngLat {
let planar = Planar::around(p1);
let (x1, y1) = planar.to_meters(p1);
let (x2, y2) = planar.to_meters(p2);
let (x3, y3) = planar.to_meters(p3);
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
planar.to_degrees((x1 + t * (x2 - x1), y1 + t * (y2 - y1)))
}
/// Calculates the perpendicular distance in meters between a line segment (defined by p1 and p2)
/// and a third point, p3. Uses simple planar geometry (ignores earth curvature).
pub fn crossarc_lnglat(p1: LngLat, p2: LngLat, p3: LngLat) -> f64 {
let planar = Planar::around(p1);
let (x1, y1) = planar.to_meters(p1);
let (x2, y2) = planar.to_meters(p2);
let (x3, y3) = planar.to_meters(p3);
crossarc(x1, y1, x2, y2, x3, y3)
}
/// Distance from the point `(x3, y3)` to the segment `(x1, y1)`-`(x2, y2)`, in the units of the
/// coordinates (planar geometry).
pub fn crossarc(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
(x3 - (x1 + t * (x2 - x1))).hypot(y3 - (y1 + t * (y2 - y1)))
}
#[cfg(test)]
mod tests {
use super::*;
fn p(lng: f64, lat: f64) -> LngLat {
LngLat { lng, lat }
}
#[test]
fn test_time_diff_and_speed_with_long_durations() {
// 40 days in milliseconds, more than an i32 can hold
let forty_days = 40 * 24 * 3_600_000_i64;
assert!(forty_days > i64::from(i32::MAX));
assert_eq!(time_diff(Some(forty_days), Some(0)), Some(forty_days));
assert_eq!(time_diff(Some(0), Some(forty_days)), Some(-forty_days));
assert_eq!(time_diff(None, Some(1)), None);
assert_eq!(time_diff(Some(1), None), None);
// 1 km in one hour
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
}
#[test]
fn test_distance() {
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
// one degree of latitude
let d = distance(p(0.0, 0.0), p(0.0, 1.0));
assert!((d - 111.195).abs() < 0.01, "{d}");
// symmetric
let a = p(4.40, 50.79);
let b = p(6.13, 45.90);
assert!((distance(a, b) - distance(b, a)).abs() < 1e-9);
// half the Earth's circumference, without NaN from rounding
let antipodal = distance(p(0.0, 0.0), p(180.0, 0.0));
assert!((antipodal - PI * EARTH_RADIUS).abs() < 1e-6);
}
#[test]
fn test_time_diff() {
assert_eq!(time_diff(Some(5000), Some(2000)), Some(3000));
assert_eq!(time_diff(Some(2000), Some(5000)), Some(-3000));
assert_eq!(time_diff(None, Some(1)), None);
assert_eq!(time_diff(Some(1), None), None);
}
#[test]
fn test_speed() {
// 1 km in 1 h
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
// 10 km in 30 min
assert!((speed(10.0, 1_800_000).unwrap() - 20.0).abs() < 1e-12);
// no duration, no speed
assert_eq!(speed(1.0, 0), None);
assert_eq!(speed(0.0, 0), None);
assert_eq!(speed(1.0, -1000), None);
}
#[test]
fn test_slope() {
// 10 m of elevation over 100 m (distance is expressed in km, hence the 0.1 factor)
assert!((slope(10.0, 0.1) - 10.0).abs() < 1e-12);
assert!((slope(-5.0, 0.1) + 5.0).abs() < 1e-12);
assert_eq!(slope(0.0, 1.0), 0.0);
assert_eq!(slope(10.0, 0.0), 100.0);
}
#[test]
fn test_crossarc() {
// perpendicular distance to the segment (0,0)-(10,0)
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 5.0, 3.0) - 3.0).abs() < 1e-12);
// beyond the ends: distance to the closest endpoint
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 13.0, 4.0) - 5.0).abs() < 1e-12);
assert!((crossarc(0.0, 0.0, 10.0, 0.0, -3.0, 4.0) - 5.0).abs() < 1e-12);
// point on the segment
assert_eq!(crossarc(0.0, 0.0, 10.0, 0.0, 4.0, 0.0), 0.0);
// degenerate segment
assert!((crossarc(1.0, 1.0, 1.0, 1.0, 4.0, 5.0) - 5.0).abs() < 1e-12);
}
#[test]
fn test_projected() {
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
assert!((proj.lng - 0.5).abs() < 1e-9);
assert!(proj.lat.abs() < 1e-9);
// clamped to the segment
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(2.0, 1.0));
assert!((proj.lng - 1.0).abs() < 1e-9);
// degenerate segment
let proj = projected(p(3.0, 4.0), p(3.0, 4.0), p(5.0, 6.0));
assert_eq!((proj.lng, proj.lat), (3.0, 4.0));
}
#[test]
fn test_crossarc_lnglat() {
// about 1 degree of latitude away from an east-west segment on the equator
let d = crossarc_lnglat(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
assert!((d - METERS_PER_LATITUDE_DEGREE).abs() < 1e-6);
}
}
-39
View File
@@ -1,39 +0,0 @@
use crate::{Command, FileId, SelectMode, Selection};
/// Everything the frontend can ask the engine to do.
///
/// Only [`Action::Edit`] changes the files (and records an undo step); the others act on the
/// history, on the selection or on the file order.
#[derive(Debug)]
pub enum Action<'a> {
Edit(Command<'a>),
Undo,
Redo,
/// Selects files, tracks, segments or waypoints (or the waypoints node of one file),
/// ignoring what does not exist: a selection left with nothing is empty, which deselects
/// everything. See [`SelectMode`] for how it combines with the current selection.
Select {
selection: Selection,
mode: SelectMode,
},
/// Selects all the elements of the same kind as the selected ones, in the same place (see
/// [`Selection::all_at_level`]). Does nothing when there is nothing more to select.
SelectAll,
/// Moves the selection to the next (`down`) or previous element of the same kind, like the
/// arrow keys do in the file list (see [`Selection::neighbour`]). With `add`, the element is
/// added to the selection (shift + arrow), otherwise it replaces it.
ArrowSelect {
down: bool,
add: bool,
},
/// Puts the selected elements in the clipboard, to be pasted by [`crate::Paste`]. Does
/// nothing when nothing is selected.
Copy,
/// Like `Copy`, but the elements are moved instead of copied when they are pasted.
Cut,
/// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder {
file_ids: Vec<FileId>,
index: usize,
},
}
-30
View File
@@ -1,30 +0,0 @@
use std::fmt;
use crate::State;
/// The effect of a command on the engine state.
///
/// Each command is a small struct holding its arguments, with its effect implemented in its
/// own file. Effects are built from the reusable patterns in `pattern/`.
pub trait Apply {
fn apply(self, state: &mut State) -> Result<(), CommandError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
/// The command would not change anything.
NothingToDo,
/// The input data could not be parsed.
InvalidData(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NothingToDo => write!(f, "nothing to do"),
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
}
}
}
impl std::error::Error for CommandError {}
@@ -1,161 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
#[derive(Debug)]
pub struct Metadata<'a> {
pub name: &'a str,
pub desc: &'a str,
}
impl Apply for Metadata<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned());
// a single track is named like the file
if let [track] = file.trk.as_mut_slice() {
track.info.name = Some(self.name.to_owned());
}
Edit::Changed
}
fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some(self.name.to_owned());
track.info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_metadata_applies_to_selected_file() {
let mut fx = Fixture::default();
New {
name: "first",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
New {
name: "second",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap(); // now selected
let selected = *fx.selected_files().iter().next().unwrap();
Metadata {
name: "renamed",
desc: "description",
}
.apply(&mut fx.state())
.unwrap();
for (id, file) in fx.files.iter() {
if *id == selected {
assert_eq!(file.info.name, "renamed");
assert_eq!(file.info.desc.as_deref(), Some("description"));
} else {
assert_eq!(file.info.name, "first");
assert_eq!(file.info.desc, None);
}
}
}
#[test]
fn test_metadata_applies_to_selected_track() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap();
crate::Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap().clone();
fx.selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([file.trk[0].id]),
};
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&file.id];
assert_eq!(after.info.name, file.info.name);
assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed"));
assert_eq!(after.trk[1].info, file.trk[1].info);
}
/// A new file with `nb_tracks` tracks (without name), selected.
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
New {
name: "file",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
for _ in 0..nb_tracks {
NewTrack.apply(&mut fx.state()).unwrap();
}
}
fn rename(fx: &mut Fixture) {
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
}
#[test]
fn test_single_track_is_renamed_like_the_file() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_single_track_with_a_name_is_renamed_too() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
let id = *fx.files.keys().next().unwrap();
std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()).trk[0]
.info
.name = Some("track".to_owned());
rename(&mut fx);
let file = &fx.files[&id];
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_several_tracks_are_not_renamed() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 2);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert!(file.trk.iter().all(|trk| trk.info.name.is_none()));
}
}
@@ -1,9 +0,0 @@
mod metadata;
mod new_track;
mod new_track_segment;
mod style;
pub use metadata::*;
pub use new_track::*;
pub use new_track_segment::*;
pub use style::*;
@@ -1,75 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
#[derive(Debug)]
pub struct NewTrack;
impl Apply for NewTrack {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit {
let mut track = Track::default();
track.info.color = common(file, |t| &t.info.color);
track.info.opacity = common(file, |t| &t.info.opacity);
track.info.width = common(file, |t| &t.info.width);
file.trk.push(track);
Edit::Changed
}
}
/// Returns the value of a style attribute if all existing tracks agree on it.
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> {
let mut tracks = file.trk.iter();
let first = get(tracks.next()?);
tracks.all(|t| get(t) == first).then(|| first.clone())?
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.trk.len(), 2);
assert_ne!(file.trk[0].id, file.trk[1].id);
}
#[test]
fn test_new_track_inherits_agreeing_style_only() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
for (i, trk) in file.trk.iter_mut().enumerate() {
trk.info.color = Some("ff0000".into());
trk.info.opacity = Some(0.5 + i as f64 * 0.1);
}
NewTrack.apply(&mut fx.state()).unwrap();
let info = &fx.files.values().next().unwrap().trk[2].info;
assert_eq!(info.color.as_deref(), Some("ff0000"));
assert_eq!(info.opacity, None);
assert_eq!(info.width, None);
}
}
@@ -1,41 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, TrackSegment, update_selected};
#[derive(Debug)]
pub struct NewTrackSegment;
impl Apply for NewTrackSegment {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrackSegment {
fn track(&mut self, track: &mut Track) -> Edit {
track.trkseg.push(TrackSegment::default());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, NewTrack, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track_segment_on_each_track_of_selected_file() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrackSegment.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
}
}
@@ -1,71 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected};
#[derive(Debug)]
pub struct Style<'a> {
pub color: Option<&'a str>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
impl Apply for Style<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for Style<'_> {
fn track(&mut self, track: &mut Track) -> Edit {
if let Some(color) = self.color {
track.info.color = Some(color.to_owned());
}
if self.opacity.is_some() {
track.info.opacity = self.opacity;
}
if self.width.is_some() {
track.info.width = self.width;
}
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_style.gpx").unwrap();
crate::Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = *fx.files.keys().next().unwrap();
let before = fx.files[&id].clone();
assert!(!before.trk.is_empty());
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Style {
color: Some("ff0000"),
opacity: None,
width: Some(7.0),
}
.apply(&mut fx.state())
.unwrap();
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
assert_eq!(a.info.color.as_deref(), Some("ff0000"));
assert_eq!(a.info.width, Some(7.0));
assert_eq!(a.info.opacity, b.info.opacity);
}
}
}
@@ -1,288 +0,0 @@
use std::rc::Rc;
use crate::{Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint};
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
/// are deleted instead, even if only a track or a waypoint is selected.
#[derive(Debug)]
pub struct Delete {
pub whole_files: bool,
}
impl Apply for Delete {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let files = state.selection.to_files();
let selection = if self.whole_files {
&files
} else {
&*state.selection
};
let next = match selection {
Selection::File { file_ids } => {
state.files.retain(|id, _| !file_ids.contains(id));
state.order.0.retain(|id| !file_ids.contains(id));
Selection::Empty
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let len = file.trk.len();
file.trk.retain(|trk| !trk_ids.contains(&trk.id));
if file.trk.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::File {
file_ids: [*file_id].into(),
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !trkseg_ids.contains(&seg.id));
if trk.trkseg.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
}
Selection::Waypoints { file_id } => delete_waypoints(state.files, *file_id, |_| true)?,
Selection::Waypoint { file_id, wpt_ids } => {
delete_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
pub(crate) fn delete_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = file.wpt.edit(&filter, |wpts| {
wpts.retain(|wpt| !filter(wpt));
true
});
if !changed {
return Err(CommandError::NothingToDo);
}
files.insert(file_id, Rc::new(file));
Ok(Selection::File {
file_ids: [file_id].into(),
})
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use crate::{FileId, Load};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_delete_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_selected_files() {
let (mut fx, id) = loaded();
crate::New {
name: "keep",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let keep = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
#[test]
fn test_delete_selected_tracks() {
let (mut fx, id) = loaded();
let before = fx.files[&id].trk.len();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before - 1);
assert!(file.trk.iter().all(|t| t.id != trk_id));
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_selected_segments() {
let (mut fx, id) = loaded();
let trk = &fx.files[&id].trk[0];
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: HashSet::from([seg_id]),
};
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before - 1);
assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
assert!(
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&trk_id))
);
}
fn with_waypoints(fx: &mut Fixture, id: FileId, n: usize) -> Vec<crate::WaypointId> {
let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect();
let ids = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = crate::Waypoints::new([crate::WaypointChunk {
wpt: wpts,
..Default::default()
}]);
fx.files.insert(id, Rc::new(file));
ids
}
#[test]
fn test_delete_selected_waypoints() {
let (mut fx, id) = loaded();
let ids = with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[1]]),
};
let rev = fx.files[&id].wpt.rev_id;
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_ne!(fx.files[&id].wpt.rev_id, rev);
let left: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
assert_eq!(left, vec![ids[0], ids[2]]);
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_all_waypoints_of_file() {
let (mut fx, id) = loaded();
with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoints { file_id: id };
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert!(fx.files[&id].wpt.is_empty());
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_unknown_ids_is_nothing_to_do() {
let (mut fx, id) = loaded();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([Default::default()]),
};
assert_eq!(
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_whole_files_of_the_selected_elements() {
for selection in [
|id: FileId, fx: &Fixture| Selection::Track {
file_id: id,
trk_ids: HashSet::from([fx.files[&id].trk[0].id]),
},
|id: FileId, fx: &Fixture| Selection::TrackSegment {
file_id: id,
trk_id: fx.files[&id].trk[0].id,
trkseg_ids: HashSet::from([fx.files[&id].trk[0].trkseg[0].id]),
},
|id: FileId, _: &Fixture| Selection::Waypoints { file_id: id },
|id: FileId, _: &Fixture| Selection::File {
file_ids: HashSet::from([id]),
},
] {
let (mut fx, id) = loaded();
crate::New {
name: "keep",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let keep = fx.order.0[1];
fx.selection = selection(id, &fx);
Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(!fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
}
#[test]
fn test_delete_whole_files_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete { whole_files: true }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -1,48 +0,0 @@
use crate::{Apply, CommandError, Selection, State};
#[derive(Debug)]
pub struct DeleteAll;
impl Apply for DeleteAll {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
if state.files.is_empty() {
return Err(CommandError::NothingToDo);
}
state.files.clear();
state.order.0.clear();
*state.selection = Selection::Empty;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_delete_all() {
let mut fx = Fixture::default();
assert_eq!(
DeleteAll.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
crate::New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
crate::New {
name: "b",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
assert!(DeleteAll.apply(&mut fx.state()).is_ok());
assert!(fx.files.is_empty());
assert!(fx.order.0.is_empty());
assert!(matches!(fx.selection, Selection::Empty));
}
}
@@ -1,120 +0,0 @@
use crate::{Apply, CommandError, FileId, Selection, State, WaypointId, delete_waypoints};
/// Deletes a waypoint of a file, whatever is selected (what the delete button of its popup does).
/// If it was the only selected element, its file gets selected.
#[derive(Debug)]
pub struct DeleteWaypoint {
pub file_id: FileId,
pub waypoint_id: WaypointId,
}
impl Apply for DeleteWaypoint {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
delete_waypoints(state.files, self.file_id, |wpt| wpt.id == self.waypoint_id)?;
if let Selection::Waypoint { file_id, wpt_ids } = &*state.selection
&& *file_id == self.file_id
&& wpt_ids.iter().all(|id| *id == self.waypoint_id)
{
*state.selection = Selection::File {
file_ids: [self.file_id].into(),
};
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
#[test]
fn test_deletes_one_waypoint_without_touching_the_selection() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: (0..3)
.map(|i| Waypoint {
name: Some(format!("w{i}")),
..Default::default()
})
.collect(),
..Default::default()
});
let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
fx.selection = Selection::File {
file_ids: [id].into(),
};
DeleteWaypoint {
file_id: id,
waypoint_id: ids[1],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx.files[&id]
.wpt
.iter()
.map(|wpt| wpt.name.clone().unwrap())
.collect();
assert_eq!(names, ["w0", "w2"]);
assert_eq!(
fx.selection,
Selection::File {
file_ids: [id].into()
}
);
}
#[test]
fn test_deleting_the_selected_waypoint_selects_its_file() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
});
let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: [ids[0]].into(),
};
DeleteWaypoint {
file_id: id,
waypoint_id: ids[0],
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(
fx.selection,
Selection::File {
file_ids: [id].into()
}
);
}
#[test]
fn test_unknown_waypoint_is_nothing_to_do() {
let mut fx = Fixture::default();
let file = File::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
let result = DeleteWaypoint {
file_id: id,
waypoint_id: WaypointId::default(),
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
}
}
@@ -1,298 +0,0 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
copy_track, copy_waypoint,
};
#[derive(Debug)]
pub struct Duplicate;
impl Apply for Duplicate {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection {
Selection::File { file_ids } => {
let mut copies = HashSet::new();
let mut order = Vec::new();
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let Some(file) = state.files.get(id) else {
continue;
};
let copy = copy_file(file);
order.push(copy.id);
copies.insert(copy.id);
state.files.insert(copy.id, Rc::new(copy));
}
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
let at = state
.order
.0
.iter()
.rposition(|id| file_ids.contains(id))
.map_or(state.order.0.len(), |i| i + 1);
state.order.0.splice(at..at, order);
Selection::File { file_ids: copies }
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let mut copies = HashSet::new();
append_copies(
&mut file.trk,
|trk| trk_ids.contains(&trk.id),
|trk| {
let copy = copy_track(trk);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: copies,
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let mut copies = HashSet::new();
append_copies(
&mut trk.trkseg,
|seg| trkseg_ids.contains(&seg.id),
|seg| {
let copy = copy_segment(seg);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: copies,
}
}
Selection::Waypoints { file_id } => {
duplicate_waypoints(state.files, *file_id, |_| true)?
}
Selection::Waypoint { file_id, wpt_ids } => {
duplicate_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
fn duplicate_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<&Waypoint> = file.wpt.iter().filter(|wpt| filter(wpt)).collect();
let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
// The copies go right after the last selected waypoint.
let mut file = (**file).clone();
file.wpt.insert_after(Some(last), copies);
files.insert(file_id, Rc::new(file));
Ok(Selection::Waypoint {
file_id,
wpt_ids: copy_ids,
})
}
/// Inserts a copy of each item matching `filter`, as a block after the last matching item.
fn append_copies<T>(items: &mut Vec<T>, filter: impl Fn(&T) -> bool, copy: impl FnMut(&T) -> T) {
let Some(last) = items.iter().rposition(&filter) else {
return;
};
let copies: Vec<T> = items.iter().filter(|item| filter(item)).map(copy).collect();
items.splice(last + 1..last + 1, copies);
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use crate::{FileId, Load};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_duplicate_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Duplicate.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_duplicate_file() {
let (mut fx, id) = loaded();
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files.len(), 2);
let copy_id = fx.order.0[1];
assert_eq!(fx.selected_files(), [copy_id].into());
let (orig, copy) = (&fx.files[&id], &fx.files[&copy_id]);
assert_ne!(copy_id, id);
assert_eq!(copy.info.name, orig.info.name);
assert_eq!(copy.trk.len(), orig.trk.len());
for (a, b) in orig.trk.iter().zip(&copy.trk) {
assert_ne!(a.id, b.id);
assert_eq!(a.info, b.info);
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_ne!(a.id, b.id);
assert_eq!(a.len(), b.len());
}
}
}
#[test]
fn test_duplicate_files_go_after_last_selected() {
let mut fx = Fixture::default();
for name in ["a", "b", "c"] {
crate::New {
name,
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
}
let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]];
fx.selection = Selection::File {
file_ids: [a, b].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0.len(), 5);
assert_eq!(fx.order.0[..2], [a, b]);
assert_eq!(fx.order.0[4], c);
assert_eq!(
fx.selected_files(),
fx.order.0[2..4].iter().copied().collect()
);
}
#[test]
fn test_duplicate_track_appends_copy() {
let (mut fx, id) = loaded();
let before = fx.files[&id].trk.len();
let (trk_id, second_id) = (fx.files[&id].trk[0].id, fx.files[&id].trk[1].id);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [trk_id].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before + 1);
assert_eq!(file.trk[0].id, trk_id);
assert_ne!(file.trk[1].id, trk_id);
// copies go right after the selected track, not at the end
assert_eq!(file.trk[2].id, second_id);
assert!(
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&file.trk[1].id))
);
}
#[test]
fn test_duplicate_selected_waypoints() {
let (mut fx, id) = loaded();
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = crate::Waypoints::new([crate::WaypointChunk {
wpt: wpts,
..Default::default()
}]);
fx.files.insert(id, Rc::new(file));
let original_chunk = fx.files[&id].wpt.chunks()[0].clone();
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[0]]),
};
let rev = fx.files[&id].wpt.rev_id;
Duplicate.apply(&mut fx.state()).unwrap();
assert_ne!(fx.files[&id].wpt.rev_id, rev);
let all: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
assert_eq!(all.len(), 4);
assert!(
matches!(&fx.selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids == &HashSet::from([all[1]]))
);
// the copy comes right after the selected waypoint, in a new chunk
assert_eq!(all[..], [ids[0], all[1], ids[1], ids[2]]);
assert_eq!(fx.files[&id].wpt.chunks().len(), 1);
assert_ne!(fx.files[&id].wpt.chunks()[0].id, original_chunk.id);
// selecting the last waypoint: the copy goes at the end
let last = fx.files[&id].wpt.iter().last().unwrap().id;
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([last]),
};
Duplicate.apply(&mut fx.state()).unwrap();
let all: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
assert_eq!(all.len(), 5);
assert_eq!(all[..4], [ids[0], all[1], ids[1], ids[2]]);
fx.selection = Selection::Waypoints { file_id: id };
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files[&id].wpt.len(), 10);
}
#[test]
fn test_duplicate_segment_appends_copy() {
let (mut fx, id) = loaded();
let trk = &fx.files[&id].trk[0];
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: [seg_id].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before + 1);
assert_eq!(trk.trkseg[0].id, seg_id);
assert_ne!(trk.trkseg[1].id, seg_id);
assert_eq!(trk.trkseg[0].len(), trk.trkseg[1].len());
}
}
@@ -1,233 +0,0 @@
use crate::{Apply, CommandError, File, State, TrackpointCategories, parse, produce};
#[derive(Debug)]
pub struct Load<'a> {
pub data: &'a [u8],
/// Name of the file when the data has none (typically the name of the file on disk, without
/// its extension).
pub name: &'a str,
}
impl Load<'_> {
fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
let mut file = parse(self.data, categories)
.map_err(|err| CommandError::InvalidData(err.to_string()))?;
if file.info.name.trim().is_empty() {
file.info.name = self.name.to_owned();
}
Ok(file)
}
}
impl Apply for Load<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
LoadFiles { files: vec![self] }.apply(state)
}
}
/// Loads several files at once, as a single command (one undo step): the files are added at the
/// end of the list, in the given order, and the first one is selected.
///
/// The files that cannot be read are skipped, unless none of them can: then nothing is loaded
/// and the error is the one of the first file.
#[derive(Debug)]
pub struct LoadFiles<'a> {
pub files: Vec<Load<'a>>,
}
impl Apply for LoadFiles<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let mut files = Vec::with_capacity(self.files.len());
let mut first_error = None;
for load in self.files {
match load.parse(state.categories) {
Ok(file) => files.push(file),
Err(error) => {
first_error.get_or_insert(error);
}
}
}
if files.is_empty() {
return Err(first_error.unwrap_or(CommandError::NothingToDo));
}
produce(state, |_| files);
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_load() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap();
assert!(
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.is_ok()
);
assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap();
assert!(!file.trk.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
}
#[test]
fn test_load_invalid_file_changes_nothing() {
let mut fx = Fixture::default();
crate::New {
name: "keep",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let selected = fx.selected_files();
let result = Load {
data: b"<gpx><trk></gpx>",
name: "file",
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected);
}
fn load_with_name(data: &[u8]) -> String {
let mut fx = Fixture::default();
Load {
data,
name: "from disk",
}
.apply(&mut fx.state())
.unwrap();
fx.files.values().next().unwrap().info.name.clone()
}
#[test]
fn test_load_keeps_the_name_of_the_file() {
let data = br#"<gpx version="1.1"><metadata><name>in the file</name></metadata></gpx>"#;
assert_eq!(load_with_name(data), "in the file");
}
#[test]
fn test_load_without_name_uses_the_given_name() {
let without = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
assert_eq!(load_with_name(without), "from disk");
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
assert_eq!(load_with_name(empty), "from disk");
}
fn gpx(name: &str) -> Vec<u8> {
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
}
fn load<'a>(data: &'a [u8], name: &'a str) -> Load<'a> {
Load { data, name }
}
#[test]
fn test_load_several_files() {
let mut fx = Fixture::default();
crate::New {
name: "before",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let before = fx.order.0[0];
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(&b, "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
// added after the others, in the order they were given
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["before", "a", "b", "c"]);
assert_eq!(fx.order.0[0], before);
// the first one is selected
assert_eq!(fx.selected_files(), [fx.order.0[1]].into());
}
#[test]
fn test_load_several_files_skips_the_invalid_ones() {
let mut fx = Fixture::default();
let (a, c) = (gpx("a"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(b"<gpx><trk></gpx>", "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["a", "c"]);
}
#[test]
fn test_load_several_invalid_files_changes_nothing() {
let mut fx = Fixture::default();
crate::New {
name: "keep",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let selected = fx.selected_files();
let result = LoadFiles {
files: vec![
load(b"<gpx><trk></gpx>", "x"),
load(b"<gpx><wpt></gpx>", "y"),
],
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected);
// nothing to load
assert_eq!(
LoadFiles { files: vec![] }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_each_file_without_name_gets_its_own_name() {
let mut fx = Fixture::default();
let unnamed = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
LoadFiles {
files: vec![load(unnamed, "first"), load(unnamed, "second")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["first", "second"]);
}
}
@@ -1,19 +0,0 @@
mod delete;
mod delete_all;
mod delete_waypoint;
mod duplicate;
mod load;
mod move_elements;
mod new;
mod paste;
mod transfer;
pub use delete::*;
pub use delete_all::*;
pub use delete_waypoint::*;
pub use duplicate::*;
pub use load::*;
pub use move_elements::*;
pub use new::*;
pub use paste::*;
pub use transfer::*;
@@ -1,447 +0,0 @@
use crate::{
Apply, Clipboard, CommandError, Destination, FileId, Place, Selection, State, TrackId, transfer,
};
/// Where moved elements go: a list of the file tree, and the position in it. The position is
/// counted among the elements of the list that are not moved, like the position of a dragged
/// element in the list it is dropped in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MoveTarget {
/// The list of files.
Files { index: usize },
/// The tracks of a file.
Tracks { file_id: FileId, index: usize },
/// The segments of a track.
Segments {
file_id: FileId,
trk_id: TrackId,
index: usize,
},
/// The waypoints of a file.
Waypoints { file_id: FileId, index: usize },
}
/// Moves elements to a place of the file tree (what drag and drop does), with their ids. The
/// moved elements are selected.
///
/// What can go where:
/// - files: among the files;
/// - tracks: among the files (each track becomes a file), or among the tracks of a file;
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each
/// segment becomes a track), or among the segments of a track;
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
/// file.
#[derive(Debug)]
pub struct Move {
pub what: Selection,
pub to: MoveTarget,
}
impl From<MoveTarget> for Destination {
fn from(target: MoveTarget) -> Self {
match target {
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
MoveTarget::Tracks { file_id, index } => {
Destination::Tracks(file_id, Place::Index(index))
}
MoveTarget::Segments {
file_id,
trk_id,
index,
} => Destination::Segments(file_id, trk_id, Place::Index(index)),
MoveTarget::Waypoints { file_id, index } => {
Destination::Waypoints(file_id, Place::Index(index))
}
}
}
}
impl Apply for Move {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let clipboard = Clipboard::new(&self.what, state.files, &state.order.0, true)
.ok_or(CommandError::NothingToDo)?;
*state.selection = transfer(state, &clipboard.content, true, self.to.into())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId,
Waypoints, engine::command::fixture::Fixture, new_file, waypoint_ids,
};
use super::*;
/// A file with the given tracks (name, number of segments) and number of waypoints.
fn file(name: &str, tracks: &[(&str, usize)], waypoints: usize) -> File {
let mut file = new_file(
name,
tracks
.iter()
.map(|(name, segments)| Track {
info: TrackInfo {
name: Some((*name).to_owned()),
..Default::default()
},
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
..Default::default()
})
.collect(),
);
if waypoints > 0 {
file.wpt = Waypoints::new([WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default()
}]);
}
file
}
fn add(fx: &mut Fixture, file: File) -> FileId {
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
id
}
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
}
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
}
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
waypoint_ids(&fx.files[&id]).collect()
}
fn do_move(fx: &mut Fixture, what: Selection, to: MoveTarget) -> Result<(), CommandError> {
Move { what, to }.apply(&mut fx.state())
}
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
}
}
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids.iter().copied().collect(),
}
}
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
Selection::Waypoint {
file_id,
wpt_ids: ids.iter().copied().collect(),
}
}
fn files(ids: &[FileId]) -> Selection {
Selection::File {
file_ids: ids.iter().copied().collect(),
}
}
#[test]
fn test_reorder_tracks_in_a_file() {
let build = || {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[("0", 1), ("1", 1), ("2", 1), ("3", 1)], 0),
);
let t = track_ids(&fx, a);
(fx, a, t)
};
let at = |a, index| MoveTarget::Tracks { file_id: a, index };
// the position counts the tracks that stay
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[0]]), at(a, 1)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[0], t[2], t[3]]);
assert_eq!(fx.selection, tracks(a, &[t[0]]));
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[3]]), at(a, 0)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[3], t[0], t[1], t[2]]);
// several at once, as a block, in their order; past the end is the end
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[1], t[0]]), at(a, 99)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[2], t[3], t[0], t[1]]);
assert_eq!(fx.selection, tracks(a, &[t[0], t[1]]));
}
#[test]
fn test_move_tracks_to_another_file_or_to_new_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t0", 1), ("t1", 1)], 0));
let b = add(&mut fx, file("b", &[("b0", 1), ("b1", 1)], 0));
let c = add(&mut fx, file("c", &[], 0));
let t = track_ids(&fx, a);
let b_tracks = track_ids(&fx, b);
do_move(
&mut fx,
tracks(a, &[t[0]]),
MoveTarget::Tracks {
file_id: b,
index: 1,
},
)
.unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![b_tracks[0], t[0], b_tracks[1]]);
assert_eq!(fx.selection, tracks(b, &[t[0]]));
// as a new file, at a position of the list of files
do_move(&mut fx, tracks(a, &[t[1]]), MoveTarget::Files { index: 1 }).unwrap();
assert!(track_ids(&fx, a).is_empty());
assert_eq!(fx.order.0.len(), 4);
let new = fx.order.0[1];
assert_eq!(fx.order.0[0], a);
assert_eq!(fx.order.0[2..], [b, c]);
assert_eq!(track_ids(&fx, new), vec![t[1]]);
assert_eq!(fx.files[&new].info.name, "t1");
assert_eq!(fx.selection, files(&[new]));
}
#[test]
fn test_move_segments() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t0", 3), ("t1", 1)], 0));
let t = track_ids(&fx, a);
(fx, a, t)
};
// within a track
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[2]]),
MoveTarget::Segments {
file_id: a,
trk_id: t[0],
index: 0,
},
)
.unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[2], s[0], s[1]]);
assert_eq!(fx.selection, segments(a, t[0], &[s[2]]));
// to another track
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
let other = segment_ids(&fx, a, 1);
do_move(
&mut fx,
segments(a, t[0], &[s[0], s[1]]),
MoveTarget::Segments {
file_id: a,
trk_id: t[1],
index: 0,
},
)
.unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
assert_eq!(segment_ids(&fx, a, 1), vec![s[0], s[1], other[0]]);
// as new tracks of a file
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[1]]),
MoveTarget::Tracks {
file_id: a,
index: 1,
},
)
.unwrap();
assert_eq!(fx.files[&a].trk.len(), 3);
assert_eq!(segment_ids(&fx, a, 1), vec![s[1]]);
assert_eq!(segment_ids(&fx, a, 0), vec![s[0], s[2]]);
assert_eq!(fx.files[&a].trk[0].id, t[0]);
assert_eq!(fx.files[&a].trk[2].id, t[1]);
// as new files
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[0], s[1]]),
MoveTarget::Files { index: 0 },
)
.unwrap();
assert_eq!(fx.order.0.len(), 3);
assert_eq!(fx.order.0[2], a);
assert_eq!(fx.files[&fx.order.0[0]].info.name, "t0");
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
}
#[test]
fn test_move_waypoints() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 4));
let b = add(&mut fx, file("b", &[], 2));
(fx, a, b)
};
let at = |file_id, index| MoveTarget::Waypoints { file_id, index };
// within a file
let (mut fx, a, _) = build();
let w = wpt_ids(&fx, a);
do_move(&mut fx, waypoints(a, &[w[3]]), at(a, 1)).unwrap();
assert_eq!(wpt_ids(&fx, a), vec![w[0], w[3], w[1], w[2]]);
assert_eq!(fx.selection, waypoints(a, &[w[3]]));
// to the start of another file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
do_move(&mut fx, waypoints(a, &[w[0], w[1]]), at(b, 0)).unwrap();
assert_eq!(wpt_ids(&fx, a), w[2..].to_vec());
assert_eq!(wpt_ids(&fx, b), [&w[..2], &before[..]].concat());
// all the waypoints of a file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
do_move(&mut fx, Selection::Waypoints { file_id: a }, at(b, 1)).unwrap();
assert!(wpt_ids(&fx, a).is_empty());
assert_eq!(
wpt_ids(&fx, b),
[&before[..1], &w[..], &before[1..]].concat()
);
}
#[test]
fn test_move_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
do_move(&mut fx, files(&[c]), MoveTarget::Files { index: 0 }).unwrap();
assert_eq!(fx.order.0, vec![c, a, b]);
do_move(&mut fx, files(&[c, a]), MoveTarget::Files { index: 1 }).unwrap();
assert_eq!(fx.order.0, vec![b, c, a]);
assert_eq!(fx.selection, files(&[c, a]));
}
#[test]
fn test_what_cannot_be_moved() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t", 1)], 1));
let t = track_ids(&fx, a);
let s = segment_ids(&fx, a, 0);
let w = wpt_ids(&fx, a);
let before = fx.files[&a].clone();
let selection = fx.selection.clone();
let invalid = [
// the wrong kind of list
(
files(&[a]),
MoveTarget::Tracks {
file_id: a,
index: 0,
},
),
(
tracks(a, &t),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(
tracks(a, &t),
MoveTarget::Segments {
file_id: a,
trk_id: t[0],
index: 0,
},
),
(
segments(a, t[0], &s),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(
waypoints(a, &w),
MoveTarget::Tracks {
file_id: a,
index: 0,
},
),
(waypoints(a, &w), MoveTarget::Files { index: 0 }),
(
Selection::Waypoints { file_id: a },
MoveTarget::Files { index: 0 },
),
(Selection::Empty, MoveTarget::Files { index: 0 }),
// elements that do not exist
(
tracks(a, &[TrackId::default()]),
MoveTarget::Files { index: 0 },
),
(
waypoints(a, &[WaypointId::default()]),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(files(&[FileId::default()]), MoveTarget::Files { index: 0 }),
// a place that does not exist
(
tracks(a, &t),
MoveTarget::Tracks {
file_id: FileId::default(),
index: 0,
},
),
(
segments(a, t[0], &s),
MoveTarget::Segments {
file_id: a,
trk_id: TrackId::default(),
index: 0,
},
),
(
waypoints(a, &w),
MoveTarget::Waypoints {
file_id: FileId::default(),
index: 0,
},
),
];
for (what, to) in invalid {
assert_eq!(
do_move(&mut fx, what.clone(), to),
Err(CommandError::NothingToDo),
"{what:?} to {to:?}"
);
// nothing changed
assert!(Rc::ptr_eq(&fx.files[&a], &before));
assert_eq!(fx.order.0, vec![a]);
assert_eq!(fx.selection, selection);
}
}
}
@@ -1,101 +0,0 @@
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce};
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single
/// undo step.
#[derive(Debug)]
pub struct New<'a> {
pub name: &'a str,
pub trackpoint: Option<NewTrackpoint>,
}
#[derive(Debug, Clone, Copy)]
pub struct NewTrackpoint {
pub lng: f64,
pub lat: f64,
pub ele: f64,
}
impl Apply for New<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
produce(state, |_| {
let mut file = File::default();
file.info.name = self.name.to_owned();
if let Some(point) = self.trackpoint {
let mut segment = TrackSegment::default();
// the ends of a segment are anchors of the routing tool
segment.splice(
0,
0,
vec![Trackpoint {
coordinates: LngLat {
lng: point.lng,
lat: point.lat,
},
ele: point.ele,
..Default::default()
}],
);
file.trk.push(Track {
trkseg: vec![segment],
..Default::default()
});
}
vec![file]
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_new() {
let mut fx = Fixture::default();
assert!(
New {
name: "new",
trackpoint: None
}
.apply(&mut fx.state())
.is_ok()
);
assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "new");
assert!(file.trk.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
assert_eq!(fx.order.0, vec![file.id]);
}
#[test]
fn test_new_with_a_trackpoint() {
let mut fx = Fixture::default();
let point = NewTrackpoint {
lng: 1.0,
lat: 2.0,
ele: 3.0,
};
New {
name: "new",
trackpoint: Some(point),
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.trk.len(), 1);
assert_eq!(file.trk[0].trkseg.len(), 1);
let segment = &file.trk[0].trkseg[0];
assert_eq!(segment.len(), 1);
assert_eq!(segment[0].coordinates.lng, 1.0);
assert_eq!(segment[0].coordinates.lat, 2.0);
assert_eq!(segment[0].ele, 3.0);
// the only trackpoint is an anchor of the routing tool
assert_eq!(segment[0].anchor, Some(0));
assert_eq!(fx.selected_files(), [file.id].into());
}
}
@@ -1,789 +0,0 @@
use std::{collections::HashSet, hash::Hash};
use crate::{
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId,
TrackSegmentId, WaypointId, transfer, waypoint_ids,
};
/// Pastes what was copied or cut (see [`crate::Clipboard`]).
///
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
/// - nothing selected: the files are copied, and tracks and segments become new files (named
/// like their track, or like the file they come from);
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are
/// added to its own;
/// - a track: the segments are added to it, tracks go after it in its file;
/// - a segment: the segments go after it in its track;
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
///
/// Copies are made, with new ids, and the pasted elements are selected. What was cut is moved
/// instead, with its ids, the first time: what is still where it was is removed.
///
/// What is pasted is always the elements as they were when they were copied or cut, even if they
/// were changed or deleted since. The clipboard is kept so that it can be pasted again: after
/// what was cut was moved it is not cut anymore, and the next pastes make copies.
#[derive(Debug)]
pub struct Paste;
impl Apply for Paste {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let clipboard = state
.clipboard
.clone()
.filter(|clipboard| clipboard.can_paste(state.selection))
.ok_or(CommandError::NothingToDo)?;
let cut = clipboard.cut;
// always the elements as they were when they were copied or cut
let to = destination(state, &clipboard.content)?;
let next = transfer(state, &clipboard.content, cut, to)?;
if cut && let Some(clipboard) = state.clipboard.as_mut() {
// moved: the next pastes make copies
clipboard.cut = false;
}
*state.selection = next;
Ok(())
}
}
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
state
.files
.get(&file_id)
.map(|file| file.trk.iter().map(|trk| trk.id).collect())
.unwrap_or_default()
}
fn segment_ids_of(state: &State, file_id: FileId, trk_id: TrackId) -> Vec<TrackSegmentId> {
state
.files
.get(&file_id)
.and_then(|file| file.trk.iter().find(|trk| trk.id == trk_id))
.map(|trk| trk.trkseg.iter().map(|seg| seg.id).collect())
.unwrap_or_default()
}
fn waypoint_ids_of(state: &State, file_id: FileId) -> Vec<WaypointId> {
state
.files
.get(&file_id)
.map(|file| waypoint_ids(file).collect())
.unwrap_or_default()
}
/// Where the content goes, depending on what is selected.
fn destination(state: &State, content: &ClipboardContent) -> Result<Destination, CommandError> {
let nothing = CommandError::NothingToDo;
let last_file = |file_ids: &HashSet<FileId>| {
last_selected(state.order.0.iter().copied(), file_ids).ok_or(CommandError::NothingToDo)
};
Ok(match (content, &*state.selection) {
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => {
Destination::Files(Place::End)
}
(
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
Selection::File { file_ids },
) => Destination::Tracks(last_file(file_ids)?, Place::End),
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => {
let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Tracks(*file_id, Place::After(last))
}
(ClipboardContent::Segments(_), Selection::Track { file_id, trk_ids }) => {
let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Segments(*file_id, last, Place::End)
}
(
ClipboardContent::Segments(_),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
) => {
let last = last_selected(
segment_ids_of(state, *file_id, *trk_id).into_iter(),
trkseg_ids,
)
.ok_or(nothing)?;
Destination::Segments(*file_id, *trk_id, Place::After(last))
}
(ClipboardContent::Waypoints(_), Selection::File { file_ids }) => {
Destination::Waypoints(last_file(file_ids)?, Place::End)
}
(ClipboardContent::Waypoints(_), Selection::Waypoints { file_id }) => {
Destination::Waypoints(*file_id, Place::End)
}
(ClipboardContent::Waypoints(_), Selection::Waypoint { file_id, wpt_ids }) => {
let last = last_selected(waypoint_ids_of(state, *file_id).into_iter(), wpt_ids)
.ok_or(nothing)?;
Destination::Waypoints(*file_id, Place::After(last))
}
_ => return Err(nothing),
})
}
/// The last of the `selected` ids in the order of `all`: the element a paste goes after.
fn last_selected<T: Copy + Eq + Hash>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<T> {
all.filter(|id| selected.contains(id)).last()
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment,
TrackSegmentId, Waypoint, WaypointId, Waypoints, engine::command::fixture::Fixture,
new_file,
};
use super::*;
/// A file with the given tracks (name, number of segments) and number of waypoints.
fn file(name: &str, tracks: &[(Option<&str>, usize)], waypoints: usize) -> File {
let mut file = new_file(
name,
tracks
.iter()
.map(|(name, segments)| Track {
info: TrackInfo {
name: name.map(str::to_owned),
..Default::default()
},
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
..Default::default()
})
.collect(),
);
if waypoints > 0 {
file.wpt = Waypoints::new([crate::WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default()
}]);
}
file
}
fn add(fx: &mut Fixture, file: File) -> FileId {
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
id
}
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
}
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
}
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
waypoint_ids(&fx.files[&id]).collect()
}
fn names(fx: &Fixture) -> Vec<String> {
fx.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect()
}
fn copy(fx: &mut Fixture, selection: Selection, cut: bool) {
fx.clipboard = Clipboard::new(&selection, &fx.files, &fx.order.0, cut);
}
/// The ids of what is in the clipboard, and whether it is cut.
fn clipboard_state(fx: &Fixture) -> Option<(ClipboardIds, bool)> {
fx.clipboard.as_ref().map(|c| (c.content.ids(), c.cut))
}
fn paste(fx: &mut Fixture) -> Result<(), CommandError> {
Paste.apply(&mut fx.state())
}
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
}
}
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids.iter().copied().collect(),
}
}
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
Selection::Waypoint {
file_id,
wpt_ids: ids.iter().copied().collect(),
}
}
fn files(ids: &[FileId]) -> Selection {
Selection::File {
file_ids: ids.iter().copied().collect(),
}
}
#[test]
fn test_paste_copied_tracks_into_a_file() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("t0"), 1), (Some("t1"), 2), (None, 1)], 0),
);
let b = add(&mut fx, file("b", &[(Some("b0"), 1)], 0));
let source = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[source[0], source[1]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// the source is untouched, copies with new ids are added at the end
assert_eq!(track_ids(&fx, a), source);
let in_b = track_ids(&fx, b);
assert_eq!(in_b.len(), 3);
assert!(!source.contains(&in_b[1]) && !source.contains(&in_b[2]));
let names: Vec<_> = fx.files[&b]
.trk
.iter()
.map(|t| t.info.name.clone())
.collect();
assert_eq!(
names,
[Some("b0".into()), Some("t0".into()), Some("t1".into())]
);
// the segments are copies too
assert_eq!(fx.files[&b].trk[2].trkseg.len(), 2);
assert_ne!(segment_ids(&fx, b, 2), segment_ids(&fx, a, 1));
// the pasted tracks are selected, and the clipboard is still there
assert_eq!(fx.selection, tracks(b, &in_b[1..]));
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![source[0], source[1]]), false))
);
}
#[test]
fn test_paste_cut_tracks_moves_them() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let source = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[source[0]]), true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![source[1]]);
assert_eq!(track_ids(&fx, b), vec![source[0]]);
assert_eq!(fx.selection, tracks(b, &[source[0]]));
}
#[test]
fn test_paste_tracks_after_the_selected_track() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file(
"a",
&[
(Some("0"), 1),
(Some("1"), 1),
(Some("2"), 1),
(Some("3"), 1),
],
0,
),
);
let t = track_ids(&fx, a);
// copy: the block goes after the selected track
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
fx.selection = tracks(a, &[t[2]]);
paste(&mut fx).unwrap();
let names: Vec<_> = fx.files[&a]
.trk
.iter()
.map(|t| t.info.name.clone().unwrap())
.collect();
assert_eq!(names, ["0", "1", "2", "0", "1", "3"]);
// cut: moved after the selected track
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = tracks(a, &[t[2]]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
// the selected track is cut itself: the tracks go at the end
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = tracks(a, &[t[0]]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
}
#[test]
fn test_paste_tracks_as_new_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("source", &[(Some("named"), 1), (None, 1)], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
// named like the track, or like the file it comes from
assert_eq!(names(&fx), ["source", "named", "source"]);
assert_eq!(fx.files.len(), 3);
let new = &fx.order.0[1..];
assert!(
matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2 && new.iter().all(|id| file_ids.contains(id)))
);
assert_eq!(fx.files[&new[0]].trk.len(), 1);
// the source keeps its tracks
assert_eq!(track_ids(&fx, a), t);
}
#[test]
fn test_paste_cut_tracks_as_new_files() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("source", &[(Some("one"), 1), (Some("two"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1]]);
let new = fx.order.0[1];
assert_eq!(track_ids(&fx, new), vec![t[0]]);
}
#[test]
fn test_paste_segments() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 3), (Some("t1"), 1)], 0));
(fx, a)
};
// at the end of the selected track
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0]]), false);
fx.selection = tracks(a, &[t[1]]);
paste(&mut fx).unwrap();
assert_eq!(segment_ids(&fx, a, 0), s);
let in_t1 = segment_ids(&fx, a, 1);
assert_eq!(in_t1.len(), 2);
assert_ne!(in_t1[1], s[0]);
assert_eq!(fx.selection, segments(a, t[1], &in_t1[1..]));
// after the selected segment, moved
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0]]), true);
fx.selection = segments(a, t[0], &[s[1]]);
paste(&mut fx).unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[1], s[0], s[2]]);
// each segment becomes a track of the selected file
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
fx.selection = files(&[a]);
paste(&mut fx).unwrap();
let tracks_now = track_ids(&fx, a);
assert_eq!(tracks_now.len(), 4);
assert!(
fx.files[&a].trk[2..]
.iter()
.all(|trk| trk.trkseg.len() == 1)
);
assert_eq!(fx.selection, tracks(a, &tracks_now[2..]));
// each segment becomes a file, named like its track
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(names(&fx), ["a", "t0", "t0"]);
assert!(
fx.order.0[1..]
.iter()
.all(|id| fx.files[id].trk[0].trkseg.len() == 1)
);
}
#[test]
fn test_paste_waypoints() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 4));
let b = add(&mut fx, file("b", &[], 2));
(fx, a, b)
};
// to the end of another file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
copy(&mut fx, waypoints(a, &[w[1], w[2]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w);
let now = wpt_ids(&fx, b);
assert_eq!(now.len(), 4);
assert_eq!(&now[..2], &before[..]);
assert!(!w.contains(&now[2]));
assert_eq!(fx.selection, waypoints(b, &now[2..]));
// after the selected waypoint
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
copy(&mut fx, waypoints(a, &[w[0]]), true);
fx.selection = waypoints(b, &[before[0]]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w[1..].to_vec());
assert_eq!(wpt_ids(&fx, b), vec![before[0], w[0], before[1]]);
// within the file, after a waypoint
let (mut fx, a, _) = build();
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &[w[0], w[1]]), true);
fx.selection = waypoints(a, &[w[2]]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), vec![w[2], w[0], w[1], w[3]]);
}
#[test]
fn test_paste_all_the_waypoints_of_a_file() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 3));
let b = add(&mut fx, file("b", &[], 1));
let w = wpt_ids(&fx, a);
copy(&mut fx, Selection::Waypoints { file_id: a }, false);
fx.selection = Selection::Waypoints { file_id: b };
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w);
assert_eq!(wpt_ids(&fx, b).len(), 4);
// moved: the source has none left
copy(&mut fx, Selection::Waypoints { file_id: a }, true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert!(wpt_ids(&fx, a).is_empty());
assert_eq!(&wpt_ids(&fx, b)[4..], &w[..]);
}
#[test]
fn test_paste_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
copy(&mut fx, files(&[b, a]), false);
fx.selection = files(&[c]);
paste(&mut fx).unwrap();
// copies, in the order of the files, at the end
assert_eq!(names(&fx), ["a", "b", "c", "a", "b"]);
assert_eq!(fx.files.len(), 5);
assert_eq!(fx.files[&fx.order.0[3]].trk.len(), 1);
assert_ne!(track_ids(&fx, fx.order.0[3]), track_ids(&fx, a));
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2));
// cut: moved to the end, they keep their ids
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
copy(&mut fx, files(&[a]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(fx.order.0, vec![b, c, a]);
assert_eq!(fx.selection, files(&[a]));
}
#[test]
fn test_nothing_to_paste() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
let t = track_ids(&fx, a);
let w = wpt_ids(&fx, a);
fx.selection = files(&[a]);
// an empty clipboard
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
// a target that does not accept it (tracks cannot go to waypoints), the clipboard stays
copy(&mut fx, tracks(a, &t), false);
fx.selection = waypoints(a, &w);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
assert!(fx.clipboard.is_some());
assert_eq!(fx.selection, waypoints(a, &w));
// nothing to copy: what does not exist is not put in the clipboard
copy(&mut fx, tracks(a, &[TrackId::default()]), false);
fx.selection = files(&[a]);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
copy(&mut fx, waypoints(a, &[WaypointId::default()]), false);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
copy(&mut fx, files(&[FileId::default()]), false);
fx.selection = Selection::Empty;
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0, vec![a]);
}
#[test]
fn test_paste_a_copy_several_times() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 1));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
// each paste made a copy with new ids, and the source is untouched
let in_b = track_ids(&fx, b);
assert_eq!(in_b.len(), 3);
assert_eq!(in_b.iter().collect::<HashSet<_>>().len(), 3);
assert!(in_b.iter().all(|id| !t.contains(id)));
assert_eq!(track_ids(&fx, a), t);
assert!(fx.clipboard.is_some());
// the selection is the last copy: the next paste goes after it
assert_eq!(fx.selection, tracks(b, &[in_b[2]]));
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, b).len(), 4);
// the same with other elements
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &w), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, b).len(), 2);
assert_eq!(wpt_ids(&fx, a), w);
copy(&mut fx, files(&[b]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 4);
}
#[test]
fn test_paste_a_cut_moves_the_first_time_then_copies() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// moved with its id, and what is in the clipboard now is a copy of it
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![t[0]]), false))
);
fx.selection = files(&[c]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
// copies: the moved track stays where it was moved to
assert_eq!(track_ids(&fx, b), vec![t[0]]);
let in_c = track_ids(&fx, c);
assert_eq!(in_c.len(), 2);
assert!(!in_c.contains(&t[0]));
assert_eq!(track_ids(&fx, a), vec![t[1]]);
}
#[test]
fn test_paste_cut_files_several_times() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
copy(&mut fx, files(&[a]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(fx.order.0, vec![b, a]);
// the next pastes copy it
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 3);
assert_eq!(fx.order.0[..2], [b, a]);
}
/// Renames the first track of the file, as an edit would.
fn rename_track(fx: &mut Fixture, id: FileId, name: &str) {
let file = std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap());
file.trk[0].info.name = Some(name.to_owned());
}
#[test]
fn test_paste_the_elements_as_they_were_when_copied() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("original"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), false);
rename_track(&mut fx, a, "edited");
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
let pasted = &fx.files[&b].trk[0];
assert_eq!(pasted.info.name.as_deref(), Some("original"));
// the source keeps what was done to it
assert_eq!(fx.files[&a].trk[0].info.name.as_deref(), Some("edited"));
// and each time
paste(&mut fx).unwrap();
assert!(
fx.files[&b]
.trk
.iter()
.all(|trk| trk.info.name.as_deref() == Some("original"))
);
assert_eq!(fx.files[&b].trk.len(), 2);
}
#[test]
fn test_paste_a_cut_as_it_was_when_cut() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("original"), 1), (Some("other"), 1)], 0),
);
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
rename_track(&mut fx, a, "edited");
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// what is in the clipboard is moved, the edited version is not what is pasted
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(fx.files[&b].trk[0].info.name.as_deref(), Some("original"));
}
#[test]
fn test_paste_after_the_elements_were_deleted() {
let delete_file = |fx: &mut Fixture, id: FileId| {
fx.files.remove(&id);
fx.order.0.retain(|other| *other != id);
};
// copied tracks, waypoints, segments and files are still there to be pasted
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &t), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(fx.files[&b].trk.len(), 1);
assert_eq!(fx.files[&b].trk[0].trkseg.len(), 2);
// the copy of a track comes from a file that is gone: named like the track
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(names(&fx), ["b", "t"]);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let (t, s) = (track_ids(&fx, a), segment_ids(&fx, a, 0));
copy(&mut fx, segments(a, t[0], &s), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(fx.files[&b].trk.len(), 2);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &w), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, b).len(), 2);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 0));
copy(&mut fx, files(&[a]), false);
fx.selection = Selection::Empty;
delete_file(&mut fx, a);
assert!(fx.files.is_empty());
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 2);
assert_eq!(names(&fx), ["a", "a"]);
let _ = (s, w);
}
#[test]
fn test_paste_a_cut_after_the_elements_were_deleted() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
// deleted before it is pasted
std::rc::Rc::make_mut(fx.files.get_mut(&a).unwrap())
.trk
.retain(|trk| trk.id != t[0]);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// nothing to remove, the track is where it was pasted, with its id
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![t[0]]), false))
);
}
}
@@ -1,361 +0,0 @@
//! Copying or moving elements to a place of the file tree: what paste and move are made of.
//! The elements come as the content of a clipboard, and a destination says where they go among
//! the elements already there.
use std::{collections::HashSet, rc::Rc};
use crate::{
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection,
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file,
copy_segment, copy_track, copy_waypoint,
};
/// Where elements are put, among the others of the same list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Place<T> {
/// After all of them.
End,
/// Right after the one with this id, or at the end if it is not there (anymore).
After(T),
/// At this position (at the end if it is past it).
Index(usize),
}
impl<T: Copy + PartialEq> Place<T> {
/// The position in the list of `ids`.
pub fn position(&self, ids: &[T]) -> usize {
match self {
Place::End => ids.len(),
Place::After(id) => ids
.iter()
.position(|other| other == id)
.map_or(ids.len(), |i| i + 1),
Place::Index(index) => (*index).min(ids.len()),
}
}
}
/// Where elements go. What can go where:
/// - files: among the files;
/// - tracks: among the files (each track becomes a file, named like the track, or like the file
/// it comes from), or among the tracks of a file;
/// - segments: among the files (each segment becomes a file with a track, named like its track,
/// or like its file), among the tracks of a file (each segment becomes a track), or among the
/// segments of a track;
/// - waypoints: among the waypoints of a file.
#[derive(Debug, Clone, Copy)]
pub enum Destination {
Files(Place<FileId>),
Tracks(FileId, Place<TrackId>),
Segments(FileId, TrackId, Place<TrackSegmentId>),
Waypoints(FileId, Place<WaypointId>),
}
pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
file.wpt.iter().map(|wpt| wpt.id)
}
fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> {
state
.files
.get_mut(&id)
.map(Rc::make_mut)
.ok_or(CommandError::NothingToDo)
}
pub fn new_file(name: &str, trk: Vec<Track>) -> File {
let mut file = File {
trk,
..Default::default()
};
file.info.name = name.to_owned();
file
}
/// Adds the files to the state, at `place` in the order of the files.
fn add_files(state: &mut State, files: Vec<Rc<File>>, place: Place<FileId>) -> Selection {
let at = place.position(&state.order.0);
let ids: Vec<FileId> = files.iter().map(|file| file.id).collect();
for file in files {
state.files.insert(file.id, file);
}
state.order.0.splice(at..at, ids.iter().copied());
Selection::File {
file_ids: ids.into_iter().collect(),
}
}
/// Removes the elements of the content from the files, wherever they are.
pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
match content {
ClipboardContent::Files(files) => {
let ids: HashSet<FileId> = files.iter().map(|file| file.id).collect();
state.files.retain(|id, _| !ids.contains(id));
state.order.0.retain(|id| !ids.contains(id));
}
ClipboardContent::Tracks(tracks) => {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
for file in state.files.values_mut() {
if file.trk.iter().any(|trk| ids.contains(&trk.id)) {
Rc::make_mut(file).trk.retain(|trk| !ids.contains(&trk.id));
}
}
}
ClipboardContent::Segments(segments) => {
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.segment.id).collect();
for file in state.files.values_mut() {
let contains = |trk: &Track| trk.trkseg.iter().any(|seg| ids.contains(&seg.id));
if file.trk.iter().any(contains) {
for trk in Rc::make_mut(file).trk.iter_mut() {
trk.trkseg.retain(|seg| !ids.contains(&seg.id));
}
}
}
}
ClipboardContent::Waypoints(waypoints) => {
let ids: HashSet<WaypointId> = waypoints.iter().map(|wpt| wpt.id).collect();
for file in state.files.values_mut() {
if waypoint_ids(file).any(|id| ids.contains(&id)) {
Rc::make_mut(file).wpt.edit(
|wpt| ids.contains(&wpt.id),
|wpts| {
wpts.retain(|wpt| !ids.contains(&wpt.id));
true
},
);
}
}
}
}
}
/// Puts the elements of the content at the destination: copies of them, or the elements
/// themselves, with their ids, when `cut` (they are removed from where they are first). Returns
/// the selection of what was put.
pub fn transfer(
state: &mut State,
content: &ClipboardContent,
cut: bool,
to: Destination,
) -> Result<Selection, CommandError> {
// the destination has to be there, before anything is changed
let exists = match to {
Destination::Files(_) => true,
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id),
Destination::Segments(file_id, trk_id, _) => state
.files
.get(&file_id)
.is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)),
Destination::Waypoints(file_id, _) => state.files.contains_key(&file_id),
};
let valid = matches!(
(content, to),
(ClipboardContent::Files(_), Destination::Files(_))
| (
ClipboardContent::Tracks(_),
Destination::Files(_) | Destination::Tracks(..)
)
| (
ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
)
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
);
let empty = match content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
};
if !exists || !valid || empty {
return Err(CommandError::NothingToDo);
}
if cut {
remove_elements(state, content);
}
let copy = !cut;
match (content, to) {
(ClipboardContent::Files(files), Destination::Files(place)) => {
let files = files
.iter()
.map(|file| {
if copy {
Rc::new(copy_file(file))
} else {
file.clone()
}
})
.collect();
Ok(add_files(state, files, place))
}
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
transfer_waypoints(state, waypoints, copy, file_id, place)
}
_ => Err(CommandError::NothingToDo),
}
}
fn transfer_tracks(
state: &mut State,
tracks: &[ClipboardTrack],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let tracks: Vec<(Track, &str)> = tracks
.iter()
.map(|clip| {
let track = if copy {
copy_track(&clip.track)
} else {
clip.track.clone()
};
(track, clip.file_name.as_str())
})
.collect();
match to {
Destination::Files(place) => {
let files = tracks
.into_iter()
.map(|(track, file_name)| {
let name = track
.info
.name
.clone()
.unwrap_or_else(|| file_name.to_owned());
Rc::new(new_file(&name, vec![track]))
})
.collect();
Ok(add_files(state, files, place))
}
Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect();
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
}
_ => Err(CommandError::NothingToDo),
}
}
fn transfer_segments(
state: &mut State,
segments: &[ClipboardSegment],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let segments: Vec<(TrackSegment, &str)> = segments
.iter()
.map(|clip| {
let segment = if copy {
copy_segment(&clip.segment)
} else {
clip.segment.clone()
};
(segment, clip.name.as_str())
})
.collect();
// a segment on its own needs a track
let own_track = |segment: TrackSegment| Track {
trkseg: vec![segment],
..Default::default()
};
match to {
Destination::Files(place) => {
let files = segments
.into_iter()
.map(|(segment, name)| Rc::new(new_file(name, vec![own_track(segment)])))
.collect();
Ok(add_files(state, files, place))
}
Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = segments
.into_iter()
.map(|(segment, _)| own_track(segment))
.collect();
let ids = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
}
Destination::Segments(file_id, trk_id, place) => {
let segments: Vec<TrackSegment> =
segments.into_iter().map(|(segment, _)| segment).collect();
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.id).collect();
let trk = file_mut(state, file_id)?
.trk
.iter_mut()
.find(|trk| trk.id == trk_id)
.ok_or(CommandError::NothingToDo)?;
let existing: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
let at = place.position(&existing);
trk.trkseg.splice(at..at, segments);
Ok(Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids,
})
}
Destination::Waypoints(..) => Err(CommandError::NothingToDo),
}
}
fn transfer_waypoints(
state: &mut State,
waypoints: &[Waypoint],
copy: bool,
file_id: FileId,
place: Place<WaypointId>,
) -> Result<Selection, CommandError> {
let waypoints: Vec<Waypoint> = waypoints
.iter()
.map(|wpt| {
if copy {
copy_waypoint(wpt)
} else {
wpt.clone()
}
})
.collect();
let ids = waypoints.iter().map(|wpt| wpt.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<WaypointId> = waypoint_ids(file).collect();
file.wpt.insert_at(place.position(&existing), waypoints);
Ok(Selection::Waypoint {
file_id,
wpt_ids: ids,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_place_position() {
let ids = [1, 2, 3];
assert_eq!(Place::End.position(&ids), 3);
assert_eq!(Place::After(1).position(&ids), 1);
assert_eq!(Place::After(3).position(&ids), 3);
// not there: at the end
assert_eq!(Place::After(9).position(&ids), 3);
assert_eq!(Place::Index(0).position(&ids), 0);
assert_eq!(Place::Index(2).position(&ids), 2);
assert_eq!(Place::Index(10).position(&ids), 3);
assert_eq!(Place::<i32>::End.position(&[]), 0);
assert_eq!(Place::<i32>::Index(4).position(&[]), 0);
}
}
@@ -1,34 +0,0 @@
//! Shared helpers for the tests of the commands.
use std::collections::HashSet;
use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
#[derive(Default)]
pub struct Fixture {
pub files: StackEntry,
pub selection: Selection,
pub order: FileOrder,
pub clipboard: Option<Clipboard>,
pub categories: TrackpointCategories,
}
impl Fixture {
pub fn state(&mut self) -> State<'_> {
State {
categories: &mut self.categories,
files: &mut self.files,
selection: &mut self.selection,
order: &mut self.order,
clipboard: &mut self.clipboard,
}
}
/// Ids of the selected files, empty unless the selection is a file selection.
pub fn selected_files(&self) -> HashSet<FileId> {
match &self.selection {
Selection::File { file_ids } => file_ids.clone(),
_ => HashSet::new(),
}
}
}
-89
View File
@@ -1,89 +0,0 @@
mod apply;
mod edit;
mod file;
#[cfg(test)]
pub(crate) mod fixture;
mod pattern;
mod tools;
pub use apply::*;
pub use edit::*;
pub use file::*;
pub use pattern::*;
pub use tools::*;
use crate::State;
/// A user action that edits the files. Each variant wraps the command's own struct, whose
/// effect is implemented in its own file (see `Apply`).
///
/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument
/// buffers, so a command is built without copying; the engine only copies what it keeps.
#[derive(Debug)]
pub enum Command<'a> {
New(New<'a>),
Load(Load<'a>),
LoadFiles(LoadFiles<'a>),
Duplicate(Duplicate),
Delete(Delete),
Paste(Paste),
Move(Move),
DeleteAll(DeleteAll),
DeleteWaypoint(DeleteWaypoint),
Metadata(Metadata<'a>),
Style(Style<'a>),
NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment),
Reverse(Reverse),
RoundTrip(RoundTrip),
Route(Route<'a>),
NewWaypoint(NewWaypoint<'a>),
EditWaypoint(EditWaypoint<'a>),
MoveWaypoint(MoveWaypoint),
Crop(Crop),
Reduce(Reduce),
Split(Split),
Time(Time),
Merge(Merge),
Extract(Extract),
InsertAnchor(InsertAnchor),
Elevation(Elevation<'a>),
Clean(Clean),
ChangeLoopStart(ChangeLoopStart),
}
impl Apply for Command<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match self {
Command::New(c) => c.apply(state),
Command::Load(c) => c.apply(state),
Command::LoadFiles(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state),
Command::Paste(c) => c.apply(state),
Command::Move(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state),
Command::DeleteWaypoint(c) => c.apply(state),
Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state),
Command::RoundTrip(c) => c.apply(state),
Command::Route(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state),
Command::EditWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state),
Command::Crop(c) => c.apply(state),
Command::Reduce(c) => c.apply(state),
Command::Split(c) => c.apply(state),
Command::Time(c) => c.apply(state),
Command::Merge(c) => c.apply(state),
Command::Extract(c) => c.apply(state),
Command::InsertAnchor(c) => c.apply(state),
Command::Elevation(c) => c.apply(state),
Command::Clean(c) => c.apply(state),
Command::ChangeLoopStart(c) => c.apply(state),
}
}
}
@@ -1,35 +0,0 @@
use crate::{File, Track, TrackSegment, Waypoint};
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
/// A copy of the segment, with a new id.
pub fn copy_segment(segment: &TrackSegment) -> TrackSegment {
let mut copy = segment.clone();
copy.id = Default::default();
copy
}
/// A copy of the track and of its segments, with new ids.
pub fn copy_track(track: &Track) -> Track {
Track {
id: Default::default(),
trkseg: track.trkseg.iter().map(copy_segment).collect(),
..track.clone()
}
}
/// A copy of the file and of its tracks and segments, with new ids.
pub fn copy_file(file: &File) -> File {
File {
id: Default::default(),
trk: file.trk.iter().map(copy_track).collect(),
..file.clone()
}
}
/// A copy of the waypoint, with a new id.
pub fn copy_waypoint(waypoint: &Waypoint) -> Waypoint {
let mut copy = waypoint.clone();
copy.id = Default::default();
copy
}
@@ -1,11 +0,0 @@
mod copy;
mod produce;
mod update_segments;
mod update_selected;
mod update_waypoint;
pub use copy::*;
pub use produce::*;
pub use update_segments::*;
pub use update_selected::*;
pub use update_waypoint::*;
@@ -1,81 +0,0 @@
use std::rc::Rc;
use crate::{File, Selection, State};
/// Pure producer: adds the files returned by `f` to the state and selects the first one.
pub fn produce<F>(state: &mut State, f: F)
where
F: FnOnce(&State) -> Vec<File>,
{
*state.selection = Selection::Empty;
for file in f(state) {
let file_id = file.id;
state.files.insert(file_id, Rc::new(file));
state.order.0.push(file_id);
if matches!(*state.selection, Selection::Empty) {
*state.selection = Selection::File {
file_ids: [file_id].into(),
};
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use super::*;
fn named(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_string();
file
}
#[test]
fn test_produce_adds_orders_and_selects_first() {
let mut fx = Fixture::default();
let (a, b) = (named("a"), named("b"));
let (id_a, id_b) = (a.id, b.id);
produce(&mut fx.state(), |_| vec![a, b]);
assert_eq!(fx.files.len(), 2);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_a]));
}
#[test]
fn test_produce_replaces_selection_and_keeps_order() {
let mut fx = Fixture::default();
let a = named("a");
let id_a = a.id;
produce(&mut fx.state(), |_| vec![a]);
let b = named("b");
let id_b = b.id;
produce(&mut fx.state(), |_| vec![b]);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_b]));
}
#[test]
fn test_produce_nothing_clears_selection() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |_| vec![]);
assert!(matches!(fx.selection, Selection::Empty));
assert_eq!(fx.files.len(), 1);
}
#[test]
fn test_produce_sees_current_state() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |state| {
assert_eq!(state.files.len(), 1);
vec![]
});
}
}
@@ -1,91 +0,0 @@
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,337 +0,0 @@
use std::rc::Rc;
use crate::{File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint};
/// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Edit {
Unchanged,
Changed,
}
impl Edit {
fn merge(self, other: Edit) -> Edit {
if self == Edit::Changed || other == Edit::Changed {
Edit::Changed
} else {
Edit::Unchanged
}
}
}
/// Independent edit of each selected element, without needing the rest of the state.
///
/// A selected file, track or segment is passed to the hook of its own level. By default a
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment).
/// Waypoints are only edited when selected, never through their file.
pub trait Editor {
fn file(&mut self, file: &mut File) -> Edit {
edit_each(&mut file.trk, |trk| self.track(trk))
}
fn track(&mut self, track: &mut Track) -> Edit {
edit_each(&mut track.trkseg, |seg| edit_segment(self, seg))
}
fn segment(&mut self, _segment: &mut TrackSegment) -> Edit {
Edit::Unchanged
}
fn waypoint(&mut self, _waypoint: &mut Waypoint) -> Edit {
Edit::Unchanged
}
}
/// Calls the segment hook and gives the segment a new revision id if it changed, so that
/// derived data (statistics) is recomputed.
fn edit_segment<E: Editor + ?Sized>(editor: &mut E, segment: &mut TrackSegment) -> Edit {
let edit = editor.segment(segment);
if edit == Edit::Changed {
segment.rev_id = Default::default();
}
edit
}
fn edit_each<T>(items: &mut [T], f: impl FnMut(&mut T) -> Edit) -> Edit {
edit_where(items, |_| true, f)
}
fn edit_where<T>(
items: &mut [T],
filter: impl Fn(&T) -> bool,
mut f: impl FnMut(&mut T) -> Edit,
) -> Edit {
items
.iter_mut()
.filter(|item| filter(item))
.fold(Edit::Unchanged, |acc, item| acc.merge(f(item)))
}
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
/// new snapshot only if something changed.
fn update_file(files: &mut StackEntry, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
let Some(current) = files.get(&id) else {
return;
};
let mut file = (**current).clone();
if f(&mut file) == Edit::Changed {
files.insert(id, Rc::new(file));
}
}
fn edit_waypoints<E: Editor + ?Sized>(
file: &mut File,
filter: impl Fn(&Waypoint) -> bool,
editor: &mut E,
) -> Edit {
let changed = file.wpt.edit(&filter, |wpts| {
edit_where(wpts, &filter, |wpt| editor.waypoint(wpt)) == Edit::Changed
});
if changed {
Edit::Changed
} else {
Edit::Unchanged
}
}
/// Applies the editor to every selected file, track or segment.
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
match &*state.selection {
Selection::File { file_ids } => {
for id in file_ids {
update_file(state.files, *id, |file| editor.file(file));
}
}
Selection::Track { file_id, trk_ids } => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.trk,
|trk| trk_ids.contains(&trk.id),
|trk| editor.track(trk),
)
});
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.trk,
|trk| trk.id == *trk_id,
|trk| {
edit_where(
&mut trk.trkseg,
|seg| trkseg_ids.contains(&seg.id),
|seg| edit_segment(editor, seg),
)
},
)
});
}
Selection::Waypoints { file_id } => {
update_file(state.files, *file_id, |file| {
edit_waypoints(file, |_| true, editor)
});
}
Selection::Waypoint { file_id, wpt_ids } => {
update_file(state.files, *file_id, |file| {
edit_waypoints(file, |wpt| wpt_ids.contains(&wpt.id), editor)
});
}
Selection::Empty => {}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use super::*;
struct Rename;
impl Editor for Rename {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name.push('!');
Edit::Changed
}
fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some("track".to_string());
Edit::Changed
}
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
Edit::Changed
}
}
struct Nothing;
impl Editor for Nothing {}
fn fixture_with_tracks() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
let file = Rc::new(crate::parse(&data, &mut Default::default()).unwrap());
let id = file.id;
fx.files.insert(id, file);
(fx, id)
}
#[test]
fn test_only_selected_files_are_updated() {
let mut fx = Fixture::default();
let (a, b, c) = (File::default(), File::default(), File::default());
let ids = [a.id, b.id, c.id];
for file in [a, b, c] {
fx.files.insert(file.id, Rc::new(file));
}
let untouched = fx.files[&ids[2]].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([ids[0], ids[1]]),
};
update_selected(&mut fx.state(), &mut Rename);
assert_eq!(fx.files[&ids[0]].info.name, "!");
assert_eq!(fx.files[&ids[1]].info.name, "!");
assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched));
}
#[test]
fn test_unchanged_file_keeps_its_snapshot() {
let (mut fx, id) = fixture_with_tracks();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
let before = fx.files[&id].clone();
update_selected(&mut fx.state(), &mut Nothing);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_track_selection_uses_track_hook() {
let (mut fx, id) = fixture_with_tracks();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
update_selected(&mut fx.state(), &mut Rename);
let file = &fx.files[&id];
assert_eq!(file.info.name, "with_tracks_and_segments");
assert_eq!(file.trk[0].info.name.as_deref(), Some("track"));
assert_ne!(file.trk[1].info.name.as_deref(), Some("track"));
}
#[test]
fn test_segment_selection_bumps_revision_id() {
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
let trk = &before.trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
};
update_selected(&mut fx.state(), &mut Rename);
let after = &fx.files[&id];
assert_ne!(after.trk[0].trkseg[0].rev_id, trk.trkseg[0].rev_id);
assert_eq!(after.trk[0].trkseg[1].rev_id, trk.trkseg[1].rev_id);
assert_eq!(
after.trk[1].trkseg[0].rev_id,
before.trk[1].trkseg[0].rev_id
);
}
#[test]
fn test_file_selection_cascades_to_segments() {
struct SegmentsOnly;
impl Editor for SegmentsOnly {
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
update_selected(&mut fx.state(), &mut SegmentsOnly);
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
for (b, a) in b.trkseg.iter().zip(a.trkseg.iter()) {
assert_ne!(b.rev_id, a.rev_id);
}
}
}
#[test]
fn test_waypoint_selection_edits_only_selected_waypoints() {
struct Name;
impl Editor for Name {
fn waypoint(&mut self, wpt: &mut Waypoint) -> Edit {
wpt.name = Some("x".into());
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = crate::Waypoints::new([crate::WaypointChunk {
wpt: wpts,
..Default::default()
}]);
fx.files.insert(id, Rc::new(file));
let chunk_id = fx.files[&id].wpt.chunks()[0].id;
// A file selection never touches waypoints.
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
update_selected(&mut fx.state(), &mut Name);
assert!(fx.files[&id].wpt.iter().all(|w| w.name.is_none()));
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[1]]),
};
let rev = fx.files[&id].wpt.rev_id;
update_selected(&mut fx.state(), &mut Name);
assert_ne!(fx.files[&id].wpt.rev_id, rev);
assert_ne!(fx.files[&id].wpt.chunks()[0].id, chunk_id);
let named: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.name.is_some()).collect();
assert_eq!(named, [false, true, false]);
fx.selection = Selection::Waypoints { file_id: id };
update_selected(&mut fx.state(), &mut Name);
assert!(fx.files[&id].wpt.iter().all(|w| w.name.is_some()));
}
#[test]
fn test_missing_files_and_other_selections_are_ignored() {
let mut fx = Fixture::default();
let file = File::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
let before = fx.files[&id].clone();
for selection in [
Selection::File {
file_ids: HashSet::from([FileId::default()]),
},
Selection::Empty,
] {
fx.selection = selection;
update_selected(&mut fx.state(), &mut Rename);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
}
@@ -1,29 +0,0 @@
use std::rc::Rc;
use crate::{CommandError, FileId, State, Waypoint, WaypointId};
/// Changes one waypoint of a file with `f`, whatever is selected. Nothing to do if the file or
/// the waypoint does not exist.
pub fn update_waypoint(
state: &mut State,
file_id: FileId,
waypoint_id: WaypointId,
f: impl Fn(&mut Waypoint),
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = file.wpt.edit(
|wpt| wpt.id == waypoint_id,
|wpts| {
wpts.iter_mut()
.filter(|wpt| wpt.id == waypoint_id)
.for_each(&f);
true
},
);
if !changed {
return Err(CommandError::NothingToDo);
}
state.files.insert(file_id, Rc::new(file));
Ok(())
}
@@ -1,144 +0,0 @@
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));
}
}
@@ -1,402 +0,0 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleanType {
Inside,
Outside,
}
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment
/// only trackpoints, selected waypoints only waypoints.
#[derive(Debug)]
pub struct Clean {
pub bounds: LngLatBounds,
pub type_: CleanType,
pub trkpt: bool,
pub wpt: bool,
}
impl Clean {
fn contains(&self, c: LngLat) -> bool {
(self.bounds.sw.lng..=self.bounds.ne.lng).contains(&c.lng)
&& (self.bounds.sw.lat..=self.bounds.ne.lat).contains(&c.lat)
}
fn removes(&self, c: LngLat) -> bool {
self.contains(c) == (self.type_ == CleanType::Inside)
}
}
impl Apply for Clean {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match &*state.selection {
Selection::Waypoint { file_id, .. } | Selection::Waypoints { file_id } => {
let file_id = *file_id;
if !self.wpt {
return Err(CommandError::NothingToDo);
}
let wpt_ids = match &*state.selection {
Selection::Waypoint { wpt_ids, .. } => Some(wpt_ids.clone()),
_ => None,
};
let selected =
|wpt: &Waypoint| wpt_ids.as_ref().is_none_or(|ids| ids.contains(&wpt.id));
*state.selection = delete_waypoints(state.files, file_id, |wpt| {
selected(wpt) && self.removes(wpt.coordinates)
})?;
Ok(())
}
Selection::Empty => Err(CommandError::NothingToDo),
_ => {
let mut cleaner = Cleaner {
clean: &self,
changed: false,
emptied: HashSet::new(),
wpt_files: HashSet::new(),
};
update_selected(state, &mut cleaner);
if cleaner.changed {
prune(state, &cleaner.emptied, &cleaner.wpt_files);
Ok(())
} else {
Err(CommandError::NothingToDo)
}
}
}
}
}
struct Cleaner<'a> {
clean: &'a Clean,
changed: bool,
/// Segments left without trackpoints by the clean.
emptied: HashSet<TrackSegmentId>,
/// Files that lost waypoints.
wpt_files: HashSet<FileId>,
}
impl Editor for Cleaner<'_> {
fn file(&mut self, file: &mut File) -> Edit {
let mut edit = Edit::Unchanged;
for trk in &mut file.trk {
if self.track(trk) == Edit::Changed {
edit = Edit::Changed;
}
}
if self.clean.wpt
&& file.wpt.edit(
|wpt| self.clean.removes(wpt.coordinates),
|wpts| {
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
true
},
)
{
edit = Edit::Changed;
self.wpt_files.insert(file.id);
}
self.changed |= edit == Edit::Changed;
edit
}
fn segment(&mut self, segment: &mut TrackSegment) -> Edit {
if !self.clean.trkpt || !segment.iter().any(|pt| self.clean.removes(pt.coordinates)) {
return Edit::Unchanged;
}
let kept: Vec<Trackpoint> = segment
.iter()
.filter(|pt| !self.clean.removes(pt.coordinates))
.cloned()
.collect();
if kept.is_empty() {
self.emptied.insert(segment.id);
}
segment.splice(0, segment.len(), kept);
self.changed = true;
Edit::Changed
}
}
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their
/// segments and the files that lost something and have nothing left. The selection falls back
/// to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state
.files
.iter()
.filter(|(id, file)| {
wpt_files.contains(id)
|| file
.trk
.iter()
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
})
.map(|(id, _)| *id)
.collect();
for id in ids {
let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
file.trk.retain_mut(|trk| {
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty()
});
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file));
}
}
*state.selection = fixed_selection(state);
}
fn fixed_selection(state: &State) -> Selection {
let files = &*state.files;
match &*state.selection {
Selection::File { file_ids } => {
let file_ids: HashSet<_> = file_ids
.iter()
.filter(|id| files.contains_key(id))
.copied()
.collect();
if file_ids.is_empty() {
Selection::Empty
} else {
Selection::File { file_ids }
}
}
Selection::Track { file_id, trk_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let trk_ids: HashSet<_> = file
.trk
.iter()
.map(|trk| trk.id)
.filter(|id| trk_ids.contains(id))
.collect();
if trk_ids.is_empty() {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let Some(trk) = file.trk.iter().find(|trk| trk.id == *trk_id) else {
return Selection::File {
file_ids: [*file_id].into(),
};
};
let trkseg_ids: HashSet<_> = trk
.trkseg
.iter()
.map(|seg| seg.id)
.filter(|id| trkseg_ids.contains(id))
.collect();
if trkseg_ids.is_empty() {
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
} else {
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids,
}
}
}
Selection::Waypoints { file_id } | Selection::Waypoint { file_id, .. } => {
if files.contains_key(file_id) {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Empty
}
}
Selection::Empty => Selection::Empty,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, WaypointChunk, Waypoints, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn bounds(w: f64, s: f64, e: f64, n: f64) -> LngLatBounds {
LngLatBounds {
sw: LngLat { lng: w, lat: s },
ne: LngLat { lng: e, lat: n },
}
}
fn clean(bounds: LngLatBounds, type_: CleanType) -> Clean {
Clean {
bounds,
type_,
trkpt: true,
wpt: true,
}
}
fn count(fx: &Fixture, id: FileId) -> usize {
fx.files
.get(&id)
.into_iter()
.flat_map(|file| &file.trk)
.flat_map(|trk| &trk.trkseg)
.map(|seg| seg.len())
.sum()
}
#[test]
fn test_inside_everything_removes_all_trackpoints() {
let (mut fx, id) = loaded();
assert!(count(&fx, id) > 0);
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(count(&fx, id), 0);
// nothing is left of the file
assert!(!fx.files.contains_key(&id));
assert!(fx.order.0.is_empty());
assert_eq!(fx.selection, Selection::Empty);
}
#[test]
fn test_emptied_segments_and_tracks_are_removed() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let seg = &before.trk[0].trkseg[0];
let pts: Vec<_> = seg.iter().map(|p| p.coordinates).collect();
let mut b = LngLatBounds::default();
pts.iter().for_each(|c| b.extend(*c));
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id];
assert!(after.trk.iter().all(|t| !t.trkseg.is_empty()));
assert!(
after
.trk
.iter()
.flat_map(|t| &t.trkseg)
.all(|s| !s.is_empty())
);
}
#[test]
fn test_outside_everything_changes_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let result =
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
// the file was not even copied
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_partial_clean_only_removes_points_inside_and_bumps_revision() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let first = before.trk[0].trkseg[0].iter().next().unwrap().coordinates;
let total = count(&fx, id);
// tiny rectangle around the first point
let b = bounds(
first.lng - 1e-9,
first.lat - 1e-9,
first.lng + 1e-9,
first.lat + 1e-9,
);
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let removed = total - count(&fx, id);
assert!(removed >= 1);
let seg = &fx.files[&id].trk[0].trkseg[0];
assert_ne!(seg.rev_id, before.trk[0].trkseg[0].rev_id);
assert!(seg.iter().all(|pt| !b_contains(&b, pt.coordinates)));
}
fn b_contains(b: &LngLatBounds, c: LngLat) -> bool {
c.lng >= b.sw.lng && c.lng <= b.ne.lng && c.lat >= b.sw.lat && c.lat <= b.ne.lat
}
#[test]
fn test_waypoints_flag_and_selection() {
let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone();
file.wpt = Waypoints::new([WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}]);
let ids: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
fx.files.insert(id, std::rc::Rc::new(file));
let everywhere = bounds(-180.0, -90.0, 180.0, 90.0);
let wpt_count = |fx: &Fixture| fx.files.get(&id).map_or(0, |f| f.wpt.len());
// trackpoints only: waypoints are kept
Clean {
wpt: false,
..clean(everywhere, CleanType::Inside)
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 2);
// one selected waypoint
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[0]]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 1);
// whole file
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 0);
}
}
@@ -1,335 +0,0 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{
Apply, CommandError, File, FileId, SegmentLocation, Selection, State, TrackSegment,
TrackSegmentId,
};
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
///
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment
/// left, if tracks or files are selected (not if only segments are), and the files that have no
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
#[derive(Debug)]
pub struct Crop {
pub start: u32,
pub end: u32,
}
/// What happens to a selected segment.
struct Plan {
location: SegmentLocation,
/// The trackpoints that are kept, `None` if the segment goes away.
keep: Option<(usize, usize)>,
len: usize,
}
impl Apply for Crop {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize);
if start > end {
return Err(CommandError::InvalidData(
"invalid trackpoints range".into(),
));
}
let mut offset = 0;
let plans: Vec<Plan> = state
.selection
.segment_locations(state.files, &state.order.0)
.into_iter()
.map(|location| {
let len =
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
let keep = (len > 0 && start < offset + len && end >= offset)
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
offset += len;
Plan {
location,
keep,
len,
}
})
.collect();
if plans
.iter()
.all(|plan| plan.keep == Some((0, plan.len.saturating_sub(1))))
{
// everything is kept as it is, or there is nothing
return Err(CommandError::NothingToDo);
}
// the files and tracks to prune, the segments to remove
let (prune_tracks, prune_files) = match &*state.selection {
Selection::File { .. } => (true, true),
Selection::Track { .. } => (true, false),
_ => (false, false),
};
let mut files: HashMap<FileId, File> = HashMap::new();
let mut removed: HashSet<TrackSegmentId> = HashSet::new();
let mut had_points: HashSet<FileId> = HashSet::new();
for plan in &plans {
let SegmentLocation { file_id, trk, seg } = plan.location;
let file = files
.entry(file_id)
.or_insert_with(|| (*state.files[&file_id]).clone());
if plan.len > 0 {
had_points.insert(file_id);
}
let segment = &mut file.trk[trk].trkseg[seg];
match plan.keep {
None => {
removed.insert(segment.id);
}
Some((from, to)) => crop_segment(segment, from, to),
}
}
let mut gone: Vec<FileId> = vec![];
for (file_id, file) in files.iter_mut() {
// the tracks that lost segments
let touched: Vec<bool> = file
.trk
.iter_mut()
.map(|track| {
let before = track.trkseg.len();
track
.trkseg
.retain(|segment| !removed.contains(&segment.id));
before != track.trkseg.len()
})
.collect();
if prune_tracks {
let mut touched = touched.into_iter();
file.trk
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
}
if prune_files && file.trk.is_empty() && had_points.contains(file_id) {
gone.push(*file_id);
}
}
for (file_id, file) in files {
if gone.contains(&file_id) {
state.files.remove(&file_id);
state.order.0.retain(|id| *id != file_id);
} else {
state.files.insert(file_id, Rc::new(file));
}
}
Ok(())
}
}
/// Keeps the trackpoints `from` to `to` of a segment, which are in it. Only the chunks at the ends
/// are changed.
fn crop_segment(segment: &mut TrackSegment, from: usize, to: usize) {
let len = segment.len();
if from == 0 && to + 1 >= len {
return;
}
segment.splice(to + 1, len, vec![]);
segment.splice(0, from, vec![]);
segment.rev_id = Default::default();
}
#[cfg(test)]
mod tests {
use crate::{Load, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn crop(start: u32, end: u32) -> Crop {
Crop { start, end }
}
/// The longitudes of the trackpoints of a file, one list per segment.
fn paths(file: &File) -> Vec<Vec<f64>> {
file.trk
.iter()
.flat_map(|track| &track.trkseg)
.map(|segment| segment.iter().map(|p| p.coordinates.lng).collect())
.collect()
}
#[test]
fn test_crop_a_file_keeps_the_range_over_its_segments() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = paths(&fx.files[&id]);
let flat: Vec<f64> = before.iter().flatten().copied().collect();
let (start, end) = (2, flat.len() - 3);
crop(start as u32, end as u32)
.apply(&mut fx.state())
.unwrap();
let after: Vec<f64> = paths(&fx.files[&id]).iter().flatten().copied().collect();
assert_eq!(after, flat[start..=end]);
// the ends of the segments that are left are anchors
for track in &fx.files[&id].trk {
for segment in &track.trkseg {
assert!(!segment.is_empty());
assert_eq!(segment[0].anchor, Some(0));
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
}
}
}
#[test]
fn test_what_is_outside_goes_away() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let lens: Vec<Vec<usize>> = file
.trk
.iter()
.map(|t| t.trkseg.iter().map(|segment| segment.len()).collect())
.collect();
assert!(lens.len() >= 2);
// from the start of the last track to the end
let before_last: usize = lens[..lens.len() - 1].iter().flatten().sum();
crop(before_last as u32, u32::MAX)
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
// the other tracks are gone, the last one is whole
assert_eq!(after.trk.len(), 1);
assert_eq!(after.trk[0].id, file.trk.last().unwrap().id);
assert_eq!(
after.trk[0]
.trkseg
.iter()
.map(|segment| segment.len())
.collect::<Vec<_>>(),
*lens.last().unwrap()
);
// the selection followed
assert!(matches!(&fx.selection, Selection::File { .. }));
}
#[test]
fn test_crop_inside_a_segment_changes_it_only() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let trk = &file.trk[0];
assert!(trk.trkseg.len() >= 2);
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[1].id]),
};
let len = trk.trkseg[1].len();
assert!(len > 4);
crop(1, len as u32 - 2).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id].trk[0];
assert_eq!(after.trkseg[1].len(), len - 2);
assert_ne!(after.trkseg[1].rev_id, trk.trkseg[1].rev_id);
// the other segments are not in the selection, whatever the range
assert_eq!(after.trkseg[0].rev_id, trk.trkseg[0].rev_id);
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
}
#[test]
fn test_segments_out_of_the_range_are_removed_but_not_their_track() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let trk = fx.files[&id].trk[0].clone();
assert!(trk.trkseg.len() >= 2);
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: trk.trkseg.iter().map(|s| s.id).collect(),
};
// only the first segment, entirely
let first = trk.trkseg[0].len();
crop(0, first as u32 - 1).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id].trk[0];
assert_eq!(after.trkseg.len(), 1);
assert_eq!(after.trkseg[0].id, trk.trkseg[0].id);
// nothing at all: the segments go, the track stays
crop(100_000, 100_001).apply(&mut fx.state()).unwrap();
assert!(fx.files[&id].trk[0].trkseg.is_empty());
}
#[test]
fn test_files_out_of_the_range_are_deleted() {
let (mut fx, first) = loaded("data/simple.gpx");
let data = std::fs::read("data/with_time.gpx").unwrap();
Load {
data: &data,
name: "second",
}
.apply(&mut fx.state())
.unwrap();
let second = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([first, second]),
};
let first_len: usize = fx.files[&first]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|segment| segment.len())
.sum();
crop(first_len as u32 + 1, u32::MAX)
.apply(&mut fx.state())
.unwrap();
assert!(!fx.files.contains_key(&first));
assert_eq!(fx.order.0, [second]);
let kept: usize = fx.files[&second]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|segment| segment.len())
.sum();
assert!(kept > 0);
}
#[test]
fn test_nothing_to_do_or_invalid() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = fx.files[&id].clone();
// the whole selection
assert_eq!(
crop(0, u32::MAX).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(matches!(
crop(5, 2).apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
fx.selection = Selection::Empty;
assert_eq!(
crop(0, 3).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
crop(0, 3).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -1,162 +0,0 @@
use crate::{Apply, CommandError, FileId, State, WaypointId, set_waypoint_fields, update_waypoint};
/// Changes a waypoint of a file, whatever is selected (what the form of the waypoint tool does):
/// its name, description, icon, link, position and elevation. The strings that are empty remove
/// the field.
#[derive(Debug)]
pub struct EditWaypoint<'a> {
pub file_id: FileId,
pub waypoint_id: WaypointId,
pub lng: f64,
pub lat: f64,
pub ele: f64,
pub name: &'a str,
pub desc: &'a str,
pub icon: &'a str,
pub link: &'a str,
}
impl Apply for EditWaypoint<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
set_waypoint_fields(
wpt,
(self.lng, self.lat, self.ele),
self.name,
self.desc,
self.icon,
self.link,
)
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Link, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
#[test]
fn test_edit_the_given_waypoint() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: vec![
Waypoint {
name: Some("old".into()),
desc: Some("old description".into()),
cmt: Some("old comment".into()),
sym: Some("Flag".into()),
links: vec![
Link {
href: "https://old".into(),
text: None,
},
Link {
href: "https://second".into(),
text: Some("second".into()),
},
],
..Default::default()
},
Waypoint {
name: Some("other".into()),
..Default::default()
},
],
..Default::default()
});
let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
EditWaypoint {
file_id: id,
waypoint_id: ids[0],
lng: 4.5,
lat: 50.5,
ele: 12.0,
name: "new",
desc: "new description",
icon: "Summit",
link: "",
}
.apply(&mut fx.state())
.unwrap();
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
let edited = wpts[0];
assert_eq!(edited.id, ids[0]);
assert_eq!(edited.name.as_deref(), Some("new"));
assert_eq!(edited.desc.as_deref(), Some("new description"));
assert_eq!(edited.cmt.as_deref(), Some("new description"));
assert_eq!(edited.sym.as_deref(), Some("Summit"));
// an empty link removes the first one, the others are not the form's business
assert_eq!(edited.links.len(), 1);
assert_eq!(edited.links[0].href, "https://second");
assert_eq!(
(edited.coordinates.lng, edited.coordinates.lat, edited.ele),
(4.5, 50.5, 12.0)
);
// the other waypoint is as it was
assert_eq!(wpts[1].name.as_deref(), Some("other"));
}
#[test]
fn test_the_link_of_the_form_is_the_first_one() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint {
links: vec![
Link {
href: "https://first".into(),
text: Some("first".into()),
},
Link {
href: "https://second".into(),
text: None,
},
],
..Default::default()
}],
..Default::default()
});
let (id, wpt_id) = (file.id, waypoint_ids(&file).next().unwrap());
fx.files.insert(id, Rc::new(file));
let edit = |fx: &mut Fixture, link: &str| {
EditWaypoint {
file_id: id,
waypoint_id: wpt_id,
lng: 0.0,
lat: 0.0,
ele: 0.0,
name: "",
desc: "",
icon: "",
link,
}
.apply(&mut fx.state())
.unwrap();
fx.files[&id].wpt.iter().next().unwrap().links.clone()
};
// the same link keeps its text
let links = edit(&mut fx, "https://first");
assert_eq!(links.len(), 2);
assert_eq!(links[0].text.as_deref(), Some("first"));
// another one replaces the first, and the text that described the old one
let links = edit(&mut fx, "https://other");
assert_eq!(links.len(), 2);
assert_eq!(
(links[0].href.as_str(), links[0].text.clone()),
("https://other", None)
);
assert_eq!(links[1].href, "https://second");
}
}
@@ -1,133 +0,0 @@
use crate::{Apply, CommandError, State, update_segments};
/// Sets the elevation of the trackpoints of the selected segments (the ones of the statistics of
/// the selection, in the same order): `ele` has one elevation per trackpoint.
#[derive(Debug)]
pub struct Elevation<'a> {
pub ele: &'a [f64],
}
impl Apply for Elevation<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let count: usize = locations
.iter()
.map(|location| {
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum();
if count == 0 {
return Err(CommandError::NothingToDo);
}
if count != self.ele.len() {
return Err(CommandError::InvalidData(format!(
"{} elevations for {count} trackpoints",
self.ele.len()
)));
}
let mut ele = self.ele.iter().copied();
update_segments(state, &locations, |_, segment| {
segment.update_all(|_, pt| pt.ele = ele.next().unwrap_or(pt.ele));
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn trackpoint_elevations(fx: &Fixture, id: FileId) -> Vec<f64> {
fx.files[&id]
.trk
.iter()
.flat_map(|trk| {
trk.trkseg
.iter()
.flat_map(|seg| seg.iter().map(|pt| pt.ele))
})
.collect()
}
#[test]
fn test_a_file_gets_elevation_on_all_its_trackpoints() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let count = trackpoint_elevations(&fx, id).len();
let ele: Vec<f64> = (0..count).map(|i| 100.0 + i as f64).collect();
Elevation { ele: &ele }.apply(&mut fx.state()).unwrap();
assert_eq!(trackpoint_elevations(&fx, id), ele);
}
#[test]
fn test_segments_only_touch_their_trackpoints() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = trackpoint_elevations(&fx, id);
let track = &fx.files[&id].trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: HashSet::from([track.trkseg[0].id]),
};
let rev_ids: Vec<_> = track.trkseg.iter().map(|seg| seg.rev_id).collect();
let len = track.trkseg[0].len();
Elevation {
ele: &vec![5.0; len],
}
.apply(&mut fx.state())
.unwrap();
let after = trackpoint_elevations(&fx, id);
assert_eq!(after[..len], vec![5.0; len]);
assert_eq!(after[len..], before[len..]);
let segments = &fx.files[&id].trk[0].trkseg;
assert_ne!(segments[0].rev_id, rev_ids[0]);
assert_eq!(segments[1].rev_id, rev_ids[1]);
}
#[test]
fn test_wrong_number_of_elevations() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = trackpoint_elevations(&fx, id);
assert!(matches!(
Elevation { ele: &[1.0] }.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert_eq!(trackpoint_elevations(&fx, id), before);
}
#[test]
fn test_nothing_selected() {
let (mut fx, _) = loaded("data/simple.gpx");
fx.selection = Selection::Empty;
assert!(matches!(
Elevation { ele: &[] }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
));
}
}
@@ -1,570 +0,0 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId,
TrackSegment, Waypoint, Waypoints, copy_waypoint, crossarc_lnglat, distance,
};
/// Splits the selected elements that are made of several segments into elements that have one
/// each:
///
/// - files: a file with several tracks becomes one file per track, in which the track is split
/// into one track per segment. A file with a single track becomes one file per segment. The new
/// files are in the place of the file, which goes, and are selected. They are named after the
/// track, or the file, and numbered. The waypoints go to the files that have the segments
/// closest to them (to the first file if the file has no trackpoint).
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
/// are named after the track and numbered.
///
/// What has a single segment is left as it is. There is nothing to do if nothing was changed.
#[derive(Debug)]
pub struct Extract;
impl Apply for Extract {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
match &selection {
Selection::File { file_ids } => extract_files(state, file_ids),
Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids),
_ => Err(CommandError::NothingToDo),
}
}
}
fn extract_files(state: &mut State, file_ids: &HashSet<FileId>) -> Result<(), CommandError> {
// the files that have something to extract, with the files that come out of them
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let file = &state.files[id];
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum();
if segments > 1 {
extracted.push((*id, extract_file(file)));
}
}
if extracted.is_empty() {
return Err(CommandError::NothingToDo);
}
let mut order = vec![];
for id in state.order.0.iter() {
match extracted.iter().find(|(original, _)| original == id) {
Some((_, files)) => order.extend(files.iter().map(|file| file.id)),
None => order.push(*id),
}
}
let mut selected: HashSet<FileId> = file_ids.clone();
for (original, files) in extracted {
state.files.remove(&original);
selected.remove(&original);
for file in files {
selected.insert(file.id);
state.files.insert(file.id, Rc::new(file));
}
}
state.order.0 = order;
*state.selection = Selection::File { file_ids: selected };
Ok(())
}
/// The files that a file with several segments becomes.
fn extract_file(file: &File) -> Vec<File> {
// for each new file: its tracks and its name, and the segments that go with it
struct Part {
tracks: Vec<Track>,
name: String,
}
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 {
// one file per track, the segments of a track follow it
let parts = file
.trk
.iter()
.enumerate()
.map(|(i, track)| Part {
tracks: split_track(track),
name: track
.info
.name
.clone()
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)),
})
.collect();
let owner = file
.trk
.iter()
.enumerate()
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
.collect();
(parts, owner)
} else {
// one file per segment
let track = &file.trk[0];
let base = track
.info
.name
.clone()
.unwrap_or_else(|| file.info.name.clone());
let parts = track
.trkseg
.iter()
.enumerate()
.map(|(i, segment)| Part {
tracks: vec![Track {
id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()],
..track.clone()
}],
name: format!("{base} ({})", i + 1),
})
.collect();
let owner = (0..track.trkseg.len()).collect();
(parts, owner)
};
// the waypoints go with the segments that are the closest to them
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect();
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
for waypoint in file.wpt.iter() {
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
.into_iter()
.map(|segment| owner[segment])
.collect();
owners.sort_unstable();
owners.dedup();
if owners.is_empty() {
owners.push(0);
}
for (n, part) in owners.into_iter().enumerate() {
// a waypoint that goes to several files has a new id in all but the first
waypoints[part].push(if n == 0 {
waypoint.clone()
} else {
copy_waypoint(waypoint)
});
}
}
parts
.into_iter()
.zip(waypoints)
.map(|(part, waypoints)| {
let mut extracted = File {
id: Default::default(),
trk: part.tracks,
wpt: Waypoints::default(),
..file.clone()
};
extracted.info.name = part.name;
extracted.wpt.insert_at(0, waypoints);
extracted
})
.collect()
}
fn extract_tracks(
state: &mut State,
file_id: FileId,
trk_ids: &HashSet<TrackId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut changed = false;
let mut tracks = vec![];
let mut selected: HashSet<TrackId> = trk_ids.clone();
for track in &file.trk {
if trk_ids.contains(&track.id) && track.trkseg.len() > 1 {
changed = true;
selected.remove(&track.id);
let pieces = split_track(track);
selected.extend(pieces.iter().map(|piece| piece.id));
tracks.extend(pieces);
} else {
tracks.push(track.clone());
}
}
if !changed {
return Err(CommandError::NothingToDo);
}
let mut file = (**file).clone();
file.trk = tracks;
state.files.insert(file_id, Rc::new(file));
*state.selection = Selection::Track {
file_id,
trk_ids: selected,
};
Ok(())
}
/// The tracks that a track becomes: one per segment, the first one with the id of the track. The
/// others have the same info, with the name of the track and the number of the segment, if it has
/// a name. A track with at most one segment stays as it is.
fn split_track(track: &Track) -> Vec<Track> {
if track.trkseg.len() <= 1 {
return vec![track.clone()];
}
track
.trkseg
.iter()
.enumerate()
.map(|(i, segment)| {
let mut piece = Track {
id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()],
..track.clone()
};
piece.info.name = track
.info
.name
.as_ref()
.map(|name| format!("{name} ({})", i + 1));
piece
})
.collect()
}
/// The indices of the segments that have the trackpoint that is the closest to `point`, which are
/// several when the closest trackpoints are as close.
///
/// The segments are looked at from the closest to the farthest bounds, which is how they are
/// left out: the ones whose bounds are farther than the closest trackpoint found.
fn closest_segments(segments: &[&TrackSegment], point: LngLat) -> Vec<usize> {
let mut by_bounds: Vec<(f64, usize)> = segments
.iter()
.enumerate()
.filter(|(_, segment)| !segment.is_empty())
.map(|(i, segment)| {
let mut bounds = LngLatBounds::default();
segment
.iter()
.for_each(|trkpt| bounds.extend(trkpt.coordinates));
(distance_to_bounds(&bounds, point), i)
})
.collect();
by_bounds.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut closest = f64::MAX;
let mut indices: Vec<usize> = vec![];
for (bounds_distance, i) in by_bounds {
if bounds_distance > closest {
break;
}
for trkpt in segments[i].iter() {
// in meters, like the distances to the bounds
let d = distance(trkpt.coordinates, point) * 1000.0;
if d < closest {
closest = d;
indices = vec![i];
} else if d == closest {
indices.push(i);
}
}
}
indices.sort_unstable();
indices.dedup();
indices
}
/// 0 inside the bounds, else the distance in meters to the closest side.
fn distance_to_bounds(bounds: &LngLatBounds, point: LngLat) -> f64 {
let (sw, ne) = (bounds.sw, bounds.ne);
if (sw.lng..=ne.lng).contains(&point.lng) && (sw.lat..=ne.lat).contains(&point.lat) {
return 0.0;
}
let nw = LngLat {
lng: sw.lng,
lat: ne.lat,
};
let se = LngLat {
lng: ne.lng,
lat: sw.lat,
};
[(nw, ne), (ne, se), (se, sw), (sw, nw)]
.into_iter()
.map(|(a, b)| crossarc_lnglat(a, b, point))
.fold(f64::MAX, f64::min)
}
#[cfg(test)]
mod tests {
use crate::{TrackInfo, Trackpoint, engine::command::fixture::Fixture};
use super::*;
/// A segment of 5 trackpoints along the equator, from the longitude `from`.
fn segment(from: f64) -> TrackSegment {
let mut segment = TrackSegment::default();
let points = (0..5)
.map(|i| Trackpoint {
coordinates: LngLat {
lng: from + i as f64 * 0.001,
lat: 0.0,
},
..Default::default()
})
.collect();
segment.splice(0, 0, points);
segment
}
fn track(name: Option<&str>, from: &[f64]) -> Track {
Track {
info: TrackInfo {
name: name.map(str::to_string),
..Default::default()
},
trkseg: from.iter().map(|from| segment(*from)).collect(),
..Default::default()
}
}
fn waypoint(name: &str, lng: f64) -> Waypoint {
Waypoint {
name: Some(name.into()),
coordinates: LngLat { lng, lat: 0.001 },
..Default::default()
}
}
fn fixture(file: File) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
fx.selection = Selection::File {
file_ids: [id].into(),
};
(fx, id)
}
fn file(name: &str, tracks: Vec<Track>, waypoints: Vec<Waypoint>) -> File {
let mut file = File {
trk: tracks,
..Default::default()
};
file.info.name = name.into();
file.wpt.insert_at(0, waypoints);
file
}
fn names(fx: &Fixture) -> Vec<String> {
fx.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect()
}
#[test]
fn test_a_file_with_one_track_becomes_one_file_per_segment() {
let original = file(
"route",
vec![track(Some("climb"), &[0.0, 10.0, 20.0])],
// near the first segment, near the last one, and in between, closer to the middle one
vec![
waypoint("a", 0.002),
waypoint("c", 20.002),
waypoint("b", 10.0),
],
);
let track_id = original.trk[0].id;
let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect();
let (mut fx, id) = fixture(original);
let other = file("other", vec![track(None, &[50.0])], vec![]);
let other_id = other.id;
fx.files.insert(other_id, Rc::new(other));
fx.order.0.push(other_id);
Extract.apply(&mut fx.state()).unwrap();
// in the place of the file, in order, named after the track
assert!(!fx.files.contains_key(&id));
assert_eq!(names(&fx), ["climb (1)", "climb (2)", "climb (3)", "other"]);
assert_eq!(fx.order.0[3], other_id);
for (i, file_id) in fx.order.0[..3].iter().enumerate() {
let file = &fx.files[file_id];
assert_eq!(file.trk.len(), 1);
assert_eq!(file.trk[0].trkseg.len(), 1);
assert_eq!(file.trk[0].trkseg[0].id, segment_ids[i]);
assert_eq!(file.trk[0].info.name.as_deref(), Some("climb"));
}
assert_eq!(fx.files[&fx.order.0[0]].trk[0].id, track_id);
// the waypoints are with the closest segment
let waypoints = |i: usize| -> Vec<String> {
fx.files[&fx.order.0[i]]
.wpt
.iter()
.map(|w| w.name.clone().unwrap())
.collect()
};
assert_eq!(waypoints(0), ["a"]);
assert_eq!(waypoints(1), ["b"]);
assert_eq!(waypoints(2), ["c"]);
// the new files are selected, the other one is not
assert!(matches!(&fx.selection, Selection::File { file_ids }
if file_ids.len() == 3 && !file_ids.contains(&other_id)));
}
#[test]
fn test_a_file_with_several_tracks_becomes_one_file_per_track() {
let original = file(
"route",
vec![
track(Some("first"), &[0.0, 10.0]),
track(None, &[20.0]),
track(Some("third"), &[30.0, 40.0]),
],
vec![],
);
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
// named after the track, or the file
assert_eq!(names(&fx), ["first", "route (2)", "third"]);
let tracks = |i: usize| &fx.files[&fx.order.0[i]].trk;
// a track with several segments is split in as many tracks
assert_eq!(tracks(0).len(), 2);
assert_eq!(tracks(0)[0].info.name.as_deref(), Some("first (1)"));
assert_eq!(tracks(0)[1].info.name.as_deref(), Some("first (2)"));
assert_eq!(tracks(0)[0].id, ids[0]);
// and one that has a single segment is not renamed
assert_eq!(tracks(1).len(), 1);
assert_eq!(tracks(1)[0].id, ids[1]);
assert_eq!(tracks(1)[0].info.name, None);
assert_eq!(tracks(2).len(), 2);
}
#[test]
fn test_waypoints_with_closest_segments_that_tie_are_copied() {
// the segments share a trackpoint, where the waypoint is: it is as close to both
let a = segment(0.0);
let b = segment(a[4].coordinates.lng);
let shared = Waypoint {
name: Some("mid".into()),
coordinates: a[4].coordinates,
..Default::default()
};
let original = file(
"f",
vec![Track {
trkseg: vec![a, b],
..Default::default()
}],
vec![shared, waypoint("lost", 100.0)],
);
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
let first = &fx.files[&fx.order.0[0]];
let second = &fx.files[&fx.order.0[1]];
// the tie goes to both, with different ids
let mid_in = |f: &File| {
f.wpt
.iter()
.find(|w| w.name.as_deref() == Some("mid"))
.map(|w| w.id)
};
assert!(mid_in(first).is_some() && mid_in(second).is_some());
assert_ne!(mid_in(first), mid_in(second));
// a far waypoint goes to its closest segment, which is the last one
assert!(second.wpt.iter().any(|w| w.name.as_deref() == Some("lost")));
}
#[test]
fn test_a_waypoint_of_a_file_without_trackpoints_is_kept() {
let mut empty = TrackSegment::default();
empty.rev_id = Default::default();
let original = file(
"f",
vec![Track {
trkseg: vec![empty.clone(), TrackSegment::default()],
..Default::default()
}],
vec![waypoint("w", 1.0)],
);
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1);
assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0);
}
#[test]
fn test_extract_tracks_in_place() {
let original = file(
"route",
vec![
track(Some("a"), &[0.0]),
track(Some("b"), &[10.0, 20.0, 30.0]),
track(None, &[40.0, 50.0]),
],
vec![],
);
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
let (mut fx, id) = fixture(original);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [ids[1], ids[2]].into(),
};
Extract.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect();
assert_eq!(
names,
[
Some("a".into()),
Some("b (1)".into()),
Some("b (2)".into()),
Some("b (3)".into()),
None,
None
]
);
assert_eq!(file.trk.len(), 6);
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
assert_eq!(file.trk[0].id, ids[0]);
assert_eq!(file.trk[1].id, ids[1]);
// the new tracks are selected
let Selection::Track { trk_ids, .. } = &fx.selection else {
panic!("tracks are selected");
};
assert_eq!(trk_ids.len(), 5);
assert!(trk_ids.contains(&ids[1]) && !trk_ids.contains(&ids[0]));
}
#[test]
fn test_nothing_to_extract() {
// a single segment
let original = file("f", vec![track(Some("t"), &[0.0])], vec![]);
let track_id = original.trk[0].id;
let segment_id = original.trk[0].trkseg[0].id;
let (mut fx, id) = fixture(original);
let before = fx.files[&id].clone();
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track_id].into(),
};
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
for selection in [
Selection::Empty,
Selection::Waypoints { file_id: id },
Selection::TrackSegment {
file_id: id,
trk_id: track_id,
trkseg_ids: [segment_id].into(),
},
] {
fx.selection = selection;
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -1,230 +0,0 @@
use std::rc::Rc;
use crate::{Apply, CommandError, LngLat, SegmentLocation, State, Trackpoint, distance, projected};
/// Under this distance (in meters) to a trackpoint, that trackpoint becomes the anchor instead of
/// a new one being inserted next to it.
const SNAP_DISTANCE: f64 = 1.0;
/// Makes an anchor of the routing tool, shown at every zoom level, of the point of the selected
/// segments that is the closest to the given coordinates. That is the trackpoint itself if the
/// coordinates are on one, otherwise a new trackpoint is inserted on the path, at the position
/// of the coordinates projected on it, with an elevation and a time in between the ones of its
/// neighbours.
#[derive(Debug)]
pub struct InsertAnchor {
pub lng: f64,
pub lat: f64,
}
/// Where the anchor goes: between the trackpoints `before` and `before + 1` of a segment, at
/// `ratio` of the distance from the first one, which is `projection`. `distance` is the one from
/// there to the coordinates, in meters.
struct Position {
location: SegmentLocation,
before: usize,
ratio: f64,
projection: LngLat,
distance: f64,
}
impl Apply for InsertAnchor {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let target = LngLat {
lng: self.lng,
lat: self.lat,
};
let mut closest: Option<Position> = None;
for location in state
.selection
.segment_locations(state.files, &state.order.0)
{
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
let mut points = segment.iter();
let Some(mut previous) = points.next() else {
continue;
};
if segment.len() == 1 {
// a single point is its own path
let d = distance(previous.coordinates, target) * 1000.0;
if closest.as_ref().is_none_or(|c| d < c.distance) {
closest = Some(Position {
location,
before: 0,
ratio: 0.0,
projection: previous.coordinates,
distance: d,
});
}
}
for (i, point) in points.enumerate() {
let projection = projected(previous.coordinates, point.coordinates, target);
let d = distance(projection, target) * 1000.0;
if closest.as_ref().is_none_or(|c| d < c.distance) {
let length = distance(previous.coordinates, point.coordinates);
let ratio = if length > 0.0 {
distance(previous.coordinates, projection) / length
} else {
0.0
};
closest = Some(Position {
location,
before: i,
ratio,
projection,
distance: d,
});
}
previous = point;
}
}
let closest = closest.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&closest.location.file_id]).clone();
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg];
let before = closest.before;
// on a trackpoint already, if it is closer than `SNAP_DISTANCE`
let snapped = if segment.len() == 1 {
Some(0)
} else {
let (a, b) = (&segment[before], &segment[before + 1]);
let meters = 1000.0 * distance(a.coordinates, b.coordinates);
if closest.ratio * meters < SNAP_DISTANCE {
Some(before)
} else if (1.0 - closest.ratio) * meters < SNAP_DISTANCE {
Some(before + 1)
} else {
None
}
};
if let Some(index) = snapped {
if segment[index].anchor == Some(0) {
return Err(CommandError::NothingToDo);
}
segment.set_anchor(index, 0);
} else {
let (a, b) = (&segment[before], &segment[before + 1]);
let ratio = closest.ratio;
let mut point: Trackpoint = a.clone();
point.coordinates = closest.projection;
point.ele = (1.0 - ratio) * a.ele + ratio * b.ele;
point.time = a
.time
.zip(b.time)
.map(|(a, b)| ((1.0 - ratio) * a as f64 + ratio * b as f64) as i64);
point.anchor = Some(0);
segment.splice(before + 1, before + 1, vec![point]);
}
segment.rev_id = Default::default();
state.files.insert(closest.location.file_id, Rc::new(file));
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn first_segment(fx: &Fixture, id: FileId) -> crate::TrackSegment {
fx.files[&id].trk[0].trkseg[0].clone()
}
#[test]
fn test_a_point_is_inserted_between_its_neighbours() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = first_segment(&fx, id);
let (a, b) = (&before[10], &before[11]);
// a bit off the line, closer to the middle of the two points
let target = LngLat {
lng: (a.coordinates.lng + b.coordinates.lng) / 2.0 + 1e-6,
lat: (a.coordinates.lat + b.coordinates.lat) / 2.0 + 1e-6,
};
InsertAnchor {
lng: target.lng,
lat: target.lat,
}
.apply(&mut fx.state())
.unwrap();
let after = first_segment(&fx, id);
assert_eq!(after.len(), before.len() + 1);
assert_ne!(after.rev_id, before.rev_id);
let point = &after[11];
assert_eq!(point.anchor, Some(0));
assert_eq!(after[10].coordinates.lng, a.coordinates.lng);
assert_eq!(after[12].coordinates.lng, b.coordinates.lng);
// between its neighbours, with the interpolated elevation and time
let ratio = (point.time.unwrap() - a.time.unwrap()) as f64
/ (b.time.unwrap() - a.time.unwrap()) as f64;
assert!((0.3..0.7).contains(&ratio), "{ratio}");
let expected = a.ele + ratio * (b.ele - a.ele);
assert!(
(point.ele - expected).abs() < 0.5,
"{} {expected}",
point.ele
);
}
#[test]
fn test_a_trackpoint_that_is_hit_becomes_the_anchor() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = first_segment(&fx, id);
assert_eq!(before[10].anchor.is_some(), before[10].anchor == Some(0));
let point = &before[10];
InsertAnchor {
lng: point.coordinates.lng,
lat: point.coordinates.lat,
}
.apply(&mut fx.state())
.unwrap();
let after = first_segment(&fx, id);
assert_eq!(after.len(), before.len());
let anchors = (0..after.len())
.filter(|i| after[*i].anchor == Some(0))
.count();
assert!(anchors >= 3, "{anchors}");
assert!(
after
.iter()
.zip(before.iter())
.all(|(a, b)| a.coordinates.lng == b.coordinates.lng)
);
// doing it again changes nothing
let again = InsertAnchor {
lng: point.coordinates.lng,
lat: point.coordinates.lat,
}
.apply(&mut fx.state());
assert!(again.is_ok() || again == Err(CommandError::NothingToDo));
}
#[test]
fn test_nothing_to_do_without_a_segment() {
let (mut fx, _) = loaded("data/with_time.gpx");
fx.selection = Selection::Empty;
assert_eq!(
InsertAnchor { lng: 1.0, lat: 1.0 }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -1,586 +0,0 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId,
TrackSegment, TrackSegmentId, Waypoint, Waypoints, replace_trackpoints,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeType {
/// The trackpoints of the elements are connected in a single segment.
Connect,
/// The elements are put together, in the first one, which keeps them as they are.
Group,
}
/// Merges the selected elements into the first of them:
///
/// - files: the other files are deleted. The tracks of all of them are in the first file, as
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of
/// all of them are in the first file too, except the ones that are the same.
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
/// connected in one segment with `Connect`. The other tracks are removed.
/// - segments of a track: they are all connected in a segment at the position of the first one
/// with `Connect`. With `Group` they are put one after the other, at that position.
///
/// The segment made by `Connect` goes on in time, if there are timestamps, at the moving speed
/// of the selection, see [`replace_trackpoints`], which `remove_gaps` is given.
///
/// There is nothing to do when there is nothing to merge: a single file to group, a single track
/// that has no more than one segment, a single segment...
#[derive(Debug)]
pub struct Merge {
pub type_: MergeType,
pub remove_gaps: bool,
}
impl Apply for Merge {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
match &selection {
Selection::File { file_ids } => self.merge_files(state, file_ids),
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => self.merge_segments(state, *file_id, *trk_id, trkseg_ids),
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {
Err(CommandError::NothingToDo)
}
}
}
}
impl Merge {
fn merge_files(
&self,
state: &mut State,
file_ids: &HashSet<FileId>,
) -> Result<(), CommandError> {
// in the order of the files
let ordered: Vec<FileId> = state
.order
.0
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !state.order.0.contains(id)))
.filter(|id| state.files.contains_key(id))
.copied()
.collect();
let Some(&target_id) = ordered.first() else {
return Err(CommandError::NothingToDo);
};
let mut tracks: Vec<Track> = vec![];
let mut waypoints: Vec<Waypoint> = vec![];
for id in &ordered {
let file = &state.files[id];
tracks.extend(file.trk.iter().cloned());
for waypoint in file.wpt.iter() {
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
waypoints.push(waypoint.clone());
}
}
}
let segments: Vec<TrackSegment> = tracks
.iter()
.flat_map(|track| track.trkseg.iter().cloned())
.collect();
match self.type_ {
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
MergeType::Connect if ordered.len() == 1 && segments.len() <= 1 => {
return Err(CommandError::NothingToDo);
}
MergeType::Connect if !segments.is_empty() => {
// a single track, which is the first one, with the connected segment
let merged = connect(&segments, segments[0].id, self.remove_gaps);
let first = Track {
trkseg: vec![merged],
..tracks[0].clone()
};
tracks = vec![first];
}
_ => {}
}
let mut target = (*state.files[&target_id]).clone();
target.trk = tracks;
target.wpt = Waypoints::default();
target.wpt.insert_at(0, waypoints);
for id in &ordered[1..] {
state.files.remove(id);
}
state.order.0.retain(|id| !ordered[1..].contains(id));
state.files.insert(target_id, Rc::new(target));
Ok(())
}
fn merge_tracks(
&self,
state: &mut State,
file_id: FileId,
trk_ids: &HashSet<TrackId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<usize> = (0..file.trk.len())
.filter(|i| trk_ids.contains(&file.trk[*i].id))
.collect();
let Some(&first) = selected.first() else {
return Err(CommandError::NothingToDo);
};
let segments: Vec<TrackSegment> = selected
.iter()
.flat_map(|i| file.trk[*i].trkseg.iter().cloned())
.collect();
if selected.len() == 1 && (self.type_ == MergeType::Group || segments.len() <= 1) {
return Err(CommandError::NothingToDo);
}
let merged = match self.type_ {
MergeType::Group => segments,
MergeType::Connect if segments.is_empty() => vec![],
MergeType::Connect => {
vec![connect(&segments, segments[0].id, self.remove_gaps)]
}
};
let mut file = (**file).clone();
file.trk[first].trkseg = merged;
let mut index = 0;
file.trk.retain(|_| {
index += 1;
!selected[1..].contains(&(index - 1))
});
state.files.insert(file_id, Rc::new(file));
Ok(())
}
fn merge_segments(
&self,
state: &mut State,
file_id: FileId,
trk_id: TrackId,
ids: &HashSet<TrackSegmentId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let trk = file
.trk
.iter()
.position(|trk| trk.id == trk_id)
.ok_or(CommandError::NothingToDo)?;
let segments = &file.trk[trk].trkseg;
let selected: Vec<usize> = (0..segments.len())
.filter(|i| ids.contains(&segments[*i].id))
.collect();
if selected.len() < 2 {
return Err(CommandError::NothingToDo);
}
let first = selected[0];
let chosen: Vec<TrackSegment> = selected.iter().map(|i| segments[*i].clone()).collect();
let merged = match self.type_ {
MergeType::Connect => vec![connect(&chosen, chosen[0].id, self.remove_gaps)],
MergeType::Group => {
if selected.iter().enumerate().all(|(i, s)| *s == first + i) {
// already one after the other
return Err(CommandError::NothingToDo);
}
chosen
}
};
let mut file = (**file).clone();
let mut remaining: Vec<TrackSegment> = file.trk[trk]
.trkseg
.iter()
.filter(|segment| !ids.contains(&segment.id))
.cloned()
.collect();
// the ones before the first selected one are all kept
remaining.splice(first..first, merged);
file.trk[trk].trkseg = remaining;
state.files.insert(file_id, Rc::new(file));
Ok(())
}
}
/// The segments connected in a single one, which has the id `id`: the trackpoints of the second
/// go after the ones of the first and so on.
///
/// If there are timestamps, the new segment goes on in time, see [`replace_trackpoints`]: the
/// trackpoints that have none get some, at the moving speed of the segments, and the ones that
/// are too early follow the previous ones. The time of the first trackpoint is the one it has, or
/// the one it would have if it was as far in time from the first timed one as its distance
/// says.
fn connect(segments: &[TrackSegment], id: TrackSegmentId, remove_gaps: bool) -> TrackSegment {
let mut global = GlobalStatistics::default();
let mut distance = 0.0;
// the first time, and the distance from the start at which it is
let mut first_timed: Option<(i64, f64)> = None;
for segment in segments {
let stats = Statistics::compute(segment);
if first_timed.is_none() {
first_timed = segment.iter().zip(&stats.local).find_map(|(point, local)| {
point
.time
.map(|time| (time, distance + local.total_distance))
});
}
distance += stats.global.total_distance;
global.merge(&stats.global);
}
let speed = global.moving_speed().filter(|speed| *speed > 0.0);
let start_time = speed
.and_then(|speed| first_timed.map(|(time, km)| time - (3_600_000.0 * km / speed) as i64));
let mut points = vec![];
for segment in segments {
let end = points.len();
replace_trackpoints(
&mut points,
end,
end,
segment.iter().cloned().collect(),
speed,
start_time,
remove_gaps,
);
}
let mut merged = TrackSegment::default();
merged.splice(0, 0, points);
merged.id = id;
merged
}
#[cfg(test)]
mod tests {
use crate::{File, Load, engine::command::fixture::Fixture};
use super::*;
fn load(fx: &mut Fixture, path: &str) -> FileId {
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
*fx.order.0.last().unwrap()
}
fn merge(type_: MergeType) -> Merge {
Merge {
type_,
remove_gaps: false,
}
}
fn select_files(fx: &mut Fixture, ids: &[FileId]) {
fx.selection = Selection::File {
file_ids: ids.iter().copied().collect(),
};
}
fn points(file: &File) -> usize {
file.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.len())
.sum()
}
fn waypoint(name: &str) -> Waypoint {
Waypoint {
name: Some(name.into()),
..Default::default()
}
}
#[test]
fn test_group_files() {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
let b = load(&mut fx, "data/simple.gpx");
let mut file_a = (*fx.files[&a]).clone();
file_a
.wpt
.insert_at(0, vec![waypoint("x"), waypoint("same")]);
fx.files.insert(a, Rc::new(file_a));
let mut file_b = (*fx.files[&b]).clone();
file_b
.wpt
.insert_at(0, vec![waypoint("same"), waypoint("y")]);
fx.files.insert(b, Rc::new(file_b));
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
select_files(&mut fx, &[b, a]);
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
// the first file (in the order of the files) gets everything, the other one goes
assert_eq!(fx.order.0, [a]);
assert!(!fx.files.contains_key(&b));
let merged = &fx.files[&a];
assert_eq!(merged.trk.len(), before_a.trk.len() + before_b.trk.len());
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
assert_eq!(
merged.trk.last().unwrap().id,
before_b.trk.last().unwrap().id
);
assert_eq!(points(merged), points(&before_a) + points(&before_b));
// the waypoints that are the same are only once
let names: Vec<_> = merged.wpt.iter().map(|w| w.name.clone().unwrap()).collect();
assert_eq!(names, ["x", "same", "y"]);
assert_eq!(merged.info, before_a.info);
// a single file has nothing to group
select_files(&mut fx, &[a]);
assert_eq!(
merge(MergeType::Group).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_connect_files() {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
let b = load(&mut fx, "data/simple.gpx");
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
select_files(&mut fx, &[a, b]);
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&a];
assert_eq!(fx.order.0, [a]);
// a single track, with a single segment that has all the trackpoints in order
assert_eq!(merged.trk.len(), 1);
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
assert_eq!(merged.trk[0].trkseg.len(), 1);
let segment = &merged.trk[0].trkseg[0];
assert_eq!(segment.len(), points(&before_a) + points(&before_b));
let expected: Vec<f64> = before_a
.trk
.iter()
.chain(&before_b.trk)
.flat_map(|t| &t.trkseg)
.flat_map(|s| s.iter().map(|p| p.coordinates.lng))
.collect();
let lngs: Vec<f64> = segment.iter().map(|p| p.coordinates.lng).collect();
assert_eq!(lngs, expected);
assert_eq!(segment.id, before_a.trk[0].trkseg[0].id);
assert_eq!(segment[0].anchor, Some(0));
}
#[test]
fn test_connect_goes_on_in_time() {
let mut fx = Fixture::default();
// the same times twice: the second part would be too early
let a = load(&mut fx, "data/with_time.gpx");
let b = load(&mut fx, "data/with_time.gpx");
select_files(&mut fx, &[a, b]);
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let segment = &fx.files[&a].trk[0].trkseg[0];
let times: Vec<i64> = segment.iter().map(|p| p.time.unwrap()).collect();
assert_eq!(times.len(), 160);
assert!(times.windows(2).all(|w| w[0] <= w[1]), "{times:?}");
assert!(times[80] > times[79]);
}
#[test]
fn test_connect_without_gaps_brings_the_parts_closer() {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_time.gpx");
// the second file comes a day later
let b = {
let data = std::fs::read("data/with_time.gpx").unwrap();
Load {
data: &data,
name: "late",
}
.apply(&mut fx.state())
.unwrap();
*fx.order.0.last().unwrap()
};
let mut file = (*fx.files[&b]).clone();
for track in file.trk.iter_mut() {
for segment in track.trkseg.iter_mut() {
let points: Vec<_> = segment
.iter()
.map(|p| {
let mut p = p.clone();
p.time = p.time.map(|t| t + 86_400_000);
p
})
.collect();
let len = segment.len();
segment.splice(0, len, points);
}
}
fx.files.insert(b, Rc::new(file));
let gap = |fx: &Fixture, remove_gaps: bool| {
let mut fx2 = Fixture {
files: fx.files.clone(),
order: crate::FileOrder(fx.order.0.clone()),
selection: Selection::File {
file_ids: [a, b].into(),
},
..Default::default()
};
Merge {
type_: MergeType::Connect,
remove_gaps,
}
.apply(&mut fx2.state())
.unwrap();
let segment = &fx2.files[&a].trk[0].trkseg[0];
segment[80].time.unwrap() - segment[79].time.unwrap()
};
let with_gap = gap(&fx, false);
let without = gap(&fx, true);
assert!(with_gap > 80_000_000, "{with_gap}");
assert!(without < 60_000, "{without}");
}
#[test]
fn test_merge_tracks() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
assert!(file.trk.len() >= 2);
let ids: HashSet<TrackId> = file.trk.iter().map(|t| t.id).collect();
fx.selection = Selection::Track {
file_id: id,
trk_ids: ids,
};
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id];
assert_eq!(merged.trk.len(), 1);
assert_eq!(merged.trk[0].id, file.trk[0].id);
assert_eq!(merged.trk[0].trkseg.len(), segments);
assert_eq!(points(merged), points(&file));
// connected: a single segment
fx.files.insert(id, file.clone());
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id];
assert_eq!(merged.trk.len(), 1);
assert_eq!(merged.trk[0].trkseg.len(), 1);
assert_eq!(merged.trk[0].trkseg[0].len(), points(&file));
// one track with a single segment: nothing to do
fx.selection = Selection::Track {
file_id: id,
trk_ids: [file.trk[0].id].into(),
};
for type_ in [MergeType::Group, MergeType::Connect] {
assert_eq!(
merge(type_).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
#[test]
fn test_connect_the_segments_of_a_track() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let (index, track) = file
.trk
.iter()
.enumerate()
.find(|(_, t)| t.trkseg.len() >= 2)
.unwrap();
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track.id].into(),
};
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id].trk[index];
assert_eq!(merged.id, track.id);
assert_eq!(merged.trkseg.len(), 1);
assert_eq!(
merged.trkseg[0].len(),
track.trkseg.iter().map(|s| s.len()).sum::<usize>()
);
// the other tracks are untouched
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
}
#[test]
fn test_merge_segments() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
// a track with three segments
let mut three = (*fx.files[&id]).clone();
let base = three.trk[0].trkseg[0].clone();
three.trk[0].trkseg = (0..3).map(|_| crate::copy_segment(&base)).collect();
fx.files.insert(id, Rc::new(three));
let file = fx.files[&id].clone();
let (index, track) = (0, &file.trk[0]);
let segments = &track.trkseg;
// the first and the last ones
let (first, last) = (&segments[0], &segments[2]);
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [first.id, last.id].into(),
};
// grouped: next to each other, at the place of the first one
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
let ids: Vec<_> = fx.files[&id].trk[index]
.trkseg
.iter()
.map(|s| s.id)
.collect();
assert_eq!(ids[..3], [first.id, last.id, segments[1].id]);
// connected: one segment, with the id of the first one, at its place
fx.files.insert(id, file.clone());
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id].trk[index].trkseg;
assert_eq!(merged.len(), segments.len() - 1);
assert_eq!(merged[0].id, first.id);
assert_eq!(merged[0].len(), first.len() + last.len());
assert_eq!(merged[1].id, segments[1].id);
// a single segment, or segments that are already together: nothing to do
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [first.id].into(),
};
assert_eq!(
merge(MergeType::Connect).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.files.insert(id, file.clone());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [first.id, segments[1].id].into(),
};
assert_eq!(
merge(MergeType::Group).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_nothing_to_merge() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/simple.gpx");
let before = fx.files[&id].clone();
for selection in [Selection::Empty, Selection::Waypoints { file_id: id }] {
fx.selection = selection;
assert_eq!(
merge(MergeType::Connect).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -1,33 +0,0 @@
mod change_loop_start;
mod clean;
mod crop;
mod edit_waypoint;
mod elevation;
mod extract;
mod insert_anchor;
mod merge;
mod move_waypoint;
mod new_waypoint;
mod reduce;
mod reverse;
mod round_trip;
mod route;
mod split;
mod time;
pub use change_loop_start::*;
pub use clean::*;
pub use crop::*;
pub use edit_waypoint::*;
pub use elevation::*;
pub use extract::*;
pub use insert_anchor::*;
pub use merge::*;
pub use move_waypoint::*;
pub use new_waypoint::*;
pub use reduce::*;
pub use reverse::*;
pub use round_trip::*;
pub use route::*;
pub use split::*;
pub use time::*;
@@ -1,107 +0,0 @@
use crate::{Apply, CommandError, FileId, LngLat, State, WaypointId, update_waypoint};
/// Moves a waypoint of a file, whatever is selected (what a drag does): only its position and
/// elevation change.
#[derive(Debug)]
pub struct MoveWaypoint {
pub file_id: FileId,
pub waypoint_id: WaypointId,
pub lng: f64,
pub lat: f64,
pub ele: f64,
}
impl Apply for MoveWaypoint {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
wpt.coordinates = LngLat {
lng: self.lng,
lat: self.lat,
};
wpt.ele = self.ele;
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: (0..3)
.map(|i| Waypoint {
name: Some(format!("w{i}")),
..Default::default()
})
.collect(),
..Default::default()
});
let id = file.id;
let ids = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
(fx, id, ids)
}
#[test]
fn test_only_the_given_waypoint_moves_whatever_is_selected() {
let (mut fx, id, ids) = fixture();
let rev = fx.files[&id].wpt.rev_id;
MoveWaypoint {
file_id: id,
waypoint_id: ids[1],
lng: 4.0,
lat: 50.0,
ele: 42.0,
}
.apply(&mut fx.state())
.unwrap();
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
assert_eq!(wpts.len(), 3);
assert_eq!(
(
wpts[1].coordinates.lng,
wpts[1].coordinates.lat,
wpts[1].ele
),
(4.0, 50.0, 42.0)
);
// the other fields and the other waypoints are as they were
assert_eq!(wpts[1].name.as_deref(), Some("w1"));
assert_eq!(wpts[1].id, ids[1]);
assert_eq!((wpts[0].coordinates.lng, wpts[0].ele), (0.0, 0.0));
assert_eq!((wpts[2].coordinates.lat, wpts[2].ele), (0.0, 0.0));
// the coordinates changed, which is noticed by what is derived from them
assert_ne!(fx.files[&id].wpt.rev_id, rev);
assert_eq!(fx.selection, Selection::Empty);
}
#[test]
fn test_unknown_waypoint_or_file_is_nothing_to_do() {
let (mut fx, id, ids) = fixture();
let before = fx.files[&id].clone();
for (file_id, waypoint_id) in [(id, WaypointId::default()), (FileId::default(), ids[0])] {
let result = MoveWaypoint {
file_id,
waypoint_id,
lng: 1.0,
lat: 1.0,
ele: 1.0,
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -1,270 +0,0 @@
use std::rc::Rc;
use crate::{Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint};
/// Adds a waypoint at the end of the waypoints of each selected file (or of the file of the
/// selected elements). The strings are empty when the waypoint does not have the field.
#[derive(Debug)]
pub struct NewWaypoint<'a> {
pub lng: f64,
pub lat: f64,
pub ele: f64,
pub name: &'a str,
pub desc: &'a str,
pub icon: &'a str,
pub link: &'a str,
}
fn non_empty(text: &str) -> Option<String> {
(!text.is_empty()).then(|| text.to_owned())
}
/// Sets the fields of a waypoint: the strings that are empty remove the field.
pub fn set_waypoint_fields(
waypoint: &mut Waypoint,
position: (f64, f64, f64),
name: &str,
desc: &str,
icon: &str,
link: &str,
) {
let (lng, lat, ele) = position;
waypoint.coordinates = LngLat { lng, lat };
waypoint.ele = ele;
waypoint.name = non_empty(name);
waypoint.desc = non_empty(desc);
// the description is also the comment, as it is what the other applications show
waypoint.cmt = non_empty(desc);
waypoint.sym = non_empty(icon);
// the form has a single link: it is the first one, the others are left as they are
match (non_empty(link), waypoint.links.first_mut()) {
(Some(href), Some(first)) if first.href != href => *first = Link { href, text: None },
(Some(href), None) => waypoint.links.push(Link { href, text: None }),
(None, Some(_)) => {
waypoint.links.remove(0);
}
_ => (),
}
}
impl Apply for NewWaypoint<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let file_ids: Vec<FileId> = match &*state.selection {
Selection::Empty => vec![],
Selection::File { file_ids } => state
.order
.0
.iter()
.filter(|id| file_ids.contains(id))
.copied()
.collect(),
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => vec![*file_id],
};
let file_ids: Vec<FileId> = file_ids
.into_iter()
.filter(|id| state.files.contains_key(id))
.collect();
if file_ids.is_empty() {
return Err(CommandError::NothingToDo);
}
for id in file_ids {
let mut waypoint = Waypoint::default();
set_waypoint_fields(
&mut waypoint,
(self.lng, self.lat, self.ele),
self.name,
self.desc,
self.icon,
self.link,
);
let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap());
file.wpt.insert_at(usize::MAX, vec![waypoint]);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{New, TrackId, TrackSegmentId, engine::command::fixture::Fixture, waypoint_ids};
use super::*;
fn new_waypoint(name: &str) -> NewWaypoint<'_> {
NewWaypoint {
lng: 4.5,
lat: 50.25,
ele: 123.0,
name,
desc: "a description",
icon: "Summit",
link: "https://example.com",
}
}
fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> {
fx.files[&id].wpt.iter().cloned().collect()
}
#[test]
fn test_new_waypoint_in_the_selected_file() {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
New {
name: "b",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let rev = fx.files[&b].wpt.rev_id;
// b is selected
new_waypoint("peak").apply(&mut fx.state()).unwrap();
assert!(waypoints(&fx, a).is_empty());
let created = waypoints(&fx, b);
assert_eq!(created.len(), 1);
let wpt = &created[0];
assert_eq!(
(wpt.coordinates.lng, wpt.coordinates.lat, wpt.ele),
(4.5, 50.25, 123.0)
);
assert_eq!(wpt.name.as_deref(), Some("peak"));
assert_eq!(wpt.desc.as_deref(), Some("a description"));
assert_eq!(wpt.cmt.as_deref(), Some("a description"));
assert_eq!(wpt.sym.as_deref(), Some("Summit"));
assert_eq!(
wpt.links.first().map(|l| l.href.as_str()),
Some("https://example.com")
);
assert_ne!(fx.files[&b].wpt.rev_id, rev);
// added after the others
let mut second = new_waypoint("second");
second.lng = 5.0;
second.apply(&mut fx.state()).unwrap();
let names: Vec<_> = waypoints(&fx, b)
.iter()
.map(|w| w.name.clone().unwrap())
.collect();
assert_eq!(names, ["peak", "second"]);
// and not selected
assert_eq!(fx.selected_files(), [b].into());
}
#[test]
fn test_empty_fields_are_not_set() {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let a = fx.order.0[0];
NewWaypoint {
lng: 1.0,
lat: 2.0,
ele: 0.0,
name: "",
desc: "",
icon: "",
link: "",
}
.apply(&mut fx.state())
.unwrap();
let wpt = &waypoints(&fx, a)[0];
assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none());
assert!(wpt.sym.is_none() && wpt.links.is_empty());
}
#[test]
fn test_each_selected_file_gets_its_own_waypoint() {
let mut fx = Fixture::default();
for name in ["a", "b", "c"] {
New {
name,
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
}
let ids = fx.order.0.clone();
fx.selection = Selection::File {
file_ids: HashSet::from([ids[0], ids[2]]),
};
new_waypoint("w").apply(&mut fx.state()).unwrap();
assert_eq!(waypoints(&fx, ids[0]).len(), 1);
assert!(waypoints(&fx, ids[1]).is_empty());
assert_eq!(waypoints(&fx, ids[2]).len(), 1);
// not the same waypoint
assert_ne!(waypoints(&fx, ids[0])[0].id, waypoints(&fx, ids[2])[0].id);
}
#[test]
fn test_the_file_of_the_selected_elements_is_used() {
for selection in [
|id| Selection::Track {
file_id: id,
trk_ids: [TrackId::default()].into(),
},
|id| Selection::TrackSegment {
file_id: id,
trk_id: TrackId::default(),
trkseg_ids: [TrackSegmentId::default()].into(),
},
|id| Selection::Waypoints { file_id: id },
|id| Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::new(),
},
] {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let a = fx.order.0[0];
fx.selection = selection(a);
new_waypoint("w").apply(&mut fx.state()).unwrap();
assert_eq!(waypoint_ids(&fx.files[&a]).count(), 1);
}
}
#[test]
fn test_nothing_selected_or_unknown_file() {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
fx.selection = Selection::Empty;
assert_eq!(
new_waypoint("w").apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints {
file_id: FileId::default(),
};
assert_eq!(
new_waypoint("w").apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert_eq!(fx.files.len(), 1);
}
}
@@ -1,149 +0,0 @@
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,294 +0,0 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, File, FileId, Selection, State, TrackId, TrackSegmentId, end_time,
reverse_segment, reverse_segments, reverse_tracks, start_time, track_end_time,
track_start_time,
};
/// Reverses the direction of the selected elements: the whole of each selected file (its tracks,
/// the segments of its tracks and their trackpoints are in the opposite order), of each selected
/// track (its segments and their trackpoints), or each selected segment on its own (its
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
#[derive(Debug)]
pub struct Reverse;
impl Apply for Reverse {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let points: usize = locations
.iter()
.map(|location| {
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum();
if points <= 1 {
return Err(CommandError::NothingToDo);
}
let mut files: Vec<(FileId, File)> = vec![];
let mut edit = |file_id: &FileId, f: &dyn Fn(&mut File)| {
if let Some(file) = state.files.get(file_id) {
let mut file = (**file).clone();
f(&mut file);
files.push((*file_id, file));
}
};
match &*state.selection {
Selection::File { file_ids } => {
for file_id in file_ids {
edit(file_id, &|file| {
let end = file.trk.last().and_then(track_end_time);
let start = file.trk.first().and_then(track_start_time);
reverse_tracks(&mut file.trk, end, start);
});
}
}
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
reverse_selected_tracks(file, trk_ids);
}),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => edit(file_id, &|file| {
reverse_selected_segments(file, *trk_id, trkseg_ids);
}),
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {}
}
for (file_id, file) in files {
state.files.insert(file_id, Rc::new(file));
}
Ok(())
}
}
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
let end = track.trkseg.last().and_then(end_time);
let start = track.trkseg.first().and_then(start_time);
reverse_segments(&mut track.trkseg, end, start);
}
}
fn reverse_selected_segments(
file: &mut File,
track_id: TrackId,
ids: &std::collections::HashSet<TrackSegmentId>,
) {
for track in file.trk.iter_mut().filter(|track| track.id == track_id) {
for segment in track
.trkseg
.iter_mut()
.filter(|segment| ids.contains(&segment.id))
{
let (end, start) = (end_time(segment), start_time(segment));
reverse_segment(segment, end, start);
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Load, TrackSegment, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn all_segments(fx: &Fixture, id: FileId) -> Vec<TrackSegment> {
fx.files[&id]
.trk
.iter()
.flat_map(|track| track.trkseg.iter().cloned())
.collect()
}
fn lngs(segment: &TrackSegment) -> Vec<f64> {
segment.iter().map(|p| p.coordinates.lng).collect()
}
#[test]
fn test_reversing_a_file_reverses_tracks_segments_and_points() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = all_segments(&fx, id);
assert!(before.len() >= 3);
let (tracks_before, ids_before): (Vec<usize>, Vec<_>) = (
fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect(),
fx.files[&id].trk.iter().map(|t| t.id).collect(),
);
Reverse.apply(&mut fx.state()).unwrap();
let after = all_segments(&fx, id);
assert_eq!(after.len(), before.len());
// the whole path is the opposite one
let path =
|segments: &[TrackSegment]| -> Vec<f64> { segments.iter().flat_map(lngs).collect() };
let mut expected = path(&before);
expected.reverse();
assert_eq!(path(&after), expected);
// the tracks are in the opposite order, each with the same number of segments
let tracks_after: Vec<usize> = fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect();
let mut reversed = tracks_before.clone();
reversed.reverse();
assert_eq!(tracks_after, reversed);
let ids_after: Vec<_> = fx.files[&id].trk.iter().map(|t| t.id).collect();
let mut reversed_ids = ids_before;
reversed_ids.reverse();
assert_eq!(ids_after, reversed_ids);
// the segments changed, and they still end where they should
assert!(after.iter().all(|s| s.rev_id != before[0].rev_id));
for segment in &after {
assert_eq!(segment[0].anchor, Some(0));
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
}
}
#[test]
fn test_reversing_a_file_twice_gives_the_same_path_and_times() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = all_segments(&fx, id);
let times = |segments: &[TrackSegment]| -> Vec<Option<i64>> {
segments
.iter()
.flat_map(|s| s.iter().map(|p| p.time))
.collect()
};
Reverse.apply(&mut fx.state()).unwrap();
Reverse.apply(&mut fx.state()).unwrap();
let after = all_segments(&fx, id);
for (a, b) in before.iter().zip(&after) {
assert_eq!(lngs(a), lngs(b));
assert_eq!(a.id, b.id);
}
assert_eq!(times(&before), times(&after));
}
#[test]
fn test_reversing_segments_keeps_the_others() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let trk = &file.trk[0];
assert!(trk.trkseg.len() >= 2);
let target = &trk.trkseg[1];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([target.id]),
};
Reverse.apply(&mut fx.state()).unwrap();
let after = &fx.files[&id].trk[0];
// the segment is reversed in place
let mut expected = lngs(target);
expected.reverse();
assert_eq!(lngs(&after.trkseg[1]), expected);
assert_eq!(after.trkseg[1].id, target.id);
assert_ne!(after.trkseg[1].rev_id, target.rev_id);
// the other ones are the same
for (i, segment) in trk.trkseg.iter().enumerate().filter(|(i, _)| *i != 1) {
assert_eq!(after.trkseg[i].rev_id, segment.rev_id, "{i}");
}
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
}
#[test]
fn test_reversing_tracks_reverses_their_segments_in_place() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let (index, trk) = file
.trk
.iter()
.enumerate()
.find(|(_, t)| t.trkseg.len() >= 2)
.unwrap();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk.id]),
};
Reverse.apply(&mut fx.state()).unwrap();
let after = &fx.files[&id];
// the track stays where it is, its segments are the opposite way
assert_eq!(after.trk[index].id, trk.id);
let path = |t: &crate::Track| -> Vec<f64> { t.trkseg.iter().flat_map(lngs).collect() };
let mut expected = path(trk);
expected.reverse();
assert_eq!(path(&after.trk[index]), expected);
// and the other tracks do not change
for (i, other) in file.trk.iter().enumerate().filter(|(i, _)| *i != index) {
assert_eq!(path(&after.trk[i]), path(other));
}
}
#[test]
fn test_times_are_mirrored() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = all_segments(&fx, id).remove(0);
Reverse.apply(&mut fx.state()).unwrap();
let after = all_segments(&fx, id).remove(0);
let (start, end) = (start_time(&before).unwrap(), end_time(&before).unwrap());
// the same period of time, the first point of the new path is the last one
assert_eq!(start_time(&after), Some(start));
assert_eq!(end_time(&after), Some(end));
let n = before.len();
for i in 0..n {
assert_eq!(
after[i].time.unwrap(),
start + end - before[n - 1 - i].time.unwrap()
);
assert_eq!(after[i].coordinates.lng, before[n - 1 - i].coordinates.lng);
}
}
#[test]
fn test_nothing_to_reverse() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = fx.files[&id].clone();
fx.selection = Selection::Empty;
assert_eq!(
Reverse.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
Reverse.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
// a single trackpoint has no direction
let mut file = (*fx.files[&id]).clone();
let segment = &mut file.trk[0].trkseg[0];
let len = segment.len();
segment.splice(1, len, vec![]);
fx.files.insert(id, Rc::new(file));
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
assert_eq!(
Reverse.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -1,144 +0,0 @@
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));
}
}
@@ -1,810 +0,0 @@
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,339 +0,0 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, File, State, Track, TrackSegment, Waypoints, copy_segment, copy_track,
copy_waypoint,
};
use super::route::find_target;
/// What is cut in two where the trackpoint is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitType {
/// The file becomes two files, which both have the waypoints (copies, in the second one).
Files,
/// The track becomes two tracks.
Tracks,
/// The segment becomes two segments.
Segments,
}
/// Cuts the file, the track or the segment of the trackpoint `at` of the selection (see
/// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first
/// one of the second part.
///
/// The first part keeps the ids of what is cut, the second one has new ids, as the elements that
/// come after the trackpoint (the following segments of the track, the following tracks of the
/// file), which are moved to it. When the file is cut, the new file comes right after it. The
/// selection does not change.
#[derive(Debug)]
pub struct Split {
pub at: u32,
pub split_type: SplitType,
}
impl Apply for Split {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let at = self.at as usize;
let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?;
let location = target.location;
let (file_id, trk, seg, index) =
(location.file_id, location.trk, location.seg, target.start);
let mut file = (*state.files[&file_id]).clone();
if index >= file.trk[trk].trkseg[seg].len() {
return Err(CommandError::InvalidData(
"trackpoint out of the segment".into(),
));
}
let track = file.trk[trk].clone();
let segment = &track.trkseg[seg];
let (first, second) = (keep_up_to(segment, index), keep_from(segment, index));
// the track is cut: what is before the segment, and its first part, then the second part
// and what is after it
let split_track = || {
let mut first_track = track.clone();
first_track.trkseg.truncate(seg);
first_track.trkseg.push(first.clone());
let mut second_track = Track {
id: Default::default(),
trkseg: vec![second.clone()],
..track.clone()
};
second_track
.trkseg
.extend(track.trkseg[seg + 1..].iter().map(copy_segment));
(first_track, second_track)
};
match self.split_type {
SplitType::Segments => {
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
}
SplitType::Tracks => {
let (first_track, second_track) = split_track();
file.trk.splice(trk..=trk, [first_track, second_track]);
}
SplitType::Files => {
let (first_track, second_track) = split_track();
let mut second_file = File {
id: Default::default(),
trk: vec![second_track],
wpt: Waypoints::default(),
..file.clone()
};
second_file
.trk
.extend(file.trk[trk + 1..].iter().map(copy_track));
second_file
.wpt
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
file.trk.truncate(trk);
file.trk.push(first_track);
let position = state.order.0.iter().position(|id| *id == file_id);
state.order.0.insert(
position.map_or(state.order.0.len(), |p| p + 1),
second_file.id,
);
state.files.insert(second_file.id, Rc::new(second_file));
}
}
state.files.insert(file_id, Rc::new(file));
Ok(())
}
}
/// The trackpoints of the segment up to `index` (included), in the same segment.
fn keep_up_to(segment: &TrackSegment, index: usize) -> TrackSegment {
let mut part = segment.clone();
let len = part.len();
part.splice(index + 1, len, vec![]);
part.rev_id = Default::default();
part
}
/// The trackpoints of the segment from `index` (included), in a new segment.
fn keep_from(segment: &TrackSegment, index: usize) -> TrackSegment {
let mut part = copy_segment(segment);
part.splice(0, index, vec![]);
part.rev_id = Default::default();
part
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, TrackSegmentId, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn split(at: u32, split_type: SplitType) -> Split {
Split { at, split_type }
}
fn lngs(segment: &TrackSegment) -> Vec<f64> {
segment.iter().map(|p| p.coordinates.lng).collect()
}
fn path(file: &File) -> Vec<f64> {
file.trk
.iter()
.flat_map(|t| &t.trkseg)
.flat_map(lngs)
.collect()
}
/// The index in the selection of the trackpoint `local` of the segment `seg` of the track `trk`.
fn index(file: &File, trk: usize, seg: usize, local: usize) -> u32 {
let before: usize = file
.trk
.iter()
.enumerate()
.flat_map(|(t, track)| {
track
.trkseg
.iter()
.enumerate()
.map(move |(s, segment)| ((t, s), segment.len()))
})
.take_while(|((t, s), _)| (*t, *s) != (trk, seg))
.map(|(_, len)| len)
.sum();
(before + local) as u32
}
/// A segment with enough trackpoints, and its position.
fn long_segment(file: &File) -> (usize, usize) {
file.trk
.iter()
.enumerate()
.flat_map(|(t, track)| (0..track.trkseg.len()).map(move |s| (t, s)))
.find(|(t, s)| file.trk[*t].trkseg[*s].len() > 4)
.unwrap()
}
#[test]
fn test_split_segments() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let (trk, seg) = long_segment(&before);
let original = &before.trk[trk].trkseg[seg];
let len = original.len();
split(index(&before, trk, seg, 2), SplitType::Segments)
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
assert_eq!(after.trk.len(), before.trk.len());
let segments = &after.trk[trk].trkseg;
assert_eq!(segments.len(), before.trk[trk].trkseg.len() + 1);
let (a, b) = (&segments[seg], &segments[seg + 1]);
// the point is the end of the first part and the start of the second
assert_eq!(lngs(a), lngs(original)[..=2]);
assert_eq!(lngs(b), lngs(original)[2..]);
assert_eq!(a.len() + b.len(), len + 1);
// the first part keeps the id, the second one is new
assert_eq!(a.id, original.id);
assert_ne!(b.id, original.id);
assert_ne!(a.rev_id, original.rev_id);
// the ends are anchors
assert_eq!(a[a.len() - 1].anchor, Some(0));
assert_eq!(b[0].anchor, Some(0));
// the rest of the file is untouched
for (i, segment) in before.trk[trk].trkseg.iter().enumerate() {
if i != seg {
let moved = if i > seg { i + 1 } else { i };
assert_eq!(segments[moved].rev_id, segment.rev_id);
}
}
}
#[test]
fn test_split_tracks() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let (trk, seg) = long_segment(&before);
split(index(&before, trk, seg, 3), SplitType::Tracks)
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
assert_eq!(after.trk.len(), before.trk.len() + 1);
let (a, b) = (&after.trk[trk], &after.trk[trk + 1]);
assert_eq!(a.id, before.trk[trk].id);
assert_ne!(b.id, a.id);
// the segments before are in the first track, the ones after in the second one
assert_eq!(a.trkseg.len(), seg + 1);
assert_eq!(b.trkseg.len(), before.trk[trk].trkseg.len() - seg);
// the segments before are the same ones, with the same trackpoints
for (kept, original) in a.trkseg[..seg].iter().zip(&before.trk[trk].trkseg[..seg]) {
assert_eq!((kept.id, kept.rev_id), (original.id, original.rev_id));
let chunks = |segment: &TrackSegment| -> Vec<_> {
segment.chunks().iter().map(|chunk| chunk.id).collect()
};
assert_eq!(chunks(kept), chunks(original));
}
// new ids for everything that moved, so that they are unique
let ids: HashSet<TrackSegmentId> = after
.trk
.iter()
.flat_map(|t| t.trkseg.iter().map(|s| s.id))
.collect();
assert_eq!(
ids.len(),
after.trk.iter().map(|t| t.trkseg.len()).sum::<usize>()
);
// the path goes on, with the point twice
let mut expected = path(&before);
let at = index(&before, trk, seg, 3) as usize;
expected.insert(at, expected[at]);
assert_eq!(path(after), expected);
// the info of the track is the same
assert_eq!(a.info, b.info);
}
#[test]
fn test_split_files() {
let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone();
let wpt = crate::Waypoint {
name: Some("w".into()),
..Default::default()
};
file.wpt.insert_at(0, vec![wpt.clone()]);
fx.files.insert(id, Rc::new(file));
let before = fx.files[&id].clone();
let (trk, seg) = long_segment(&before);
let at = index(&before, trk, seg, 1);
split(at, SplitType::Files).apply(&mut fx.state()).unwrap();
assert_eq!(fx.files.len(), 2);
let second_id = fx.order.0[1];
assert_eq!(fx.order.0[0], id);
assert_ne!(second_id, id);
let (a, b) = (&fx.files[&id], &fx.files[&second_id]);
// the first file stops at the point, the second one starts there
let all = path(&before);
assert_eq!(path(a), all[..=at as usize]);
assert_eq!(path(b), all[at as usize..]);
assert_eq!(a.info, b.info);
// each has the waypoints, the copy has its own ids
assert_eq!(a.wpt.len(), 1);
assert_eq!(b.wpt.len(), 1);
assert_ne!(a.wpt[0].id, b.wpt[0].id);
assert_eq!(b.wpt[0].name, wpt.name);
// nothing is shared by id between the two
let ids = |f: &File| -> HashSet<_> {
f.trk
.iter()
.flat_map(|t| std::iter::once(t.id.0).chain(t.trkseg.iter().map(|s| s.id.0)))
.collect()
};
assert!(ids(a).is_disjoint(&ids(b)));
// the selection is the same
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.contains(&id)));
}
#[test]
fn test_invalid_or_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let total = path(&before).len() as u32;
assert!(matches!(
split(total + 5, SplitType::Segments).apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
// after the last trackpoint: appending, not a trackpoint
assert!(matches!(
split(total, SplitType::Segments).apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
fx.selection = Selection::Empty;
assert_eq!(
split(0, SplitType::Files).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -1,193 +0,0 @@
use crate::{
Apply, CommandError, State, TrackSegment, artificial_segment_times, changed_segment_times,
update_segments,
};
/// How the timestamps of the selection are set.
#[derive(Debug, Clone, Copy)]
pub enum TimeKind {
/// Starts at `Time::start_time`. The trackpoints that have timestamps keep their durations
/// multiplied by `ratio`, and the ones that have none follow the previous one at `speed`
/// (km/h).
Change { speed: f64, ratio: f64 },
/// Makes timestamps up for the whole selection, which lasts `total_time` seconds: the longer
/// and the steeper an interval, the more time it takes.
Artificial { total_time: f64 },
}
/// Sets the timestamps of the selected segments, one after the other: each one goes on from the
/// end of the previous one, the first one starts at `start_time` (in milliseconds).
#[derive(Debug)]
pub struct Time {
pub start_time: i64,
pub kind: TimeKind,
}
impl Apply for Time {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match self.kind {
TimeKind::Change { speed, ratio } => {
if !(speed.is_finite() && speed > 0.0 && ratio.is_finite() && ratio > 0.0) {
return Err(CommandError::InvalidData(format!(
"speed {speed} and ratio {ratio} should be positive"
)));
}
}
TimeKind::Artificial { total_time } => {
if !(total_time.is_finite() && total_time >= 0.0) {
return Err(CommandError::InvalidData(format!(
"total time {total_time} should not be negative"
)));
}
}
}
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let segments: Vec<&TrackSegment> = locations
.iter()
.map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg])
.collect();
if segments.iter().all(|segment| segment.is_empty()) {
return Err(CommandError::NothingToDo);
}
let times = match self.kind {
TimeKind::Change { speed, ratio } => {
changed_segment_times(&segments, self.start_time, speed, ratio)
}
TimeKind::Artificial { total_time } => {
artificial_segment_times(&segments, self.start_time, total_time)
}
};
update_segments(state, &locations, |i, segment| {
let times = &times[i];
segment.update_all(|j, pt| pt.time = times.get(j).copied().flatten());
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn all_times(fx: &Fixture, id: FileId) -> Vec<Option<i64>> {
fx.files[&id]
.trk
.iter()
.flat_map(|trk| {
trk.trkseg
.iter()
.flat_map(|seg| seg.iter().map(|pt| pt.time))
})
.collect()
}
#[test]
fn test_artificial_timestamps_last_the_total_time() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
Time {
start_time: 1_000_000,
kind: TimeKind::Artificial { total_time: 3600.0 },
}
.apply(&mut fx.state())
.unwrap();
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(times[0], 1_000_000);
assert!(times.windows(2).all(|w| w[0] <= w[1]));
assert!((times.last().unwrap() - 1_000_000 - 3_600_000).abs() <= times.len() as i64);
}
#[test]
fn test_change_starts_at_the_start_time_and_follows_the_speed() {
let (mut fx, id) = loaded("data/simple.gpx");
Time {
start_time: 5_000,
kind: TimeKind::Change {
speed: 10.0,
ratio: 1.0,
},
}
.apply(&mut fx.state())
.unwrap();
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(times[0], 5_000);
assert!(times.windows(2).all(|w| w[0] <= w[1]));
assert!(times.last() > times.first());
}
#[test]
fn test_change_shifts_existing_timestamps_and_scales_them() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
Time {
start_time: 0,
kind: TimeKind::Change {
speed: 10.0,
ratio: 2.0,
},
}
.apply(&mut fx.state())
.unwrap();
let after: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(after[0], 0);
let (d_before, d_after) = (before[1] - before[0], after[1] - after[0]);
assert!((d_after - 2 * d_before).abs() <= 1);
}
#[test]
fn test_invalid_input_and_empty_selection() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = all_times(&fx, id);
assert!(matches!(
Time {
start_time: 0,
kind: TimeKind::Change {
speed: 0.0,
ratio: 1.0
}
}
.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert_eq!(all_times(&fx, id), before);
fx.selection = Selection::Empty;
assert!(matches!(
Time {
start_time: 0,
kind: TimeKind::Artificial { total_time: 10.0 }
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
));
}
}
@@ -1,156 +0,0 @@
use std::collections::{HashMap, HashSet};
use crate::{
FileId, FileWaypointsRevisionId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId,
};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
/// of its file) changes, so editing a segment does not touch the buffers of the others.
#[derive(Debug, Default)]
pub struct CoordinatesCache {
segments: HashMap<TrackSegmentId, (TrackSegmentRevisionId, Vec<f64>)>,
waypoints: HashMap<FileId, (FileWaypointsRevisionId, Vec<f64>)>,
}
impl CoordinatesCache {
pub fn update(&mut self, files: Option<&Files>) {
let mut segments = HashSet::new();
let mut waypoints = HashSet::new();
for file in files.into_iter().flat_map(|files| files.values()) {
waypoints.insert(file.id);
if self
.waypoints
.get(&file.id)
.is_none_or(|(r, _)| *r != file.wpt.rev_id)
{
let mut coordinates = Vec::new();
for wpt in file.wpt.iter() {
coordinates.extend([wpt.coordinates.lng, wpt.coordinates.lat]);
}
self.waypoints
.insert(file.id, (file.wpt.rev_id, coordinates));
}
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
segments.insert(seg.id);
if self
.segments
.get(&seg.id)
.is_none_or(|(r, _)| *r != seg.rev_id)
{
let mut coordinates = Vec::with_capacity(seg.len() * 2);
for trkpt in seg.iter() {
coordinates.extend([trkpt.coordinates.lng, trkpt.coordinates.lat]);
}
self.segments.insert(seg.id, (seg.rev_id, coordinates));
}
}
}
self.segments.retain(|id, _| segments.contains(id));
self.waypoints.retain(|id, _| waypoints.contains(id));
}
/// Coordinates of the trackpoints of a segment, empty if it does not exist.
pub fn segment(&self, id: &TrackSegmentId) -> &[f64] {
self.segments.get(id).map_or(&[], |(_, c)| c)
}
/// Coordinates of the waypoints of a file, empty if it does not exist.
pub fn waypoints(&self, id: &FileId) -> &[f64] {
self.waypoints.get(id).map_or(&[], |(_, c)| c)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, LngLat, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_segment_buffers_match_trackpoints() {
let (fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
for seg in fx.files[&id].trk.iter().flat_map(|t| &t.trkseg) {
let buffer = cache.segment(&seg.id);
assert_eq!(buffer.len(), seg.len() * 2);
for (i, p) in seg.iter().enumerate() {
assert_eq!(buffer[2 * i], p.coordinates.lng);
assert_eq!(buffer[2 * i + 1], p.coordinates.lat);
}
}
assert!(cache.segment(&TrackSegmentId::default()).is_empty());
}
#[test]
fn test_only_changed_buffers_are_rebuilt_and_stale_ones_dropped() {
let (mut fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
let ids: Vec<_> = fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.id)
.collect();
assert!(ids.len() >= 2);
let ptr = |c: &CoordinatesCache, i: usize| c.segment(&ids[i]).as_ptr();
let (p0, p1) = (ptr(&cache, 0), ptr(&cache, 1));
let mut file = (*fx.files[&id]).clone();
let seg = &mut file.trk[0].trkseg[0];
seg.splice(0, 0, vec![Default::default()]);
seg.rev_id = Default::default();
let len = seg.len();
fx.files.insert(id, Rc::new(file));
cache.update(Some(&fx.files));
assert_eq!(cache.segment(&ids[0]).len(), len * 2);
assert_ne!(ptr(&cache, 0), p0);
assert_eq!(ptr(&cache, 1), p1);
cache.update(None);
assert!(cache.segment(&ids[0]).is_empty());
assert!(cache.waypoints(&id).is_empty());
}
#[test]
fn test_waypoint_buffer() {
let (mut fx, id) = loaded();
let wpt = |lng, lat| Waypoint {
coordinates: LngLat { lng, lat },
..Default::default()
};
let mut file = (*fx.files[&id]).clone();
file.wpt = crate::Waypoints::new([
WaypointChunk {
wpt: vec![wpt(1.0, 2.0)],
..Default::default()
},
WaypointChunk {
wpt: vec![wpt(3.0, 4.0)],
..Default::default()
},
]);
fx.files.insert(id, Rc::new(file));
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
assert_eq!(cache.waypoints(&id), [1.0, 2.0, 3.0, 4.0]);
}
}
@@ -1,155 +0,0 @@
use crate::{
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId,
WaypointId,
};
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints,
/// with the ids that reference every element. Coordinates are not part of it, they are read
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
/// a segment).
#[derive(Debug, Clone, PartialEq)]
pub struct FileStructure {
pub id: FileId,
pub name: String,
pub desc: Option<String>,
pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change.
pub wpt_rev_id: FileWaypointsRevisionId,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub name: Option<String>,
pub desc: Option<String>,
/// Style of the track, only when the file defines it.
pub color: Option<String>,
pub opacity: Option<f64>,
pub width: Option<f64>,
pub segments: Vec<SegmentNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentNode {
pub id: TrackSegmentId,
/// Changes whenever the trackpoints of the segment (hence their buffer) change.
pub rev_id: TrackSegmentRevisionId,
pub len: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WaypointNode {
pub id: WaypointId,
pub name: Option<String>,
pub sym: Option<String>,
}
impl FileStructure {
pub fn new(file: &File) -> Self {
Self {
id: file.id,
name: file.info.name.clone(),
desc: file.info.desc.clone(),
tracks: file
.trk
.iter()
.map(|trk| TrackNode {
id: trk.id,
name: trk.info.name.clone(),
desc: trk.info.desc.clone(),
color: trk.info.color.clone(),
opacity: trk.info.opacity,
width: trk.info.width,
segments: trk
.trkseg
.iter()
.map(|seg| SegmentNode {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
})
.collect(),
})
.collect(),
waypoints: file
.wpt
.iter()
.map(|wpt| WaypointNode {
id: wpt.id,
name: wpt.name.clone(),
sym: wpt.sym.clone(),
})
.collect(),
wpt_rev_id: file.wpt.rev_id,
}
}
}
#[cfg(test)]
mod tests {
use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_structure_of_a_file() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&fx.order.0[0]];
let node = FileStructure::new(file);
assert_eq!(
(node.id, &node.name, &node.desc),
(file.id, &file.info.name, &file.info.desc)
);
assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc));
assert_eq!(
(&n.color, n.opacity, n.width),
(&t.info.color, t.info.opacity, t.info.width)
);
assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
}
}
}
#[test]
fn test_waypoints_in_buffer_order_and_revision() {
let mut file = crate::File::default();
let wpt = |n: &str| Waypoint {
name: Some(n.to_string()),
..Default::default()
};
let before = file.wpt.rev_id;
file.wpt = crate::Waypoints::new([
WaypointChunk {
wpt: vec![wpt("a"), wpt("b")],
..Default::default()
},
WaypointChunk {
wpt: vec![wpt("c")],
..Default::default()
},
]);
let node = FileStructure::new(&file);
let names: Vec<_> = node
.waypoints
.iter()
.map(|w| w.name.clone().unwrap())
.collect();
assert_eq!(names, ["a", "b", "c"]);
assert_ne!(node.wpt_rev_id, before);
assert_eq!(node.wpt_rev_id, file.wpt.rev_id);
}
}
@@ -1,92 +0,0 @@
use std::collections::HashMap;
use crate::{Diff, FileId, FileStructure, StackEntry as Files};
/// The structure of each file, only recomputed for the files that changed according to the
/// [`Diff`] of the stack.
#[derive(Debug, Default)]
pub struct FileStructureCache {
map: HashMap<FileId, FileStructure>,
}
impl FileStructureCache {
/// Brings the cache in line with `files`, given what changed since the last update.
pub fn update(&mut self, files: Option<&Files>, diff: Option<&Diff>) {
if let Some(diff) = diff {
for id in diff.removed.iter() {
self.map.remove(id);
}
for id in diff.added.iter().chain(&diff.modified) {
if let Some(file) = files.and_then(|files| files.get(id)) {
self.map.insert(*id, FileStructure::new(file));
}
}
}
}
pub fn get(&self, id: &FileId) -> Option<&FileStructure> {
self.map.get(id)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, Load, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_only_files_of_the_diff_are_recomputed() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let mut cache = FileStructureCache::default();
cache.update(
Some(&fx.files),
Some(&Diff {
added: vec![a, b],
..Default::default()
}),
);
let before = cache.get(&b).unwrap() as *const FileStructure;
let mut file = (*fx.files[&a]).clone();
file.info.name = "renamed".into();
fx.files.insert(a, Rc::new(file));
cache.update(
Some(&fx.files),
Some(&Diff {
modified: vec![a],
..Default::default()
}),
);
assert_eq!(cache.get(&a).unwrap().name, "renamed");
assert!(std::ptr::eq(cache.get(&b).unwrap(), before));
fx.files.remove(&b);
cache.update(
Some(&fx.files),
Some(&Diff {
removed: vec![b],
..Default::default()
}),
);
assert!(cache.get(&b).is_none());
assert!(cache.get(&a).is_some());
}
}
-13
View File
@@ -1,13 +0,0 @@
mod coordinates_cache;
mod file_structure;
mod file_structure_cache;
mod routing_buffer;
mod statistics_buffer;
mod statistics_cache;
pub use coordinates_cache::*;
pub use file_structure::*;
pub use file_structure_cache::*;
pub use routing_buffer::*;
pub use statistics_buffer::*;
pub use statistics_cache::*;
@@ -1,162 +0,0 @@
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);
}
}
@@ -1,693 +0,0 @@
use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options};
/// Value of the timestamps of the trackpoints that have none.
pub const NO_TIME: i64 = i64::MIN;
/// Flattened statistics of the selected segments, one entry per trackpoint, in selection order.
///
/// The distance, time, moving and elevation values are cumulative over the whole selection (the
/// second segment continues where the first one ended), so the statistics of any range of
/// trackpoints are the difference between its two ends: see [`StatisticsBuffer::slice`].
/// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are
/// [`NO_TIME`], missing measures are NaN.
///
/// The optional values (`moving_distance`, `total_time`, `moving_time`, `speed`, `time`, `hr`,
/// `cad`, `atemp`, `power`) are `None` when no trackpoint of the selection has one, and the OSM
/// attributes are stored as [`Intervals`]. The cumulative ones can only be missing before the
/// first trackpoint that has them, where they are 0.
#[derive(Debug, Default)]
pub struct StatisticsBuffer {
/// Statistics of the whole selection.
pub global: GlobalStatistics,
pub total_distance: Vec<f64>,
pub moving_distance: Option<Vec<f64>>,
pub total_time: Option<Vec<i64>>,
pub moving_time: Option<Vec<i64>>,
pub speed: Option<Vec<f64>>,
pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>,
pub slope_segment_slope: Vec<f64>,
pub slope_segment_distance: Vec<f64>,
pub lng: Vec<f64>,
pub lat: Vec<f64>,
pub ele: Vec<f64>,
/// Timestamps, in milliseconds since the epoch.
pub time: Option<Vec<i64>>,
pub hr: Option<Vec<f64>>,
pub cad: Option<Vec<f64>>,
pub atemp: Option<Vec<f64>>,
pub power: Option<Vec<f64>>,
/// Surface of the trackpoints, see [`Intervals`].
pub surface: Intervals,
/// Highway of the trackpoints, as the surface.
pub highway: Intervals,
/// SAC hiking scale of the trackpoints, as the surface.
pub sac_scale: Intervals,
/// Mountain biking scale of the trackpoints, as the surface.
pub mtb_scale: Intervals,
}
/// A value per trackpoint that rarely changes from one trackpoint to the next, stored as the
/// intervals of trackpoints that share it.
///
/// The values are 0 when unknown, else 1 + the code in the categories of the engine (so that it
/// is never 0 for a known value). `starts[i]` is the index of the first trackpoint of the
/// interval `i`, which ends where the next one starts, or at the last trackpoint for the last one.
/// Both are empty when there are no trackpoints.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Intervals {
pub starts: Vec<u32>,
pub values: Vec<u8>,
}
impl Intervals {
fn clear(&mut self) {
self.starts.clear();
self.values.clear();
}
/// Adds the value of the trackpoint at `index`, the indices being pushed in order.
fn push(&mut self, index: usize, value: u8) {
if self.values.last() != Some(&value) {
self.starts.push(index as u32);
self.values.push(value);
}
}
}
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
/// fits.
fn unknown_or_next(code: Option<u8>) -> u8 {
code.and_then(|code| code.checked_add(1)).unwrap_or(0)
}
/// Adds the value of the trackpoint at `index` to an optional column, the indices being pushed in
/// order. The column is created at the first present value, the previous trackpoints getting
/// `missing`.
fn push_optional<T: Copy>(column: &mut Option<Vec<T>>, index: usize, value: Option<T>, missing: T) {
match (column.as_mut(), value) {
(Some(column), value) => column.push(value.unwrap_or(missing)),
(None, Some(value)) => {
let mut new_column = vec![missing; index];
new_column.push(value);
*column = Some(new_column);
}
(None, None) => {}
}
}
impl StatisticsBuffer {
pub fn len(&self) -> usize {
self.total_distance.len()
}
pub fn is_empty(&self) -> bool {
self.total_distance.is_empty()
}
/// `selected`: the segments and their statistics, in order.
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
self.global = GlobalStatistics::default();
self.total_distance.clear();
self.moving_distance = None;
self.total_time = None;
self.moving_time = None;
self.speed = None;
self.elevation_gain.clear();
self.elevation_loss.clear();
self.slope.clear();
self.slope_segment_slope.clear();
self.slope_segment_distance.clear();
self.lng.clear();
self.lat.clear();
self.ele.clear();
self.time = None;
self.hr = None;
self.cad = None;
self.atemp = None;
self.power = None;
self.surface.clear();
self.highway.clear();
self.sac_scale.clear();
self.mtb_scale.clear();
for (segment, stats) in selected {
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
let index = self.total_distance.len();
let cumul_stats = &self.global;
self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
push_optional(
&mut self.moving_distance,
index,
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance),
0.0,
);
push_optional(
&mut self.total_time,
index,
sum_options(cumul_stats.total_time, trkpt_stats.total_time),
0,
);
push_optional(
&mut self.moving_time,
index,
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time),
0,
);
push_optional(&mut self.speed, index, trkpt_stats.speed, f64::NAN);
self.elevation_gain
.push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain);
self.elevation_loss
.push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss);
self.slope.push(trkpt_stats.slope);
self.slope_segment_slope
.push(trkpt_stats.slope_segment.slope);
self.slope_segment_distance
.push(trkpt_stats.slope_segment.distance);
self.lng.push(trkpt.coordinates.lng);
self.lat.push(trkpt.coordinates.lat);
self.ele.push(trkpt.ele);
push_optional(&mut self.time, index, trkpt.time, NO_TIME);
push_optional(&mut self.hr, index, trkpt.hr.map(f64::from), f64::NAN);
push_optional(&mut self.cad, index, trkpt.cad.map(f64::from), f64::NAN);
push_optional(&mut self.atemp, index, trkpt.atemp.map(f64::from), f64::NAN);
push_optional(&mut self.power, index, trkpt.power.map(f64::from), f64::NAN);
self.surface.push(index, unknown_or_next(trkpt.surface));
self.highway.push(index, unknown_or_next(trkpt.highway));
self.sac_scale.push(index, unknown_or_next(trkpt.sac_scale));
self.mtb_scale.push(index, unknown_or_next(trkpt.mtb_scale));
}
self.global.merge(&stats.global);
}
}
/// Statistics of the trackpoints from `start` to `end` (both included) of the selection, for
/// example the part of an elevation profile the user dragged over. `None` if the range is
/// not inside the selection.
///
/// It is done in constant time, from the cumulative values at both ends: the bounds and
/// the averages are the ones of the whole selection, they are not computed for the range.
pub fn slice(&self, start: usize, end: usize) -> Option<GlobalStatistics> {
if start > end || end >= self.len() {
return None;
}
let delta = |values: &[f64]| values[end] - values[start];
// the total time is the span of the timestamps, which is negative where they go backwards
let delta_time = |values: &[i64]| (values[end] - values[start]).max(0);
let time = |i: usize| {
let time = self.time.as_ref()?[i];
(time != NO_TIME).then_some(time)
};
Some(GlobalStatistics {
total_distance: delta(&self.total_distance),
moving_distance: self.moving_distance.as_deref().map(delta),
total_time: self.total_time.as_deref().map(delta_time),
moving_time: self.moving_time.as_deref().map(delta_time),
elevation_gain: delta(&self.elevation_gain),
elevation_loss: delta(&self.elevation_loss),
start_time: time(start),
end_time: time(end),
bounds: self.global.bounds,
})
}
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn computed(path: &str) -> (TrackSegment, Statistics) {
let data = std::fs::read(path).unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let stats = Statistics::compute(&segment);
(segment, stats)
}
/// The value of the trackpoint at `index`, 0 before the first interval.
fn value_at(intervals: &Intervals, index: usize) -> u8 {
match intervals
.starts
.partition_point(|start| *start as usize <= index)
{
0 => 0,
next => intervals.values[next - 1],
}
}
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
vec![
buffer.total_distance.len(),
buffer.elevation_gain.len(),
buffer.elevation_loss.len(),
buffer.slope.len(),
buffer.slope_segment_slope.len(),
buffer.slope_segment_distance.len(),
buffer.lng.len(),
buffer.lat.len(),
buffer.ele.len(),
]
}
#[test]
fn test_empty() {
let mut buffer = StatisticsBuffer::default();
buffer.update(&[]);
assert!(buffer.is_empty());
assert!(buffer.speed.is_none());
assert_eq!(buffer.global.total_distance, 0.0);
assert!(buffer.slice(0, 0).is_none());
}
#[test]
fn test_single_segment_matches_local_stats() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = s.local.len();
assert!(all_lengths(&buffer).iter().all(|len| *len == n));
assert_eq!(buffer.len(), n);
for (i, local) in s.local.iter().enumerate() {
assert_eq!(buffer.total_distance[i], local.total_distance);
assert_eq!(buffer.slope[i], local.slope);
}
// the trackpoints
for (i, trkpt) in segment.iter().enumerate() {
assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
assert_eq!(buffer.lat[i], trkpt.coordinates.lat);
assert_eq!(buffer.ele[i], trkpt.ele);
}
}
#[test]
fn test_global_is_the_merge_of_the_segments() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let global = &buffer.global;
assert!((global.total_distance - 2.0 * s.global.total_distance).abs() < 1e-9);
assert!((global.elevation_gain - 2.0 * s.global.elevation_gain).abs() < 1e-9);
assert!((global.elevation_loss - 2.0 * s.global.elevation_loss).abs() < 1e-9);
// and it is the last cumulative value of the points
assert_eq!(
global.total_distance,
*buffer.total_distance.last().unwrap()
);
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.global.total_distance, s.global.total_distance);
buffer.update(&[]);
assert_eq!(buffer.global.total_distance, 0.0);
}
#[test]
fn test_distances_accumulate_over_segments() {
let (segment, s) = computed("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.total_distance.len(), 2 * n);
// the second segment starts where the first one ended
assert_eq!(buffer.total_distance[n], s.global.total_distance);
assert!((buffer.total_distance[2 * n - 1] - 2.0 * s.global.total_distance).abs() < 1e-9);
assert!(buffer.total_distance.windows(2).all(|w| w[0] <= w[1]));
assert!((buffer.elevation_gain[2 * n - 1] - 2.0 * s.global.elevation_gain).abs() < 1e-9);
}
#[test]
fn test_total_time_accumulates_over_segments() {
let (segment, s) = computed("data/with_time.gpx");
let n = s.local.len();
let duration = s.global.total_time.unwrap();
assert!(duration > 0);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let total_time = buffer.total_time.as_ref().unwrap();
assert_eq!(total_time[n - 1], duration);
assert_eq!(total_time[n], duration);
assert_eq!(total_time[2 * n - 1], 2 * duration);
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
assert!(buffer.time.unwrap().iter().all(|t| *t != NO_TIME));
}
#[test]
fn test_absent_optional_values_have_no_buffer() {
let (segment, s) = computed("data/simple.gpx");
assert!(segment.iter().all(|trkpt| trkpt.time.is_none()));
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert!(buffer.time.is_none());
assert!(buffer.hr.is_none());
assert!(buffer.cad.is_none());
assert!(buffer.atemp.is_none());
assert!(buffer.power.is_none());
assert_eq!(buffer.global.total_time, None);
assert_eq!(buffer.len(), s.local.len());
// they need times
assert!(buffer.speed.is_none());
assert!(buffer.moving_distance.is_none());
assert!(buffer.total_time.is_none());
assert!(buffer.moving_time.is_none());
// the slices do not need them
let slice = buffer.slice(0, 2).unwrap();
assert_eq!((slice.start_time, slice.end_time), (None, None));
}
#[test]
fn test_present_optional_values_have_one_entry_per_trackpoint() {
let (segment, s) = computed("data/with_time.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.time.as_ref().unwrap().len(), buffer.len());
assert!(buffer.hr.is_none());
let data = std::fs::read("data/with_hr.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let s = Statistics::compute(&segment);
buffer.update(&[(&segment, &s)]);
let hr = buffer.hr.as_ref().unwrap();
assert_eq!(hr.len(), buffer.len());
assert!(hr.iter().any(|v| !v.is_nan()));
}
#[test]
fn test_a_value_missing_for_some_trackpoints_is_a_sentinel() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
let mut point = segment[1].clone();
point.time = None;
point.hr = Some(120);
segment.splice(1, 2, vec![point]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let time = buffer.time.as_ref().unwrap();
assert_eq!(time.len(), n);
assert_eq!(time[1], NO_TIME);
assert_ne!(time[0], NO_TIME);
// the first trackpoint has no heart rate: it is filled once the first one is found
let hr = buffer.hr.as_ref().unwrap();
assert_eq!(hr.len(), n);
assert_eq!(hr[1], 120.0);
assert!(hr[0].is_nan() && hr[2].is_nan());
assert!(buffer.cad.is_none());
}
#[test]
fn test_cumulative_values_are_zero_before_the_first_one() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
let mut first = segment[0].clone();
first.time = None;
segment.splice(0, 1, vec![first]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let total_time = buffer.total_time.as_ref().unwrap();
assert_eq!(total_time.len(), n);
assert_eq!(total_time[0], 0);
assert_eq!(total_time[n - 1], s.global.total_time.unwrap());
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
// so a slice that starts on the first trackpoint is right
assert_eq!(
buffer.slice(0, n - 1).unwrap().total_time,
s.global.total_time
);
}
#[test]
fn test_surface_and_highway_intervals() {
let (segment, s) = computed("data/with_highway.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
// 0 for the trackpoints that have none, else the code of the engine plus one
let surface = &buffer.surface;
assert_eq!(surface.starts, [0, 2, 3]);
assert_eq!(surface.values, [1, 0, 2]);
let highway = &buffer.highway;
assert_eq!(
(&highway.starts[..], &highway.values[..]),
(&[0, 2, 3, 4][..], &[1, 0, 2, 0][..])
);
let sac_scale = &buffer.sac_scale;
assert_eq!(
(&sac_scale.starts[..], &sac_scale.values[..]),
(&[0, 3, 4][..], &[0, 1, 2][..])
);
let mtb_scale = &buffer.mtb_scale;
assert_eq!(
(&mtb_scale.starts[..], &mtb_scale.values[..]),
(&[0, 3][..], &[0, 1][..])
);
assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
// and they can be looked up
let values: Vec<u8> = (0..6).map(|i| value_at(&buffer.surface, i)).collect();
assert_eq!(values, [1, 1, 0, 2, 2, 2]);
}
#[test]
fn test_intervals_merge_equal_neighbours_across_segments() {
let (mut segment, _) = computed("data/simple.gpx");
let points: Vec<_> = segment
.iter()
.map(|trkpt| {
let mut trkpt = trkpt.clone();
trkpt.surface = Some(4);
trkpt
})
.collect();
segment.splice(0, points.len(), points);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
// one interval for the two segments
assert_eq!(buffer.surface.starts, [0]);
assert_eq!(buffer.surface.values, [5]);
// and a single one of unknown values when nothing is known
assert_eq!(buffer.highway.values, [0]);
}
#[test]
fn test_codes_of_the_trackpoints_are_used_as_they_are() {
let (mut segment, _) = computed("data/simple.gpx");
let mut point = segment[0].clone();
point.surface = Some(7);
point.highway = Some(254);
point.sac_scale = Some(0);
point.mtb_scale = Some(3);
segment.splice(0, 1, vec![point]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let at = |i: usize| {
(
value_at(&buffer.surface, i),
value_at(&buffer.highway, i),
value_at(&buffer.sac_scale, i),
value_at(&buffer.mtb_scale, i),
)
};
assert_eq!(at(0), (8, 255, 1, 4));
assert_eq!(at(1), (0, 0, 0, 0));
}
#[test]
fn test_unknown_intervals_without_surface_and_highway() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
// a single interval of unknown values
assert_eq!(buffer.surface.starts, [0]);
assert_eq!(buffer.surface.values, [0]);
assert_eq!(buffer.highway.starts, [0]);
assert_eq!(buffer.highway.values, [0]);
assert_eq!(buffer.sac_scale.values, [0]);
assert_eq!(buffer.mtb_scale.values, [0]);
// and they are reset by the next update
buffer.update(&[]);
assert_eq!(buffer.surface, Intervals::default());
assert_eq!(buffer.highway, Intervals::default());
assert_eq!(buffer.sac_scale, Intervals::default());
assert_eq!(buffer.mtb_scale, Intervals::default());
assert_eq!(value_at(&buffer.surface, 0), 0);
}
#[test]
fn test_update_replaces_previous_content() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.total_distance.len(), s.local.len());
assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len()));
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
assert!(all_lengths(&buffer).iter().all(|len| *len == 0));
}
#[test]
fn test_slice_of_the_whole_selection_is_the_global_statistics() {
let (segment, s) = computed("data/with_time.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let slice = buffer.slice(0, buffer.len() - 1).unwrap();
let global = &buffer.global;
assert!((slice.total_distance - global.total_distance).abs() < 1e-9);
assert!((slice.elevation_gain - global.elevation_gain).abs() < 1e-9);
assert!((slice.elevation_loss - global.elevation_loss).abs() < 1e-9);
assert_eq!(slice.total_time, global.total_time);
assert_eq!(slice.moving_time, global.moving_time);
assert_eq!(slice.start_time, global.start_time);
assert_eq!(slice.end_time, global.end_time);
}
#[test]
fn test_slices_of_corrupt_timestamps_are_never_negative_nor_infinite() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
assert!(n > 40);
// timestamps that go back, then forward, and some that are missing
let times: Vec<_> = segment.iter().map(|p| p.time.unwrap()).collect();
segment.update_all(|i, pt| {
pt.time = match i {
10..=19 => Some(times[29 - i]),
20..=24 => None,
25..=29 => Some(times[0] - 1_000 * i as i64),
_ => pt.time,
};
});
let stats = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &stats)]);
for start in 0..n {
for end in start..n {
let slice = buffer.slice(start, end).unwrap();
for time in [slice.total_time, slice.moving_time].into_iter().flatten() {
assert!(time >= 0, "time of {start}..={end}");
}
for speed in [slice.total_speed(), slice.moving_speed()]
.into_iter()
.flatten()
{
assert!(
speed.is_finite() && speed >= 0.0,
"speed of {start}..={end}"
);
}
}
}
}
#[test]
fn test_slice_of_a_range() {
let (segment, s) = computed("data/with_time.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let (start, end) = (n / 4, n / 2);
let slice = buffer.slice(start, end).unwrap();
assert!(
(slice.total_distance - (buffer.total_distance[end] - buffer.total_distance[start]))
.abs()
< 1e-12
);
assert_eq!(
slice.total_time,
Some(
buffer.total_time.as_ref().unwrap()[end]
- buffer.total_time.as_ref().unwrap()[start]
)
);
assert_eq!(
slice.moving_time,
Some(
buffer.moving_time.as_ref().unwrap()[end]
- buffer.moving_time.as_ref().unwrap()[start]
)
);
assert_eq!(slice.start_time, Some(buffer.time.as_ref().unwrap()[start]));
assert_eq!(slice.end_time, Some(buffer.time.as_ref().unwrap()[end]));
assert!(slice.total_distance < buffer.global.total_distance);
assert!(slice.total_speed().is_some());
// a single point has nothing between its ends
let point = buffer.slice(start, start).unwrap();
assert_eq!(point.total_distance, 0.0);
assert_eq!(point.total_time, Some(0));
}
#[test]
fn test_slice_keeps_the_bounds_of_the_selection() {
let data = std::fs::read("data/with_hr.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let slice = buffer.slice(1, 3).unwrap();
let (global, bounds) = (&buffer.global, &slice.bounds);
assert_eq!(
(bounds.sw.lng, bounds.sw.lat),
(global.bounds.sw.lng, global.bounds.sw.lat)
);
assert_eq!(
(bounds.ne.lng, bounds.ne.lat),
(global.bounds.ne.lng, global.bounds.ne.lat)
);
}
#[test]
fn test_durations_are_not_limited_to_24_days() {
// 30 days of recording, in milliseconds
let (segment, mut s) = computed("data/with_time.gpx");
let month = 30 * 24 * 3_600_000_i64;
s.global.total_time = Some(month);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.global.total_time, Some(2 * month));
assert!(2 * month > i64::from(i32::MAX));
}
#[test]
fn test_slice_across_segments() {
let (segment, s) = computed("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
// from the middle of the first segment to the middle of the second one
let slice = buffer.slice(n / 2, n + n / 2).unwrap();
let expected = buffer.total_distance[n + n / 2] - buffer.total_distance[n / 2];
assert!((slice.total_distance - expected).abs() < 1e-9);
assert_eq!(slice.total_time, None);
}
#[test]
fn test_slice_out_of_range() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = buffer.len();
assert!(buffer.slice(0, n).is_none());
assert!(buffer.slice(n, n).is_none());
assert!(buffer.slice(5, 4).is_none());
assert!(buffer.slice(0, n - 1).is_some());
}
}
@@ -1,299 +0,0 @@
use std::collections::{HashMap, HashSet, hash_map::Entry};
use crate::{
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
TrackSegmentRevisionId,
};
/// A segment and its statistics.
pub type SelectedSegment<'a> = (&'a TrackSegment, &'a Statistics);
#[derive(Debug, Default)]
pub struct StatisticsCache {
map: HashMap<TrackSegmentRevisionId, Statistics>,
}
impl StatisticsCache {
pub fn update(&mut self, state: Option<&StackEntry>) {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
for trk in file.trk.iter() {
for trkseg in trk.trkseg.iter() {
match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id);
}
Entry::Vacant(e) => {
e.insert(Statistics::compute(trkseg));
}
}
}
}
}
}
for id in stale.iter() {
self.map.remove(id);
}
}
/// Statistics of the segments of the selection.
pub fn get<'a>(
&'a self,
state: Option<&'a StackEntry>,
selection: &Selection,
) -> Vec<&'a Statistics> {
self.selected(state, selection, &[])
.into_iter()
.map(|(_, stats)| stats)
.collect()
}
/// The segments of the selection with their statistics. The segments of several selected
/// files follow `order` (the files missing from it come last).
pub fn selected<'a>(
&'a self,
state: Option<&'a StackEntry>,
selection: &Selection,
order: &[FileId],
) -> Vec<SelectedSegment<'a>> {
let mut selected = vec![];
if let Some(state) = state {
match selection {
Selection::Empty => (),
Selection::File { file_ids } => {
let ordered = order
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
if let Some(file) = state.get(id) {
self.add_file_stats(file, &mut selected);
}
}
}
Selection::Track { file_id, trk_ids } => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if trk_ids.contains(&trk.id) {
self.add_track_stats(trk, &mut selected);
}
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if *trk_id == trk.id {
for trkseg in trk.trkseg.iter() {
if trkseg_ids.contains(&trkseg.id) {
self.add_segment_stats(trkseg, &mut selected);
}
}
break;
}
}
}
}
Selection::Waypoints { file_id } => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut selected);
}
}
Selection::Waypoint {
file_id,
wpt_ids: _,
} => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut selected);
}
}
}
}
selected
}
/// Global statistics of a file: its segments merged, in order.
pub fn file_global(&self, file: &File) -> GlobalStatistics {
let mut selected = vec![];
self.add_file_stats(file, &mut selected);
let mut global = GlobalStatistics::default();
for (_, stats) in selected {
global.merge(&stats.global);
}
global
}
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
for trk in file.trk.iter() {
self.add_track_stats(trk, selected);
}
}
fn add_track_stats<'a>(&'a self, trk: &'a Track, selected: &mut Vec<SelectedSegment<'a>>) {
for trkseg in trk.trkseg.iter() {
self.add_segment_stats(trkseg, selected);
}
}
fn add_segment_stats<'a>(
&'a self,
trkseg: &'a TrackSegment,
selected: &mut Vec<SelectedSegment<'a>>,
) {
if let Some(stats) = self.map.get(&trkseg.rev_id) {
selected.push((trkseg, stats));
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashSet, rc::Rc};
use crate::{FileId, parse};
use super::*;
fn state(path: &str) -> (StackEntry, Rc<File>) {
let data = std::fs::read(path).unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
let mut state = StackEntry::default();
state.insert(file.id, file.clone());
(state, file)
}
fn file_selection(id: FileId) -> Selection {
Selection::File {
file_ids: HashSet::from([id]),
}
}
#[test]
fn test_update_and_select_file() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let nb_segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
assert_eq!(cache.map.len(), nb_segments);
assert_eq!(
cache.get(Some(&state), &file_selection(file.id)).len(),
nb_segments
);
assert!(cache.get(Some(&state), &Selection::Empty).is_empty());
assert!(cache.get(None, &file_selection(file.id)).is_empty());
// unknown file
assert!(
cache
.get(Some(&state), &file_selection(FileId::default()))
.is_empty()
);
}
#[test]
fn test_select_track_and_segment() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let trk = &file.trk[0];
let selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([trk.id]),
};
assert_eq!(cache.get(Some(&state), &selection).len(), trk.trkseg.len());
let trkseg = &trk.trkseg[1];
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trkseg.id]),
};
let stats = cache.get(Some(&state), &selection);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].local.len(), trkseg.len());
// a segment id under the wrong track selects nothing
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[1].id,
trkseg_ids: HashSet::from([trkseg.id]),
};
assert!(cache.get(Some(&state), &selection).is_empty());
}
#[test]
fn test_unchanged_segments_are_not_recomputed() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
// a new snapshot of the file that shares its segments (same revision ids)
let mut next = StackEntry::default();
let renamed = {
let mut f = (*file).clone();
f.info.name = "other".to_string();
Rc::new(f)
};
next.insert(renamed.id, renamed);
cache.update(Some(&next));
let after: *const Statistics = cache.get(Some(&next), &file_selection(file.id))[0];
assert!(std::ptr::eq(before, after));
}
#[test]
fn test_modified_segment_is_recomputed_and_stale_entries_dropped() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let old_rev = file.trk[0].trkseg[0].rev_id;
assert!(cache.map.contains_key(&old_rev));
let mut f = (*file).clone();
f.trk[0].trkseg[0].rev_id = Default::default();
let new_rev = f.trk[0].trkseg[0].rev_id;
let mut next = StackEntry::default();
next.insert(f.id, Rc::new(f));
cache.update(Some(&next));
assert!(cache.map.contains_key(&new_rev));
assert!(!cache.map.contains_key(&old_rev));
cache.update(None);
assert!(cache.map.is_empty());
}
#[test]
fn test_segments_of_several_files_follow_the_given_order() {
let (mut state, first) = state("data/simple.gpx");
let (other, second) = {
let data = std::fs::read("data/with_time.gpx").unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
(file.clone(), file)
};
state.insert(other.id, other);
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let selection = Selection::File {
file_ids: HashSet::from([first.id, second.id]),
};
let ids = |order: &[FileId]| -> Vec<_> {
cache
.selected(Some(&state), &selection, order)
.iter()
.map(|(segment, _)| segment.id)
.collect()
};
let (a, b) = (first.trk[0].trkseg[0].id, second.trk[0].trkseg[0].id);
assert_eq!(ids(&[first.id, second.id]), vec![a, b]);
assert_eq!(ids(&[second.id, first.id]), vec![b, a]);
// the files that are not in the order come last
assert_eq!(ids(&[second.id]), vec![b, a]);
}
}
-372
View File
@@ -1,372 +0,0 @@
mod action;
mod command;
mod derived;
mod outcome;
mod state;
pub use action::*;
pub use command::*;
pub use derived::*;
pub use outcome::*;
pub use state::*;
use crate::{
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint,
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
};
#[derive(Debug, Default)]
pub struct Engine {
stack: Stack,
selection: Selection,
order: FileOrder,
statistics_cache: StatisticsCache,
coordinates_cache: CoordinatesCache,
structure_cache: FileStructureCache,
statistics_buffer: StatisticsBuffer,
routing_buffer: RoutingBuffer,
categories: TrackpointCategories,
clipboard: Option<Clipboard>,
/// What the tests of the last action read, see `Engine::run`.
#[cfg(test)]
last: Outcome,
}
impl Engine {
pub fn statistics(&self) -> &StatisticsBuffer {
&self.statistics_buffer
}
/// The anchors of the routing tool among the trackpoints of the selection.
pub fn routing(&self) -> &RoutingBuffer {
&self.routing_buffer
}
/// The names of the surfaces and highways that the trackpoints refer to by code.
pub fn categories(&self) -> &TrackpointCategories {
&self.categories
}
/// Files in display order.
pub fn order(&self) -> &[FileId] {
&self.order.0
}
/// Name, tracks, segments and waypoints (with their ids) of a file.
pub fn file_structure(&self, id: &FileId) -> Option<&FileStructure> {
self.structure_cache.get(id)
}
/// Global statistics (distance, time, elevation, bounds...) of a file.
pub fn file_statistics(&self, id: &FileId) -> Option<GlobalStatistics> {
let file = self.stack.current()?.get(id)?;
Some(self.statistics_cache.file_global(file))
}
/// What was copied or cut and is waiting to be pasted.
pub fn clipboard(&self) -> Option<&Clipboard> {
self.clipboard.as_ref()
}
/// Whether the clipboard can be pasted with the current selection.
pub fn can_paste(&self) -> bool {
self.clipboard
.as_ref()
.is_some_and(|clipboard| clipboard.can_paste(&self.selection))
}
/// A waypoint of a file, with all its data. `None` if it does not exist.
pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> {
self.stack
.current()?
.get(file_id)?
.wpt
.iter()
.find(|wpt| wpt.id == *id)
}
/// A trackpoint of a segment of a file. `None` if it does not exist.
pub fn trackpoint(
&self,
file_id: &FileId,
segment_id: &TrackSegmentId,
index: usize,
) -> Option<&Trackpoint> {
let segment = self
.stack
.current()?
.get(file_id)?
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.find(|seg| seg.id == *segment_id)?;
(index < segment.len()).then(|| &segment[index])
}
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
/// which `Reduce` keeps it, see [`reduction_distances`].
pub fn reduction_distances(&self) -> Vec<f64> {
let Some(files) = self.stack.current() else {
return vec![];
};
self.selection
.segment_locations(files, &self.order.0)
.iter()
.flat_map(|location| {
reduction_distances(
&files[&location.file_id].trk[location.trk].trkseg[location.seg],
)
})
.collect()
}
/// What there is to keep of the engine, see [`crate::Persister`].
pub fn snapshot(&self) -> Snapshot {
Snapshot {
files: self.stack.current().cloned().unwrap_or_default(),
order: self.order.0.clone(),
categories: self.categories.clone(),
}
}
/// Puts files in an engine that has none, typically the ones read from the storage. They are
/// the state to start from: they cannot be undone. The files are reported as added, and the
/// order, the selection and the clipboard as changed.
pub fn restore(&mut self, restored: Restored) -> Outcome {
let files: StackEntry = restored
.files
.into_iter()
.map(|file| (file.id, file))
.collect();
self.categories = restored.categories;
self.order = FileOrder(restored.order);
self.selection = Selection::Empty;
self.clipboard = None;
let mut diff = Some(Diff {
added: files.keys().copied().collect(),
..Default::default()
});
self.stack = Stack::restored(files);
self.refresh(diff.as_ref());
sort_diff(&mut diff, &self.order.0);
let outcome = Outcome {
changed: true,
diff,
order_changed: true,
selection_changed: true,
clipboard_changed: true,
error: None,
};
#[cfg(test)]
{
self.last = outcome.clone();
}
outcome
}
/// A file as GPX (UTF-8), `None` if it does not exist.
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
let file = self.stack.current()?.get(id)?;
Some(write(file, &self.categories, options))
}
/// The data that the given files have, which the options of `export` can leave out.
pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions {
let Some(files) = self.stack.current() else {
return ExportOptions::NONE;
};
ids.iter()
.filter_map(|id| files.get(id))
.fold(ExportOptions::NONE, |data, file| {
data.union(file.exportable_data())
})
}
/// Whether there is something to undo.
pub fn can_undo(&self) -> bool {
self.stack.can_undo()
}
/// Whether there is something to redo.
pub fn can_redo(&self) -> bool {
self.stack.can_redo()
}
/// Currently selected elements.
pub fn selection(&self) -> &Selection {
&self.selection
}
/// Coordinates (`[lng, lat, ...]`) of the trackpoints of a segment.
pub fn segment_coordinates(&self, id: &TrackSegmentId) -> &[f64] {
self.coordinates_cache.segment(id)
}
/// Coordinates (`[lng, lat, ...]`) of the waypoints of a file.
pub fn waypoint_coordinates(&self, id: &FileId) -> &[f64] {
self.coordinates_cache.waypoints(id)
}
/// Single entry point for every action coming from the frontend. Says what it changed.
pub fn execute(&mut self, action: Action) -> Outcome {
let selection_before = self.selection.clone();
let order_before = self.order.0.clone();
let clipboard_before = self.clipboard.clone();
let is_edit = matches!(action, Action::Edit(_));
let mut error = None;
// the files change iff there is a diff, but selection and order actions have none
let mut diff = match action {
Action::Edit(command) => self.edit(command).unwrap_or_else(|err| {
error = Some(err);
None
}),
Action::Undo => self.stack.undo(),
Action::Redo => self.stack.redo(),
Action::Select { selection, mode } => {
self.selection.select(self.stack.current(), selection, mode);
None
}
Action::SelectAll => {
if let Some(files) = self.stack.current() {
self.selection.select_all(files, &self.order.0);
}
None
}
Action::ArrowSelect { down, add } => {
if let Some(files) = self.stack.current() {
self.selection
.select_neighbour(files, &self.order.0, down, add);
}
None
}
Action::Copy => {
self.copy(false);
None
}
Action::Cut => {
self.copy(true);
None
}
Action::Reorder { file_ids, index } => {
self.order.move_files(&file_ids, index);
None
}
};
let changed = diff.is_some()
|| self.selection != selection_before
|| self.order.0 != order_before
|| self.clipboard != clipboard_before;
if is_edit && !changed && error.is_none() {
error = Some(CommandError::NothingToDo);
}
if changed {
self.refresh(diff.as_ref());
sort_diff(&mut diff, &self.order.0);
}
// after the refresh, which also syncs the order and selection with the files (undo, redo)
let outcome = Outcome {
changed,
diff,
order_changed: self.order.0 != order_before,
selection_changed: self.selection != selection_before,
clipboard_changed: self.clipboard != clipboard_before,
error,
};
#[cfg(test)]
{
self.last = outcome.clone();
}
outcome
}
/// Puts the selection in the clipboard, if there is one.
fn copy(&mut self, cut: bool) {
if let Some(files) = self.stack.current()
&& let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut)
{
self.clipboard = Some(clipboard);
}
}
fn edit(&mut self, command: Command) -> Result<Option<Diff>, CommandError> {
self.stack.create_and_push_next(|files| {
let mut state = State {
categories: &mut self.categories,
files,
selection: &mut self.selection,
order: &mut self.order,
clipboard: &mut self.clipboard,
};
command.apply(&mut state)
})
}
/// Brings everything derived from the files back in line with the current stack entry.
fn refresh(&mut self, diff: Option<&Diff>) {
let current = self.stack.current();
match current {
Some(files) => {
// files coming back after an undo/redo are added at the end of the order
self.order.sync(files);
self.selection.retain_existing(files);
}
None => {
self.order.0.clear();
self.selection = Selection::Empty;
}
}
self.statistics_cache.update(current);
self.coordinates_cache.update(current);
self.structure_cache.update(current, diff);
self.routing_buffer
.update(current, &self.selection, &self.order.0);
self.statistics_buffer
.update(
&self
.statistics_cache
.selected(current, &self.selection, &self.order.0),
);
}
}
/// Puts the files of a diff in the order of the files (the stack does not know it), so that
/// whatever is done with them one after the other, like giving them colors, follows it.
fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
let position = |id: &FileId| order.iter().position(|other| other == id);
if let Some(diff) = diff {
diff.added.sort_by_key(position);
diff.modified.sort_by_key(position);
}
}
#[cfg(test)]
impl Engine {
/// Executes an action and tells whether it changed anything. What it did is kept for the
/// accessors below, which the tests read right after.
pub(crate) fn run(&mut self, action: Action) -> bool {
self.execute(action).changed
}
pub(crate) fn last_diff(&self) -> Option<&Diff> {
self.last.diff.as_ref()
}
pub(crate) fn last_error(&self) -> Option<&CommandError> {
self.last.error.as_ref()
}
pub(crate) fn order_changed(&self) -> bool {
self.last.order_changed
}
pub(crate) fn selection_changed(&self) -> bool {
self.last.selection_changed
}
pub(crate) fn clipboard_changed(&self) -> bool {
self.last.clipboard_changed
}
}
#[cfg(test)]
mod tests;
-21
View File
@@ -1,21 +0,0 @@
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>,
}
-469
View File
@@ -1,469 +0,0 @@
use std::rc::Rc;
use uuid::Uuid;
use crate::{
File, FileId, Selection, StackEntry, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
WaypointId,
};
/// A track in the clipboard, with the name of the file it comes from (the name of the new file
/// when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardTrack {
pub track: Track,
pub file_name: String,
}
/// A segment in the clipboard, with the name of its track, or of its file when the track has
/// none (the name of the new file when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardSegment {
pub segment: TrackSegment,
pub name: String,
}
/// The elements that were copied or cut, in the order of the files.
///
/// It holds the elements themselves, not references to them: they are cheap to keep (files are
/// shared, and so are the trackpoints of tracks and segments), and the clipboard stays valid
/// when the elements are changed or deleted.
#[derive(Debug, Clone)]
pub enum ClipboardContent {
Files(Vec<Rc<File>>),
Tracks(Vec<ClipboardTrack>),
Segments(Vec<ClipboardSegment>),
Waypoints(Vec<Waypoint>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClipboardId(Uuid);
impl Default for ClipboardId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
/// What was copied or cut, to be pasted, as many times as needed.
///
/// What is pasted is always the elements as they were when they were copied or cut, whatever
/// happened to them since, even if they were deleted. Pasting what was copied makes copies, with
/// new ids. What was cut is moved, with its ids, the first time it is pasted (the elements that
/// are still there are removed): the clipboard is not cut anymore after that, and the next
/// pastes make copies.
#[derive(Debug, Clone)]
pub struct Clipboard {
id: ClipboardId,
pub content: ClipboardContent,
pub cut: bool,
}
/// Two clipboards are the same when they come from the same copy, and are cut the same way.
impl PartialEq for Clipboard {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.cut == other.cut
}
}
impl Clipboard {
/// The clipboard for the selected elements, `None` if nothing is selected (or if what is
/// selected does not exist).
pub fn new(
selection: &Selection,
files: &StackEntry,
order: &[FileId],
cut: bool,
) -> Option<Clipboard> {
let content = match selection {
Selection::Empty => return None,
Selection::File { file_ids } => ClipboardContent::Files(
order
.iter()
.filter(|id| file_ids.contains(id))
.filter_map(|id| files.get(id).cloned())
.collect(),
),
Selection::Track { file_id, trk_ids } => {
let file = files.get(file_id)?;
ClipboardContent::Tracks(
file.trk
.iter()
.filter(|trk| trk_ids.contains(&trk.id))
.map(|trk| ClipboardTrack {
track: trk.clone(),
file_name: file.info.name.clone(),
})
.collect(),
)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = files.get(file_id)?;
let trk = file.trk.iter().find(|trk| trk.id == *trk_id)?;
let name = trk
.info
.name
.clone()
.unwrap_or_else(|| file.info.name.clone());
ClipboardContent::Segments(
trk.trkseg
.iter()
.filter(|seg| trkseg_ids.contains(&seg.id))
.map(|seg| ClipboardSegment {
segment: seg.clone(),
name: name.clone(),
})
.collect(),
)
}
Selection::Waypoints { file_id } => {
ClipboardContent::Waypoints(files.get(file_id)?.wpt.iter().cloned().collect())
}
Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints(
files
.get(file_id)?
.wpt
.iter()
.filter(|wpt| wpt_ids.contains(&wpt.id))
.cloned()
.collect(),
),
};
let clipboard = Clipboard {
id: ClipboardId::default(),
content,
cut,
};
(!clipboard.is_empty()).then_some(clipboard)
}
pub fn is_empty(&self) -> bool {
match &self.content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
}
}
/// Whether the content can be pasted when `target` is selected: files and tracks can be
/// pasted anywhere above them in the file tree, and nothing selected stands for the list of
/// files.
///
/// | copied | pasted when selected |
/// |-----------|------------------------------------------|
/// | files | nothing, a file |
/// | tracks | nothing, a file, a track |
/// | segments | nothing, a file, a track, a segment |
/// | waypoints | a file, the waypoints, a waypoint |
pub fn can_paste(&self, target: &Selection) -> bool {
use ClipboardContent as C;
use Selection::*;
matches!(
(&self.content, target),
(C::Files(_), Empty | File { .. })
| (C::Tracks(_), Empty | File { .. } | Track { .. })
| (
C::Segments(_),
Empty | File { .. } | Track { .. } | TrackSegment { .. }
)
| (
C::Waypoints(_),
File { .. } | Waypoints { .. } | Waypoint { .. }
)
)
}
}
/// The ids of the elements of the content, by kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClipboardIds {
Files(Vec<FileId>),
Tracks(Vec<TrackId>),
Segments(Vec<TrackSegmentId>),
Waypoints(Vec<WaypointId>),
}
impl ClipboardContent {
pub fn ids(&self) -> ClipboardIds {
match self {
ClipboardContent::Files(files) => {
ClipboardIds::Files(files.iter().map(|file| file.id).collect())
}
ClipboardContent::Tracks(tracks) => {
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
}
ClipboardContent::Segments(segments) => {
ClipboardIds::Segments(segments.iter().map(|seg| seg.segment.id).collect())
}
ClipboardContent::Waypoints(waypoints) => {
ClipboardIds::Waypoints(waypoints.iter().map(|wpt| wpt.id).collect())
}
}
}
}
#[cfg(test)]
mod tests {
use crate::{TrackInfo, WaypointChunk};
use super::*;
/// A file with 3 tracks (named "t0", "t1", "t2") of 2 segments and 3 waypoints.
fn file(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_owned();
for i in 0..3 {
file.trk.push(Track {
info: TrackInfo {
name: (i < 2).then(|| format!("t{i}")),
..Default::default()
},
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
});
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
});
file
}
fn files_of(files: Vec<File>) -> StackEntry {
files
.into_iter()
.map(|file| (file.id, Rc::new(file)))
.collect()
}
fn order_of(files: &StackEntry) -> Vec<FileId> {
files.keys().copied().collect()
}
fn tracks(file: &File, ids: &[usize]) -> Selection {
Selection::Track {
file_id: file.id,
trk_ids: ids.iter().map(|i| file.trk[*i].id).collect(),
}
}
#[test]
fn test_the_clipboard_holds_what_is_selected() {
let file = file("source");
let (t0, t2) = (file.trk[0].id, file.trk[2].id);
let (s0, s1) = (file.trk[1].trkseg[0].id, file.trk[1].trkseg[1].id);
let wpt: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
let files = files_of(vec![file.clone()]);
// tracks, in the order of the file, with the name of their file
let clipboard =
Clipboard::new(&tracks(&file, &[2, 0]), &files, &order_of(&files), false).unwrap();
assert!(!clipboard.cut);
match &clipboard.content {
ClipboardContent::Tracks(copied) => {
assert_eq!(
copied.iter().map(|c| c.track.id).collect::<Vec<_>>(),
vec![t0, t2]
);
assert!(copied.iter().all(|c| c.file_name == "source"));
}
other => panic!("{other:?}"),
}
// segments, with the name of their track, or else of their file
let segments = |trk: usize, ids: &[TrackSegmentId]| Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[trk].id,
trkseg_ids: ids.iter().copied().collect(),
};
match Clipboard::new(&segments(1, &[s1, s0]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => {
assert_eq!(
copied.iter().map(|c| c.segment.id).collect::<Vec<_>>(),
vec![s0, s1]
);
assert!(copied.iter().all(|c| c.name == "t1"));
}
other => panic!("{other:?}"),
}
let unnamed = file.trk[2].trkseg[0].id;
match Clipboard::new(&segments(2, &[unnamed]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => assert_eq!(copied[0].name, "source"),
other => panic!("{other:?}"),
}
// waypoints: some of them, or all of them
let some = Selection::Waypoint {
file_id: file.id,
wpt_ids: [wpt[2], wpt[0]].into(),
};
assert_eq!(
Clipboard::new(&some, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(vec![wpt[0], wpt[2]])
);
assert_eq!(
Clipboard::new(
&Selection::Waypoints { file_id: file.id },
&files,
&order_of(&files),
false
)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(wpt)
);
// files
let selection = Selection::File {
file_ids: [file.id].into(),
};
assert_eq!(
Clipboard::new(&selection, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Files(vec![file.id])
);
}
#[test]
fn test_nothing_to_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
assert!(Clipboard::new(&Selection::Empty, &files, &order_of(&files), false).is_none());
// elements that do not exist
let unknown = Selection::Track {
file_id: FileId::default(),
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
let unknown = Selection::Track {
file_id: file.id,
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
assert!(
Clipboard::new(
&Selection::File {
file_ids: [FileId::default()].into()
},
&files,
&order_of(&files),
false
)
.is_none()
);
// a file without waypoints
let empty = files_of(vec![File::default()]);
let id = *empty.keys().next().unwrap();
assert!(
Clipboard::new(
&Selection::Waypoints { file_id: id },
&empty,
&order_of(&empty),
true
)
.is_none()
);
}
#[test]
fn test_two_clipboards_are_the_same_when_they_come_from_the_same_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection = tracks(&file, &[0]);
let clipboard = Clipboard::new(&selection, &files, &order_of(&files), false).unwrap();
assert_eq!(clipboard, clipboard.clone());
// the same elements copied again make another clipboard
assert_ne!(
clipboard,
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
);
// and it is not the same once it is not cut
let mut copy = clipboard.clone();
copy.cut = true;
assert_ne!(clipboard, copy);
}
#[test]
fn test_the_clipboard_survives_what_happens_to_the_files() {
let file = file("f");
let mut files = files_of(vec![file.clone()]);
let clipboard =
Clipboard::new(&tracks(&file, &[0, 1]), &files, &order_of(&files), true).unwrap();
files.clear();
assert!(!clipboard.is_empty());
assert_eq!(
clipboard.content.ids(),
ClipboardIds::Tracks(vec![file.trk[0].id, file.trk[1].id])
);
}
#[test]
fn test_where_things_can_be_pasted() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection_of = |selection: Selection| {
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
};
let file_selection = Selection::File {
file_ids: [file.id].into(),
};
let track = tracks(&file, &[0]);
let segment = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[0].id,
trkseg_ids: [file.trk[0].trkseg[0].id].into(),
};
let waypoints = Selection::Waypoints { file_id: file.id };
let waypoint = Selection::Waypoint {
file_id: file.id,
wpt_ids: [file.wpt[0].id].into(),
};
let targets = [
Selection::Empty,
file_selection.clone(),
track.clone(),
segment.clone(),
waypoints.clone(),
waypoint.clone(),
];
let expected = [
// nothing, file, track, segment, waypoints, waypoint
(file_selection, [true, true, false, false, false, false]),
(track, [true, true, true, false, false, false]),
(segment, [true, true, true, true, false, false]),
(waypoints, [false, true, false, false, true, true]),
(waypoint, [false, true, false, false, true, true]),
];
for (copied, allowed) in expected {
let clipboard = selection_of(copied.clone());
for (target, allowed) in targets.iter().zip(allowed) {
assert_eq!(
clipboard.can_paste(target),
allowed,
"{copied:?} on {target:?}"
);
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
mod clipboard;
mod order;
mod selection;
mod stack;
pub use clipboard::*;
pub use order::*;
pub use selection::*;
pub use stack::*;
pub struct State<'a> {
pub categories: &'a mut crate::TrackpointCategories,
pub files: &'a mut StackEntry,
pub selection: &'a mut Selection,
pub order: &'a mut FileOrder,
pub clipboard: &'a mut Option<Clipboard>,
}
-97
View File
@@ -1,97 +0,0 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry as Files};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileOrder(pub Vec<FileId>);
impl FileOrder {
/// Moves the given files, as a block and in the given order, so that they start at `index`
/// among the other files (clamped to the end). Unknown ids are ignored. Returns whether the
/// order changed.
pub fn move_files(&mut self, file_ids: &[FileId], index: usize) -> bool {
let mut seen = HashSet::new();
let moved: Vec<FileId> = file_ids
.iter()
.copied()
.filter(|id| self.0.contains(id) && seen.insert(*id))
.collect();
let mut order: Vec<FileId> = self
.0
.iter()
.copied()
.filter(|id| !seen.contains(id))
.collect();
let index = index.min(order.len());
order.splice(index..index, moved);
let changed = order != self.0;
self.0 = order;
changed
}
/// Drops the files that do not exist anymore and appends the new ones at the end.
pub fn sync(&mut self, files: &Files) {
self.0.retain(|id| files.contains_key(id));
let known: HashSet<FileId> = self.0.iter().copied().collect();
self.0
.extend(files.keys().filter(|id| !known.contains(id)).copied());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> [FileId; 3] {
[FileId::default(), FileId::default(), FileId::default()]
}
#[test]
fn test_move_files_as_block_in_given_order() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c, a], 1));
// index counts among the files that are not moved
assert_eq!(order.0, vec![b, c, a]);
}
#[test]
fn test_move_files_start_and_clamped_end() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c], 0));
assert_eq!(order.0, vec![c, a, b]);
assert!(order.move_files(&[c], 100));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_move_files_ignores_unknown_and_duplicate_ids() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[FileId::default(), b, b], 0));
assert_eq!(order.0, vec![b, a, c]);
}
#[test]
fn test_move_files_unchanged() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(!order.move_files(&[], 0));
assert!(!order.move_files(&[a, b], 0));
assert!(!order.move_files(&[FileId::default()], 0));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_sync_drops_missing_and_appends_new_at_the_end() {
let [a, b, c] = ids();
let mut files = Files::default();
for id in [b, c] {
files.insert(id, Default::default());
}
let mut order = FileOrder(vec![a, b]);
order.sync(&files);
assert_eq!(order.0, vec![b, c]);
}
}
-836
View File
@@ -1,836 +0,0 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
/// How a new selection combines with the current one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectMode {
/// The new selection replaces the current one.
Replace,
/// The elements are added when they are of the same kind and in the same place as the
/// current selection (see [`Selection::extend`]), otherwise they replace it.
Add,
/// Like `Add`, but the elements already selected are removed instead (see
/// [`Selection::toggle`]).
Toggle,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Selection {
#[default]
Empty,
File {
file_ids: HashSet<FileId>,
},
Track {
file_id: FileId,
trk_ids: HashSet<TrackId>,
},
TrackSegment {
file_id: FileId,
trk_id: TrackId,
trkseg_ids: HashSet<TrackSegmentId>,
},
Waypoints {
file_id: FileId,
},
Waypoint {
file_id: FileId,
wpt_ids: HashSet<WaypointId>,
},
}
/// Where a track segment is: its file, then its track and its own position in it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentLocation {
pub file_id: FileId,
pub trk: usize,
pub seg: usize,
}
impl Selection {
/// Combines `selection` with this one according to `mode`, ignoring the elements of
/// `selection` that do not exist in `files`. Nothing to add or toggle leaves the selection
/// as it is, while replacing it with nothing deselects everything.
pub fn select(
&mut self,
files: Option<&StackEntry>,
mut selection: Selection,
mode: SelectMode,
) {
match files {
Some(files) => selection.retain_existing(files),
None => selection = Selection::Empty,
}
match mode {
SelectMode::Replace => *self = selection,
_ if selection == Selection::Empty => {}
SelectMode::Add => self.extend(selection),
SelectMode::Toggle => self.toggle(selection),
}
}
/// Selects all the elements of the same kind as the selected ones, see
/// [`Selection::all_at_level`]. Does nothing when there is nothing more to select.
pub fn select_all(&mut self, files: &StackEntry, order: &[FileId]) {
if let Some(all) = self.all_at_level(files, order) {
*self = all;
}
}
/// Moves the selection to the next (`down`) or previous element, see
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
/// replacing it.
pub fn select_neighbour(
&mut self,
files: &StackEntry,
order: &[FileId],
down: bool,
add: bool,
) {
if let Some(next) = self.neighbour(files, order, down) {
if add {
self.extend(next);
} else {
*self = next;
}
}
}
}
impl Selection {
/// The track segments covered by the selection, in order: the segments of several selected
/// files follow `order` (the files missing from it come last), then the tracks and segments
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
pub fn segment_locations(&self, files: &StackEntry, order: &[FileId]) -> Vec<SegmentLocation> {
let mut locations = vec![];
let mut add = |file_id: FileId,
trk_filter: &dyn Fn(&crate::Track) -> bool,
seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| {
let Some(file) = files.get(&file_id) else {
return;
};
for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) {
for (seg, segment) in track.trkseg.iter().enumerate() {
if seg_filter(segment) {
locations.push(SegmentLocation { file_id, trk, seg });
}
}
}
};
match self {
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => (),
Selection::File { file_ids } => {
let ordered = order
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
add(*id, &|_| true, &|_| true);
}
}
Selection::Track { file_id, trk_ids } => {
add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => add(*file_id, &|trk| trk.id == *trk_id, &|seg| {
trkseg_ids.contains(&seg.id)
}),
}
locations
}
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
pub fn retain_existing(&mut self, files: &StackEntry) {
let empty = match self {
Selection::Empty => false,
Selection::File { file_ids } => {
file_ids.retain(|id| files.contains_key(id));
file_ids.is_empty()
}
Selection::Track { file_id, trk_ids } => match files.get(file_id) {
Some(file) => {
trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id));
trk_ids.is_empty()
}
None => true,
},
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => match files
.get(file_id)
.and_then(|file| file.trk.iter().find(|trk| trk.id == *trk_id))
{
Some(trk) => {
trkseg_ids.retain(|id| trk.trkseg.iter().any(|seg| seg.id == *id));
trkseg_ids.is_empty()
}
None => true,
},
Selection::Waypoints { file_id } => !files.contains_key(file_id),
Selection::Waypoint { file_id, wpt_ids } => match files.get(file_id) {
Some(file) => {
wpt_ids.retain(|id| file.wpt.iter().any(|wpt| wpt.id == *id));
wpt_ids.is_empty()
}
None => true,
},
};
if empty {
*self = Selection::Empty;
}
}
/// Adds `other` to the selection: the elements are merged when they are of the same kind and
/// in the same place (same file, same track for segments), otherwise `other` replaces the
/// selection.
pub fn extend(&mut self, other: Selection) {
match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
file_ids.extend(other)
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => trk_ids.extend(other),
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => trkseg_ids.extend(other),
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other => {}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => wpt_ids.extend(other),
(selection, other) => *selection = other,
}
}
/// Like [`Selection::extend`], but the elements of `other` that are already selected are
/// removed from the selection instead. A selection left with nothing is empty.
pub fn toggle(&mut self, other: Selection) {
fn toggle_ids<T: std::hash::Hash + Eq>(ids: &mut HashSet<T>, other: HashSet<T>) {
for id in other {
if !ids.remove(&id) {
ids.insert(id);
}
}
}
let empty = match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
toggle_ids(file_ids, other);
file_ids.is_empty()
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => {
toggle_ids(trk_ids, other);
trk_ids.is_empty()
}
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => {
toggle_ids(trkseg_ids, other);
trkseg_ids.is_empty()
}
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other =>
{
true
}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => {
toggle_ids(wpt_ids, other);
wpt_ids.is_empty()
}
(selection, other) => {
*selection = other;
false
}
};
if empty {
*self = Selection::Empty;
}
}
}
/// Position of the first and last of the `selected` ids among the `all` ids.
fn selected_range<T: std::hash::Hash + Eq>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<(usize, usize)> {
let mut range: Option<(usize, usize)> = None;
for (i, id) in all.enumerate() {
if selected.contains(&id) {
range = Some(range.map_or((i, i), |(first, _)| (first, i)));
}
}
range
}
/// The id before or after the selected ones among `ids`, without wrapping around.
fn neighbour<T: Copy + PartialEq + std::hash::Hash + Eq>(
ids: &[T],
selected: &HashSet<T>,
down: bool,
) -> Option<T> {
let (first, last) = selected_range(ids.iter().copied(), selected)?;
if down {
ids.get(last + 1).copied()
} else {
first.checked_sub(1).map(|i| ids[i])
}
}
impl Selection {
/// The files holding the selected elements, as a selection of files.
pub fn to_files(&self) -> Selection {
match self {
Selection::Empty => Selection::Empty,
Selection::File { .. } => self.clone(),
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => Selection::File {
file_ids: [*file_id].into(),
},
}
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
/// of the file. All the files when nothing is selected. `None` when there is nothing to
/// select (the waypoints node, or a file that does not exist).
pub fn all_at_level(&self, files: &StackEntry, order: &[FileId]) -> Option<Selection> {
match self {
Selection::Empty | Selection::File { .. } => Some(Selection::File {
file_ids: order.iter().copied().collect(),
}),
Selection::Track { file_id, .. } => Some(Selection::Track {
file_id: *file_id,
trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(),
}),
Selection::TrackSegment {
file_id, trk_id, ..
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: trk.trkseg.iter().map(|seg| seg.id).collect(),
})
}
Selection::Waypoints { .. } => None,
Selection::Waypoint { file_id, .. } => Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect(),
}),
}
}
/// The element next to the selected ones, as a selection of that one element: the next one
/// (`down`) or the previous one among the elements of the same kind, in the same place.
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
/// the ones that are already selected. `None` when there is no such element.
pub fn neighbour(&self, files: &StackEntry, order: &[FileId], down: bool) -> Option<Selection> {
match self {
Selection::Empty | Selection::Waypoints { .. } => None,
Selection::File { file_ids } => {
let (first, last) = selected_range(order.iter(), &file_ids.iter().collect())?;
let limit = if down { last } else { first };
let n = order.len();
let mut i = limit;
loop {
i = if down { (i + 1) % n } else { (i + n - 1) % n };
if i == limit {
return None;
}
if !file_ids.contains(&order[i]) {
return Some(Selection::File {
file_ids: [order[i]].into(),
});
}
}
}
Selection::Track { file_id, trk_ids } => {
let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect();
Some(Selection::Track {
file_id: *file_id,
trk_ids: [neighbour(&ids, trk_ids, down)?].into(),
})
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
let ids: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: [neighbour(&ids, trkseg_ids, down)?].into(),
})
}
Selection::Waypoint { file_id, wpt_ids } => {
let ids: Vec<WaypointId> =
files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect();
Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: [neighbour(&ids, wpt_ids, down)?].into(),
})
}
}
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{File, Track, TrackSegment, Waypoint, WaypointChunk};
use super::*;
#[test]
fn test_retain_existing() {
let file = File::default();
let (kept, gone) = (file.id, FileId::default());
let mut files = StackEntry::default();
files.insert(kept, Rc::new(file));
let mut selection = Selection::File {
file_ids: [kept, gone].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::File { file_ids } if file_ids.len() == 1 && file_ids.contains(&kept))
);
let mut selection = Selection::File {
file_ids: [gone].into(),
};
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
let mut selection = Selection::Waypoints { file_id: kept };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Waypoints { .. }));
let mut selection = Selection::Waypoints { file_id: gone };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
}
#[test]
fn test_retain_existing_elements() {
let mut file = File::default();
file.trk.push(Track {
trkseg: vec![TrackSegment::default()],
..Default::default()
});
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
});
let (file_id, trk_id) = (file.id, file.trk[0].id);
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
let mut files = StackEntry::default();
files.insert(file_id, Rc::new(file));
let mut selection = Selection::Track {
file_id,
trk_ids: [trk_id, TrackId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(
selection,
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
);
let mut selection = Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg_id, TrackSegmentId::default()].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::TrackSegment { trkseg_ids, .. } if trkseg_ids.len() == 1)
);
// a segment under the wrong track
let mut selection = Selection::TrackSegment {
file_id,
trk_id: TrackId::default(),
trkseg_ids: [seg_id].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [wpt_id, WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert!(matches!(&selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids.len() == 1));
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
}
#[test]
fn test_extend() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: merged
let mut selection = tracks(&[a]);
selection.extend(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
let other = Selection::Track {
file_id: FileId::default(),
trk_ids: [b].into(),
};
selection.extend(other.clone());
assert_eq!(selection, other);
selection.extend(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// segments of another track: replaced
let seg = TrackSegmentId::default();
let segments = |trk_id| Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg].into(),
};
let mut selection = segments(a);
selection.extend(segments(b));
assert_eq!(selection, segments(b));
}
#[test]
fn test_toggle() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: toggled one by one
let mut selection = tracks(&[a]);
selection.toggle(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
selection.toggle(tracks(&[a]));
assert_eq!(selection, tracks(&[b]));
selection.toggle(tracks(&[a, b]));
assert_eq!(selection, tracks(&[a]));
// nothing left: empty
selection.toggle(tracks(&[a]));
assert_eq!(selection, Selection::Empty);
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// the waypoints node toggles itself off
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Empty);
// files
let (f1, f2) = (FileId::default(), FileId::default());
let files = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let mut selection = files(&[f1]);
selection.toggle(files(&[f2]));
assert_eq!(selection, files(&[f1, f2]));
selection.toggle(files(&[f1]));
assert_eq!(selection, files(&[f2]));
}
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
struct Tree {
files: StackEntry,
order: Vec<FileId>,
file: FileId,
tracks: Vec<TrackId>,
segments: Vec<Vec<TrackSegmentId>>,
waypoints: Vec<WaypointId>,
}
fn tree() -> Tree {
let mut file = File::default();
for _ in 0..3 {
file.trk.push(Track {
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
});
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
});
Tree {
file: file.id,
tracks: file.trk.iter().map(|t| t.id).collect(),
segments: file
.trk
.iter()
.map(|t| t.trkseg.iter().map(|s| s.id).collect())
.collect(),
waypoints: file.wpt.iter().map(|w| w.id).collect(),
order: vec![file.id],
files: StackEntry::from([(file.id, Rc::new(file))]),
}
}
#[test]
fn test_all_at_level() {
let t = tree();
let other = FileId::default();
let order = [other, t.file];
let all = |selection: &Selection| selection.all_at_level(&t.files, &order);
// the files, in the order
let all_files = Selection::File {
file_ids: [other, t.file].into(),
};
assert_eq!(all(&Selection::Empty), Some(all_files.clone()));
assert_eq!(
all(&Selection::File {
file_ids: [t.file].into()
}),
Some(all_files)
);
// the siblings of the selected elements
assert_eq!(
all(&Selection::Track {
file_id: t.file,
trk_ids: [t.tracks[1]].into()
}),
Some(Selection::Track {
file_id: t.file,
trk_ids: t.tracks.iter().copied().collect()
})
);
assert_eq!(
all(&Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: [t.segments[2][0]].into()
}),
Some(Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: t.segments[2].iter().copied().collect()
})
);
assert_eq!(
all(&Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[0]].into()
}),
Some(Selection::Waypoint {
file_id: t.file,
wpt_ids: t.waypoints.iter().copied().collect()
})
);
// nothing to select
assert_eq!(all(&Selection::Waypoints { file_id: t.file }), None);
let unknown = FileId::default();
assert_eq!(
all(&Selection::Track {
file_id: unknown,
trk_ids: HashSet::new()
}),
None
);
}
#[test]
fn test_neighbour_of_tracks_stops_at_the_ends() {
let t = tree();
let tracks = |ids: &[TrackId]| Selection::Track {
file_id: t.file,
trk_ids: ids.iter().copied().collect(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
assert_eq!(
next(&tracks(&[t.tracks[1]]), true),
Some(tracks(&[t.tracks[2]]))
);
assert_eq!(
next(&tracks(&[t.tracks[1]]), false),
Some(tracks(&[t.tracks[0]]))
);
assert_eq!(next(&tracks(&[t.tracks[2]]), true), None);
assert_eq!(next(&tracks(&[t.tracks[0]]), false), None);
// from the end of the selected range, whatever its holes
let range = tracks(&[t.tracks[0], t.tracks[1]]);
assert_eq!(next(&range, true), Some(tracks(&[t.tracks[2]])));
assert_eq!(next(&range, false), None);
let holes = tracks(&[t.tracks[0], t.tracks[2]]);
assert_eq!(next(&holes, true), None);
assert_eq!(next(&holes, false), None);
}
#[test]
fn test_neighbour_of_segments_and_waypoints() {
let t = tree();
let segments = |trk: usize, seg: usize| Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[trk],
trkseg_ids: [t.segments[trk][seg]].into(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
// the segments stay in their track
assert_eq!(next(&segments(1, 0), true), Some(segments(1, 1)));
assert_eq!(next(&segments(1, 1), true), None);
assert_eq!(next(&segments(1, 1), false), Some(segments(1, 0)));
assert_eq!(next(&segments(1, 0), false), None);
// the waypoints are in the order of the file, across its chunks
let waypoint = |i: usize| Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[i]].into(),
};
assert_eq!(next(&waypoint(1), true), Some(waypoint(2)));
assert_eq!(next(&waypoint(2), true), None);
assert_eq!(next(&waypoint(0), false), None);
assert_eq!(next(&waypoint(2), false), Some(waypoint(1)));
assert_eq!(next(&Selection::Waypoints { file_id: t.file }, true), None);
assert_eq!(next(&Selection::Empty, true), None);
}
#[test]
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
let files = StackEntry::default();
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
let order = [a, b, c];
let select = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let next = |ids: &[FileId], down| select(ids).neighbour(&files, &order, down);
assert_eq!(next(&[a], true), Some(select(&[b])));
assert_eq!(next(&[c], true), Some(select(&[a])));
assert_eq!(next(&[a], false), Some(select(&[c])));
assert_eq!(next(&[b], false), Some(select(&[a])));
// from the end of the selection, skipping the files already selected
assert_eq!(next(&[a, b], true), Some(select(&[c])));
assert_eq!(next(&[a, b], false), Some(select(&[c])));
assert_eq!(next(&[c, a], true), Some(select(&[b])));
// everything is selected
assert_eq!(next(&[a, b, c], true), None);
// none of them is in the order
assert_eq!(next(&[FileId::default()], true), None);
}
#[test]
fn test_to_files() {
let (file_id, trk_id) = (FileId::default(), TrackId::default());
let file = Selection::File {
file_ids: [file_id].into(),
};
assert_eq!(Selection::Empty.to_files(), Selection::Empty);
assert_eq!(file.to_files(), file);
assert_eq!(
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
.to_files(),
file
);
assert_eq!(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: HashSet::new()
}
.to_files(),
file
);
assert_eq!(Selection::Waypoints { file_id }.to_files(), file);
assert_eq!(
Selection::Waypoint {
file_id,
wpt_ids: HashSet::new()
}
.to_files(),
file
);
let several = Selection::File {
file_ids: [file_id, FileId::default()].into(),
};
assert_eq!(several.to_files(), several);
}
}
-429
View File
@@ -1,429 +0,0 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{CommandError, File, FileId};
#[derive(Debug, Default)]
pub struct Stack {
entries: Vec<StackEntry>,
index: Option<usize>,
/// Whether the first entry is the state the stack starts from, which is not undone: files
/// restored from the storage.
floor: bool,
}
impl Stack {
/// A stack whose first entry is `entry`, from which nothing can be undone.
pub fn restored(entry: StackEntry) -> Self {
Self {
entries: vec![entry],
index: Some(0),
floor: true,
}
}
pub fn current(&self) -> Option<&StackEntry> {
self.index.map_or_default(|i| self.get(i))
}
/// Applies `f` to a copy of the current entry and makes the copy the new current entry.
///
/// Nothing is pushed, and `None` is returned, if `f` fails (the error is returned) or if it
/// leaves every file as it was: a step that undoes nothing is not worth a place in the
/// history. Files are told apart by their identity, not their content.
pub fn create_and_push_next<F>(&mut self, f: F) -> Result<Option<Diff>, CommandError>
where
F: FnOnce(&mut StackEntry) -> Result<(), CommandError>,
{
let mut next = self.current().map_or_default(|c| c.clone());
f(&mut next)?;
if self.is_current(&next) {
return Ok(None);
}
Ok(self.record_diff(|stack| stack.push(next)))
}
/// Whether `entry` holds exactly the files of the current entry.
fn is_current(&self, entry: &StackEntry) -> bool {
let current = self.current();
let len = current.map_or(0, |current| current.len());
entry.len() == len
&& current.is_none_or(|current| {
entry
.iter()
.all(|(id, file)| current.get(id).is_some_and(|c| Rc::ptr_eq(c, file)))
})
}
pub fn undo(&mut self) -> Option<Diff> {
self.record_diff(|stack| {
if let Some(i) = stack.index {
if i == 0 {
if !stack.floor {
stack.index = None;
}
} else {
stack.index = Some(i - 1);
}
}
})
}
pub fn redo(&mut self) -> Option<Diff> {
self.record_diff(|stack| match stack.index {
Some(i) => {
if i + 1 < stack.entries.len() {
stack.index = Some(i + 1);
}
}
None => {
if !stack.entries.is_empty() {
stack.index = Some(0);
}
}
})
}
pub fn can_undo(&self) -> bool {
self.index.is_some_and(|i| i > 0 || !self.floor)
}
pub fn can_redo(&self) -> bool {
match self.index {
Some(i) => i + 1 < self.entries.len(),
None => !self.entries.is_empty(),
}
}
fn get(&self, index: usize) -> Option<&StackEntry> {
self.entries.get(index)
}
fn push(&mut self, entry: StackEntry) {
if let Some(i) = self.index {
self.entries.truncate(i + 1);
}
self.entries.push(entry);
self.index = Some(self.entries.len() - 1);
}
fn record_diff<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut Self),
{
let prev = self.index;
f(self);
let cur = self.index;
if prev == cur {
return None;
}
let prev = prev.map_or_default(|i| self.get(i));
let cur = cur.map_or_default(|i| self.get(i));
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
let mut modified = vec![];
for id in prev_ids.intersection(&cur_ids) {
let before = prev.map_or_default(|e| e.get(id));
let after = cur.map_or_default(|e| e.get(id));
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) {
modified.push(*id);
}
}
Some(Diff {
added: cur_ids.difference(&prev_ids).copied().collect(),
removed: prev_ids.difference(&cur_ids).copied().collect(),
modified,
})
}
}
pub type StackEntry = HashMap<FileId, Rc<File>>;
/// Which files were added, removed or modified by a change of the current stack entry.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Diff {
pub added: Vec<FileId>,
pub removed: Vec<FileId>,
pub modified: Vec<FileId>,
}
#[cfg(test)]
mod tests {
use super::*;
impl Stack {
/// A step that is expected not to fail.
fn push_ok(
&mut self,
f: impl FnOnce(&mut StackEntry) -> Result<(), CommandError>,
) -> Option<Diff> {
self.create_and_push_next(f).unwrap()
}
}
#[test]
fn test_new_file() {
let mut stack = Stack::default();
assert!(!stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_none());
let diff = stack.push_ok(|e| {
let file = Rc::new(File::default());
e.insert(file.id, file);
Ok(())
});
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_some());
assert!(diff.is_some());
let diff = diff.unwrap();
assert_eq!(diff.added.len(), 1);
assert!(diff.removed.is_empty());
assert!(diff.modified.is_empty());
}
fn add_file(stack: &mut Stack) -> FileId {
let file = Rc::new(File::default());
let id = file.id;
stack
.push_ok(|e| {
e.insert(id, file);
Ok(())
})
.unwrap();
id
}
#[test]
fn test_failed_command_pushes_nothing() {
let mut stack = Stack::default();
let diff = stack.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default()));
Err(CommandError::InvalidData("nope".to_string()))
});
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
assert!(!stack.can_undo());
assert!(stack.current().is_none());
}
#[test]
fn test_undo_redo() {
let mut stack = Stack::default();
assert!(stack.undo().is_none());
assert!(stack.redo().is_none());
let id = add_file(&mut stack);
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty());
assert!(stack.current().is_none());
assert!(!stack.can_undo());
assert!(stack.can_redo());
assert!(stack.undo().is_none());
let diff = stack.redo().unwrap();
assert_eq!(diff.added, vec![id]);
assert!(stack.current().unwrap().contains_key(&id));
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
}
#[test]
fn test_modified_and_removed() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
let diff = stack
.push_ok(|e| {
let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty());
// a step that changes nothing is not a step
assert!(stack.push_ok(|_| Ok(())).is_none());
assert!(
stack
.push_ok(|e| {
let file = e[&id].clone();
e.insert(id, file);
Ok(())
})
.is_none()
);
let diff = stack
.push_ok(|e| {
e.remove(&id);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(stack.current().unwrap().is_empty());
// going back restores the renamed file
stack.undo();
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
// a copy, even if it has the same content, is another file for the stack: files are
// told apart by their identity, comparing them would read all of them
let diff = stack
.push_ok(|e| {
let copy = (**e.get(&id).unwrap()).clone();
e.insert(id, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
// and the files that were not replaced are not
let other = add_file(&mut stack);
let diff = stack
.push_ok(|e| {
let copy = (**e.get(&other).unwrap()).clone();
e.insert(other, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![other]);
}
#[test]
fn test_push_after_undo_drops_redo_branch() {
let mut stack = Stack::default();
let first = add_file(&mut stack);
let second = add_file(&mut stack);
stack.undo();
assert!(stack.can_redo());
assert!(!stack.current().unwrap().contains_key(&second));
let third = add_file(&mut stack);
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
let current = stack.current().unwrap();
assert!(current.contains_key(&first));
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
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
-7
View File
@@ -1,7 +0,0 @@
mod core;
mod engine;
mod storage;
pub use core::*;
pub use engine::*;
pub use storage::*;
-593
View File
@@ -1,593 +0,0 @@
//! 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
@@ -1,318 +0,0 @@
//! 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
@@ -1,219 +0,0 @@
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
@@ -1,172 +0,0 @@
//! 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
@@ -1,575 +0,0 @@
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());
}
}
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "gpx-rs"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1.7"
gpx-engine = { path = "../engine" }
idb = "0.6.5"
js-sys = "0.3.106"
uuid = { version = "1.26.1", features = ["js"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4.79"
[dev-dependencies]
wasm-bindgen-test = "0.3"
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff. Show more