149 Commits
Author SHA1 Message Date
vcoppe 45f877ec78 route export, filter out anchors at high zoom level 2026-10-26 19:27:12 +01:00
vcoppe 0f957fb1f2 create route file action 2026-10-26 19:22:14 +01:00
vcoppe 764d0b233a route - track conversion 2026-10-26 19:06:19 +01:00
vcoppe b6c0f5190a route full support 2026-10-26 18:51:56 +01:00
vcoppe c842da8dc1 reorder context menu items 2026-10-18 19:09:06 +02:00
vcoppe 5bc35f30d9 strip native menu 2026-10-18 18:43:12 +02:00
vcoppe 873e2e1654 Merge branch 'dev' into tauri 2026-10-18 18:15:37 +02:00
vcoppe 6c2d2879f4 use new endpoint for openhikingmap 2026-10-18 17:20:09 +02:00
vcoppe f5670cdd8e remove custom ign styles 2026-10-18 17:14:50 +02:00
vcoppe 741d35db1e sparse desktop build 2026-10-18 16:53:39 +02:00
vcoppe 6556c29492 fix 2026-10-18 16:38:59 +02:00
vcoppe 7fa850e4c8 fix routing control size 2026-10-18 16:34:03 +02:00
vcoppe f9e8d4c644 show popup once data is loaded 2026-10-18 16:27:51 +02:00
vcoppe a07da16163 sparser data transfers 2026-10-18 16:20:32 +02:00
vcoppe 4583874e7e sparser data transfers 2026-10-18 12:35:32 +02:00
vcoppe 48325e6016 performance improvements 2026-10-18 10:50:34 +02:00
vcoppe 9033401094 improved preview 2026-10-18 09:32:18 +02:00
vcoppe b64c192c4f consistent highlight color 2026-10-18 09:17:01 +02:00
vcoppe 9dd4af98a5 small fix 2026-10-18 09:09:00 +02:00
vcoppe 05510ece92 full screen 2026-10-18 00:38:53 +02:00
vcoppe 8bbcbdc097 remove focus-ring 2026-10-18 00:33:03 +02:00
vcoppe 3012f6bf73 fixes 2026-10-18 00:24:16 +02:00
vcoppe 5373d0f0d4 cleaning 2026-10-17 23:55:46 +02:00
vcoppe 8fd47d8b18 drop anywhere in folder 2026-10-17 23:39:44 +02:00
vcoppe a5c1f6ad33 fix folder styling 2026-10-17 23:35:56 +02:00
vcoppe 3ef2bf135a explorer actions 2026-10-17 23:23:29 +02:00
vcoppe adbf89e318 clarify filename 2026-10-17 22:17:17 +02:00
vcoppe 11709316d2 avoid selecting on resize 2026-10-17 20:02:09 +02:00
vcoppe d52b61f707 commit missing changes 2026-10-17 20:01:59 +02:00
vcoppe 358253b7a9 open links in browser 2026-10-17 19:57:50 +02:00
vcoppe 8d6885d58b remember language 2026-10-17 19:36:57 +02:00
vcoppe 128338aa05 fix sorting 2026-10-17 19:10:50 +02:00
vcoppe c84e1c9a06 fix routing on other file 2026-10-17 19:03:33 +02:00
vcoppe 9f49119eec no file delete on desktop 2026-10-17 18:57:40 +02:00
vcoppe 67a47cf065 fix tab size 2026-10-17 18:50:23 +02:00
vcoppe e57949dc01 fix 2026-10-17 18:33:50 +02:00
vcoppe da92fd15d2 fix sortable 2026-10-17 18:28:32 +02:00
vcoppe 01443943b3 fix sortable 2026-10-17 17:21:56 +02:00
vcoppe 970cf7d1e1 improved sticky folders 2026-10-17 16:39:41 +02:00
vcoppe 593b43e433 sticky folders 2026-10-17 16:24:19 +02:00
vcoppe 8fa88680b3 fix 2026-10-17 16:17:45 +02:00
vcoppe b2495d1466 progress 2026-10-17 16:14:17 +02:00
vcoppe ed90ba6a1f file explorer 2026-10-17 14:42:49 +02:00
vcoppe b0a3c3a9ba progress 2026-10-16 18:14:59 +02:00
vcoppe 005cc7b305 fix 2026-10-16 09:05:45 +02:00
vcoppe 1bfd319758 use history variant 2026-10-16 09:02:31 +02:00
vcoppe 187296a277 keep original flag 2026-10-16 08:54:40 +02:00
vcoppe 55a8b020e0 per file history 2026-10-16 08:42:45 +02:00
vcoppe 1ba5436af3 progress 2026-10-14 19:15:02 +02:00
vcoppe 2e1ca00afa fix 2026-10-14 09:41:07 +02:00
vcoppe 97d597e139 progress 2026-10-14 09:22:07 +02:00
vcoppe 41b9116c6a progress 2026-10-14 08:51:38 +02:00
vcoppe ce8398f749 renaming 2026-10-14 08:17:51 +02:00
vcoppe 3c2055b50e improve flow 2026-10-14 08:07:58 +02:00
vcoppe 1d13972014 fix 2026-10-11 11:19:57 +02:00
vcoppe 890bcd765a refactoring 2026-10-11 11:18:36 +02:00
vcoppe 4795513344 reorder methods 2026-10-11 10:58:15 +02:00
vcoppe d84ca47745 refactoring 2026-10-11 10:54:06 +02:00
vcoppe 9adb8e7221 outdated state 2026-10-11 10:48:43 +02:00
vcoppe a2b4d89150 cross-tab sync 2026-10-10 11:07:07 +02:00
vcoppe 77929cfa04 per-chunk categories storage 2026-10-09 17:55:07 +02:00
vcoppe dc36de2a48 import and export of routes, ref #192 2026-10-09 09:26:38 +02:00
vcoppe da9abbae5e refactoring and tests 2026-10-09 08:58:00 +02:00
vcoppe 3bb553d6b5 remove unused fields 2026-10-08 21:19:53 +02:00
vcoppe 83b76bb838 fix link fields 2026-10-08 21:13:44 +02:00
vcoppe 42a55e6759 progress 2026-10-08 21:04:29 +02:00
vcoppe b085a1d476 cleaning 2026-10-08 19:04:46 +02:00
vcoppe 840e943626 update readme 2026-10-08 18:41:27 +02:00
vcoppe 16e2e0f679 remove todo 2026-10-08 18:37:46 +02:00
vcoppe 0f9557ea82 remove gpx js package 2026-10-08 18:37:40 +02:00
vcoppe 67a4c090bd progress 2026-10-08 18:34:19 +02:00
vcoppe 606a0e974a export 2026-10-08 09:24:15 +02:00
vcoppe e8c4eb92f9 handle corrupt time data 2026-10-08 08:59:53 +02:00
vcoppe f0851086c0 fix reactivity 2026-10-08 08:47:14 +02:00
vcoppe bec1ce4c9b reduce 2026-10-08 08:36:29 +02:00
vcoppe b29c4cdb08 fix bounds for waypoints 2026-10-08 08:15:22 +02:00
vcoppe 63ba8596a7 time tool 2026-10-08 08:12:44 +02:00
vcoppe 82d2f79da0 progress 2026-10-07 22:20:40 +02:00
vcoppe f642d60f42 merge 2026-10-07 19:57:27 +02:00
vcoppe 24b877c4ee split 2026-10-07 19:38:21 +02:00
vcoppe e07ec97e12 crop 2026-10-07 18:54:00 +02:00
vcoppe 290f4d8c44 reverse 2026-10-07 18:45:56 +02:00
vcoppe a48608840f reverse 2026-10-07 18:36:00 +02:00
vcoppe df07892e76 refactoring 2026-10-07 18:22:30 +02:00
vcoppe 594dd8979a routing 2026-10-07 17:58:17 +02:00
vcoppe 29850c1c8f speed fix 2026-10-07 09:35:27 +02:00
vcoppe adbe4a2693 copy on wasm side to avoid any data corruption 2026-10-07 08:57:37 +02:00
vcoppe b843a18441 reduce data exchanges across boundary 2026-10-07 08:45:50 +02:00
vcoppe da8a9e3d84 handle self closing elements 2026-10-04 22:01:23 +02:00
vcoppe c6bd5b06e8 clean tool 2026-10-04 20:38:46 +02:00
vcoppe c04367dd86 progress 2026-10-04 19:19:53 +02:00
vcoppe 5e2f506120 progress 2026-10-04 19:14:41 +02:00
vcoppe c536e6247b progress 2026-10-04 17:29:57 +02:00
vcoppe 594d714b7f small fix 2026-10-04 17:19:35 +02:00
vcoppe 543fecadb8 fix popups 2026-10-04 17:09:41 +02:00
vcoppe 017d8521d6 use engine info 2026-10-04 16:27:03 +02:00
vcoppe 78e9d45072 small fix 2026-10-04 16:21:48 +02:00
vcoppe 7651d859c4 progress 2026-10-04 16:11:44 +02:00
vcoppe ec448b72e2 progress 2026-10-04 15:47:09 +02:00
vcoppe bf946f179a progress 2026-10-04 15:26:58 +02:00
vcoppe 9ff9b5475a progress 2026-10-04 15:03:37 +02:00
vcoppe 55f1d275b7 progress 2026-10-04 14:49:53 +02:00
vcoppe 1d534881f8 progress 2026-10-04 13:18:18 +02:00
vcoppe 03cbacfbb4 fix 2026-10-04 12:33:15 +02:00
vcoppe 9d0905959b fix markers 2026-10-04 12:23:42 +02:00
vcoppe b7ca462f58 progress 2026-10-04 11:52:46 +02:00
vcoppe 9015da4876 fixes 2026-10-04 11:07:12 +02:00
vcoppe f458a1104f progress 2026-10-04 11:00:46 +02:00
vcoppe caba9388af progress 2026-10-04 10:29:39 +02:00
vcoppe 763a7406ce progress 2026-10-04 10:15:51 +02:00
vcoppe 68c8933c35 progress 2026-10-04 09:55:08 +02:00
vcoppe 84b1558b2e progress 2026-10-04 09:50:34 +02:00
vcoppe cd870f0186 visibility 2026-10-04 09:40:17 +02:00
vcoppe 9ac2a2b69c progress 2026-10-04 08:53:00 +02:00
vcoppe 86aba8f4b1 progress 2026-10-04 08:26:15 +02:00
vcoppe 74b674e8e8 progress 2026-10-04 08:21:39 +02:00
vcoppe 6a0d434371 Merge branch 'dev' 2026-07-24 19:07:00 +02:00
vcoppe b319440a57 upgrade to maplibre v6 2026-07-24 19:06:39 +02:00
vcoppe b0d78b5f3c Merge branch 'dev' 2026-07-21 12:30:07 +02:00
vcoppe 2e8b2d6f30 add ukrainian 2026-07-21 11:20:31 +02:00
Dobrovolskyi Bohdan 5259d007c9 Fix Locale subscription (#369) 2026-07-21 12:04:42 +02:00
vcoppe cb36725a46 New Crowdin updates (#373)
* New translations en.json (German)

[ci skip]

* New translations view.mdx (German)

[ci skip]
2026-07-21 12:00:16 +02:00
vcoppe deef1bab47 fix embedding playground zoom 2026-07-21 11:11:13 +02:00
Dobrovolskyi Bohdan 108880f8cd Update language on map (#365) 2026-07-14 18:50:51 +02:00
vcoppe 37a659831b New Crowdin updates (#359)
* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations file.mdx (Ukrainian)

[ci skip]

* New translations file.mdx (Ukrainian)

[ci skip]

* New translations settings.mdx (Ukrainian)

[ci skip]

* New translations view.mdx (Ukrainian)

[ci skip]

* New translations clean.mdx (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations clean.mdx (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations extract.mdx (Ukrainian)

[ci skip]

* New translations merge.mdx (Ukrainian)

[ci skip]

* New translations minify.mdx (Ukrainian)

[ci skip]

* New translations poi.mdx (Ukrainian)

[ci skip]

* New translations elevation.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations scissors.mdx (Ukrainian)

[ci skip]

* New translations time.mdx (Ukrainian)

[ci skip]

* New translations faq.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations view.mdx (Ukrainian)

[ci skip]

* New translations faq.mdx (Ukrainian)

[ci skip]

* New translations files-and-stats.mdx (Ukrainian)

[ci skip]

* New translations getting-started.mdx (Ukrainian)

[ci skip]

* New translations gpx.mdx (Ukrainian)

[ci skip]

* New translations integration.mdx (Ukrainian)

[ci skip]

* New translations map-controls.mdx (Ukrainian)

[ci skip]

* New translations menu.mdx (Ukrainian)

[ci skip]

* New translations toolbar.mdx (Ukrainian)

[ci skip]

* New translations en.json (Basque)

[ci skip]
2026-07-14 18:49:25 +02:00
vcoppe fde6bafc10 Merge branch 'dev' 2026-06-18 23:07:43 +02:00
vcoppe 4537ea4730 Merge branch 'dev' 2026-06-03 19:05:25 +02:00
vcoppe 1954bbd764 Merge branch 'dev' 2026-06-03 18:48:43 +02:00
vcoppe 9d2da80893 Merge branch 'dev' 2026-05-31 15:26:17 +02:00
vcoppe 043d368c74 Merge branch 'dev' 2026-05-29 08:37:39 +02:00
vcoppe bb11d3375b Merge branch 'dev' 2026-05-28 19:29:34 +02:00
vcoppe bfe5961e27 Merge branch 'dev' 2026-05-21 20:27:41 +02:00
vcoppe 17559913fd Merge branch 'dev' 2026-04-19 16:49:41 +02:00
vcoppe 55590d68a6 Merge branch 'dev' 2026-04-19 16:34:17 +02:00
vcoppe 8e9f16c460 Merge branch 'dev' 2026-04-19 14:47:35 +02:00
vcoppe c60b64f24f Merge branch 'dev' 2026-04-17 22:10:40 +02:00
vcoppe fdb6fe4e52 Merge branch 'dev' 2026-04-17 20:11:25 +02:00
vcoppe 0bf168e67e Merge branch 'dev' 2026-04-09 21:10:56 +02:00
vcoppe d762a45eb9 Merge branch 'dev' 2026-04-09 20:57:30 +02:00
vcoppe 548ab9a459 Merge branch 'dev' 2026-04-07 22:19:15 +02:00
vcoppe 315c1f6a61 Merge branch 'dev' 2026-04-07 22:02:14 +02:00
vcoppe 98257bee12 Merge branch 'dev' 2026-04-06 18:26:22 +02:00
vcoppe 5561b7d9fe Merge branch 'dev' 2026-04-06 14:26:56 +02:00
vcoppe 320887206c Merge branch 'dev' 2026-04-04 23:15:01 +02:00
vcoppe 04a1bf6a55 Merge branch 'dev' 2026-04-03 08:47:57 +02:00
vcoppe 0989371874 Merge branch 'dev' 2026-04-03 08:06:19 +02:00
vcoppe 3dcd6e52d3 Merge branch 'dev' 2026-04-02 22:35:46 +02:00
vcoppe 0f06d0d461 update 2026-04-02 22:23:34 +02:00
vcoppe db33310a10 revert some changes 2026-04-02 22:20:47 +02:00
539 changed files with 50041 additions and 51763 deletions

No files matched your search

+19 -7
View File
@@ -15,19 +15,31 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: npm cache: npm
cache-dependency-path: | cache-dependency-path: website/package-lock.json
gpx/package-lock.json
website/package-lock.json
- name: Install dependencies for gpx - name: Install Rust
run: npm install --prefix gpx uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Build gpx - name: Install wasm-pack
run: npm run build --prefix gpx uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- name: Test gpx-rs
run: cargo test
- name: Build wasm
run: wasm-pack build --target bundler
working-directory: wasm
- name: Install dependencies for website - name: Install dependencies for website
run: npm install --prefix website run: npm install --prefix website
- name: Test website
run: npm test --prefix website
- name: Create env file - name: Create env file
run: | run: |
touch website/.env touch website/.env
+1
View File
@@ -1 +1,2 @@
.DS_Store .DS_Store
target/
Generated
+5330
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
[workspace]
members = ["gpx-rs", "wasm", "tauri"]
# `cargo test` at the root only tests the engine: the others need their own tooling
default-members = ["gpx-rs"]
resolver = "3"
+9 -11
View File
@@ -25,19 +25,20 @@ Any help is greatly appreciated!
## Development ## Development
The code is split into two parts: The code is split into four parts:
- `gpx`: a Typescript library for parsing and manipulating GPX files, - `gpx-rs`: a Rust library for parsing and manipulating GPX files (the engine), and the interface to it that the next two share (`gpx_engine::api`),
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. - `wasm`: the engine for the browser, compiled to WebAssembly,
- `tauri`: the desktop app, which runs the engine natively,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. It also is the UI of the desktop app.
You will need [Node.js](https://nodejs.org/) to build and run these two parts. You will need [Node.js](https://nodejs.org/), [Rust](https://rustup.rs/) and [wasm-pack](https://rustwasm.github.io/wasm-pack/) to build and run the website. See [`tauri`](tauri/README.md) for the desktop app.
### Building the `gpx` library ### Building the WebAssembly engine
```bash ```bash
cd gpx cd wasm
npm install wasm-pack build --target bundler
npm run build
``` ```
### Running the website ### Running the website
@@ -64,9 +65,6 @@ This project has been made possible thanks to the following open source projects
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling - [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts - [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic: - Logic:
- [immer](https://github.com/immerjs/immer) — complex state management
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree - [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping: - Mapping:
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering - [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
-2
View File
@@ -1,2 +0,0 @@
target/
pkg/
-398
View File
@@ -1,398 +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 = "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 = "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 = "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 = "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",
"quick-xml",
"uuid",
]
[[package]]
name = "gpx-rs"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"gpx-engine",
"js-sys",
"uuid",
"wasm-bindgen",
]
[[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 = "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 = "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 = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[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 = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[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 = "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 = "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",
"wasm-bindgen",
]
[[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-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 = "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",
]
+10 -5
View File
@@ -1,6 +1,11 @@
[workspace] [package]
members = ["engine", "wasm"] name = "gpx-engine"
resolver = "3" version = "0.1.0"
edition = "2024"
[profile.release] [dependencies]
debug = true 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"] }
+10 -3
View File
@@ -1,3 +1,10 @@
``` # gpx-rs
wasm-pack build wasm --target bundler --out-dir ../pkg
``` The GPX engine: plain Rust, no knowledge of who uses it. Tested natively with `cargo test` (from
here or from the root).
- `src/core`, `src/engine`, `src/storage`: the engine itself.
- `src/api`: the interface to the engine that the frontends (`../wasm`, `../tauri`) share. Each
function takes the engine and typed arguments, and gives plain data (`api::dto`, serde types, or
slices for the numeric buffers). A frontend only converts what crosses: ids from strings or
bytes, results to JS objects or JSON, slices to typed arrays or raw bytes.
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="gpx.studio" xmlns="http://www.topografix.com/GPX/1/1"
xmlns:ext="https://example.com/ext/0.1">
<metadata>
<name>self closing points</name>
<desc>Points without children are self-closing elements.</desc>
<extensions>
<ext:version>2.0</ext:version>
<ext:paths/>
</extensions>
</metadata>
<wpt lat="50.0" lon="4.0"/>
<wpt lat="50.1" lon="4.1"><name>with children</name></wpt>
<wpt lat="50.2" lon="4.2"/>
<trk>
<name>track</name>
<trkseg>
<trkpt lat="50.00" lon="4.00"/>
<trkpt lat="50.01" lon="4.01"><ele>12.5</ele></trkpt>
<trkpt lat="50.02" lon="4.02"/>
</trkseg>
<trkseg>
<trkpt lat="51.00" lon="5.00"/>
</trkseg>
</trk>
</gpx>
File renamed without changes.
File renamed without changes.
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_highway</name>
</metadata>
<trk>
<name>with_highway</name>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791000" lon="4.405100">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791200" lon="4.405300">
<ele>111.0</ele>
</trkpt>
<trkpt lat="50.791400" lon="4.405500">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<highway>track</highway>
<sac_scale>mountain_hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791600" lon="4.405700">
<ele>113.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<sac_scale>hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
File renamed without changes.
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="test">
<metadata>
<name>with links</name>
<author>
<name>someone</name>
<email id="someone" domain="example.com"/>
<link href="https://example.com/author"><text>author</text></link>
</author>
<link href="https://example.com/file-1"><text>first</text></link>
<link href="https://example.com/file-2"/>
</metadata>
<wpt lat="50" lon="4">
<name>summit</name>
<link href="https://example.com/wpt-1"><text>one</text></link>
<link href="https://example.com/wpt-2"/>
<link href="https://example.com/wpt-3"><text>three</text></link>
</wpt>
<trk>
<name>track</name>
<link href="https://example.com/trk-1"/>
<link href="https://example.com/trk-2"><text>two</text></link>
<trkseg>
<trkpt lat="50" lon="4"/>
<trkpt lat="50.1" lon="4.1"/>
</trkseg>
</trk>
<rte>
<name>route</name>
<link href="https://example.com/rte-1"/>
<link href="https://example.com/rte-2"/>
<rtept lat="51" lon="5"/>
<rtept lat="51.1" lon="5.1"/>
</rte>
<wpt lat="51" lon="5">
<name>email variants</name>
</wpt>
</gpx>
File renamed without changes.
File renamed without changes.
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
<rte>
<name>with extensions</name>
<rtept lat="1" lon="1">
<ele>10</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="1.1" lon="1.1"/>
<gpxx:rpt lat="1.2" lon="1.2"></gpxx:rpt>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="2" lon="2">
<ele>20</ele>
<time>2024-01-01T00:00:00Z</time>
</rtept>
<rtept lat="3" lon="3"/>
</rte>
<rte>
<name>empty</name>
</rte>
</gpx>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
<rte>
<name>Lausanne</name>
<cmt>comment</cmt>
<desc>description</desc>
<type>Cycling</type>
<rtept lat="46.544628096744418" lon="6.658079791814089">
<ele>500</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="46.544483928009868" lon="6.658018603920937"/>
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="46.544100000000000" lon="6.659000000000000">
<ele>510</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="46.543900000000000" lon="6.659500000000000"/>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="46.543000000000000" lon="6.660000000000000">
<ele>505</ele>
</rtept>
</rte>
</gpx>
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "gpx-engine"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.45"
quick-xml = "0.42.0"
uuid = { version = "1.26.1", features = ["v4"] }
-4
View File
@@ -1,4 +0,0 @@
mod simplify;
mod smooth;
pub use simplify::*;
@@ -1,133 +0,0 @@
use crate::{
core::gpx::{TrackSegment, TrackSegmentIndex},
core::utils::crossarc,
};
pub fn ramer_douglas_peucker<F>(
trkseg: &TrackSegment,
mapping: &F,
epsilon: f64,
) -> Vec<TrackSegmentIndex>
where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
match trkseg.len() {
0 => vec![],
1 => vec![trkseg.first_index().unwrap()],
2 => vec![trkseg.first_index().unwrap(), trkseg.last_index().unwrap()],
_ => {
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
let mut indices = vec![first];
ramer_douglas_peucker_helper(trkseg, first, last, mapping, epsilon, &mut indices);
indices.push(last);
indices
}
}
}
fn ramer_douglas_peucker_helper<F>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
mapping: &F,
epsilon: f64,
indices: &mut Vec<TrackSegmentIndex>,
) where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
let mut max_idx = None;
let mut max_dist = 0.0;
let start_pt = mapping(start);
let end_pt = mapping(end);
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let pt = mapping(idx);
let dist = crossarc(start_pt.0, start_pt.1, end_pt.0, end_pt.1, pt.0, pt.1);
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, mapping, epsilon, indices);
indices.push(idx);
ramer_douglas_peucker_helper(trkseg, idx, end, mapping, epsilon, indices);
}
}
#[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());
}
}
-100
View File
@@ -1,100 +0,0 @@
use uuid::Uuid;
use crate::{Trackpoint, Waypoint};
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
#[derive(Debug, PartialEq, Eq)]
pub struct TrackpointChunkId(Uuid);
impl Default for TrackpointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default)]
pub struct TrackpointChunk {
pub id: TrackpointChunkId,
pub trkpt: Vec<Trackpoint>,
}
impl PartialEq for TrackpointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl TrackpointChunk {
pub fn is_full(&self) -> bool {
self.trkpt.len() == MAX_TRKPT_CHUNK_SIZE
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaypointChunkId(Uuid);
impl Default for WaypointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
const MAX_WPT_CHUNK_SIZE: usize = 128;
#[derive(Debug, Default)]
pub struct WaypointChunk {
pub id: WaypointChunkId,
pub wpt: Vec<Waypoint>,
}
impl PartialEq for WaypointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl WaypointChunk {
pub fn is_full(&self) -> bool {
self.wpt.len() == MAX_WPT_CHUNK_SIZE
}
}
#[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_chunk_equality_is_by_id() {
let a = TrackpointChunk::default();
let b = TrackpointChunk::default();
assert_ne!(a, b);
let c = TrackpointChunk {
id: TrackpointChunkId(a.id.0),
trkpt: vec![Trackpoint::default()],
};
assert_eq!(a, c);
}
}
-49
View File
@@ -1,49 +0,0 @@
use std::rc::Rc;
use uuid::Uuid;
use crate::{Link, Track, WaypointChunk};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileId(pub Uuid);
impl Default for FileId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileWaypointsRevisionId(pub Uuid);
impl Default for FileWaypointsRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
pub trk: Vec<Track>,
pub wpt: Vec<Rc<WaypointChunk>>,
pub wpt_rev_id: FileWaypointsRevisionId,
// TODO routes
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
pub author: Option<Author>,
pub link: Option<Link>,
pub time: Option<i64>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Author {
pub name: Option<String>,
pub email: Option<String>,
pub link: Option<Link>,
}
-454
View File
@@ -1,454 +0,0 @@
use std::{ops::Index, rc::Rc};
use uuid::Uuid;
use crate::{Trackpoint, TrackpointChunk};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackSegmentId(pub Uuid);
impl Default for TrackSegmentId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackSegmentRevisionId(pub Uuid);
impl Default for TrackSegmentRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackSegment {
pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId,
chunks: Vec<Rc<TrackpointChunk>>,
cumul_length: Vec<usize>,
}
impl TrackSegment {
pub fn push(&mut self, chunk: TrackpointChunk) {
self.push_shared(Rc::new(chunk));
}
fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
if chunk.trkpt.is_empty() {
return;
}
self.cumul_length
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.trkpt.len());
self.chunks.push(chunk);
}
/// Replaces the points in `start..end` by `points`. Panics if the range is out of bounds.
///
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the
/// range are copied and refilled.
pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
assert!(
start <= end && end <= self.len(),
"splice range out of bounds"
);
let old = std::mem::take(&mut self.chunks);
self.cumul_length.clear();
let mut pending = TrackpointChunk::default();
let mut inserted = false;
let mut offset = 0;
for chunk in old {
let (lo, hi) = (offset, offset + chunk.trkpt.len());
offset = hi;
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
if hi < start || (hi == start && chunk.is_full()) {
self.push_shared(chunk);
continue;
}
if !inserted {
self.fill(&mut pending, chunk.trkpt[..start - lo].iter().cloned());
self.fill(&mut pending, points.iter().cloned());
inserted = true;
}
if hi <= end {
continue;
}
if lo >= end {
self.flush(&mut pending);
self.push_shared(chunk);
} else {
self.fill(&mut pending, chunk.trkpt[end - lo..].iter().cloned());
}
}
if !inserted {
self.fill(&mut pending, points);
}
self.flush(&mut pending);
}
fn fill(
&mut self,
pending: &mut TrackpointChunk,
points: impl IntoIterator<Item = Trackpoint>,
) {
for trkpt in points {
pending.trkpt.push(trkpt);
if pending.is_full() {
self.flush(pending);
}
}
}
fn flush(&mut self, pending: &mut TrackpointChunk) {
self.push(std::mem::take(pending));
}
pub fn len(&self) -> usize {
self.cumul_length.last().copied().unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
pub fn iter(&self) -> TrackSegmentIterator<'_> {
TrackSegmentIterator::new(self)
}
pub fn first_index(&self) -> Option<TrackSegmentIndex> {
self.next_index(None)
}
pub fn last_index(&self) -> Option<TrackSegmentIndex> {
self.prev_index(None)
}
pub fn next_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut next = cur.map_or_default(|idx| TrackSegmentIndex {
chunk: idx.chunk,
pos: idx.pos + 1,
flat: idx.flat + 1,
});
loop {
if next.chunk >= self.chunks.len() {
return None;
}
if next.pos == self.chunks[next.chunk].trkpt.len() {
next.chunk += 1;
next.pos = 0;
} else {
return Some(next);
}
}
}
pub fn prev_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut prev = cur.unwrap_or(TrackSegmentIndex {
chunk: self.chunks.len(),
pos: 0,
flat: self.cumul_length.last().copied().unwrap_or_default(),
});
if prev.pos == 0 {
while prev.chunk > 0 {
prev.chunk -= 1;
if !self.chunks[prev.chunk].trkpt.is_empty() {
prev.pos = self.chunks[prev.chunk].trkpt.len() - 1;
prev.flat -= 1;
return Some(prev);
}
}
None
} else {
prev.pos -= 1;
prev.flat -= 1;
Some(prev)
}
}
fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> {
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
if chunk >= self.chunks.len() {
return None;
}
let pos = if chunk > 0 {
idx - self.cumul_length[chunk - 1]
} else {
idx
};
if pos >= self.chunks[chunk].trkpt.len() {
None
} else {
Some(TrackSegmentIndex {
chunk,
pos,
flat: idx,
})
}
}
}
impl Index<TrackSegmentIndex> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: TrackSegmentIndex) -> &Self::Output {
&self.chunks[idx.chunk].trkpt[idx.pos]
}
}
impl Index<usize> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: usize) -> &Self::Output {
&self[self.locate(idx).unwrap()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct TrackSegmentIndex {
pub chunk: usize,
pub pos: usize,
pub flat: usize,
}
#[derive(Debug, Clone)]
pub struct TrackSegmentIterator<'a> {
trkseg: &'a TrackSegment,
idx: Option<TrackSegmentIndex>,
}
impl<'a> TrackSegmentIterator<'a> {
pub fn new(trkseg: &'a TrackSegment) -> Self {
Self {
trkseg,
idx: Default::default(),
}
}
}
impl<'a> Iterator for TrackSegmentIterator<'a> {
type Item = &'a Trackpoint;
fn next(&mut self) -> Option<Self::Item> {
self.idx = self.trkseg.next_index(self.idx);
self.idx.map(|idx| &self.trkseg[idx])
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let idx = self.idx.map_or_default(|idx| idx.flat) + n;
self.idx = self.trkseg.locate(idx);
self.idx.map(|idx| &self.trkseg[idx])
}
}
#[cfg(test)]
mod tests {
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());
}
#[test]
fn test_splice_keeps_untouched_chunks_shared() {
let mut trkseg = create_track_segment(5);
let before = trkseg.chunks.clone();
// 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.cumul_length.last().unwrap(), 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;
}
}
}
-13
View File
@@ -1,13 +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>,
// TODO OSM data? or store intervals at a higher level?
}
-26
View File
@@ -1,26 +0,0 @@
use uuid::Uuid;
use crate::{Link, LngLat};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WaypointId(pub Uuid);
impl Default for WaypointId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[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 link: Option<Link>,
pub sym: Option<String>,
pub type_: Option<String>,
}
-579
View File
@@ -1,579 +0,0 @@
use std::rc::Rc;
use crate::{
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointChunk, Waypoint,
WaypointChunk,
};
use chrono::DateTime;
use quick_xml::Error;
use quick_xml::events::Event;
use quick_xml::events::attributes::Attributes;
use quick_xml::reader::Reader;
enum GPXElement {
Metadata,
Name,
Comment,
Description,
Source,
Author(Author),
Link(Link),
Text,
Track(Track),
Segment(TrackSegment),
Trackpoint(Trackpoint),
Waypoint(Waypoint),
Elevation,
Time,
Temperature,
Heartrate,
Cadence,
Power,
Symbol,
Type,
Color,
Opacity,
Width,
}
fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
let mut coordinates = LngLat::default();
for attr in attributes.flatten() {
match attr.key.as_ref() {
"lat" => coordinates.lat = attr.value.parse().unwrap_or_default(),
"lon" => coordinates.lng = attr.value.parse().unwrap_or_default(),
_ => (),
}
}
coordinates
}
pub fn parse(data: &[u8]) -> Result<File, Error> {
let mut reader = Reader::from_reader(data);
let mut buf = vec![];
let mut gpx = File::default();
let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => match e.name().as_ref() {
"metadata" => stack.push(GPXElement::Metadata),
"name" => stack.push(GPXElement::Name),
"cmt" => stack.push(GPXElement::Comment),
"desc" => stack.push(GPXElement::Description),
"src" => stack.push(GPXElement::Source),
"author" => stack.push(GPXElement::Author(Author::default())),
"link" => {
let mut link = Link::default();
for attr in e.attributes().flatten() {
if attr.key.as_ref() == "href" {
link.href = attr.value.to_string();
}
}
stack.push(GPXElement::Link(link));
}
"text" => stack.push(GPXElement::Text),
"trk" => stack.push(GPXElement::Track(Track::default())),
"trkseg" => {
stack.push(GPXElement::Segment(TrackSegment::default()));
}
"trkpt" => {
stack.push(GPXElement::Trackpoint(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
}));
}
"wpt" => {
stack.push(GPXElement::Waypoint(Waypoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
}));
}
"ele" => stack.push(GPXElement::Elevation),
"time" => stack.push(GPXElement::Time),
e if e.ends_with("atemp") => stack.push(GPXElement::Temperature),
e if e.ends_with("hr") => stack.push(GPXElement::Heartrate),
e if e.ends_with("cad") => stack.push(GPXElement::Cadence),
"power" => stack.push(GPXElement::Power),
e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power),
"sym" => stack.push(GPXElement::Symbol),
"type" => stack.push(GPXElement::Type),
e if e.ends_with("color") => stack.push(GPXElement::Color),
e if e.ends_with("opacity") => stack.push(GPXElement::Opacity),
e if e.ends_with("width") => stack.push(GPXElement::Width),
_ => (),
},
Ok(Event::End(e)) => match e.name().as_ref() {
"gpx" => {
if !wpt_chunk.wpt.is_empty() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
"metadata" => {
stack.pop();
}
"author" => {
if let Some(GPXElement::Author(author)) = stack.pop() {
gpx.info.author = Some(author);
}
}
"link" => {
if let Some(GPXElement::Link(link)) = stack.pop() {
match stack.last_mut() {
Some(GPXElement::Author(author)) => {
author.link = Some(link);
}
Some(GPXElement::Track(trk)) => {
trk.info.link = Some(link);
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.link = Some(link);
}
_ => (),
}
}
}
"trk" => {
if let Some(GPXElement::Track(trk)) = stack.pop() {
gpx.trk.push(trk);
}
}
"trkseg" => {
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
{
// `push` ignores the chunk if it is empty
trkseg.push(std::mem::take(&mut trkpt_chunk));
trk.trkseg.push(trkseg);
}
}
"trkpt" => {
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
&& let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
{
trkpt_chunk.trkpt.push(trkpt);
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"wpt" => {
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
wpt_chunk.wpt.push(wpt);
if wpt_chunk.is_full() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
}
_ => (),
},
Ok(Event::Text(e)) => match stack.last_mut() {
Some(GPXElement::Name) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Metadata) => {
gpx.info.name = e.to_string();
}
Some(GPXElement::Author(author)) => {
author.name = Some(e.to_string());
}
Some(GPXElement::Track(trk)) => {
trk.info.name = Some(e.to_string());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.name = Some(e.to_string());
}
_ => (),
}
}
Some(GPXElement::Comment) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.cmt = Some(e.to_string());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.cmt = Some(e.to_string());
}
_ => (),
}
}
Some(GPXElement::Description) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Metadata) => {
gpx.info.desc = Some(e.to_string());
}
Some(GPXElement::Track(trk)) => {
trk.info.desc = Some(e.to_string());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.desc = Some(e.to_string());
}
_ => (),
}
}
Some(GPXElement::Source) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.src = Some(e.to_string());
}
}
Some(GPXElement::Text) => {
stack.pop();
if let Some(GPXElement::Link(link)) = stack.last_mut() {
link.text = Some(e.to_string());
}
}
Some(GPXElement::Elevation) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Trackpoint(trkpt)) => {
trkpt.ele = e.parse().unwrap_or_default();
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.ele = e.parse().unwrap_or_default();
}
_ => (),
}
}
Some(GPXElement::Time) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.time = DateTime::parse_from_rfc3339(e.as_ref())
.ok()
.map(|time| time.timestamp_millis());
}
}
Some(GPXElement::Temperature) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.atemp = e.parse().ok();
}
}
Some(GPXElement::Heartrate) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.hr = e.parse().ok();
}
}
Some(GPXElement::Cadence) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.cad = e.parse().ok();
}
}
Some(GPXElement::Power) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.power = e.parse().ok();
}
}
Some(GPXElement::Symbol) => {
stack.pop();
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
wpt.sym = Some(e.to_string());
}
}
Some(GPXElement::Type) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.type_ = Some(e.to_string());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.type_ = Some(e.to_string());
}
_ => (),
}
}
Some(GPXElement::Color) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(format!("#{}", &*e));
}
}
Some(GPXElement::Opacity) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.opacity = e.parse().ok();
}
}
Some(GPXElement::Width) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.width = e.parse().ok();
}
}
_ => (),
},
Ok(Event::Eof) => break,
Err(e) => return Err(e),
_ => (),
}
buf.clear();
}
Ok(gpx)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_data(name: &str) -> File {
parse(&std::fs::read(format!("data/{name}.gpx")).unwrap()).unwrap()
}
#[test]
fn test_parse_simple() {
let gpx = parse_data("simple");
assert_eq!(gpx.info.name, "simple");
assert!(gpx.info.desc.is_some_and(|d| d == "description"));
assert!(gpx.info.author.is_some());
let author = gpx.info.author.as_ref().unwrap();
assert!(author.name.as_ref().is_some_and(|n| n == "gpx.studio"));
assert!(author.link.is_some());
let link = author.link.as_ref().unwrap();
assert_eq!(link.href, "https://gpx.studio");
assert!(link.text.as_ref().is_some_and(|t| t == "link text"));
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert!(trk.info.name.as_ref().is_some_and(|n| n == "track name"));
assert!(trk.info.cmt.as_ref().is_some_and(|c| c == "track comment"));
assert!(
trk.info
.desc
.as_ref()
.is_some_and(|d| d == "track description")
);
assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source"));
assert!(trk.info.link.is_some());
let link = trk.info.link.as_ref().unwrap();
assert_eq!(link.href, "https://gpx.studio");
assert!(link.text.as_ref().is_some_and(|t| t == "track link text"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 80);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
}
#[test]
fn test_parse_tracks() {
let gpx = parse_data("with_tracks");
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 1"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 49);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trk = &gpx.trk[1];
assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 2"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 28);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.782212);
assert_eq!(trkpt.coordinates.lng, 4.406377);
assert_eq!(trkpt.ele, 115.5);
}
#[test]
fn test_parse_segments() {
let gpx = parse_data("with_segments");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 2);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 49);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trkseg = &trk.trkseg[1];
assert_eq!(trkseg.len(), 28);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.782212);
assert_eq!(trkpt.coordinates.lng, 4.406377);
assert_eq!(trkpt.ele, 115.5);
}
#[test]
fn test_parse_tracks_and_segments() {
let gpx = parse_data("with_tracks_and_segments");
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 2);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 16);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trkseg = &trk.trkseg[1];
assert_eq!(trkseg.len(), 34);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.78727108169855);
assert_eq!(trkpt.coordinates.lng, 4.406133681127736);
assert_eq!(trkpt.ele, 115.0);
let trk = &gpx.trk[1];
assert_eq!(trk.trkseg.len(), 2);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 19);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.782212);
assert_eq!(trkpt.coordinates.lng, 4.406377);
assert_eq!(trkpt.ele, 115.5);
let trkseg = &trk.trkseg[1];
assert_eq!(trkseg.len(), 10);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.77906316558724);
assert_eq!(trkpt.coordinates.lng, 4.412547477922485);
assert_eq!(trkpt.ele, 133.3);
}
#[test]
fn test_parse_waypoint() {
let gpx = parse_data("with_waypoint");
assert_eq!(gpx.wpt.len(), 1);
let chunk = &gpx.wpt[0];
assert_eq!(chunk.wpt.len(), 1);
let wpt = &chunk.wpt[0];
assert_eq!(wpt.coordinates.lat, 50.7836710064975);
assert_eq!(wpt.coordinates.lng, 4.410764082658738);
assert!(wpt.name.as_ref().is_some_and(|n| n == "waypoint name"));
assert!(wpt.cmt.as_ref().is_some_and(|c| c == "waypoint comment"));
assert!(
wpt.desc
.as_ref()
.is_some_and(|d| d == "waypoint description")
);
assert!(wpt.link.is_some());
let link = wpt.link.as_ref().unwrap();
assert_eq!(link.href, "https://gpx.studio");
assert!(
link.text
.as_ref()
.is_some_and(|t| t == "waypoint link text")
);
assert!(wpt.sym.as_ref().is_some_and(|s| s == "Bike Trail"));
assert!(wpt.type_.as_ref().is_some_and(|t| t == "Bike Trail"));
}
#[test]
fn test_parse_trackpoint_time() {
let gpx = parse_data("with_time");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.time.is_some_and(|t| t == 1704063600000));
}
#[test]
fn test_parse_trackpoint_hr() {
let gpx = parse_data("with_hr");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.hr.is_some_and(|h| h == 150));
}
#[test]
fn test_parse_trackpoint_cad() {
let gpx = parse_data("with_cad");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.cad.is_some_and(|c| c == 80));
}
#[test]
fn test_parse_trackpoint_power_1() {
let gpx = parse_data("with_power_1");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.power.is_some_and(|p| p == 200));
}
#[test]
fn test_parse_trackpoint_power_2() {
let gpx = parse_data("with_power_2");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.power.is_some_and(|p| p == 200));
}
#[test]
fn test_parse_trackpoint_atemp() {
let gpx = parse_data("with_temp");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert!(!trkseg.is_empty());
let trkpt = &trkseg[0];
assert!(trkpt.atemp.is_some_and(|t| t == 21));
}
#[test]
fn test_parse_track_style() {
let gpx = parse_data("with_style");
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
assert!(trk.info.color.as_ref().is_some_and(|c| c == "#2d3ee9"));
assert!(trk.info.opacity.is_some_and(|o| o == 0.5));
assert!(trk.info.width.is_some_and(|w| w == 6.0));
}
}
-5
View File
@@ -1,5 +0,0 @@
mod statistics;
mod utils;
pub use statistics::*;
pub use utils::*;
-24
View File
@@ -1,24 +0,0 @@
use crate::{Command, FileId};
/// 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,
Select {
file_ids: Vec<FileId>,
},
AddSelect {
file_ids: Vec<FileId>,
},
SelectAll,
/// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder {
file_ids: Vec<FileId>,
index: usize,
},
}
@@ -1,86 +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());
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, Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_metadata_applies_to_selected_file() {
let mut fx = Fixture::default();
New { name: "first" }.apply(&mut fx.state()).unwrap();
New { name: "second" }.apply(&mut fx.state()).unwrap(); // now selected
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 }.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);
}
}
@@ -1,48 +0,0 @@
use crate::{Apply, CommandError, State, parse, produce};
#[derive(Debug)]
pub struct Load<'a> {
pub data: &'a [u8],
}
impl Apply for Load<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let file = parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?;
produce(state, |_| vec![file]);
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 }.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" }.apply(&mut fx.state()).unwrap();
let selected = fx.selected_files();
let result = Load {
data: b"<gpx><trk></gpx>",
}
.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);
}
}
@@ -1,11 +0,0 @@
mod delete;
mod delete_all;
mod duplicate;
mod load;
mod new;
pub use delete::*;
pub use delete_all::*;
pub use duplicate::*;
pub use load::*;
pub use new::*;
@@ -1,36 +0,0 @@
use crate::{Apply, CommandError, File, State, produce};
#[derive(Debug)]
pub struct New<'a> {
pub name: &'a str,
}
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();
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" }.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]);
}
}
-15
View File
@@ -1,15 +0,0 @@
mod apply;
mod command;
mod edit;
mod file;
#[cfg(test)]
pub(crate) mod fixture;
mod pattern;
mod tools;
pub use apply::*;
pub use command::*;
pub use edit::*;
pub use file::*;
pub use pattern::*;
pub use tools::*;
@@ -1,40 +0,0 @@
use std::rc::Rc;
use crate::{File, Waypoint, WaypointChunk};
/// Edits the waypoints of the chunks containing a waypoint accepted by `filter`.
///
/// `f` receives a copy of the chunk's waypoints and returns whether it changed them. A
/// changed chunk is replaced by a new one (with a new id, so that derived data is recomputed)
/// and chunks left empty are dropped. Returns whether anything changed.
pub fn edit_waypoint_chunks(
file: &mut File,
filter: impl Fn(&Waypoint) -> bool,
mut f: impl FnMut(&mut Vec<Waypoint>) -> bool,
) -> bool {
let mut changed = false;
let mut chunks = Vec::with_capacity(file.wpt.len());
for chunk in file.wpt.drain(..) {
if !chunk.wpt.iter().any(&filter) {
chunks.push(chunk);
continue;
}
let mut wpt = chunk.wpt.clone();
if !f(&mut wpt) {
chunks.push(chunk);
continue;
}
changed = true;
if !wpt.is_empty() {
chunks.push(Rc::new(WaypointChunk {
wpt,
..Default::default()
}));
}
}
file.wpt = chunks;
if changed {
file.wpt_rev_id = Default::default();
}
changed
}
@@ -1,7 +0,0 @@
mod edit_waypoint_chunks;
mod produce;
mod update_selected;
pub use edit_waypoint_chunks::*;
pub use produce::*;
pub use update_selected::*;
@@ -1,22 +0,0 @@
use crate::{Apply, CommandError, LngLatBounds, State};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleanType {
Inside,
Outside,
}
#[derive(Debug)]
pub struct Clean {
pub bounds: LngLatBounds,
pub type_: CleanType,
pub trkpt: bool,
pub wpt: bool,
}
impl Apply for Clean {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("clean"))
}
}
@@ -1,14 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Crop {
pub start: u32,
pub end: u32,
}
impl Apply for Crop {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("crop"))
}
}
@@ -1,13 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Elevation<'a> {
pub ele: &'a [f64],
}
impl Apply for Elevation<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("elevation"))
}
}
@@ -1,11 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Extract;
impl Apply for Extract {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("extract"))
}
}
@@ -1,19 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeType {
Connect,
Group,
}
#[derive(Debug)]
pub struct Merge {
pub type_: MergeType,
}
impl Apply for Merge {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("merge"))
}
}
@@ -1,15 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct MoveWaypoint {
pub lng: f64,
pub lat: f64,
pub ele: f64,
}
impl Apply for MoveWaypoint {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("move waypoint"))
}
}
@@ -1,19 +0,0 @@
use crate::{Apply, CommandError, State};
#[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,
}
impl Apply for NewWaypoint<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("new waypoint"))
}
}
@@ -1,11 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Reverse;
impl Apply for Reverse {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("reverse"))
}
}
@@ -1,305 +0,0 @@
use std::rc::Rc;
use crate::{Apply, CommandError, File, FileId, LngLat, Selection, State, Trackpoint};
/// Replaces the trackpoints `start..end` of the last track segment of the selection by the
/// given ones (a pure insertion when `start == end`, a pure removal when there are no new
/// points).
///
/// The selection can be made of several files, tracks or track segments: the indices are
/// meant for its very last segment, in file order, then track order, then segment order.
#[derive(Debug)]
pub struct SpliceTrackpoints<'a> {
pub start: u32,
pub end: u32,
pub lng: &'a [f64],
pub lat: &'a [f64],
pub ele: &'a [f64],
}
impl Apply for SpliceTrackpoints<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize);
if start > end || self.lng.len() != self.lat.len() || self.lat.len() != self.ele.len() {
return Err(CommandError::InvalidData(
"invalid trackpoints range".into(),
));
}
let (file_id, trk, seg) = last_segment(state).ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&file_id]).clone();
let segment = &mut file.trk[trk].trkseg[seg];
if end > segment.len() {
return Err(CommandError::InvalidData(
"trackpoints range out of bounds".into(),
));
}
if start == end && self.lng.is_empty() {
return Err(CommandError::NothingToDo);
}
let points = (0..self.lng.len())
.map(|i| Trackpoint {
coordinates: LngLat {
lng: self.lng[i],
lat: self.lat[i],
},
ele: self.ele[i],
..Default::default()
})
.collect();
segment.splice(start, end, points);
segment.rev_id = Default::default();
state.files.insert(file_id, Rc::new(file));
Ok(())
}
}
/// Position (file, track index, segment index) of the last segment of the selection.
fn last_segment(state: &State) -> Option<(FileId, usize, usize)> {
let last_of_track = |file: &File, trk: usize| file.trk[trk].trkseg.len().checked_sub(1);
let last_of_file = |file: &File, filter: &dyn Fn(usize) -> bool| {
(0..file.trk.len())
.rev()
.filter(|&trk| filter(trk))
.find_map(|trk| last_of_track(file, trk).map(|seg| (trk, seg)))
};
match &*state.selection {
Selection::File { file_ids } => state
.order
.0
.iter()
.rev()
.filter(|id| file_ids.contains(id))
.find_map(|id| {
let (trk, seg) = last_of_file(state.files.get(id)?, &|_| true)?;
Some((*id, trk, seg))
}),
Selection::Track { file_id, trk_ids } => {
let file = state.files.get(file_id)?;
let (trk, seg) = last_of_file(file, &|trk| trk_ids.contains(&file.trk[trk].id))?;
Some((*file_id, trk, seg))
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state.files.get(file_id)?;
let trk = file.trk.iter().position(|trk| trk.id == *trk_id)?;
let seg = file.trk[trk]
.trkseg
.iter()
.rposition(|seg| trkseg_ids.contains(&seg.id))?;
Some((*file_id, trk, seg))
}
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
let trk = &fx.files[&id].trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
};
(fx, id)
}
fn splice<'a>(
start: u32,
end: u32,
lng: &'a [f64],
lat: &'a [f64],
ele: &'a [f64],
) -> SpliceTrackpoints<'a> {
SpliceTrackpoints {
start,
end,
lng,
lat,
ele,
}
}
#[test]
fn test_splice_replaces_points_of_selected_segment_only() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let len = before.trk[0].trkseg[0].len();
assert!(len >= 3);
splice(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0])
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]);
assert_eq!(new.len(), len + 1);
assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng);
assert_eq!(new[1].coordinates.lng, 1.0);
assert_eq!(new[3].coordinates.lat, 6.0);
assert_eq!(new[3].ele, 9.0);
assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng);
assert_ne!(new.rev_id, old.rev_id);
assert_eq!(new.id, old.id);
// other segments and tracks keep their revision
for (b, a) in before.trk.iter().zip(&after.trk) {
for (b, a) in b.trkseg.iter().zip(&a.trkseg) {
if a.id != new.id {
assert_eq!(a.rev_id, b.rev_id);
}
}
}
}
#[test]
fn test_splice_append_and_remove() {
let (mut fx, id) = loaded();
let len = fx.files[&id].trk[0].trkseg[0].len() as u32;
splice(len, len, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let seg = &fx.files[&id].trk[0].trkseg[0];
assert_eq!(seg.len(), len as usize + 1);
assert_eq!(seg[len as usize].ele, 3.0);
splice(0, len + 1, &[], &[], &[])
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.files[&id].trk[0].trkseg[0].len(), 0);
}
#[test]
fn test_splice_invalid_arguments_change_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let len = before.trk[0].trkseg[0].len() as u32;
let results = [
splice(2, 1, &[], &[], &[]).apply(&mut fx.state()),
splice(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
splice(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
];
assert!(
results
.iter()
.all(|r| matches!(r, Err(CommandError::InvalidData(_))))
);
assert_eq!(
splice(0, 0, &[], &[], &[]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_splice_needs_a_segment_selection() {
let (mut fx, _) = loaded();
fx.selection = Selection::Empty;
assert_eq!(
splice(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_splice_targets_last_segment_of_the_selection() {
let (mut fx, id) = loaded();
let file = fx.files[&id].clone();
assert!(file.trk.len() >= 2);
let last_trk = file.trk.len() - 1;
let last_seg = file.trk[last_trk].trkseg.len() - 1;
// whole file selected: last segment of its last track
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
for (t, (b, a)) in file.trk.iter().zip(&after.trk).enumerate() {
for (s, (b, a)) in b.trkseg.iter().zip(&a.trkseg).enumerate() {
let target = (t, s) == (last_trk, last_seg);
assert_eq!(a.len(), b.len() + usize::from(target));
assert_eq!(a.rev_id != b.rev_id, target);
}
}
// several tracks selected: last selected one (in file order, whatever the set order)
let ids: Vec<_> = file.trk.iter().map(|t| t.id).collect();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([ids[1], ids[0]]),
};
let len = |fx: &Fixture, t: usize| {
let trk = &fx.files[&id].trk[t];
trk.trkseg.last().unwrap().len()
};
let (l0, l1) = (len(&fx, 0), len(&fx, 1));
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(len(&fx, 0), l0);
assert_eq!(len(&fx, 1), l1 + 1);
// several segments selected
let trk = &fx.files[&id].trk[0];
let seg_ids: Vec<_> = trk.trkseg.iter().map(|s| s.id).collect();
if seg_ids.len() >= 2 {
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: seg_ids.iter().copied().collect(),
};
let lens: Vec<_> = trk.trkseg.iter().map(|s| s.len()).collect();
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let after: Vec<_> = fx.files[&id].trk[0]
.trkseg
.iter()
.map(|s| s.len())
.collect();
let n = lens.len();
assert_eq!(after[..n - 1], lens[..n - 1]);
assert_eq!(after[n - 1], lens[n - 1] + 1);
}
}
#[test]
fn test_splice_on_multiple_files_uses_last_in_file_order() {
let (mut fx, first) = loaded();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let second = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([second, first]),
};
let total = |fx: &Fixture, id: FileId| -> usize {
fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.len())
.sum()
};
let (a, b) = (total(&fx, first), total(&fx, second));
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(total(&fx, first), a);
assert_eq!(total(&fx, second), b + 1);
}
}
@@ -1,13 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Split {
pub at: u32,
}
impl Apply for Split {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("split"))
}
}
@@ -1,11 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Time;
impl Apply for Time {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("time"))
}
}
@@ -1,149 +0,0 @@
use crate::{GlobalStatistics, Statistics, sum_options};
#[derive(Debug, Default)]
pub struct StatisticsBuffer {
pub total_distance: Vec<f64>,
pub moving_distance: Vec<f64>,
pub total_time: Vec<i32>,
pub moving_time: Vec<i32>,
pub speed: 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>,
}
impl StatisticsBuffer {
pub fn update(&mut self, stats: &[&Statistics]) {
self.total_distance.clear();
self.moving_distance.clear();
self.total_time.clear();
self.moving_time.clear();
self.speed.clear();
self.elevation_gain.clear();
self.elevation_loss.clear();
self.slope.clear();
self.slope_segment_slope.clear();
self.slope_segment_distance.clear();
let mut cumul_stats = GlobalStatistics::default();
for stats in stats {
for trkpt_stats in stats.local.iter() {
self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
self.moving_distance.push(
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance)
.unwrap_or_default(),
);
self.total_time.push(
sum_options(cumul_stats.total_time, trkpt_stats.total_time).unwrap_or_default(),
);
self.moving_time.push(
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time)
.unwrap_or_default(),
);
self.speed.push(trkpt_stats.speed.unwrap_or_default());
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);
}
cumul_stats.merge(&stats.global);
}
}
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn stats(path: &str) -> Statistics {
let data = std::fs::read(path).unwrap();
let file = parse(&data).unwrap();
Statistics::compute(&file.trk[0].trkseg[0])
}
#[test]
fn test_empty() {
let mut buffer = StatisticsBuffer::default();
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
assert!(buffer.speed.is_empty());
}
#[test]
fn test_single_segment_matches_local_stats() {
let s = stats("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s]);
let n = s.local.len();
for len in [
buffer.total_distance.len(),
buffer.moving_distance.len(),
buffer.total_time.len(),
buffer.moving_time.len(),
buffer.speed.len(),
buffer.elevation_gain.len(),
buffer.elevation_loss.len(),
buffer.slope.len(),
buffer.slope_segment_slope.len(),
buffer.slope_segment_distance.len(),
] {
assert_eq!(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);
}
}
#[test]
fn test_distances_accumulate_over_segments() {
let s = stats("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s, &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 s = stats("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(&[&s, &s]);
assert_eq!(buffer.total_time[n - 1], duration);
assert_eq!(buffer.total_time[n], duration);
assert_eq!(buffer.total_time[2 * n - 1], 2 * duration);
assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1]));
}
#[test]
fn test_update_replaces_previous_content() {
let s = stats("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s]);
buffer.update(&[&s]);
assert_eq!(buffer.total_distance.len(), s.local.len());
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
}
}
-449
View File
@@ -1,449 +0,0 @@
// TODO license in every file
use crate::{
Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure,
FileStructureCache, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId,
};
#[derive(Debug, Default)]
pub struct Engine {
stack: Stack,
selection: Selection,
order: FileOrder,
statistics_cache: StatisticsCache,
coordinates_cache: CoordinatesCache,
structure_cache: FileStructureCache,
diff: Option<Diff>,
order_changed: bool,
selection_changed: bool,
statistics_buffer: StatisticsBuffer,
}
impl Engine {
pub fn statistics(&self) -> &StatisticsBuffer {
&self.statistics_buffer
}
/// 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)
}
/// Which files the last action added, removed or modified.
pub fn last_diff(&self) -> Option<&Diff> {
self.diff.as_ref()
}
/// Whether the last action changed the order of the files.
pub fn order_changed(&self) -> bool {
self.order_changed
}
/// Currently selected elements.
pub fn selection(&self) -> &Selection {
&self.selection
}
/// Whether the last action changed the selection.
pub fn selection_changed(&self) -> bool {
self.selection_changed
}
/// 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. Returns whether anything
/// changed.
pub fn execute(&mut self, action: Action) -> bool {
let selection_before = self.selection.clone();
let order_before = self.order.0.clone();
// the files change iff there is a diff, but selection and order actions have none
self.diff = match action {
Action::Edit(command) => self.edit(command),
Action::Undo => self.stack.undo(),
Action::Redo => self.stack.redo(),
Action::Select { file_ids } => {
self.selection = Selection::File {
file_ids: file_ids.into_iter().collect(),
};
None
}
Action::AddSelect { file_ids } => {
match &mut self.selection {
Selection::File { file_ids: ids } => ids.extend(file_ids),
selection => {
*selection = Selection::File {
file_ids: file_ids.into_iter().collect(),
}
}
}
None
}
Action::Reorder { file_ids, index } => {
self.order.move_files(&file_ids, index);
None
}
Action::SelectAll => {
self.selection = Selection::File {
file_ids: self.order.0.iter().copied().collect(),
};
None
}
};
let changed = self.diff.is_some()
|| self.selection != selection_before
|| self.order.0 != order_before;
if changed {
self.refresh();
}
// after the refresh, which also syncs the order and selection with the files (undo, redo)
self.order_changed = self.order.0 != order_before;
self.selection_changed = self.selection != selection_before;
changed
}
fn edit(&mut self, command: Command) -> Option<Diff> {
self.stack.create_and_push_next(|files| {
let mut state = State {
files,
selection: &mut self.selection,
order: &mut self.order,
};
command.apply(&mut state).map_err(|err| err.to_string())
})
}
/// Brings everything derived from the files back in line with the current stack entry.
fn refresh(&mut self) {
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, self.diff.as_ref());
self.statistics_buffer
.update(&self.statistics_cache.get(current, &self.selection));
}
}
#[cfg(test)]
mod tests {
use crate::{Load, Metadata, New, NewTrack, Style};
use super::*;
fn edit(engine: &mut Engine, command: Command) -> bool {
engine.execute(Action::Edit(command))
}
fn new(engine: &mut Engine, name: &str) -> bool {
edit(engine, Command::New(New { name }))
}
fn load(engine: &mut Engine, path: &str) -> bool {
let data = std::fs::read(path).unwrap();
edit(engine, Command::Load(Load { data: &data }))
}
fn selected(engine: &Engine) -> Vec<FileId> {
match &engine.selection {
Selection::File { file_ids } => file_ids.iter().copied().collect(),
_ => vec![],
}
}
#[test]
fn test_load_file() {
let mut engine = Engine::default();
assert!(load(&mut engine, "data/simple.gpx"));
assert_eq!(engine.statistics_buffer.total_distance.len(), 80);
}
#[test]
fn test_new_file_has_no_statistics() {
let mut engine = Engine::default();
assert!(new(&mut engine, "empty"));
assert_eq!(engine.stack.current().unwrap().len(), 1);
assert_eq!(engine.order.0.len(), 1);
assert!(engine.statistics().total_distance.is_empty());
}
#[test]
fn test_statistics_follow_selection() {
let mut engine = Engine::default();
assert!(load(&mut engine, "data/simple.gpx"));
let n = engine.statistics().total_distance.len();
assert!(n > 0);
// a newly created (empty) file becomes the selection
assert!(new(&mut engine, "empty"));
assert!(engine.statistics().total_distance.is_empty());
assert_eq!(engine.stack.current().unwrap().len(), 2);
// loading a file selects it again
assert!(load(&mut engine, "data/simple.gpx"));
assert_eq!(engine.statistics().total_distance.len(), n);
}
#[test]
fn test_invalid_load_is_rejected() {
let mut engine = Engine::default();
assert!(!edit(
&mut engine,
Command::Load(Load {
data: b"<gpx><trk></gpx>"
})
));
assert!(engine.stack.current().is_none());
assert!(!engine.stack.can_undo());
assert!(engine.order.0.is_empty());
}
#[test]
fn test_not_implemented_command_is_rejected() {
let mut engine = Engine::default();
new(&mut engine, "file");
assert!(!edit(&mut engine, Command::Reverse(crate::Reverse)));
assert!(!engine.stack.can_redo());
assert_eq!(engine.stack.current().unwrap().len(), 1);
}
#[test]
fn test_metadata_renames_selected_file() {
let mut engine = Engine::default();
new(&mut engine, "before");
assert!(edit(
&mut engine,
Command::Metadata(Metadata {
name: "after",
desc: "about",
})
));
let file = engine.stack.current().unwrap().values().next().unwrap();
assert_eq!(file.info.name, "after");
assert_eq!(file.info.desc.as_deref(), Some("about"));
}
#[test]
fn test_metadata_without_selection_does_not_modify_files() {
let mut engine = Engine::default();
new(&mut engine, "file");
engine.selection = Selection::Empty;
edit(
&mut engine,
Command::Metadata(Metadata {
name: "renamed",
desc: "",
}),
);
let file = engine.stack.current().unwrap().values().next().unwrap();
assert_eq!(file.info.name, "file");
}
#[test]
fn test_edit_changes_statistics_when_segments_change() {
let mut engine = Engine::default();
load(&mut engine, "data/simple.gpx");
let before = engine
.statistics_cache
.get(engine.stack.current(), &engine.selection)[0] as *const _;
edit(&mut engine, Command::NewTrack(NewTrack));
edit(
&mut engine,
Command::Style(Style {
color: Some("ff0000"),
opacity: None,
width: None,
}),
);
// styling does not touch the segments: their statistics are reused
let after = engine
.statistics_cache
.get(engine.stack.current(), &engine.selection)[0] as *const _;
assert!(std::ptr::eq(before, after));
}
#[test]
fn test_undo_redo_keep_selection_and_order_consistent() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = selected(&engine)[0];
new(&mut engine, "b");
let b = selected(&engine)[0];
assert_eq!(engine.order.0, vec![a, b]);
assert!(engine.execute(Action::Undo));
assert_eq!(engine.order.0, vec![a]);
assert!(selected(&engine).is_empty());
assert!(engine.execute(Action::Redo));
assert_eq!(engine.order.0, vec![a, b]);
assert!(engine.execute(Action::Undo));
assert!(engine.execute(Action::Undo));
assert!(engine.order.0.is_empty());
assert!(!engine.execute(Action::Undo));
assert!(engine.stack.current().is_none());
}
#[test]
fn test_reorder_is_not_undoable_and_returning_files_go_last() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = engine.order.0[0];
new(&mut engine, "b");
let b = engine.order.0[1];
new(&mut engine, "c");
let c = engine.order.0[2];
assert!(engine.execute(Action::Reorder {
file_ids: vec![c],
index: 0
}));
assert_eq!(engine.order.0, vec![c, a, b]);
assert!(!engine.execute(Action::Reorder {
file_ids: vec![c],
index: 0
}));
// undoing the creation of c does not undo the reorder
assert!(engine.execute(Action::Undo));
assert_eq!(engine.order.0, vec![a, b]);
// c comes back at the end
assert!(engine.execute(Action::Redo));
assert_eq!(engine.order.0, vec![a, b, c]);
}
#[test]
fn test_structures_and_coordinates_follow_actions() {
let mut engine = Engine::default();
assert!(engine.order().is_empty());
load(&mut engine, "data/simple.gpx");
let loaded_id = engine.order()[0];
let diff = engine.last_diff().unwrap();
assert_eq!(diff.added, vec![loaded_id]);
assert!(engine.order_changed());
new(&mut engine, "empty");
let empty_id = engine.order()[1];
// only the new file is reported, the other one is not recomputed
let diff = engine.last_diff().unwrap();
assert_eq!(diff.added, vec![empty_id]);
assert!(diff.modified.is_empty() && diff.removed.is_empty());
assert_eq!(engine.file_structure(&empty_id).unwrap().name, "empty");
let structure = engine.file_structure(&loaded_id).unwrap();
let seg = &structure.tracks[0].segments[0];
let (seg_id, len) = (seg.id, seg.len);
assert_eq!(engine.segment_coordinates(&seg_id).len(), len * 2);
assert!(engine.waypoint_coordinates(&loaded_id).is_empty());
// reordering only changes the order
assert!(engine.execute(Action::Reorder {
file_ids: vec![loaded_id],
index: 1
}));
assert_eq!(engine.order(), [empty_id, loaded_id]);
assert!(engine.last_diff().is_none());
assert!(engine.order_changed());
// an action that changes nothing reports nothing
assert!(!engine.execute(Action::Reorder {
file_ids: vec![loaded_id],
index: 1
}));
assert!(engine.last_diff().is_none());
assert!(!engine.order_changed());
// undo removes the empty file, redoing the load removes the other one too
assert!(engine.execute(Action::Undo));
let diff = engine.last_diff().unwrap();
assert_eq!(diff.removed, vec![empty_id]);
assert!(engine.order_changed());
assert!(engine.file_structure(&empty_id).is_none());
assert!(engine.execute(Action::Undo));
assert!(engine.order().is_empty());
assert!(engine.file_structure(&loaded_id).is_none());
assert!(engine.segment_coordinates(&seg_id).is_empty());
}
#[test]
fn test_selection_changed_flag() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = engine.order()[0];
// a new file is selected by the edit
assert!(engine.selection_changed());
assert!(!engine.execute(Action::Select { file_ids: vec![a] }));
assert!(!engine.selection_changed());
assert!(engine.execute(Action::Select { file_ids: vec![] }));
assert!(engine.selection_changed());
// undoing drops the selected file from the selection
engine.execute(Action::Select { file_ids: vec![a] });
assert!(engine.execute(Action::Undo));
assert!(engine.selection_changed());
assert_eq!(engine.selection(), &Selection::Empty);
}
#[test]
fn test_undo_updates_statistics() {
let mut engine = Engine::default();
load(&mut engine, "data/simple.gpx");
assert!(!engine.statistics().total_distance.is_empty());
engine.execute(Action::Undo);
assert!(engine.statistics().total_distance.is_empty());
engine.execute(Action::Redo);
engine.execute(Action::SelectAll);
assert_eq!(engine.statistics().total_distance.len(), 80);
}
#[test]
fn test_selection_actions() {
let mut engine = Engine::default();
new(&mut engine, "a");
let a = selected(&engine)[0];
new(&mut engine, "b");
let b = selected(&engine)[0];
assert_eq!(selected(&engine), vec![b]);
assert!(engine.execute(Action::Select { file_ids: vec![a] }));
assert_eq!(selected(&engine), vec![a]);
assert!(engine.execute(Action::AddSelect { file_ids: vec![b] }));
assert_eq!(selected(&engine).len(), 2);
engine.execute(Action::Select { file_ids: vec![] });
assert!(engine.execute(Action::SelectAll));
assert_eq!(selected(&engine).len(), 2);
}
#[test]
fn test_selection_does_not_create_undo_steps() {
let mut engine = Engine::default();
new(&mut engine, "a");
engine.execute(Action::SelectAll);
engine.execute(Action::Undo);
assert!(engine.stack.current().is_none());
}
}
-11
View File
@@ -1,11 +0,0 @@
mod action;
mod command;
mod derived;
mod engine;
mod state;
pub use action::*;
pub use command::*;
pub use derived::*;
pub use engine::*;
pub use state::*;
-13
View File
@@ -1,13 +0,0 @@
mod order;
mod selection;
mod stack;
pub use order::*;
pub use selection::*;
pub use stack::*;
pub struct State<'a> {
pub files: &'a mut StackEntry,
pub selection: &'a mut Selection,
pub order: &'a mut FileOrder,
}
@@ -1,89 +0,0 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
#[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>,
},
}
impl Selection {
/// Drops the files that do not exist anymore (e.g. after an undo).
pub fn retain_existing(&mut self, files: &StackEntry) {
match self {
Selection::Empty => {}
Selection::File { file_ids } => {
file_ids.retain(|id| files.contains_key(id));
if file_ids.is_empty() {
*self = Selection::Empty;
}
}
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => {
if !files.contains_key(file_id) {
*self = Selection::Empty;
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::File;
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));
}
}
-252
View File
@@ -1,252 +0,0 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{File, FileId};
#[derive(Debug, Default)]
pub struct Stack {
entries: Vec<StackEntry>,
index: Option<usize>,
}
impl Stack {
pub fn current(&self) -> Option<&StackEntry> {
self.index.map_or_default(|i| self.get(i))
}
pub fn create_and_push_next<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut StackEntry) -> Result<(), String>,
{
self.record_diff(|stack| {
let mut next = stack.current().map_or_default(|c| c.clone());
if f(&mut next).is_ok() {
stack.push(next);
}
})
}
pub fn undo(&mut self) -> Option<Diff> {
self.record_diff(|stack| {
if let Some(i) = stack.index {
if i == 0 {
stack.index = None;
} else {
stack.index = Some(i - 1);
}
}
})
}
pub fn redo(&mut self) -> Option<Diff> {
self.record_diff(|stack| match stack.index {
Some(i) => {
if i + 1 < stack.entries.len() {
stack.index = Some(i + 1);
}
}
None => {
if !stack.entries.is_empty() {
stack.index = Some(0);
}
}
})
}
pub fn can_undo(&self) -> bool {
self.index.is_some()
}
pub fn can_redo(&self) -> bool {
match self.index {
Some(i) => i + 1 < self.entries.len(),
None => !self.entries.is_empty(),
}
}
fn get(&self, index: usize) -> Option<&StackEntry> {
self.entries.get(index)
}
fn push(&mut self, entry: StackEntry) {
if let Some(i) = self.index {
self.entries.truncate(i + 1);
}
self.entries.push(entry);
self.index = Some(self.entries.len() - 1);
}
fn record_diff<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut Self),
{
let prev = self.index;
f(self);
let cur = self.index;
if prev == cur {
return None;
}
let prev = prev.map_or_default(|i| self.get(i));
let cur = cur.map_or_default(|i| self.get(i));
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
let mut modified = vec![];
for id in prev_ids.intersection(&cur_ids) {
let before = prev.map_or_default(|e| e.get(id));
let after = cur.map_or_default(|e| e.get(id));
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) && before != after {
modified.push(*id);
}
}
Some(Diff {
added: cur_ids.difference(&prev_ids).copied().collect(),
removed: prev_ids.difference(&cur_ids).copied().collect(),
modified,
})
}
}
pub type StackEntry = HashMap<FileId, Rc<File>>;
/// Which files were added, removed or modified by a change of the current stack entry.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Diff {
pub added: Vec<FileId>,
pub removed: Vec<FileId>,
pub modified: Vec<FileId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_file() {
let mut stack = Stack::default();
assert!(!stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_none());
let diff = stack.create_and_push_next(|e| {
let file = Rc::new(File::default());
e.insert(file.id, file);
Ok(())
});
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_some());
assert!(diff.is_some());
let diff = diff.unwrap();
assert_eq!(diff.added.len(), 1);
assert!(diff.removed.is_empty());
assert!(diff.modified.is_empty());
}
fn add_file(stack: &mut Stack) -> FileId {
let file = Rc::new(File::default());
let id = file.id;
stack
.create_and_push_next(|e| {
e.insert(id, file);
Ok(())
})
.unwrap();
id
}
#[test]
fn test_failed_command_pushes_nothing() {
let mut stack = Stack::default();
let diff = stack.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default()));
Err("nope".to_string())
});
assert!(diff.is_none());
assert!(!stack.can_undo());
assert!(stack.current().is_none());
}
#[test]
fn test_undo_redo() {
let mut stack = Stack::default();
assert!(stack.undo().is_none());
assert!(stack.redo().is_none());
let id = add_file(&mut stack);
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty());
assert!(stack.current().is_none());
assert!(!stack.can_undo());
assert!(stack.can_redo());
assert!(stack.undo().is_none());
let diff = stack.redo().unwrap();
assert_eq!(diff.added, vec![id]);
assert!(stack.current().unwrap().contains_key(&id));
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
}
#[test]
fn test_modified_and_removed() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
let diff = stack
.create_and_push_next(|e| {
let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty());
// an unchanged file is not reported as modified
let diff = stack.create_and_push_next(|_| Ok(())).unwrap();
assert!(diff.modified.is_empty());
let diff = stack
.create_and_push_next(|e| {
e.remove(&id);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(stack.current().unwrap().is_empty());
// going back restores the renamed file
stack.undo();
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_push_after_undo_drops_redo_branch() {
let mut stack = Stack::default();
let first = add_file(&mut stack);
let second = add_file(&mut stack);
stack.undo();
assert!(stack.can_redo());
assert!(!stack.current().unwrap().contains_key(&second));
let third = add_file(&mut stack);
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
let current = stack.current().unwrap();
assert!(current.contains_key(&first));
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
}
-5
View File
@@ -1,5 +0,0 @@
mod core;
mod engine;
pub use core::*;
pub use engine::*;
+672
View File
@@ -0,0 +1,672 @@
//! The functions that edit the files or change the selection.
use uuid::Uuid;
use super::dto::*;
use crate::{self as engine, Action, Command, Engine, History, LngLat, LngLatBounds};
/// Executes an action and tells what it did.
pub fn execute<H: History>(engine: &mut Engine<H>, action: Action) -> Outcome {
match engine.execute(action) {
Ok(outcome) => Outcome::new(&outcome, None),
Err(error) => Outcome::failed(error.to_string()),
}
}
pub fn edit<H: History>(engine: &mut Engine<H>, command: Command) -> Outcome {
execute(engine, Action::Edit(command))
}
fn file_ids(ids: &[Uuid]) -> Vec<engine::FileId> {
ids.iter().copied().map(engine::FileId).collect()
}
// Selection
/// Selects files. Unknown files are ignored, and selecting nothing deselects everything.
pub fn select<H: History>(
engine: &mut Engine<H>,
file_ids_: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
select_elements(
engine,
engine::Selection::File {
file_ids: file_ids(file_ids_).into_iter().collect(),
},
mode,
)
}
/// Selects elements: they are merged with the selection when it holds elements of the same kind in
/// the same place (same file, same track for segments), otherwise they replace it.
pub fn select_elements<H: History>(
engine: &mut Engine<H>,
selection: engine::Selection,
mode: engine::SelectMode,
) -> Outcome {
execute(engine, Action::Select { selection, mode })
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
/// files, the routes or the tracks of the file, the segments of the track, the waypoints of the file. All the
/// files when nothing is selected.
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::SelectAll)
}
/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow
/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection,
/// otherwise it replaces it.
pub fn arrow_select<H: History>(engine: &mut Engine<H>, down: bool, add: bool) -> Outcome {
execute(engine, Action::ArrowSelect { down, add })
}
pub fn select_routes<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
route_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Route {
file_id: engine::FileId(file_id),
rte_ids: route_ids.iter().copied().map(engine::RouteId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_tracks<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
track_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Track {
file_id: engine::FileId(file_id),
trk_ids: track_ids.iter().copied().map(engine::TrackId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_segments<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
track_id: Uuid,
segment_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::TrackSegment {
file_id: engine::FileId(file_id),
trk_id: engine::TrackId(track_id),
trkseg_ids: segment_ids
.iter()
.copied()
.map(engine::TrackSegmentId)
.collect(),
};
select_elements(engine, selection, mode)
}
/// Selects the node standing for all the waypoints of a file.
pub fn select_waypoint_group<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Waypoints {
file_id: engine::FileId(file_id),
};
select_elements(engine, selection, mode)
}
pub fn select_waypoints<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Waypoint {
file_id: engine::FileId(file_id),
wpt_ids: waypoint_ids
.iter()
.copied()
.map(engine::WaypointId)
.collect(),
};
select_elements(engine, selection, mode)
}
// History
pub fn undo<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Undo)
}
pub fn redo<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Redo)
}
// Files
/// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
/// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
/// with `route`, of a track otherwise.
pub fn new_file<H: History>(
engine: &mut Engine<H>,
name: &str,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: bool,
) -> Outcome {
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
lng,
lat,
ele: ele.unwrap_or_default(),
});
edit(
engine,
Command::New(engine::New {
name,
trackpoint,
route,
}),
)
}
/// Loads files as a single command (one undo step). The files are in `data`, one after the other,
/// `lengths` being the number of bytes of each one. `names`: the name of each file when its data
/// has none (the name on disk, without extension). The files that cannot be read are skipped, the
/// first one that was read is selected.
pub fn load_files<H: History>(
engine: &mut Engine<H>,
data: &[u8],
lengths: &[usize],
names: &[String],
) -> Outcome {
if lengths.len() != names.len() {
return Outcome::failed(format!(
"{} lengths for {} names",
lengths.len(),
names.len()
));
}
let mut files = Vec::with_capacity(names.len());
let mut offset = 0;
for (length, name) in lengths.iter().zip(names) {
let end = offset + *length;
let Some(data) = data.get(offset..end) else {
return Outcome::failed(format!("the data of {name} is shorter than its length"));
};
files.push(engine::Load { data, name });
offset = end;
}
edit(engine, Command::LoadFiles(engine::LoadFiles { files }))
}
/// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is
/// `(id, data, name)`, and replaces the file of that id, or is added if there is none; it is called
/// `name`, whatever its data says. The files to `remove` are deleted. The selection is left alone.
pub fn sync_files<H: History>(
engine: &mut Engine<H>,
files: &[(Uuid, &[u8], &str)],
remove: &[Uuid],
) -> Outcome {
let files = files
.iter()
.map(|(id, data, name)| engine::ExternalFile {
id: engine::FileId(*id),
data,
name,
})
.collect();
edit(
engine,
Command::SyncFiles(engine::SyncFiles {
files,
remove: file_ids(remove),
}),
)
}
pub fn duplicate<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Duplicate(engine::Duplicate))
}
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements are
/// deleted instead, even if only a track or a waypoint is selected.
pub fn delete<H: History>(engine: &mut Engine<H>, whole_files: bool) -> Outcome {
edit(engine, Command::Delete(engine::Delete { whole_files }))
}
pub fn delete_all<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::DeleteAll(engine::DeleteAll))
}
/// Moves the files to `index` among the other files, in the given order (not undoable).
pub fn reorder<H: History>(engine: &mut Engine<H>, file_ids_: &[Uuid], index: usize) -> Outcome {
execute(
engine,
Action::Reorder {
file_ids: file_ids(file_ids_),
index,
},
)
}
/// Names a file in all its states, as if it had always been called so (not undoable): for a file
/// that is written to a file of another name, whose name it takes.
pub fn rename_file<H: History>(engine: &mut Engine<H>, file_id: Uuid, name: &str) -> Outcome {
execute(
engine,
Action::Rename {
file_id: engine::FileId(file_id),
name: name.to_owned(),
},
)
}
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Copy)
}
/// Like `copy`, but the elements are moved when they are pasted.
pub fn cut<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Cut)
}
/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always the
/// elements as they were when they were copied or cut, even if they were changed or deleted since.
/// It can be pasted several times: what was copied is copied again, what was cut is moved the first
/// time (and the clipboard is not cut after that), then it is copied.
pub fn paste<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Paste(engine::Paste))
}
/// Moves elements to a place of the file tree, keeping their ids, like dragging and dropping them
/// does. The moved elements are selected.
///
/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks
/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes
/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the
/// waypoints of a file.
pub fn move_elements<H: History>(
engine: &mut Engine<H>,
what: Selection,
to: MoveTarget,
) -> Outcome {
edit(
engine,
Command::Move(engine::Move {
what: what.into(),
to: to.into(),
}),
)
}
pub fn metadata<H: History>(engine: &mut Engine<H>, name: &str, desc: &str) -> Outcome {
edit(engine, Command::Metadata(engine::Metadata { name, desc }))
}
/// Only the given fields are changed.
pub fn style<H: History>(
engine: &mut Engine<H>,
color: Option<&str>,
opacity: Option<f64>,
width: Option<f64>,
) -> Outcome {
edit(
engine,
Command::Style(engine::Style {
color,
opacity,
width,
}),
)
}
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
pub fn convert<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Convert(engine::Convert))
}
pub fn new_route<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewRoute(engine::NewRoute))
}
pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrack(engine::NewTrack))
}
pub fn new_track_segment<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrackSegment(engine::NewTrackSegment))
}
// Waypoints. The strings that are empty are the fields that the waypoint does not have.
/// Adds a waypoint to each selected file (or to the file of the selected elements).
#[allow(clippy::too_many_arguments)]
pub fn new_waypoint<H: History>(
engine: &mut Engine<H>,
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> Outcome {
edit(
engine,
Command::NewWaypoint(engine::NewWaypoint {
lng,
lat,
ele,
name,
desc,
icon,
link,
}),
)
}
/// Moves a waypoint of a file, whatever is selected.
pub fn move_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
) -> Outcome {
edit(
engine,
Command::MoveWaypoint(engine::MoveWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
}),
)
}
/// Deletes a waypoint of a file, whatever is selected.
pub fn delete_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Outcome {
edit(
engine,
Command::DeleteWaypoint(engine::DeleteWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
}),
)
}
/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link,
/// position and elevation.
#[allow(clippy::too_many_arguments)]
pub fn update_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> Outcome {
edit(
engine,
Command::EditWaypoint(engine::EditWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
name,
desc,
icon,
link,
}),
)
}
// Tools
pub fn reverse<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Reverse(engine::Reverse))
}
/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so
/// that it goes back to where it started.
pub fn round_trip<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::RoundTrip(engine::RoundTrip))
}
/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its
/// segments one after the other, and removes the others (and what is left empty).
pub fn crop<H: History>(engine: &mut Engine<H>, start: u32, end: u32) -> Outcome {
if start > end {
return Outcome::failed(format!("the range {start}..{end} is reversed"));
}
edit(engine, Command::Crop(engine::Crop { start, end }))
}
/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the
/// line of their neighbours, except the ends of the segments.
pub fn reduce<H: History>(engine: &mut Engine<H>, tolerance: f64) -> Outcome {
edit(engine, Command::Reduce(engine::Reduce { tolerance }))
}
/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the
/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the
/// previous one at `speed` (km/h).
pub fn change_timestamps<H: History>(
engine: &mut Engine<H>,
start_time: f64,
speed: f64,
ratio: f64,
) -> Outcome {
edit(
engine,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Change { speed, ratio },
}),
)
}
/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and
/// lasts `total_time` seconds.
pub fn create_artificial_timestamps<H: History>(
engine: &mut Engine<H>,
start_time: f64,
total_time: f64,
) -> Outcome {
edit(
engine,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Artificial { total_time },
}),
)
}
/// Merges the selection into its first element. `remove_gaps` brings the parts that are connected
/// closer in time, if they have timestamps. Merged files are kept (and the result is a new file) if
/// the history needs it, see [`History::KEEPS_ORIGINALS`].
pub fn merge<H: History>(
engine: &mut Engine<H>,
type_: engine::MergeType,
remove_gaps: bool,
) -> Outcome {
edit(
engine,
Command::Merge(engine::Merge {
type_,
remove_gaps,
keep_originals: H::KEEPS_ORIGINALS,
}),
)
}
/// Extracted files are kept (and the result is new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`].
pub fn extract<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(
engine,
Command::Extract(engine::Extract {
keep_originals: H::KEEPS_ORIGINALS,
}),
)
}
/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.
pub fn elevation<H: History>(engine: &mut Engine<H>, ele: &[f64]) -> Outcome {
edit(engine, Command::Elevation(engine::Elevation { ele }))
}
/// Removes the trackpoints and/or waypoints of the selection inside (or outside) a rectangle.
#[allow(clippy::too_many_arguments)]
pub fn clean<H: History>(
engine: &mut Engine<H>,
west: f64,
south: f64,
east: f64,
north: f64,
type_: engine::CleanType,
trkpt: bool,
wpt: bool,
) -> Outcome {
edit(
engine,
Command::Clean(engine::Clean {
bounds: LngLatBounds {
sw: LngLat {
lng: west,
lat: south,
},
ne: LngLat {
lng: east,
lat: north,
},
},
type_,
trkpt,
wpt,
}),
)
}
// Routing
//
// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only
// mean something for the `revision` they were read with (`routing_revision`), and the commands
// that use them do nothing if the selection is not the one of that revision anymore.
/// Makes the call `f`, unless the selection is not the one of the revision anymore.
fn at_revision<H: History>(
engine: &mut Engine<H>,
revision: u32,
f: impl FnOnce(&mut Engine<H>) -> Outcome,
) -> Outcome {
if engine.routing().revision == revision {
f(engine)
} else {
Outcome::failed("the selection changed since the routing buffers were read")
}
}
/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment
/// (a pure insertion if `start == end`, a pure removal without new points). `attributes` holds the
/// surface, highway, SAC scale and MTB scale of the new points, as intervals, and `anchors` the
/// indices among the new points of the ones that become anchors.
#[allow(clippy::too_many_arguments)]
pub fn route<H: History>(
engine: &mut Engine<H>,
revision: u32,
start: u32,
end: u32,
lng: &[f64],
lat: &[f64],
ele: &[f64],
attributes: &RouteAttributes,
anchors: &[u32],
) -> Outcome {
if lng.len() != lat.len() || lat.len() != ele.len() {
return Outcome::failed("lng, lat and ele differ in length");
}
at_revision(engine, revision, |engine| {
edit(
engine,
Command::Reroute(engine::Reroute {
start,
end,
lng,
lat,
ele,
surface: attributes.surface.view(),
highway: attributes.highway.view(),
sac_scale: attributes.sac_scale.view(),
mtb_scale: attributes.mtb_scale.view(),
anchors,
}),
)
})
}
/// Makes an anchor of the point of the selected segments that is the closest to the coordinates,
/// inserting a trackpoint on the path if none is there.
pub fn insert_anchor<H: History>(
engine: &mut Engine<H>,
revision: u32,
lng: f64,
lat: f64,
) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::InsertAnchor(engine::InsertAnchor { lng, lat }),
)
})
}
/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the
/// trackpoints before it go to the end, and the loop closes on it).
pub fn change_loop_start<H: History>(engine: &mut Engine<H>, revision: u32, index: u32) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::ChangeLoopStart(engine::ChangeLoopStart { index }),
)
})
}
/// Cuts the file, the route, the track or the segment (see `SplitType`) of the trackpoint `at` of the
/// selection in two, there: the trackpoint ends the first part and starts the second one. A file that
/// is cut is kept (and its two parts are new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`].
pub fn split<H: History>(
engine: &mut Engine<H>,
revision: u32,
at: u32,
split_type: engine::SplitType,
) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::Split(engine::Split {
at,
split_type,
keep_originals: H::KEEPS_ORIGINALS,
}),
)
})
}
+636
View File
@@ -0,0 +1,636 @@
//! What crosses to the frontend, as plain data (serde): the shapes of the objects that the frontend
//! reads, which `wasm/src/ts.rs` declares in TypeScript. Ids are hyphenated UUID strings, times
//! are numbers (ms since the epoch), absent fields are skipped.
use crate as engine;
use engine::{FileId, History};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn ids(items: &[FileId]) -> Vec<String> {
items.iter().map(|id| id.0.to_string()).collect()
}
// Outcome
/// What a call that edits the files or changes the selection did.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Outcome {
pub changed: bool,
order_changed: bool,
selection_changed: bool,
clipboard_changed: bool,
added: Vec<String>,
modified: Vec<String>,
removed: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// Whether the files changed in a way that has to be saved (not sent).
#[serde(skip)]
pub saves: bool,
}
impl Outcome {
pub fn new(outcome: &engine::Outcome, error: Option<String>) -> Self {
let diff = outcome.diff.clone().unwrap_or_default();
Self {
changed: outcome.changed,
order_changed: outcome.order_changed,
selection_changed: outcome.selection_changed,
clipboard_changed: outcome.clipboard_changed,
added: ids(&diff.added),
modified: ids(&diff.modified),
removed: ids(&diff.removed),
error,
saves: outcome.changed && (outcome.diff.is_some() || outcome.order_changed),
}
}
/// The outcome of a frontend that knows nothing of the engine: all the files are added, and the
/// order, the selection and the clipboard are to be read.
pub fn everything<H: History>(engine: &engine::Engine<H>) -> Self {
Self {
changed: true,
order_changed: true,
selection_changed: true,
clipboard_changed: true,
added: ids(engine.order()),
modified: vec![],
removed: vec![],
error: None,
saves: false,
}
}
/// The outcome of a call that did nothing and has nothing to say.
pub fn failed_none() -> Self {
Self::new(&engine::Outcome::default(), None)
}
/// The ids of the files that the call added.
pub fn added(&self) -> &[String] {
&self.added
}
/// Whether the call did nothing and says why.
pub fn has_error(&self) -> bool {
self.error.is_some()
}
/// What two calls that were made one after the other did together.
pub fn merge(mut self, other: Self) -> Self {
fn union(into: &mut Vec<String>, from: Vec<String>) {
for id in from {
if !into.contains(&id) {
into.push(id);
}
}
}
self.changed |= other.changed;
self.order_changed |= other.order_changed;
self.selection_changed |= other.selection_changed;
self.clipboard_changed |= other.clipboard_changed;
self.saves |= other.saves;
// a file that was added and then modified is still an added one, and one that was added
// and then removed is not there at all
let added_before = self.added.clone();
union(&mut self.added, other.added);
for id in other.modified {
if !self.added.contains(&id) && !self.modified.contains(&id) {
self.modified.push(id);
}
}
for id in other.removed {
self.added.retain(|added| *added != id);
self.modified.retain(|modified| *modified != id);
if !added_before.contains(&id) && !self.removed.contains(&id) {
self.removed.push(id);
}
}
self.error = self.error.or(other.error);
self
}
/// What a call that does nothing returns.
pub fn failed(message: impl Into<String>) -> Self {
Self::new(&engine::Outcome::default(), Some(message.into()))
}
}
// Selection and clipboard
#[derive(Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum Selection {
Empty,
File {
file_ids: Vec<Uuid>,
},
Route {
file_id: Uuid,
route_ids: Vec<Uuid>,
},
Track {
file_id: Uuid,
track_ids: Vec<Uuid>,
},
Segment {
file_id: Uuid,
track_id: Uuid,
segment_ids: Vec<Uuid>,
},
Waypoints {
file_id: Uuid,
},
Waypoint {
file_id: Uuid,
waypoint_ids: Vec<Uuid>,
},
}
impl From<&engine::Selection> for Selection {
fn from(selection: &engine::Selection) -> Self {
use engine::Selection as S;
match selection {
S::Empty => Self::Empty,
S::File { file_ids } => Self::File {
file_ids: file_ids.iter().map(|id| id.0).collect(),
},
S::Route { file_id, rte_ids } => Self::Route {
file_id: file_id.0,
route_ids: rte_ids.iter().map(|id| id.0).collect(),
},
S::Track { file_id, trk_ids } => Self::Track {
file_id: file_id.0,
track_ids: trk_ids.iter().map(|id| id.0).collect(),
},
S::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => Self::Segment {
file_id: file_id.0,
track_id: trk_id.0,
segment_ids: trkseg_ids.iter().map(|id| id.0).collect(),
},
S::Waypoints { file_id } => Self::Waypoints { file_id: file_id.0 },
S::Waypoint { file_id, wpt_ids } => Self::Waypoint {
file_id: file_id.0,
waypoint_ids: wpt_ids.iter().map(|id| id.0).collect(),
},
}
}
}
impl From<Selection> for engine::Selection {
fn from(selection: Selection) -> Self {
use engine::Selection as S;
match selection {
Selection::Empty => S::Empty,
Selection::File { file_ids } => S::File {
file_ids: file_ids.into_iter().map(FileId).collect(),
},
Selection::Route { file_id, route_ids } => S::Route {
file_id: FileId(file_id),
rte_ids: route_ids.into_iter().map(engine::RouteId).collect(),
},
Selection::Track { file_id, track_ids } => S::Track {
file_id: FileId(file_id),
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
},
Selection::Segment {
file_id,
track_id,
segment_ids,
} => S::TrackSegment {
file_id: FileId(file_id),
trk_id: engine::TrackId(track_id),
trkseg_ids: segment_ids
.into_iter()
.map(engine::TrackSegmentId)
.collect(),
},
Selection::Waypoints { file_id } => S::Waypoints {
file_id: FileId(file_id),
},
Selection::Waypoint {
file_id,
waypoint_ids,
} => S::Waypoint {
file_id: FileId(file_id),
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
},
}
}
}
/// Where moved elements go. A negative index is the start, an infinite one the end (which JSON
/// writes as `null`, so the frontend sends a very large number instead).
#[derive(Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum MoveTarget {
Files {
index: f64,
},
Routes {
file_id: Uuid,
index: f64,
},
Tracks {
file_id: Uuid,
index: f64,
},
Segments {
file_id: Uuid,
track_id: Uuid,
index: f64,
},
Waypoints {
file_id: Uuid,
index: f64,
},
}
impl From<MoveTarget> for engine::MoveTarget {
fn from(target: MoveTarget) -> Self {
use engine::MoveTarget as T;
let index = |index: f64| index.max(0.0) as usize;
match target {
MoveTarget::Files { index: i } => T::Files { index: index(i) },
MoveTarget::Routes { file_id, index: i } => T::Routes {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Segments {
file_id,
track_id,
index: i,
} => T::Segments {
file_id: FileId(file_id),
trk_id: engine::TrackId(track_id),
index: index(i),
},
MoveTarget::Waypoints { file_id, index: i } => T::Waypoints {
file_id: FileId(file_id),
index: index(i),
},
}
}
}
#[derive(Serialize)]
pub struct Clipboard {
#[serde(rename = "type")]
kind: &'static str,
ids: Vec<String>,
cut: bool,
}
impl Clipboard {
pub fn new(clipboard: &engine::Clipboard) -> Self {
let strings = |ids: Vec<Uuid>| ids.iter().map(Uuid::to_string).collect();
let (kind, ids) = match clipboard.content.ids() {
engine::ClipboardIds::Files(ids) => {
("files", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Routes(ids) => {
("routes", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Tracks(ids) => {
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Segments(ids) => (
"segments",
strings(ids.into_iter().map(|id| id.0).collect()),
),
engine::ClipboardIds::Waypoints(ids) => (
"waypoints",
strings(ids.into_iter().map(|id| id.0).collect()),
),
};
Self {
kind,
ids,
cut: clipboard.cut,
}
}
}
// Files
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileStructure {
id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
routes: Vec<RouteNode>,
tracks: Vec<TrackNode>,
waypoints: Vec<WaypointNode>,
waypoints_rev: String,
}
/// The name, description and style of a route or a track.
#[derive(Serialize)]
struct PathInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
opacity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<f64>,
}
impl From<&engine::PathInfo> for PathInfo {
fn from(info: &engine::PathInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
#[derive(Serialize)]
struct RouteNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segment: SegmentNode,
}
#[derive(Serialize)]
struct TrackNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segments: Vec<SegmentNode>,
}
#[derive(Serialize)]
struct SegmentNode {
id: String,
rev: String,
length: f64,
}
impl From<&engine::SegmentNode> for SegmentNode {
fn from(seg: &engine::SegmentNode) -> Self {
Self {
id: seg.id.0.to_string(),
rev: seg.rev_id.0.to_string(),
length: seg.len as f64,
}
}
}
#[derive(Serialize)]
struct WaypointNode {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sym: Option<String>,
}
impl FileStructure {
pub fn new(file: &engine::FileStructure) -> Self {
Self {
id: file.id.0.to_string(),
name: file.name.clone(),
desc: file.desc.clone(),
routes: file
.routes
.iter()
.map(|rte| RouteNode {
id: rte.id.0.to_string(),
info: (&rte.info).into(),
segment: (&rte.segment).into(),
})
.collect(),
tracks: file
.tracks
.iter()
.map(|trk| TrackNode {
id: trk.id.0.to_string(),
info: (&trk.info).into(),
segments: trk.segments.iter().map(Into::into).collect(),
})
.collect(),
waypoints: file
.waypoints
.iter()
.map(|wpt| WaypointNode {
id: wpt.id.0.to_string(),
name: wpt.name.clone(),
sym: wpt.sym.clone(),
})
.collect(),
waypoints_rev: file.wpt_rev_id.0.to_string(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalStatistics {
total_distance: f64,
#[serde(skip_serializing_if = "Option::is_none")]
moving_distance: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
moving_time: Option<f64>,
elevation_gain: f64,
elevation_loss: f64,
#[serde(skip_serializing_if = "Option::is_none")]
start_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
end_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_speed: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
moving_speed: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
bounds: Option<Bounds>,
}
#[derive(Serialize)]
struct Bounds {
west: f64,
south: f64,
east: f64,
north: f64,
}
impl GlobalStatistics {
pub fn new(stats: &engine::GlobalStatistics) -> Self {
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
Self {
total_distance: stats.total_distance,
moving_distance: stats.moving_distance,
total_time: seconds(stats.total_time),
moving_time: seconds(stats.moving_time),
elevation_gain: stats.elevation_gain,
elevation_loss: stats.elevation_loss,
start_time: stats.start_time.map(|t| t as f64),
end_time: stats.end_time.map(|t| t as f64),
total_speed: stats.total_speed(),
moving_speed: stats.moving_speed(),
bounds: (sw.lng <= ne.lng && sw.lat <= ne.lat).then_some(Bounds {
west: sw.lng,
south: sw.lat,
east: ne.lng,
north: ne.lat,
}),
}
}
}
#[derive(Serialize)]
pub struct WaypointDetails {
id: String,
lng: f64,
lat: f64,
ele: f64,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cmt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sym: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
links: Option<Vec<Link>>,
}
#[derive(Serialize)]
struct Link {
href: String,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
}
impl WaypointDetails {
pub fn new(wpt: &engine::Waypoint) -> Self {
Self {
id: wpt.id.0.to_string(),
lng: wpt.coordinates.lng,
lat: wpt.coordinates.lat,
ele: wpt.ele,
time: wpt.time.map(|t| t as f64),
name: wpt.name.clone(),
desc: wpt.desc.clone(),
cmt: wpt.cmt.clone(),
sym: wpt.sym.clone(),
links: (!wpt.links.is_empty()).then(|| {
wpt.links
.iter()
.map(|link| Link {
href: link.href.clone(),
text: link.text.clone(),
})
.collect()
}),
}
}
}
#[derive(Serialize)]
pub struct TrackpointDetails {
lng: f64,
lat: f64,
ele: f64,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f64>,
}
impl TrackpointDetails {
pub fn new(pt: &engine::Trackpoint) -> Self {
Self {
lng: pt.coordinates.lng,
lat: pt.coordinates.lat,
ele: pt.ele,
time: pt.time.map(|t| t as f64),
}
}
}
/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
/// Absent fields are unknown.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionPoint {
/// Slope (%) at the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope: Option<f64>,
/// ms since the epoch.
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<f64>,
/// Slope (%) and length (km) of the smoothed elevation segment of the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_slope: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_distance: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub highway: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sac_scale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtb_scale: Option<String>,
}
// Routing
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
#[derive(Default, Deserialize)]
pub struct RouteCategory {
pub starts: Vec<u32>,
pub values: Vec<u8>,
pub names: Vec<String>,
}
impl RouteCategory {
pub fn view(&self) -> engine::RouteCategory<'_> {
engine::RouteCategory {
starts: &self.starts,
values: &self.values,
names: &self.names,
}
}
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RouteAttributes {
pub surface: RouteCategory,
pub highway: RouteCategory,
pub sac_scale: RouteCategory,
pub mtb_scale: RouteCategory,
}
+21
View File
@@ -0,0 +1,21 @@
//! The interface to the engine, which the interfaces to the frontend share: the web page
//! (`wasm`) and the desktop app (`tauri`) each expose these functions, and only convert what
//! crosses to their own way of passing data.
//!
//! - `commands`: the functions that edit the files or change the selection. Each returns an
//! `Outcome`: whether anything changed, what changed (to update what the frontend keeps), and why
//! nothing was done when that is so (the command had nothing to do, the data is invalid, an
//! argument is wrong).
//! - `queries`: the functions that read the state of the engine.
//! - `dto`: the data that crosses, as serde types.
//!
//! Ids are `Uuid`s: how the frontend sends them (strings, bytes) is for the interfaces to parse.
//! Coordinates and statistics are slices of numbers, which the interfaces copy to typed arrays.
pub mod commands;
pub mod dto;
pub mod queries;
pub use commands::*;
pub use dto::*;
pub use queries::*;
+277
View File
@@ -0,0 +1,277 @@
//! The functions that read the state of the engine.
//!
//! The files are described by one ordered list of ids and one structure per file, so that the UI
//! can react to what changed only: after each command, its `Outcome` tells which structures to
//! (re)read or drop and whether the order changed. The trackpoints of a segment and the waypoints
//! of a file are in the same order as the coordinates of their buffers.
use uuid::Uuid;
use super::dto::*;
use crate::{self as engine, Engine, FileId, History};
/// Ids of the files in display order.
pub fn file_order<H: History>(engine: &Engine<H>) -> Vec<String> {
ids(engine.order())
}
/// Structure of a file, `None` if the id is unknown.
pub fn file_structure<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<FileStructure> {
engine
.file_structure(&FileId(file_id))
.map(FileStructure::new)
}
/// Global statistics of a file, `None` if the id is unknown.
pub fn file_statistics<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<GlobalStatistics> {
engine
.file_statistics(&FileId(file_id))
.map(|stats| GlobalStatistics::new(&stats))
}
/// The current selection.
pub fn selection<H: History>(engine: &Engine<H>) -> Selection {
engine.selection().into()
}
/// What was copied or cut and is waiting to be pasted.
pub fn clipboard<H: History>(engine: &Engine<H>) -> Option<Clipboard> {
engine.clipboard().map(Clipboard::new)
}
pub fn can_undo<H: History>(engine: &Engine<H>) -> bool {
engine.can_undo()
}
pub fn can_redo<H: History>(engine: &Engine<H>) -> bool {
engine.can_redo()
}
/// Whether the clipboard can be pasted with the current selection.
pub fn can_paste<H: History>(engine: &Engine<H>) -> bool {
engine.can_paste()
}
/// All the data of a waypoint of a file, `None` if it does not exist.
pub fn waypoint<H: History>(
engine: &Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Option<WaypointDetails> {
engine
.waypoint(&FileId(file_id), &engine::WaypointId(waypoint_id))
.map(WaypointDetails::new)
}
/// A trackpoint of a segment of a file, `None` if it does not exist.
pub fn trackpoint<H: History>(
engine: &Engine<H>,
file_id: Uuid,
segment_id: Uuid,
index: usize,
) -> Option<TrackpointDetails> {
engine
.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
.map(TrackpointDetails::new)
}
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
/// trackpoints to keep.
pub fn export_file<H: History>(
engine: &Engine<H>,
file_id: Uuid,
options: engine::ExportOptions,
) -> Option<Vec<u8>> {
engine.export(&FileId(file_id), options)
}
/// The data that some files have, so that the ones that none has are not offered when exporting
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
/// OpenStreetMap data (32).
pub fn exportable_data<H: History>(engine: &Engine<H>, file_ids: &[Uuid]) -> u8 {
let ids: Vec<FileId> = file_ids.iter().copied().map(FileId).collect();
let data = engine.exportable_data(&ids);
[
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
]
.iter()
.enumerate()
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
}
/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance
/// (m) of `reduce` that keeps it: infinite for the ends of the segments.
pub fn reduction_distances<H: History>(engine: &Engine<H>) -> Vec<f64> {
engine.reduction_distances()
}
// Coordinates, as flat `[lng, lat, ...]` slices. Compare `rev` / `waypointsRev` of the file
// structures with the previous ones to know which ones actually changed.
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
pub fn segment_coordinates<H: History>(engine: &Engine<H>, segment_id: Uuid) -> &[f64] {
engine.segment_coordinates(&engine::TrackSegmentId(segment_id))
}
/// Coordinates of the waypoints of a file (empty if the id is unknown).
pub fn waypoint_coordinates<H: History>(engine: &Engine<H>, file_id: Uuid) -> &[f64] {
engine.waypoint_coordinates(&FileId(file_id))
}
// Statistics of the selection
/// Global statistics of the whole selection (the buffers below cover it).
pub fn selection_statistics<H: History>(engine: &Engine<H>) -> GlobalStatistics {
GlobalStatistics::new(&engine.statistics().global)
}
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
/// as indexed by the buffers. `None` when the range is not in the selection.
pub fn slice_statistics<H: History>(
engine: &Engine<H>,
start: usize,
end: usize,
) -> Option<GlobalStatistics> {
engine
.statistics()
.slice(start, end)
.map(|stats| GlobalStatistics::new(&stats))
}
macro_rules! buffers {
($($name:ident),*) => {$(
pub fn $name<H: History>(engine: &Engine<H>) -> &[f64] {
&engine.statistics().$name
}
)*};
}
// One entry per trackpoint of the selection.
buffers!(
total_distance,
elevation_gain,
elevation_loss,
lng,
lat,
ele
);
macro_rules! optional_buffers {
($($name:ident: $type:ty),*) => {$(
/// `None` when no trackpoint of the selection has the value, else an entry per trackpoint,
/// the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the first trackpoint
/// that has them for the cumulative distances and durations).
pub fn $name<H: History>(engine: &Engine<H>) -> Option<&[$type]> {
engine.statistics().$name.as_deref()
}
)*};
}
optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64);
optional_buffers!(total_time: i64, moving_time: i64);
// The smoothed elevation segments of the selection, over which the slope is averaged, as
// intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each
// segment, and `slope_segment_slopes` its slope (%). Their length is in `selection_point`.
pub fn slope_segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.statistics().slope_segments.starts
}
pub fn slope_segment_slopes<H: History>(engine: &Engine<H>) -> &[f64] {
&engine.statistics().slope_segments.slopes
}
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`
/// (numbered as in the buffers), `None` if there is none: for example the one that is hovered on
/// the elevation profile.
pub fn selection_point<H: History>(engine: &Engine<H>, index: usize) -> Option<SelectionPoint> {
let stats = engine.statistics();
if index >= stats.len() {
return None;
}
let categories = engine.categories();
let name = |intervals: &engine::Intervals, categories: &engine::Categories| {
let value = intervals.value_at(index);
let code = value.checked_sub(1)?;
categories.name(code).map(str::to_owned)
};
let segment = stats.slope_segments.at(index);
let finite = |value: f64| value.is_finite().then_some(value);
Some(SelectionPoint {
slope: finite(stats.slope[index]),
time: stats
.time
.as_ref()
.map(|time| time[index])
.filter(|time| *time != engine::NO_TIME)
.map(|time| time as f64),
slope_segment_slope: segment.and_then(|i| finite(stats.slope_segments.slopes[i])),
slope_segment_distance: segment.and_then(|i| finite(stats.slope_segments.distances[i])),
surface: name(&stats.surface, &categories.surface),
highway: name(&stats.highway, &categories.highway),
sac_scale: name(&stats.sac_scale, &categories.sac_scale),
mtb_scale: name(&stats.mtb_scale, &categories.mtb_scale),
})
}
// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that
// have the same value: `<name>_starts` holds the index of the first trackpoint of each interval,
// and `<name>_values` its value (0 when unknown, else 1 + the index in the names of the category).
// A value that is unknown for every trackpoint is a single interval.
macro_rules! categories {
($starts:ident, $values:ident, $names:ident, $field:ident) => {
pub fn $starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.statistics().$field.starts
}
pub fn $values<H: History>(engine: &Engine<H>) -> &[u8] {
&engine.statistics().$field.values
}
/// The names of the category, in the order of their codes. The list only grows: a name
/// keeps its index.
pub fn $names<H: History>(engine: &Engine<H>) -> &[String] {
engine.categories().$field.names()
}
};
}
categories!(surface_starts, surface_values, surfaces, surface);
categories!(highway_starts, highway_values, highways, highway);
categories!(sac_scale_starts, sac_scale_values, sac_scales, sac_scale);
categories!(mtb_scale_starts, mtb_scale_values, mtb_scales, mtb_scale);
// The anchors of the routing tool among the trackpoints of the selection (numbered as in the
// statistics). The ends of the segments are always anchors.
/// The index in the selection of each anchor.
pub fn anchor_indices<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.routing().anchor_indices
}
/// The lowest map zoom level at which each anchor is shown.
pub fn anchor_zooms<H: History>(engine: &Engine<H>) -> &[u8] {
&engine.routing().anchor_zooms
}
/// Index in the selection of the first trackpoint of each selected segment that has some.
pub fn segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.routing().segment_starts
}
/// The id of each selected segment that has trackpoints, as `segment_starts`.
pub fn segment_ids<H: History>(engine: &Engine<H>) -> Vec<String> {
engine
.routing()
.segment_ids
.iter()
.map(|id| id.0.to_string())
.collect()
}
/// Changes when the selected segments or their trackpoints change: the indices in the selection
/// are only valid while it stays the same.
pub fn routing_revision<H: History>(engine: &Engine<H>) -> u32 {
engine.routing().revision
}
+112
View File
@@ -0,0 +1,112 @@
use std::f64::consts::PI;
use crate::{
TrackSegment, TrackSegmentIndex, core::utils::crossarc_lnglat, ramer_douglas_peucker_by,
};
/// Highest zoom level of the map at which an anchor can appear.
pub const MAX_ANCHOR_ZOOM: u8 = 22;
/// Details smaller than this (in meters) do not get an anchor.
const ANCHOR_EPSILON: f64 = 1.0;
const EARTH_RADIUS_METERS: f64 = 6371008.8;
/// Lowest map zoom level at which an anchor selected at `distance` meters from the line of its
/// neighbours is worth showing: the smaller the detail, the higher the zoom. The ends of a
/// segment (no distance) are always shown.
pub fn anchor_zoom(lat: f64, distance: Option<f64>) -> u8 {
let Some(distance) = distance else {
return 0;
};
let width = EARTH_RADIUS_METERS * (lat * PI / 180.0).cos() / distance;
width.log2().round().clamp(0.0, MAX_ANCHOR_ZOOM as f64) as u8
}
/// The anchors of a segment, as `(index, zoom)` sorted by index: the trackpoints kept by the
/// Ramer-Douglas-Peucker simplification of its path, the ones for smaller details being shown at
/// higher zoom levels only.
pub fn compute_anchors(trkseg: &TrackSegment) -> Vec<(usize, u8)> {
let measure = |start: TrackSegmentIndex, end: TrackSegmentIndex, idx: TrackSegmentIndex| {
crossarc_lnglat(
trkseg[start].coordinates,
trkseg[end].coordinates,
trkseg[idx].coordinates,
)
};
ramer_douglas_peucker_by(trkseg, &measure, ANCHOR_EPSILON)
.into_iter()
.map(|(idx, distance)| (idx.flat, anchor_zoom(trkseg[idx].coordinates.lat, distance)))
.collect()
}
#[cfg(test)]
mod tests {
use crate::{LngLat, Trackpoint, TrackpointChunk};
use super::*;
fn segment(points: &[(f64, f64)]) -> TrackSegment {
let mut trkseg = TrackSegment::default();
let mut chunk = TrackpointChunk::default();
for (lng, lat) in points {
chunk.trkpt.push(Trackpoint {
coordinates: LngLat {
lng: *lng,
lat: *lat,
},
..Default::default()
});
}
trkseg.push(chunk);
trkseg
}
#[test]
fn test_zoom_depends_on_the_size_of_the_detail() {
assert_eq!(anchor_zoom(45.0, None), 0);
// 2^13 meters is close to the radius of the earth divided by 1 km
assert_eq!(anchor_zoom(0.0, Some(1_000.0)), 13);
assert!(anchor_zoom(0.0, Some(10.0)) > anchor_zoom(0.0, Some(1_000.0)));
assert_eq!(anchor_zoom(0.0, Some(1e-9)), MAX_ANCHOR_ZOOM);
assert_eq!(anchor_zoom(0.0, Some(1e9)), 0);
// the poles do not give NaN or a panic
assert_eq!(anchor_zoom(90.0, Some(10.0)), 0);
}
#[test]
fn test_anchors_of_a_straight_line_are_its_ends() {
let line: Vec<_> = (0..10).map(|i| (i as f64 * 0.001, 0.0)).collect();
assert_eq!(compute_anchors(&segment(&line)), vec![(0, 0), (9, 0)]);
}
#[test]
fn test_anchors_keep_the_corners() {
// a big corner and a small bump
let points = [
(0.0, 0.0),
(0.01, 0.0),
(0.02, 0.0),
(0.02, 0.01),
(0.02, 0.02),
(0.02, 0.020005),
(0.02, 0.03),
];
let anchors = compute_anchors(&segment(&points));
let indices: Vec<_> = anchors.iter().map(|(i, _)| *i).collect();
assert_eq!(indices, vec![0, 2, 6]);
assert_eq!(anchors[0].1, 0);
assert_eq!(anchors[2].1, 0);
assert!(anchors[1].1 > 0);
}
#[test]
fn test_anchors_of_short_segments() {
assert!(compute_anchors(&segment(&[])).is_empty());
assert_eq!(compute_anchors(&segment(&[(1.0, 2.0)])), vec![(0, 0)]);
assert_eq!(
compute_anchors(&segment(&[(1.0, 2.0), (1.0, 2.1)])),
vec![(0, 0), (1, 0)]
);
}
}
+10
View File
@@ -0,0 +1,10 @@
mod anchors;
mod reverse;
mod simplify;
mod smooth;
mod timestamps;
pub use anchors::*;
pub use reverse::*;
pub use simplify::*;
pub use timestamps::*;
+245
View File
@@ -0,0 +1,245 @@
//! 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::{Route, 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 routes of a file, and their trackpoints, like the segments of a track (see
/// [`reverse_segments`]).
pub fn reverse_routes(routes: &mut [Route], original_next: Option<i64>, new_previous: Option<i64>) {
routes.reverse();
let (mut next, mut previous) = (original_next, new_previous);
for route in routes.iter_mut() {
let original_start = start_time(&route.seg);
reverse_segment(&mut route.seg, next, previous);
next = original_start;
previous = end_time(&route.seg);
}
}
/// 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)]);
}
}
+232
View File
@@ -0,0 +1,232 @@
use crate::{
core::gpx::{TrackSegment, TrackSegmentIndex},
core::utils::{crossarc, crossarc_lnglat},
};
pub fn ramer_douglas_peucker<F>(
trkseg: &TrackSegment,
mapping: &F,
epsilon: f64,
) -> Vec<TrackSegmentIndex>
where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
let measure = |start, end, idx| {
let (start, end, pt) = (mapping(start), mapping(end), mapping(idx));
crossarc(start.0, start.1, end.0, end.1, pt.0, pt.1)
};
ramer_douglas_peucker_by(trkseg, &measure, epsilon)
.into_iter()
.map(|(idx, _)| idx)
.collect()
}
/// Ramer-Douglas-Peucker with any distance from a point (the last argument) to the line made of
/// two others (the first ones). Gives the kept points, with the distance at which each of them
/// was selected (`None` for the ends of the segment, which are always kept).
pub fn ramer_douglas_peucker_by<M>(
trkseg: &TrackSegment,
measure: &M,
epsilon: f64,
) -> Vec<(TrackSegmentIndex, Option<f64>)>
where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{
match trkseg.len() {
0 => vec![],
1 => vec![(trkseg.first_index().unwrap(), None)],
2 => vec![
(trkseg.first_index().unwrap(), None),
(trkseg.last_index().unwrap(), None),
],
_ => {
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
let mut kept = vec![(first, None)];
ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept);
kept.push((last, None));
kept
}
}
}
fn ramer_douglas_peucker_helper<M>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
measure: &M,
epsilon: f64,
kept: &mut Vec<(TrackSegmentIndex, Option<f64>)>,
) where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{
let mut max_idx = None;
let mut max_dist = 0.0;
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let dist = measure(start, end, idx);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
kept.push((idx, Some(max_dist)));
ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
}
}
/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`].
pub const MIN_REDUCE_TOLERANCE: f64 = 0.1;
/// The distance (in meters) from a trackpoint to the line between two others of a segment.
fn lnglat_measure(
trkseg: &TrackSegment,
) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ {
move |start, end, idx| {
crossarc_lnglat(
trkseg[start].coordinates,
trkseg[end].coordinates,
trkseg[idx].coordinates,
)
}
}
/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance`
/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept.
pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec<usize> {
ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance)
.into_iter()
.map(|(idx, _)| idx.flat)
.collect()
}
/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by
/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that
/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its
/// value is greater than it.
///
/// A trackpoint is only selected if the ones that split the path before it are, so its value is
/// the smallest distance at which it or one of them was selected.
pub fn reduction_distances(trkseg: &TrackSegment) -> Vec<f64> {
let mut distances = vec![0.0; trkseg.len()];
let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else {
return distances;
};
distances[first.flat] = f64::INFINITY;
distances[last.flat] = f64::INFINITY;
if trkseg.len() > 2 {
let measure = lnglat_measure(trkseg);
reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances);
}
distances
}
fn reduction_distances_helper<M>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
measure: &M,
bound: f64,
distances: &mut [f64],
) where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{
let mut max_idx = None;
let mut max_dist = 0.0;
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let dist = measure(start, end, idx);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) {
let distance = max_dist.min(bound);
distances[idx.flat] = distance;
reduction_distances_helper(trkseg, start, idx, measure, distance, distances);
reduction_distances_helper(trkseg, idx, end, measure, distance, distances);
}
}
#[cfg(test)]
mod tests {
use crate::{Trackpoint, TrackpointChunk};
use super::*;
fn segment(eles: &[f64]) -> TrackSegment {
let mut trkseg = TrackSegment::default();
// several small chunks, to also exercise the chunk boundaries
for part in eles.chunks(3) {
let mut chunk = TrackpointChunk::default();
for ele in part {
chunk.trkpt.push(Trackpoint {
ele: *ele,
..Default::default()
});
}
trkseg.push(chunk);
}
trkseg
}
fn simplify(eles: &[f64], epsilon: f64) -> Vec<usize> {
let trkseg = segment(eles);
ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon)
.iter()
.map(|idx| idx.flat)
.collect()
}
#[test]
fn test_empty_and_single_point() {
assert!(simplify(&[], 1.0).is_empty());
assert_eq!(simplify(&[5.0], 1.0), vec![0]);
}
#[test]
fn test_collinear_points_are_removed() {
let eles: Vec<f64> = (0..20).map(|i| i as f64 * 2.0).collect();
assert_eq!(simplify(&eles, 0.1), vec![0, 19]);
}
#[test]
fn test_peak_is_kept_when_above_epsilon() {
let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0];
assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]);
}
#[test]
fn test_peak_is_dropped_when_below_epsilon() {
let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0];
assert_eq!(simplify(&eles, 1.0), vec![0, 6]);
}
#[test]
fn test_indices_are_sorted_and_keep_extremities() {
let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0];
let kept = simplify(&eles, 0.5);
assert_eq!(kept.first(), Some(&0));
assert_eq!(kept.last(), Some(&9));
assert!(kept.windows(2).all(|w| w[0] < w[1]));
// a larger epsilon never keeps more points
assert!(simplify(&eles, 5.0).len() <= kept.len());
}
}
+465
View File
@@ -0,0 +1,465 @@
use crate::{Statistics, TrackSegment, Trackpoint, distance};
/// The new timestamps of the trackpoints of `segments`, one after the other: each segment goes on
/// from the end of the previous one, the first one starts at `start_time` (in milliseconds).
///
/// A segment that has timestamps keeps its durations multiplied by `ratio`; one that has none
/// (judged by its first trackpoint) follows the previous trackpoint at `speed` (km/h). See
/// [`shifted_and_compressed`] and [`with_timestamps`].
pub fn changed_segment_times(
segments: &[&TrackSegment],
start_time: i64,
speed: f64,
ratio: f64,
) -> Vec<Vec<Option<i64>>> {
let mut last: Option<Trackpoint> = None;
segments
.iter()
.map(|segment| {
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
let Some(first) = points.first() else {
return vec![];
};
let start = last.clone().unwrap_or_else(|| {
let mut start = first.clone();
start.time = Some(start_time);
start
});
let points = if first.time.is_none() {
with_timestamps(points, Some(speed), Some(&start), Some(start_time))
} else {
shifted_and_compressed(points, Some(speed), ratio, &start)
};
last = points.last().cloned();
points.iter().map(|point| point.time).collect()
})
.collect()
}
/// Makes up timestamps for the trackpoints of `segments`, one after the other: they start at
/// `start_time` (in milliseconds) and last `total_time` seconds together, the longer and the
/// steeper an interval the more of it. Each segment goes on from the end of the previous one.
pub fn artificial_segment_times(
segments: &[&TrackSegment],
start_time: i64,
total_time: f64,
) -> Vec<Vec<Option<i64>>> {
let mut weights: Vec<Vec<f64>> = segments
.iter()
.map(|segment| {
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
let slopes: Vec<f64> = Statistics::compute(segment)
.local
.iter()
.map(|l| l.slope)
.collect();
artificial_weights(&points, &slopes)
})
.collect();
let mut total: f64 = weights.iter().flatten().sum();
if total.is_nan() || total <= 0.0 {
// no distance to share the time by: every interval takes as long
for weights in &mut weights {
weights.iter_mut().for_each(|w| *w = 1.0);
}
total = weights.iter().map(Vec::len).sum::<usize>() as f64;
}
let ms_per_weight = if total > 0.0 {
total_time * 1000.0 / total
} else {
0.0
};
let mut start = start_time;
segments
.iter()
.zip(&weights)
.map(|(segment, weights)| {
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
if points.is_empty() {
return vec![];
}
let points = with_artificial_timestamps(points, weights, ms_per_weight, start);
// the next segment goes on from the end of this one
start = points.last().and_then(|point| point.time).unwrap_or(start);
points.iter().map(|point| point.time).collect()
})
.collect()
}
/// Replaces the trackpoints `start..end` of `trkpt` by `points`, keeping the timestamps of the
/// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library.
///
/// The timestamps only matter if the path has some, or if `speed` (in km/h) is given: then the
/// new points that have none get some (a trackpoint after the previous one at `speed`, or one
/// second after it without a speed), and so do the points before or after the range if they
/// had none. `start_time` is the time of the first trackpoint when nothing else gives it.
/// New points that have timestamps are shifted if they are too early to follow the previous
/// trackpoint, and so are the points after the range. With `remove_gaps`, the new points are
/// also brought closer to the previous trackpoint if they come long after it.
///
/// Panics if `start..end` is not inside `trkpt`.
pub fn replace_trackpoints(
trkpt: &mut Vec<Trackpoint>,
start: usize,
end: usize,
mut points: Vec<Trackpoint>,
speed: Option<f64>,
start_time: Option<i64>,
remove_gaps: bool,
) {
assert!(start <= end && end <= trkpt.len());
let has_times = trkpt.first().is_some_and(|first| first.time.is_some());
if speed.is_some() || has_times {
if start > 0 && trkpt[0].time.is_none() {
// the points before the range have no timestamps
let before: Vec<_> = trkpt.drain(..start).collect();
trkpt.splice(0..0, with_timestamps(before, speed, None, start_time));
}
if !points.is_empty() {
let last = start.checked_sub(1).map(|i| &trkpt[i]);
let missing =
points[0].time.is_none() || points.get(1).is_some_and(|p| p.time.is_none());
if missing {
points = with_timestamps(points, speed, last, start_time);
} else if let Some(last) = last {
if is_before(points[0].time, last.time) {
// too early
points = shifted_and_compressed(points, speed, 1.0, last);
} else if remove_gaps {
if same_place(&points[0], last) {
// the same place: the new points start at the previous trackpoint, which
// is the new first one
if is_before(last.time, points[0].time) {
points = shifted_and_compressed(points, speed, 1.0, last);
points.remove(0);
}
} else if matches!((points[0].time, last.time), (Some(new), Some(old)) if new - old > 1000)
{
// another place: the new points start one second after the previous one
let mut artificial_last = points[0].clone();
artificial_last.time = last.time.map(|time| time + 1000);
points = shifted_and_compressed(points, speed, 1.0, &artificial_last);
}
}
}
}
if end < trkpt.len() {
// the points after the range
let last = points
.last()
.or_else(|| start.checked_sub(1).map(|i| &trkpt[i]))
.cloned();
if trkpt[end].time.is_none() {
let after: Vec<_> = trkpt.drain(end..).collect();
let after = with_timestamps(after, speed, last.as_ref(), start_time);
trkpt.extend(after);
} else if let Some(last) = last
&& is_before(trkpt[end].time, last.time)
{
let after: Vec<_> = trkpt.drain(end..).collect();
trkpt.extend(shifted_and_compressed(after, speed, 1.0, &last));
}
}
}
trkpt.splice(start..end, points);
}
/// How long each interval between two consecutive trackpoints of a segment should last, relative
/// to the others, for a made-up pace: the longer the interval, and the steeper it goes up, the
/// longer. `slopes` has the slope (in %) at each trackpoint.
pub fn artificial_weights(points: &[Trackpoint], slopes: &[f64]) -> Vec<f64> {
points
.windows(2)
.zip(slopes)
.map(|(pair, slope)| {
let km = distance(pair[0].coordinates, pair[1].coordinates);
km * (0.5 + 1.0 / (1.0 + (-0.2 * slope).exp()))
})
.collect()
}
/// `points` with made-up timestamps: the first one is at `start`, and each next one follows the
/// previous one after `weights[i]` times `ms_per_weight` milliseconds (see [`artificial_weights`]).
pub fn with_artificial_timestamps(
mut points: Vec<Trackpoint>,
weights: &[f64],
ms_per_weight: f64,
start: i64,
) -> Vec<Trackpoint> {
let mut elapsed = 0.0;
for (i, point) in points.iter_mut().enumerate() {
if i > 0 {
elapsed += weights[i - 1] * ms_per_weight;
}
point.time = Some(start + elapsed as i64);
}
points
}
fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool {
a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat
}
/// Whether both times are known and `a` is before `b`.
fn is_before(a: Option<i64>, b: Option<i64>) -> bool {
matches!((a, b), (Some(a), Some(b)) if a < b)
}
/// The time at which `b` is reached after `a`, at `speed` km/h: one second later without a
/// speed, unknown if `a` has no time.
///
/// The old code gave an invalid date for a null or negative speed: it is as if there was none.
fn timestamp_after(a: &Trackpoint, b: &Trackpoint, speed: Option<f64>) -> Option<i64> {
let time = a.time?;
match speed.filter(|speed| speed.is_finite() && *speed > 0.0) {
None => Some(time + 1000),
Some(speed) => {
let km = distance(a.coordinates, b.coordinates);
Some(time + (3_600_000.0 * km / speed) as i64)
}
}
}
/// `points` with the timestamps that follow `last` (the trackpoint before them), one after the
/// other. Without `last`, the first one is at `start_time`.
///
/// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy
/// used here.
pub fn with_timestamps(
points: Vec<Trackpoint>,
speed: Option<f64>,
last: Option<&Trackpoint>,
start_time: Option<i64>,
) -> Vec<Trackpoint> {
let Some(first) = points.first() else {
return points;
};
let mut last = match last {
Some(last) if last.time.is_some() => last.clone(),
other => {
let mut last = other.unwrap_or(first).clone();
last.time = start_time;
last
}
};
points
.into_iter()
.map(|mut point| {
point.time = timestamp_after(&last, &point, speed);
last = point.clone();
point
})
.collect()
}
/// `points` with their timestamps moved so that the first one follows `last`, and their durations
/// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in
/// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known.
pub fn shifted_and_compressed(
points: Vec<Trackpoint>,
speed: Option<f64>,
ratio: f64,
last: &Trackpoint,
) -> Vec<Trackpoint> {
let Some(first) = points.first() else {
return points;
};
let start = timestamp_after(last, first, speed);
let first_time = first.time;
let mut previous = first.clone();
points
.into_iter()
.map(|point| {
let mut shifted = point.clone();
shifted.time = match point.time {
None => timestamp_after(&previous, &point, speed),
Some(time) => start
.zip(first_time)
.map(|(start, first)| start + (ratio * (time - first) as f64) as i64),
};
previous = shifted.clone();
shifted
})
.collect()
}
#[cfg(test)]
mod tests {
use crate::LngLat;
use super::*;
/// A point every 1 km along the equator... about, at 0.009 degrees of longitude.
fn point(i: usize, time: Option<i64>) -> Trackpoint {
Trackpoint {
coordinates: LngLat {
lng: i as f64 * 0.009,
lat: 0.0,
},
time,
..Default::default()
}
}
fn timed(range: std::ops::Range<usize>, start: i64, step: i64) -> Vec<Trackpoint> {
range
.map(|i| point(i, Some(start + step * i as i64)))
.collect()
}
fn untimed(range: std::ops::Range<usize>) -> Vec<Trackpoint> {
range.map(|i| point(i, None)).collect()
}
fn times(trkpt: &[Trackpoint]) -> Vec<Option<i64>> {
trkpt.iter().map(|p| p.time).collect()
}
#[test]
fn test_without_times_nor_speed_it_is_a_splice() {
let mut trkpt = untimed(0..5);
replace_trackpoints(&mut trkpt, 1, 3, untimed(10..13), None, None, false);
assert_eq!(trkpt.len(), 6);
assert!(trkpt.iter().all(|p| p.time.is_none()));
assert_eq!(trkpt[1].coordinates.lng, point(10, None).coordinates.lng);
assert_eq!(trkpt[4].coordinates.lng, point(3, None).coordinates.lng);
}
#[test]
fn test_new_points_follow_the_previous_one_at_the_speed() {
// 1 km in 10 s is 360 km/h
let mut trkpt = timed(0..4, 0, 10_000);
let km = distance(point(0, None).coordinates, point(1, None).coordinates);
let speed = 36.0 * km / 0.01; // the time of 1 point to the next is 100 s
replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), Some(speed), None, false);
let expected = 10_000 + (3_600_000.0 * km / speed) as i64;
assert_eq!(trkpt[2].time, Some(expected));
assert!(trkpt[2].time > trkpt[1].time);
// the points after follow the new ones, they were not too early
assert_eq!(trkpt.len(), 5);
assert_eq!(trkpt[4].time, Some(30_000));
}
#[test]
fn test_without_a_speed_new_points_are_one_second_apart() {
let mut trkpt = timed(0..3, 5_000, 60_000);
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..13), None, None, false);
assert_eq!(
times(&trkpt[..4]),
[Some(5_000), Some(6_000), Some(7_000), Some(8_000)]
);
// what comes after was after, it is kept
assert_eq!(trkpt[4].time, Some(125_000));
}
#[test]
fn test_the_points_after_are_shifted_if_they_are_too_early() {
let mut trkpt = timed(0..4, 0, 10_000);
// 30 new points, one second apart: the last one is at 30 s, but the next original
// trackpoint is at 20 s
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..40), None, None, false);
assert_eq!(trkpt.len(), 33);
assert_eq!(trkpt[1].time, Some(1_000));
assert_eq!(trkpt[30].time, Some(30_000));
// the following points keep the duration between them, after the new ones
assert_eq!(trkpt[31].time, Some(31_000));
assert_eq!(trkpt[32].time, Some(41_000));
}
#[test]
fn test_a_path_without_times_gets_them_from_the_speed_and_start_time() {
let mut trkpt = untimed(0..4);
replace_trackpoints(
&mut trkpt,
3,
4,
untimed(3..6),
Some(10.0),
Some(1_000),
false,
);
let times: Vec<_> = times(&trkpt);
assert_eq!(times[0], Some(1_000));
assert!(times.iter().all(Option::is_some));
assert!(times.windows(2).all(|w| w[0].unwrap() < w[1].unwrap()));
}
#[test]
fn test_an_unusable_speed_is_as_if_there_was_none() {
for speed in [0.0, -5.0, f64::NAN, f64::INFINITY] {
let mut trkpt = timed(0..2, 0, 10_000);
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(speed), None, false);
assert_eq!(
times(&trkpt),
[Some(0), Some(10_000), Some(11_000), Some(12_000)],
"{speed}"
);
}
}
#[test]
fn test_new_points_with_times_that_are_too_early_are_shifted() {
let mut trkpt = timed(0..3, 100_000, 10_000);
// they are at 0, 5 s: they are moved to follow the trackpoint at 110 s
let new = vec![point(5, Some(0)), point(6, Some(5_000))];
replace_trackpoints(&mut trkpt, 2, 3, new, None, None, false);
// they follow the previous trackpoint after one second, keeping their own durations
assert_eq!(trkpt[2].time, Some(111_000));
assert_eq!(trkpt[3].time, Some(116_000));
}
#[test]
fn test_remove_gaps_at_the_same_place_and_elsewhere() {
// same place: the first new point is the previous trackpoint
let mut trkpt = timed(0..3, 0, 10_000);
let new = vec![point(2, Some(500_000)), point(3, Some(510_000))];
replace_trackpoints(&mut trkpt, 3, 3, new.clone(), None, None, true);
assert_eq!(trkpt.len(), 4);
// without a speed, it is one second after the previous trackpoint, not at its time
assert_eq!(trkpt[3].time, Some(31_000));
// with a speed, the distance is zero
let mut trkpt = timed(0..3, 0, 10_000);
replace_trackpoints(&mut trkpt, 3, 3, new, Some(10.0), None, true);
assert_eq!(trkpt[3].time, Some(30_000));
// another place: one second after the previous one
let mut trkpt = timed(0..3, 0, 10_000);
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, true);
assert_eq!(trkpt.len(), 5);
// the old code adds the second twice with no speed, see `timestamp_after`
assert_eq!(trkpt[3].time, Some(22_000));
assert_eq!(trkpt[4].time, Some(32_000));
// without removing the gaps they stay
let mut trkpt = timed(0..3, 0, 10_000);
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, false);
assert_eq!(trkpt[3].time, Some(500_000));
}
#[test]
fn test_removing_points_adapts_nothing_if_the_next_one_is_after() {
let mut trkpt = timed(0..5, 0, 10_000);
replace_trackpoints(&mut trkpt, 1, 3, vec![], None, None, false);
assert_eq!(times(&trkpt), [Some(0), Some(30_000), Some(40_000)]);
// from the beginning
replace_trackpoints(&mut trkpt, 0, 2, vec![], None, None, false);
assert_eq!(times(&trkpt), [Some(40_000)]);
replace_trackpoints(&mut trkpt, 0, 1, vec![], None, None, false);
assert!(trkpt.is_empty());
}
#[test]
fn test_points_without_a_known_previous_time_get_none() {
// nothing gives a time to start with
let mut trkpt = untimed(0..2);
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(10.0), None, false);
assert!(trkpt.iter().all(|p| p.time.is_none()));
}
}
+96
View File
@@ -0,0 +1,96 @@
/// 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 {
/// 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
@@ -0,0 +1,133 @@
use crate::{Trackpoint, Waypoint};
use super::common::uuid_id;
/// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk
/// that is not touched by an edit is not copied.
///
/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new
/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is.
pub trait Chunk {
type Item: Clone;
/// Maximum number of items of a chunk.
const MAX_SIZE: usize;
/// A chunk with a new identity, holding `items`.
fn new(items: Vec<Self::Item>) -> Self;
/// Gives the chunk a new identity, after its items were changed in place.
fn renew(&mut self);
fn items(&self) -> &Vec<Self::Item>;
fn items_mut(&mut self) -> &mut Vec<Self::Item>;
fn is_full(&self) -> bool {
self.items().len() >= Self::MAX_SIZE
}
}
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
uuid_id!(TrackpointChunkId);
#[derive(Debug, Default)]
pub struct TrackpointChunk {
pub id: TrackpointChunkId,
pub trkpt: Vec<Trackpoint>,
}
impl Chunk for TrackpointChunk {
type Item = Trackpoint;
const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE;
fn new(trkpt: Vec<Trackpoint>) -> Self {
Self {
trkpt,
..Default::default()
}
}
fn renew(&mut self) {
self.id = Default::default();
}
fn items(&self) -> &Vec<Trackpoint> {
&self.trkpt
}
fn items_mut(&mut self) -> &mut Vec<Trackpoint> {
&mut self.trkpt
}
}
uuid_id!(WaypointChunkId);
const MAX_WPT_CHUNK_SIZE: usize = 128;
#[derive(Debug, Default)]
pub struct WaypointChunk {
pub id: WaypointChunkId,
pub wpt: Vec<Waypoint>,
}
impl Chunk for WaypointChunk {
type Item = Waypoint;
const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
fn new(wpt: Vec<Waypoint>) -> Self {
Self {
wpt,
..Default::default()
}
}
fn renew(&mut self) {
self.id = Default::default();
}
fn items(&self) -> &Vec<Waypoint> {
&self.wpt
}
fn items_mut(&mut self) -> &mut Vec<Waypoint> {
&mut self.wpt
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trackpoint_chunk_full() {
let mut chunk = TrackpointChunk::default();
assert!(!chunk.is_full());
for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 {
chunk.trkpt.push(Trackpoint::default());
}
assert!(!chunk.is_full());
chunk.trkpt.push(Trackpoint::default());
assert!(chunk.is_full());
}
#[test]
fn test_waypoint_chunk_full() {
let mut chunk = WaypointChunk::default();
assert!(!chunk.is_full());
for _ in 0..MAX_WPT_CHUNK_SIZE {
chunk.wpt.push(Waypoint::default());
}
assert!(chunk.is_full());
}
#[test]
fn test_new_chunks_have_their_own_identity() {
let a = TrackpointChunk::new(vec![Trackpoint::default()]);
let b = TrackpointChunk::new(vec![Trackpoint::default()]);
assert_ne!(a.id, b.id);
assert_eq!(a.items().len(), 1);
assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id);
}
}
+619
View File
@@ -0,0 +1,619 @@
use std::{ops::Index, rc::Rc};
use crate::Chunk;
/// A list of items, stored in [`Chunk`]s that are shared between its versions: editing it only
/// copies the chunks around the change, so that a version costs little more than the change.
///
/// There are no empty chunks.
#[derive(Debug)]
pub struct Chunked<C: Chunk> {
chunks: Vec<Rc<C>>,
cumul_length: Vec<usize>,
}
impl<C: Chunk> Clone for Chunked<C> {
fn clone(&self) -> Self {
Self {
chunks: self.chunks.clone(),
cumul_length: self.cumul_length.clone(),
}
}
}
impl<C: Chunk> Default for Chunked<C> {
fn default() -> Self {
Self {
chunks: vec![],
cumul_length: vec![],
}
}
}
/// The position of an item: in which chunk, where in it, and among all the items.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct ChunkIndex {
pub chunk: usize,
pub pos: usize,
pub flat: usize,
}
impl<C: Chunk> Chunked<C> {
/// Adds a chunk at the end, which is dropped if it is empty.
pub fn push(&mut self, chunk: C) {
self.push_shared(Rc::new(chunk));
}
pub fn push_shared(&mut self, chunk: Rc<C>) {
if chunk.items().is_empty() {
return;
}
self.cumul_length
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.items().len());
self.chunks.push(chunk);
}
pub fn chunks(&self) -> &[Rc<C>] {
&self.chunks
}
/// Replaces the items in `start..end` by `items`. Panics if the range is out of bounds.
///
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the
/// range are copied and refilled.
pub fn splice(&mut self, start: usize, end: usize, items: Vec<C::Item>) {
assert!(
start <= end && end <= self.len(),
"splice range out of bounds"
);
let old = std::mem::take(&mut self.chunks);
self.cumul_length.clear();
let mut items = Some(items);
let mut pending = vec![];
let mut offset = 0;
for chunk in old {
let (lo, hi) = (offset, offset + chunk.items().len());
offset = hi;
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
if hi < start || (hi == start && chunk.is_full()) {
self.push_shared(chunk);
continue;
}
if let Some(items) = items.take() {
self.fill(&mut pending, chunk.items()[..start - lo].iter().cloned());
self.fill(&mut pending, items);
}
if hi <= end {
continue;
}
if lo >= end {
self.flush(&mut pending);
self.push_shared(chunk);
} else {
self.fill(&mut pending, chunk.items()[end - lo..].iter().cloned());
}
}
if let Some(items) = items {
self.fill(&mut pending, items);
}
self.flush(&mut pending);
}
fn fill(&mut self, pending: &mut Vec<C::Item>, items: impl IntoIterator<Item = C::Item>) {
for item in items {
pending.push(item);
if pending.len() >= C::MAX_SIZE {
self.flush(pending);
}
}
}
fn flush(&mut self, pending: &mut Vec<C::Item>) {
self.push(C::new(std::mem::take(pending)));
}
/// Edits the items of the chunks that contain an item accepted by `filter`.
///
/// `f` receives a copy of the items of such a chunk and returns whether it changed them. A
/// changed chunk is replaced by a new one (so that what is derived from it is computed
/// again) and the chunks left empty are dropped. The other chunks are kept. Returns whether
/// anything changed.
pub fn edit(
&mut self,
filter: impl Fn(&C::Item) -> bool,
mut f: impl FnMut(&mut Vec<C::Item>) -> bool,
) -> bool {
let mut changed = false;
let old = std::mem::take(&mut self.chunks);
self.cumul_length.clear();
for chunk in old {
if !chunk.items().iter().any(&filter) {
self.push_shared(chunk);
continue;
}
let mut items = chunk.items().clone();
if !f(&mut items) {
self.push_shared(chunk);
continue;
}
changed = true;
self.push(C::new(items));
}
changed
}
/// Changes what is in the chunk `chunk`, but not how many items there are. A chunk that is
/// shared is copied first, so that the other versions do not change.
fn replace_chunk(&mut self, chunk: usize, f: impl FnOnce(&mut Vec<C::Item>)) {
let len = self.chunks[chunk].items().len();
match Rc::get_mut(&mut self.chunks[chunk]) {
Some(unique) => {
f(unique.items_mut());
unique.renew();
}
None => {
let mut items = self.chunks[chunk].items().clone();
f(&mut items);
self.chunks[chunk] = Rc::new(C::new(items));
}
}
debug_assert_eq!(self.chunks[chunk].items().len(), len);
}
/// Applies `f` to the item at `index`, which is copied in a new chunk. Panics if there is no
/// such item.
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut C::Item)) {
let ChunkIndex { chunk, pos, .. } = self.locate(index).unwrap();
self.replace_chunk(chunk, |items| f(&mut items[pos]));
}
/// Applies `f` to every item, with its index, in new chunks.
pub fn update_all(&mut self, mut f: impl FnMut(usize, &mut C::Item)) {
let mut offset = 0;
for chunk in 0..self.chunks.len() {
let len = self.chunks[chunk].items().len();
self.replace_chunk(chunk, |items| {
for (i, item) in items.iter_mut().enumerate() {
f(offset + i, item);
}
});
offset += len;
}
}
pub fn len(&self) -> usize {
self.cumul_length.last().copied().unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
pub fn iter(&self) -> ChunkedIter<'_, C> {
ChunkedIter::new(self)
}
pub fn first_index(&self) -> Option<ChunkIndex> {
self.next_index(None)
}
pub fn last_index(&self) -> Option<ChunkIndex> {
self.prev_index(None)
}
pub fn next_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
let mut next = cur.map_or_default(|idx| ChunkIndex {
chunk: idx.chunk,
pos: idx.pos + 1,
flat: idx.flat + 1,
});
loop {
if next.chunk >= self.chunks.len() {
return None;
}
if next.pos == self.chunks[next.chunk].items().len() {
next.chunk += 1;
next.pos = 0;
} else {
return Some(next);
}
}
}
pub fn prev_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
let mut prev = cur.unwrap_or(ChunkIndex {
chunk: self.chunks.len(),
pos: 0,
flat: self.cumul_length.last().copied().unwrap_or_default(),
});
if prev.pos == 0 {
while prev.chunk > 0 {
prev.chunk -= 1;
if !self.chunks[prev.chunk].items().is_empty() {
prev.pos = self.chunks[prev.chunk].items().len() - 1;
prev.flat -= 1;
return Some(prev);
}
}
None
} else {
prev.pos -= 1;
prev.flat -= 1;
Some(prev)
}
}
/// The position of the item `idx` among all the items.
pub fn locate(&self, idx: usize) -> Option<ChunkIndex> {
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
if chunk >= self.chunks.len() {
return None;
}
let pos = if chunk > 0 {
idx - self.cumul_length[chunk - 1]
} else {
idx
};
if pos >= self.chunks[chunk].items().len() {
None
} else {
Some(ChunkIndex {
chunk,
pos,
flat: idx,
})
}
}
}
impl<C: Chunk> Index<ChunkIndex> for Chunked<C> {
type Output = C::Item;
fn index(&self, idx: ChunkIndex) -> &Self::Output {
&self.chunks[idx.chunk].items()[idx.pos]
}
}
impl<C: Chunk> Index<usize> for Chunked<C> {
type Output = C::Item;
fn index(&self, idx: usize) -> &Self::Output {
&self[self.locate(idx).unwrap()]
}
}
impl<'a, C: Chunk> IntoIterator for &'a Chunked<C> {
type Item = &'a C::Item;
type IntoIter = ChunkedIter<'a, C>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
pub struct ChunkedIter<'a, C: Chunk> {
chunked: &'a Chunked<C>,
/// The item returned last.
idx: Option<ChunkIndex>,
exhausted: bool,
}
impl<C: Chunk> Clone for ChunkedIter<'_, C> {
fn clone(&self) -> Self {
Self {
chunked: self.chunked,
idx: self.idx,
exhausted: self.exhausted,
}
}
}
impl<'a, C: Chunk> ChunkedIter<'a, C> {
pub fn new(chunked: &'a Chunked<C>) -> Self {
Self {
chunked,
idx: None,
exhausted: false,
}
}
fn move_to(&mut self, idx: Option<ChunkIndex>) -> Option<&'a C::Item> {
self.exhausted = idx.is_none();
self.idx = idx.or(self.idx);
idx.map(|idx| &self.chunked[idx])
}
}
impl<'a, C: Chunk> Iterator for ChunkedIter<'a, C> {
type Item = &'a C::Item;
fn next(&mut self) -> Option<Self::Item> {
if self.exhausted {
return None;
}
let next = self.chunked.next_index(self.idx);
self.move_to(next)
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
if self.exhausted {
return None;
}
let target = self.idx.map_or(0, |idx| idx.flat + 1).saturating_add(n);
let next = self.chunked.locate(target);
self.move_to(next)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Chunks of at most 3 numbers, with an identity like the real ones.
#[derive(Debug)]
struct Numbers {
id: usize,
items: Vec<u32>,
}
thread_local! {
static NEXT_ID: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn next_id() -> usize {
NEXT_ID.with(|id| id.replace(id.get() + 1))
}
impl Chunk for Numbers {
type Item = u32;
const MAX_SIZE: usize = 3;
fn new(items: Vec<u32>) -> Self {
Self {
id: next_id(),
items,
}
}
fn renew(&mut self) {
self.id = next_id();
}
fn items(&self) -> &Vec<u32> {
&self.items
}
fn items_mut(&mut self) -> &mut Vec<u32> {
&mut self.items
}
}
/// The numbers `0..n` in chunks of 3.
fn numbers(n: u32) -> Chunked<Numbers> {
let mut chunked = Chunked::default();
for start in (0..n).step_by(3) {
chunked.push(Numbers::new((start..(start + 3).min(n)).collect()));
}
chunked
}
fn values(chunked: &Chunked<Numbers>) -> Vec<u32> {
chunked.iter().copied().collect()
}
fn ids(chunked: &Chunked<Numbers>) -> Vec<usize> {
chunked.chunks().iter().map(|chunk| chunk.id).collect()
}
fn assert_consistent(chunked: &Chunked<Numbers>) {
let sizes: Vec<usize> = chunked.chunks().iter().map(|c| c.items.len()).collect();
assert!(
sizes
.iter()
.all(|&size| 0 < size && size <= Numbers::MAX_SIZE)
);
assert_eq!(chunked.len(), sizes.iter().sum::<usize>());
for index in 0..chunked.len() {
assert_eq!(chunked.locate(index).unwrap().flat, index);
}
assert!(chunked.locate(chunked.len()).is_none());
}
#[test]
fn test_push_drops_empty_chunks() {
let mut chunked = numbers(4);
chunked.push(Numbers::new(vec![]));
assert_eq!(chunked.chunks().len(), 2);
assert_eq!(chunked.len(), 4);
assert!(Chunked::<Numbers>::default().is_empty());
assert!(!chunked.is_empty());
}
#[test]
fn test_locate_index_and_iterate() {
let chunked = numbers(7);
assert_eq!(
chunked.locate(4),
Some(ChunkIndex {
chunk: 1,
pos: 1,
flat: 4
})
);
assert_eq!(chunked[4], 4);
assert_eq!(chunked[chunked.locate(6).unwrap()], 6);
assert!(chunked.locate(7).is_none());
assert_eq!(values(&chunked), (0..7).collect::<Vec<_>>());
assert_eq!(chunked.iter().count(), 7);
assert_eq!((&chunked).into_iter().last(), Some(&6));
assert_eq!(values(&Chunked::default()), Vec::<u32>::new());
}
#[test]
fn test_first_last_next_and_previous_index() {
let chunked = numbers(7);
let first = chunked.first_index().unwrap();
let last = chunked.last_index().unwrap();
assert_eq!((first.flat, last.flat), (0, 6));
assert_eq!((last.chunk, last.pos), (2, 0));
let mut forward = vec![];
let mut cur = None;
while let Some(next) = chunked.next_index(cur) {
forward.push(chunked[next]);
cur = Some(next);
}
assert_eq!(forward, (0..7).collect::<Vec<_>>());
let mut backward = vec![];
let mut cur = None;
while let Some(prev) = chunked.prev_index(cur) {
backward.push(chunked[prev]);
cur = Some(prev);
}
assert_eq!(backward, (0..7).rev().collect::<Vec<_>>());
let empty = Chunked::<Numbers>::default();
assert!(empty.first_index().is_none() && empty.last_index().is_none());
}
#[test]
#[allow(clippy::iter_nth_zero)]
fn test_iterator_nth_goes_on_from_the_current_item() {
let chunked = numbers(10);
let mut iter = chunked.iter();
assert_eq!(iter.nth(1), Some(&1));
assert_eq!(iter.next(), Some(&2));
// skips 3 and 4
assert_eq!(iter.nth(2), Some(&5));
assert_eq!(iter.nth(0), Some(&6));
assert_eq!(iter.nth(2), Some(&9));
assert_eq!(iter.nth(0), None);
assert_eq!(iter.next(), None, "an exhausted iterator stays exhausted");
assert_eq!(values(&numbers(10)).iter().skip(4).count(), 6);
assert_eq!(
chunked.iter().skip(4).copied().collect::<Vec<_>>(),
(4..10).collect::<Vec<_>>()
);
assert_eq!(
chunked.iter().step_by(4).copied().collect::<Vec<_>>(),
vec![0, 4, 8]
);
}
#[test]
fn test_splice_replaces_inserts_deletes_and_appends() {
let mut chunked = numbers(10);
chunked.splice(2, 5, vec![100, 101]);
assert_eq!(values(&chunked), [0, 1, 100, 101, 5, 6, 7, 8, 9]);
assert_consistent(&chunked);
chunked.splice(0, 0, vec![50]);
assert_eq!(values(&chunked)[..3], [50, 0, 1]);
chunked.splice(chunked.len(), chunked.len(), vec![7, 7, 7, 7]);
assert_eq!(values(&chunked)[chunked.len() - 5..], [9, 7, 7, 7, 7]);
assert_consistent(&chunked);
let len = chunked.len();
chunked.splice(0, len, vec![]);
assert!(chunked.is_empty());
assert_eq!(chunked.len(), 0);
}
#[test]
#[should_panic(expected = "splice range out of bounds")]
fn test_splice_out_of_bounds_panics() {
numbers(4).splice(2, 5, vec![]);
}
#[test]
fn test_splice_keeps_the_chunks_it_does_not_touch() {
let mut chunked = numbers(12);
let before = ids(&chunked);
// inside the second chunk
chunked.splice(4, 5, vec![40]);
let after = ids(&chunked);
assert_eq!(after.len(), before.len());
assert_eq!(
(after[0], after[2], after[3]),
(before[0], before[2], before[3])
);
assert_ne!(after[1], before[1]);
}
#[test]
fn test_edit_replaces_only_the_changed_chunks() {
let mut chunked = numbers(9);
let before = ids(&chunked);
// nothing is changed: nothing is replaced
assert!(!chunked.edit(|n| *n == 4, |_| false));
assert_eq!(ids(&chunked), before);
// the filter picks the chunk, the hook changes it
assert!(chunked.edit(
|n| *n == 4,
|items| {
items[1] = 400;
true
}
));
let after = ids(&chunked);
assert_eq!((after[0], after[2]), (before[0], before[2]));
assert_ne!(after[1], before[1]);
assert_eq!(values(&chunked), [0, 1, 2, 3, 400, 5, 6, 7, 8]);
// a chunk left empty is dropped
assert!(chunked.edit(
|n| *n == 0,
|items| {
items.clear();
true
}
));
assert_eq!(chunked.chunks().len(), 2);
assert_consistent(&chunked);
}
#[test]
fn test_update_copies_the_chunk_so_that_other_versions_do_not_change() {
let mut chunked = numbers(6);
let version = chunked.clone();
let before = ids(&chunked);
chunked.update(4, |n| *n = 40);
assert_eq!(values(&chunked), [0, 1, 2, 3, 40, 5]);
assert_eq!(values(&version), (0..6).collect::<Vec<_>>());
assert_eq!(ids(&version), before);
assert_eq!(ids(&chunked)[0], before[0]);
assert_ne!(ids(&chunked)[1], before[1]);
}
#[test]
fn test_updated_chunks_get_a_new_identity_even_when_nothing_shares_them() {
// what is derived from or kept of a chunk is keyed by its identity
let mut chunked = numbers(6);
let before = ids(&chunked);
chunked.update(1, |n| *n += 1);
assert_ne!(ids(&chunked)[0], before[0]);
assert_eq!(ids(&chunked)[1], before[1]);
let before = ids(&chunked);
chunked.update_all(|_, n| *n += 1);
let after = ids(&chunked);
assert!(before.iter().zip(&after).all(|(a, b)| a != b));
}
#[test]
fn test_update_all_gives_the_index_of_each_item() {
let mut chunked = numbers(8);
let mut seen = vec![];
chunked.update_all(|index, n| {
seen.push(index);
*n = 10 * index as u32;
});
assert_eq!(seen, (0..8).collect::<Vec<_>>());
assert_eq!(values(&chunked), [0, 10, 20, 30, 40, 50, 60, 70]);
}
}
@@ -1,3 +1,20 @@
/// Defines an identifier made of a UUID, which is a new one by default.
macro_rules! uuid_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct $name(pub uuid::Uuid);
impl Default for $name {
fn default() -> Self {
Self(uuid::Uuid::new_v4())
}
}
};
}
pub(crate) use uuid_id;
#[derive(Debug, Default, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Link { pub struct Link {
pub href: String, pub href: String,
@@ -10,7 +27,7 @@ pub struct LngLat {
pub lat: f64, pub lat: f64,
} }
#[derive(Debug)] #[derive(Debug, Clone, Copy)]
pub struct LngLatBounds { pub struct LngLatBounds {
pub sw: LngLat, pub sw: LngLat,
pub ne: LngLat, pub ne: LngLat,
+81
View File
@@ -0,0 +1,81 @@
use crate::{Link, Route, Track, TrackSegment, Waypoints};
use super::common::uuid_id;
uuid_id!(FileId);
#[derive(Debug, Default, Clone)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
/// The routes come before the tracks, as in GPX: everything that goes through the segments
/// of a file (statistics, the elevation profile...) takes the ones of the routes first.
pub rte: Vec<Route>,
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>,
}
/// Where a segment is in its file: the one of a route, or one of a track.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SegmentIndex {
Route(usize),
Track { trk: usize, seg: usize },
}
impl File {
/// The segments of the routes, then the ones of the tracks.
pub fn segments(&self) -> impl Iterator<Item = &TrackSegment> {
self.rte
.iter()
.map(|rte| &rte.seg)
.chain(self.trk.iter().flat_map(|trk| &trk.trkseg))
}
/// Where the segments are, in the order of [`File::segments`].
pub fn segment_indices(&self) -> impl Iterator<Item = SegmentIndex> + '_ {
(0..self.rte.len())
.map(SegmentIndex::Route)
.chain(self.trk.iter().enumerate().flat_map(|(trk, track)| {
(0..track.trkseg.len()).map(move |seg| SegmentIndex::Track { trk, seg })
}))
}
/// The segment at `index`. Panics if there is none.
pub fn segment(&self, index: SegmentIndex) -> &TrackSegment {
match index {
SegmentIndex::Route(rte) => &self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &self.trk[trk].trkseg[seg],
}
}
/// The segment at `index`. Panics if there is none.
pub fn segment_mut(&mut self, index: SegmentIndex) -> &mut TrackSegment {
match index {
SegmentIndex::Route(rte) => &mut self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &mut self.trk[trk].trkseg[seg],
}
}
/// Whether the file has no route nor track.
pub fn has_no_path(&self) -> bool {
self.rte.is_empty() && self.trk.is_empty()
}
}
@@ -1,15 +1,23 @@
mod categories;
mod chunk; mod chunk;
mod chunked;
mod common; mod common;
mod file; mod file;
mod route;
mod segment; mod segment;
mod track; mod track;
mod trackpoint; mod trackpoint;
mod waypoint; mod waypoint;
mod waypoints;
pub use categories::*;
pub use chunk::*; pub use chunk::*;
pub use chunked::*;
pub use common::*; pub use common::*;
pub use file::*; pub use file::*;
pub use route::*;
pub use segment::*; pub use segment::*;
pub use track::*; pub use track::*;
pub use trackpoint::*; pub use trackpoint::*;
pub use waypoint::*; pub use waypoint::*;
pub use waypoints::*;
+15
View File
@@ -0,0 +1,15 @@
use crate::{TrackInfo, TrackSegment};
use super::common::uuid_id;
uuid_id!(RouteId);
/// A route of the file (`rte`): like a track, but with a single segment, its points.
///
/// It has the information of a track: GPX gives the same to both.
#[derive(Debug, Default, Clone)]
pub struct Route {
pub id: RouteId,
pub info: TrackInfo,
pub seg: TrackSegment,
}
+411
View File
@@ -0,0 +1,411 @@
use std::ops::Deref;
use std::rc::Rc;
use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors};
use super::common::uuid_id;
uuid_id!(TrackSegmentId);
uuid_id!(TrackSegmentRevisionId);
#[derive(Debug, Default, Clone)]
pub struct TrackSegment {
pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId,
points: Chunked<TrackpointChunk>,
}
/// The position of a trackpoint in a segment.
pub type TrackSegmentIndex = ChunkIndex;
pub type TrackSegmentIterator<'a> = ChunkedIter<'a, TrackpointChunk>;
impl Deref for TrackSegment {
type Target = Chunked<TrackpointChunk>;
fn deref(&self) -> &Self::Target {
&self.points
}
}
impl TrackSegment {
/// Adds a chunk after the last trackpoint, which is dropped if it is empty.
pub fn push(&mut self, chunk: TrackpointChunk) {
self.points.push(chunk);
self.rev_id = Default::default();
}
/// Adds a chunk that is shared with something else after the last trackpoint.
pub fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
self.points.push_shared(chunk);
self.rev_id = Default::default();
}
/// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no
/// such trackpoint.
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) {
self.points.update(index, f);
self.rev_id = Default::default();
}
/// Applies `f` to every trackpoint, with its index, see [`Chunked::update_all`].
pub fn update_all(&mut self, f: impl FnMut(usize, &mut Trackpoint)) {
self.points.update_all(f);
self.rev_id = Default::default();
}
/// Replaces the points in `start..end` by `points`, see [`Chunked::splice`]. The first and
/// last trackpoints are anchors afterwards.
pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
self.points.splice(start, end, points);
self.rev_id = Default::default();
self.ensure_end_anchors();
}
/// The first and the last trackpoints are always anchors, shown at every zoom level.
///
/// It is done by [`TrackSegment::splice`]; commands that build segments in another way need to
/// call it.
pub fn ensure_end_anchors(&mut self) {
let Some(last) = self.len().checked_sub(1) else {
return;
};
for index in [0, last] {
if self[index].anchor != Some(0) {
self.set_anchor(index, 0);
}
}
}
/// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if
/// there is no such trackpoint.
pub fn set_anchor(&mut self, index: usize, zoom: u8) {
self.update(index, |trkpt| trkpt.anchor = Some(zoom));
}
/// Sets the anchors of the trackpoints from the details of the path of the segment (see
/// [`compute_anchors`]), forgetting the previous ones.
pub fn compute_anchors(&mut self) {
let anchors = compute_anchors(self);
let mut anchors = anchors.into_iter().peekable();
self.update_all(|index, trkpt| {
trkpt.anchor = match anchors.peek() {
Some(&(anchor, zoom)) if anchor == index => {
anchors.next();
Some(zoom)
}
_ => None,
};
});
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::Chunk;
use super::*;
fn create_track_segment(nb_chunks: usize) -> TrackSegment {
let mut trkseg = TrackSegment::default();
let mut count = 0;
for n in 1..=nb_chunks {
let mut chunk = TrackpointChunk::default();
for _ in 0..n {
chunk.trkpt.push(Trackpoint {
ele: count as f64,
..Default::default()
});
count += 1;
}
trkseg.push(chunk);
}
trkseg
}
fn eles(trkseg: &TrackSegment) -> Vec<f64> {
trkseg.iter().map(|p| p.ele).collect()
}
fn points(eles: &[f64]) -> Vec<Trackpoint> {
eles.iter()
.map(|&ele| Trackpoint {
ele,
..Default::default()
})
.collect()
}
#[test]
fn test_splice_replace_insert_delete_append() {
// chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14]
let mut trkseg = create_track_segment(5);
trkseg.splice(4, 8, points(&[-1.0, -2.0]));
let mut expected: Vec<f64> = (0..4).map(f64::from).collect();
expected.extend([-1.0, -2.0]);
expected.extend((8..15).map(f64::from));
assert_eq!(eles(&trkseg), expected);
assert_eq!(trkseg.len(), expected.len());
for i in 0..trkseg.len() {
assert_eq!(trkseg[i].ele, expected[i]);
}
trkseg.splice(2, 2, points(&[100.0]));
expected.insert(2, 100.0);
assert_eq!(eles(&trkseg), expected);
trkseg.splice(0, 3, vec![]);
expected.drain(..3);
assert_eq!(eles(&trkseg), expected);
let len = trkseg.len();
trkseg.splice(len, len, points(&[7.0, 8.0]));
expected.extend([7.0, 8.0]);
assert_eq!(eles(&trkseg), expected);
let len = trkseg.len();
trkseg.splice(0, len, vec![]);
assert_eq!(trkseg.len(), 0);
assert!(trkseg.first_index().is_none());
}
fn anchors(trkseg: &TrackSegment) -> Vec<Option<u8>> {
trkseg.iter().map(|p| p.anchor).collect()
}
#[test]
fn test_splice_keeps_the_ends_anchors() {
let mut trkseg = TrackSegment::default();
assert_eq!(anchors(&trkseg), vec![]);
trkseg.splice(0, 0, points(&[0.0, 1.0, 2.0]));
assert_eq!(anchors(&trkseg), [Some(0), None, Some(0)]);
// new ends: the previous ones stay anchors
trkseg.splice(3, 3, points(&[3.0]));
trkseg.splice(0, 0, points(&[-1.0]));
assert_eq!(anchors(&trkseg), [Some(0), Some(0), None, Some(0), Some(0)]);
// removing the ends: the new ones become anchors
trkseg.splice(4, 5, vec![]);
trkseg.splice(0, 2, vec![]);
assert_eq!(anchors(&trkseg), [Some(0), Some(0)]);
// a single point is both ends
trkseg.splice(1, 2, vec![]);
assert_eq!(anchors(&trkseg), [Some(0)]);
}
#[test]
fn test_compute_anchors_replaces_the_previous_ones() {
let mut trkseg = TrackSegment::default();
let line: Vec<_> = (0..10)
.map(|i| Trackpoint {
coordinates: crate::LngLat {
lng: i as f64 * 0.001,
lat: 0.0,
},
anchor: Some(5),
..Default::default()
})
.collect();
trkseg.splice(0, 0, line);
trkseg.compute_anchors();
let mut expected = vec![None; 10];
expected[0] = Some(0);
expected[9] = Some(0);
assert_eq!(anchors(&trkseg), expected);
}
#[test]
fn test_splice_keeps_untouched_chunks_shared() {
let mut trkseg = create_track_segment(5);
// the ends are anchors already, or their chunks would be copied
trkseg.ensure_end_anchors();
let before = trkseg.chunks().to_vec();
// inside the third chunk only
trkseg.splice(4, 5, points(&[-1.0]));
assert!(Rc::ptr_eq(&trkseg.chunks()[0], &before[0]));
assert!(Rc::ptr_eq(&trkseg.chunks()[1], &before[1]));
assert!(Rc::ptr_eq(trkseg.chunks().last().unwrap(), &before[4]));
assert!(Rc::ptr_eq(
&trkseg.chunks()[trkseg.chunks().len() - 2],
&before[3]
));
}
#[test]
fn test_splice_append_extends_last_chunk_and_respects_max_size() {
let mut trkseg = TrackSegment::default();
for i in 0..10_000 {
let len = trkseg.len();
trkseg.splice(len, len, points(&[i as f64]));
}
assert_eq!(trkseg.len(), 10_000);
assert!(trkseg.chunks().len() <= 3);
assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096));
assert_eq!(trkseg[9_999].ele, 9_999.0);
assert_eq!(trkseg.len(), 10_000);
}
#[test]
fn test_len() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
assert_eq!(trkseg.len(), nb_chunks * (nb_chunks + 1) / 2);
}
#[test]
fn test_locate() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for i in 0..trkseg.len() {
let idx = trkseg.locate(i);
assert!(idx.is_some());
assert_eq!(trkseg[idx.unwrap()].ele, i as f64);
}
assert!(trkseg.locate(trkseg.len()).is_none());
}
#[test]
fn test_index() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for i in 0..trkseg.len() {
assert_eq!(trkseg[i].ele, i as f64);
}
}
#[test]
#[should_panic]
fn test_index_out_of_bounds_1() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let _ = trkseg[trkseg.len()];
}
#[test]
#[should_panic]
fn test_index_out_of_bounds_2() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let _ = trkseg[TrackSegmentIndex {
chunk: trkseg.chunks().len(),
pos: 0,
flat: 0,
}];
}
#[test]
fn test_iter() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for (i, trkpt) in trkseg.iter().enumerate() {
assert!(std::ptr::eq(&trkseg[i], trkpt));
}
}
#[test]
fn test_iter_nth() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let tenth = trkseg.iter().nth(10);
assert!(tenth.is_some());
assert!(std::ptr::eq(&trkseg[10], tenth.unwrap()));
}
#[test]
fn test_iter_skip() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for (i, trkpt) in trkseg.iter().enumerate().skip(5) {
assert!(std::ptr::eq(&trkseg[i], trkpt));
}
}
#[test]
fn test_first_last_index() {
assert!(TrackSegment::default().first_index().is_none());
assert!(TrackSegment::default().last_index().is_none());
let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0));
assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14));
assert_eq!(trkseg[last].ele, 14.0);
assert_eq!(trkseg.locate(14), Some(last));
assert!(trkseg.next_index(Some(last)).is_none());
assert!(trkseg.prev_index(Some(first)).is_none());
}
#[test]
fn test_prev_next_are_inverse() {
let trkseg = create_track_segment(6);
let mut idx = trkseg.last_index();
let mut visited = vec![];
while let Some(i) = idx {
assert_eq!(trkseg.locate(i.flat), Some(i));
visited.push(i.flat);
idx = trkseg.prev_index(Some(i));
}
assert_eq!(visited, (0..trkseg.len()).rev().collect::<Vec<_>>());
let mut idx = trkseg.first_index();
while let Some(i) = idx {
let next = trkseg.next_index(Some(i));
if let Some(n) = next {
assert_eq!(trkseg.prev_index(Some(n)), Some(i));
}
idx = next;
}
}
fn points_of(n: usize) -> Vec<Trackpoint> {
(0..n).map(|_| Trackpoint::default()).collect()
}
#[test]
fn test_every_change_gives_a_new_revision() {
type Change = (&'static str, fn(&mut TrackSegment));
let changes: [Change; 7] = [
("push", |s| s.push(TrackpointChunk::new(points_of(1)))),
("push_shared", |s| {
s.push_shared(Rc::new(TrackpointChunk::new(points_of(1))))
}),
("update", |s| s.update(0, |p| p.ele += 1.0)),
("update_all", |s| s.update_all(|_, p| p.ele += 1.0)),
("splice", |s| s.splice(1, 2, points_of(2))),
("set_anchor", |s| s.set_anchor(1, 5)),
("compute_anchors", |s| s.compute_anchors()),
];
for (name, change) in changes {
let mut trkseg = create_track_segment(3);
let before = trkseg.rev_id;
change(&mut trkseg);
assert_ne!(trkseg.rev_id, before, "{name}");
}
// reading does not
let trkseg = create_track_segment(3);
let before = trkseg.rev_id;
let _ = trkseg.iter().count();
assert_eq!(trkseg.rev_id, before);
}
#[test]
fn test_ends_are_anchors_after_a_splice() {
let mut trkseg = create_track_segment(3);
trkseg.splice(0, 1, points_of(2));
let last = trkseg.len() - 1;
assert_eq!(trkseg[0].anchor, Some(0));
assert_eq!(trkseg[last].anchor, Some(0));
// nothing to anchor in an empty segment
let mut empty = TrackSegment::default();
empty.ensure_end_anchors();
assert!(empty.is_empty());
}
}
@@ -1,17 +1,10 @@
use uuid::Uuid;
use crate::{Link, TrackSegment}; use crate::{Link, TrackSegment};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] use super::common::uuid_id;
pub struct TrackId(pub Uuid);
impl Default for TrackId { uuid_id!(TrackId);
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)] #[derive(Debug, Default, Clone)]
pub struct Track { pub struct Track {
pub id: TrackId, pub id: TrackId,
pub info: TrackInfo, pub info: TrackInfo,
@@ -24,7 +17,7 @@ pub struct TrackInfo {
pub cmt: Option<String>, pub cmt: Option<String>,
pub desc: Option<String>, pub desc: Option<String>,
pub src: Option<String>, pub src: Option<String>,
pub link: Option<Link>, pub links: Vec<Link>,
pub type_: Option<String>, pub type_: Option<String>,
pub color: Option<String>, pub color: Option<String>,
pub opacity: Option<f64>, pub opacity: Option<f64>,
+24
View File
@@ -0,0 +1,24 @@
use crate::LngLat;
#[derive(Debug, Default, Clone)]
pub struct Trackpoint {
pub coordinates: LngLat,
pub ele: f64,
pub time: Option<i64>,
pub atemp: Option<i16>,
pub hr: Option<u16>,
pub cad: Option<u16>,
pub power: Option<u16>,
/// Code of the surface, see [`crate::TrackpointCategories`].
pub surface: Option<u8>,
/// Code of the highway, see [`crate::TrackpointCategories`].
pub highway: Option<u8>,
/// Code of the SAC hiking scale, see [`crate::TrackpointCategories`].
pub sac_scale: Option<u8>,
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
pub mtb_scale: Option<u8>,
/// Whether the trackpoint is an anchor of the routing tool (a point the user can drag to
/// reroute the segment): the lowest map zoom level at which it is shown. The first and last
/// trackpoints of a segment are always anchors that are always shown (zoom 0).
pub anchor: Option<u8>,
}
+35
View File
@@ -0,0 +1,35 @@
use crate::{Link, LngLat};
use super::common::uuid_id;
uuid_id!(WaypointId);
#[derive(Debug, Default, Clone)]
pub struct Waypoint {
pub id: WaypointId,
pub coordinates: LngLat,
pub ele: f64,
pub time: Option<i64>,
pub name: Option<String>,
pub cmt: Option<String>,
pub desc: Option<String>,
pub links: Vec<Link>,
pub sym: Option<String>,
pub type_: Option<String>,
}
impl Waypoint {
/// Whether the waypoints are the same, whatever their ids.
pub fn same_content(&self, other: &Waypoint) -> bool {
self.coordinates.lng == other.coordinates.lng
&& self.coordinates.lat == other.coordinates.lat
&& self.ele == other.ele
&& self.time == other.time
&& self.name == other.name
&& self.cmt == other.cmt
&& self.desc == other.desc
&& self.links == other.links
&& self.sym == other.sym
&& self.type_ == other.type_
}
}
+315
View File
@@ -0,0 +1,315 @@
use std::ops::Deref;
use crate::{Chunked, Waypoint, WaypointChunk, WaypointId};
use super::common::uuid_id;
uuid_id!(FileWaypointsRevisionId);
/// The waypoints of a file, in chunks that are shared between the versions of the file (see
/// [`Chunked`]).
///
/// Everything that changes the waypoints changes the revision too, which is why there is no
/// mutable access to the list itself: the revision tells what is derived from the waypoints
/// (coordinates for the map...) that it is out of date.
#[derive(Debug, Default, Clone)]
pub struct Waypoints {
chunks: Chunked<WaypointChunk>,
/// Changes when the waypoints do.
pub rev_id: FileWaypointsRevisionId,
}
impl Deref for Waypoints {
type Target = Chunked<WaypointChunk>;
fn deref(&self) -> &Self::Target {
&self.chunks
}
}
impl Waypoints {
/// Waypoints made of the given chunks, which are used as they are.
pub fn new(chunks: impl IntoIterator<Item = WaypointChunk>) -> Self {
let mut waypoints = Self::default();
for chunk in chunks {
waypoints.push(chunk);
}
waypoints
}
/// Adds a chunk after the last waypoint.
pub fn push(&mut self, chunk: WaypointChunk) {
self.chunks.push(chunk);
self.rev_id = Default::default();
}
/// Adds a chunk that is shared with something else after the last waypoint.
pub fn push_shared(&mut self, chunk: std::rc::Rc<WaypointChunk>) {
self.chunks.push_shared(chunk);
self.rev_id = Default::default();
}
/// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`].
pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec<Waypoint>) {
self.chunks.splice(start, end, waypoints);
self.rev_id = Default::default();
}
/// Inserts waypoints, so that the first one is at `index` among the waypoints (at the end if
/// the index is past it). Only the chunks around the insertion are copied.
pub fn insert_at(&mut self, index: usize, waypoints: Vec<Waypoint>) {
if waypoints.is_empty() {
return;
}
let index = index.min(self.len());
self.splice(index, index, waypoints);
}
/// Inserts waypoints right after the waypoint `after`, or at the end if there is none (or if
/// it is not there). See [`Waypoints::insert_at`].
pub fn insert_after(&mut self, after: Option<WaypointId>, waypoints: Vec<Waypoint>) {
let index = after
.and_then(|after| self.position(after))
.map_or(usize::MAX, |i| i + 1);
self.insert_at(index, waypoints);
}
/// The position of the waypoint among the waypoints.
pub fn position(&self, id: WaypointId) -> Option<usize> {
self.iter().position(|wpt| wpt.id == id)
}
/// Edits the waypoints of the chunks that contain one accepted by `filter`, see
/// [`Chunked::edit`]. Returns whether anything changed.
pub fn edit(
&mut self,
filter: impl Fn(&Waypoint) -> bool,
f: impl FnMut(&mut Vec<Waypoint>) -> bool,
) -> bool {
let changed = self.chunks.edit(filter, f);
if changed {
self.rev_id = Default::default();
}
changed
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use super::*;
fn waypoint() -> Waypoint {
Waypoint::default()
}
fn waypoints_with(chunks: &[usize]) -> (Waypoints, Vec<WaypointId>) {
let mut waypoints = Waypoints::default();
let mut ids = vec![];
for n in chunks {
let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect();
ids.extend(wpt.iter().map(|w| w.id));
waypoints.push(WaypointChunk {
wpt,
..Default::default()
});
}
(waypoints, ids)
}
fn ids(waypoints: &Waypoints) -> Vec<WaypointId> {
waypoints.iter().map(|w| w.id).collect()
}
#[test]
fn test_new_and_push() {
let (waypoints, ids_) = waypoints_with(&[2, 1]);
assert_eq!(waypoints.len(), 3);
assert_eq!(waypoints.chunks().len(), 2);
assert_eq!(ids(&waypoints), ids_);
assert_eq!(waypoints.position(ids_[2]), Some(2));
assert_eq!(waypoints.position(WaypointId::default()), None);
// empty chunks are dropped
let waypoints = Waypoints::new([WaypointChunk::default()]);
assert!(waypoints.is_empty());
}
#[test]
fn test_insert_at_the_end() {
let (mut waypoints, before) = waypoints_with(&[2, 1]);
let first = waypoints.chunks()[0].clone();
let rev = waypoints.rev_id;
let new = vec![waypoint(), waypoint()];
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
waypoints.insert_after(None, new);
assert_eq!(ids(&waypoints), [before.clone(), new_ids].concat());
// the chunk that is not touched is kept, the last one is extended
assert_eq!(waypoints.chunks().len(), 2);
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
assert_ne!(waypoints.rev_id, rev);
// an unknown waypoint is an insertion at the end too
let (mut waypoints, before) = waypoints_with(&[2]);
let one = waypoint();
let one_id = one.id;
waypoints.insert_after(Some(WaypointId::default()), vec![one]);
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
}
#[test]
fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_next_ones() {
let (mut waypoints, before) = waypoints_with(&[2, 2]);
let last = waypoints.chunks()[1].clone();
let one = waypoint();
let one_id = one.id;
waypoints.insert_after(Some(before[1]), vec![one]);
assert_eq!(
ids(&waypoints),
[&before[..2], &[one_id], &before[2..]].concat()
);
assert_eq!(waypoints.chunks().len(), 2);
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
}
#[test]
fn test_insert_in_the_middle_of_a_chunk_cuts_it() {
let (mut waypoints, before) = waypoints_with(&[3, 1]);
let last = waypoints.chunks()[1].clone();
let new = vec![waypoint(), waypoint()];
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
waypoints.insert_after(Some(before[0]), new);
assert_eq!(
ids(&waypoints),
[&before[..1], &new_ids[..], &before[1..]].concat()
);
// the cut chunk is refilled, the other one is kept
assert_eq!(waypoints.chunks().len(), 2);
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
}
#[test]
fn test_insert_nothing() {
let (mut waypoints, before) = waypoints_with(&[2]);
let rev = waypoints.rev_id;
waypoints.insert_after(Some(before[0]), vec![]);
assert_eq!(ids(&waypoints), before);
assert_eq!(waypoints.rev_id, rev);
}
#[test]
fn test_insert_many_fills_chunks() {
let mut waypoints = Waypoints::default();
let new: Vec<_> = (0..300).map(|_| waypoint()).collect();
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
waypoints.insert_after(None, new);
assert_eq!(ids(&waypoints), new_ids);
assert_eq!(waypoints.chunks().len(), 3);
assert!(waypoints.chunks()[..2].iter().all(|chunk| {
use crate::Chunk;
chunk.is_full()
}));
}
#[test]
fn test_insert_at_an_index() {
// at the start
let (mut waypoints, before) = waypoints_with(&[2, 1]);
let first = waypoints.chunks()[0].clone();
let one = waypoint();
let one_id = one.id;
waypoints.insert_at(0, vec![one]);
assert_eq!(ids(&waypoints), [vec![one_id], before.clone()].concat());
// the existing chunks are kept
assert_eq!(waypoints.chunks().len(), 3);
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &first));
// between two chunks
let (mut waypoints, before) = waypoints_with(&[2, 1]);
let last = waypoints.chunks()[1].clone();
let one = waypoint();
let one_id = one.id;
waypoints.insert_at(2, vec![one]);
assert_eq!(
ids(&waypoints),
[&before[..2], &[one_id], &before[2..]].concat()
);
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
// inside a chunk
let (mut waypoints, before) = waypoints_with(&[3]);
let one = waypoint();
let one_id = one.id;
waypoints.insert_at(1, vec![one]);
assert_eq!(
ids(&waypoints),
[&before[..1], &[one_id], &before[1..]].concat()
);
// at the end, or past it
for index in [3, 100, usize::MAX] {
let (mut waypoints, before) = waypoints_with(&[3]);
let one = waypoint();
let one_id = one.id;
waypoints.insert_at(index, vec![one]);
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
}
// in a file without waypoints
let mut waypoints = Waypoints::default();
let one = waypoint();
let one_id = one.id;
waypoints.insert_at(0, vec![one]);
assert_eq!(ids(&waypoints), vec![one_id]);
}
#[test]
fn test_splice_replaces_waypoints() {
let (mut waypoints, before) = waypoints_with(&[2, 2]);
let rev = waypoints.rev_id;
waypoints.splice(1, 3, vec![]);
assert_eq!(ids(&waypoints), [before[0], before[3]]);
assert_ne!(waypoints.rev_id, rev);
}
#[test]
fn test_edit_changes_the_chunks_with_a_match_only() {
let (mut waypoints, before) = waypoints_with(&[2, 2, 1]);
let (first, last) = (waypoints.chunks()[0].clone(), waypoints.chunks()[2].clone());
let rev = waypoints.rev_id;
let target = before[2];
let changed = waypoints.edit(
|wpt| wpt.id == target,
|wpts| {
wpts.retain(|wpt| wpt.id != target);
true
},
);
assert!(changed);
assert_ne!(waypoints.rev_id, rev);
assert_eq!(ids(&waypoints), [&before[..2], &before[3..]].concat());
// the chunks without a match are the same ones, the changed one is new
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
assert!(Rc::ptr_eq(&waypoints.chunks()[2], &last));
assert_eq!(waypoints.chunks().len(), 3);
// a chunk left empty is dropped
let (mut waypoints, before) = waypoints_with(&[1, 2]);
waypoints.edit(
|wpt| wpt.id == before[0],
|wpts| {
wpts.clear();
true
},
);
assert_eq!(waypoints.chunks().len(), 1);
assert_eq!(ids(&waypoints), before[1..]);
// a chunk that is not changed is kept, and so is the revision
let (mut waypoints, before) = waypoints_with(&[2]);
let (chunk, rev) = (waypoints.chunks()[0].clone(), waypoints.rev_id);
assert!(!waypoints.edit(|wpt| wpt.id == before[0], |_| false));
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &chunk));
assert_eq!(waypoints.rev_id, rev);
assert!(!waypoints.edit(|_| false, |_| true));
}
}
@@ -1,3 +1,5 @@
mod parse; mod parse;
mod write;
pub use parse::*; pub use parse::*;
pub use write::*;
File diff suppressed because it is too large. Load diff
+791
View File
@@ -0,0 +1,791 @@
use std::io;
use chrono::DateTime;
use quick_xml::Writer;
use quick_xml::events::{BytesDecl, BytesText, Event};
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// What the written file contains: the data of the trackpoints that are `false` are left out.
/// The same type tells which data a file has, see [`File::exportable_data`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions {
pub time: bool,
pub hr: bool,
pub cad: bool,
pub atemp: bool,
pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool,
}
impl ExportOptions {
pub const ALL: Self = Self::of(true);
pub const NONE: Self = Self::of(false);
const fn of(value: bool) -> Self {
Self {
time: value,
hr: value,
cad: value,
atemp: value,
power: value,
osm: value,
}
}
/// The options that are on in `self` or in `other`.
pub fn union(self, other: Self) -> Self {
Self {
time: self.time || other.time,
hr: self.hr || other.hr,
cad: self.cad || other.cad,
atemp: self.atemp || other.atemp,
power: self.power || other.power,
osm: self.osm || other.osm,
}
}
}
impl File {
/// The data that the trackpoints of the file have (and waypoints for the time), which the
/// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE;
for point in self.segments().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
}
}
/// The highest zoom level from which an anchor is shown that makes it a route point, see [`write`]:
/// the anchors that only appear when zooming in further are details of the path.
pub const ROUTE_POINT_MAX_ZOOM: u8 = 12;
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 routes are written as routes (`rte`): the anchors of their segment that are shown from the
/// zoom level [`ROUTE_POINT_MAX_ZOOM`] or a lower one are the `rtept`, and the trackpoints between
/// two of them 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<'a>(
w: &mut XmlWriter,
file: &'a 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)?;
}
// a single unnamed route or track is named like the file
let alone = file.rte.len() + file.trk.len() == 1;
let name = |info: &'a crate::TrackInfo| match &info.name {
Some(name) if !name.is_empty() => Some(name.as_str()),
_ if alone => Some(file.info.name.as_str()),
_ => None,
};
for rte in &file.rte {
write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?;
}
for trk in &file.trk {
write_track(w, trk, name(&trk.info), 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,
info: &crate::TrackInfo,
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, info)?;
let points: Vec<&Trackpoint> = segment.iter().collect();
let last = points.len().saturating_sub(1);
let is_route_point = |i: usize| {
i == 0
|| i == last
|| points[i]
.anchor
.is_some_and(|zoom| zoom <= ROUTE_POINT_MAX_ZOOM)
};
let mut i = 0;
while i < points.len() {
// the trackpoints up to the next route point
let mut next = i + 1;
while next < points.len() && !is_route_point(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.segments()
.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);
}
/// The file with each segment of its tracks as a route, which has the information of its
/// track.
fn as_routes(mut file: File) -> File {
file.rte = std::mem::take(&mut file.trk)
.into_iter()
.flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| crate::Route {
info: trk.info.clone(),
seg,
..Default::default()
})
})
.collect();
file
}
fn count(text: &str, pattern: &str) -> usize {
text.matches(pattern).count()
}
#[test]
fn test_routes_are_written_with_their_route_points_and_their_path() {
let (file, categories) = read("with_route_points");
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<trk>"), 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);
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_written() {
let (file, mut categories) = read("with_route_points");
let written = write(&file, &categories, ExportOptions::ALL);
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.rte.len(), 1);
assert_eq!(again.rte[0].info, file.rte[0].info);
let (a, b) = (&file.rte[0].seg, &again.rte[0].seg);
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_the_anchors_shown_up_to_zoom_12_are_the_route_points() {
let (file, mut categories) = read("with_tracks_and_segments");
let file = as_routes(file);
assert!(file.rte.len() > 2);
let written = write(&file, &categories, ExportOptions::ALL);
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.rte.len(), file.rte.len());
let anchors = |s: &crate::TrackSegment, max_zoom: u8| {
s.iter()
.enumerate()
.filter(|(_, p)| p.anchor.is_some_and(|zoom| zoom <= max_zoom))
.map(|(i, _)| i)
.collect::<Vec<_>>()
};
for (a, b) in file.rte.iter().zip(&again.rte) {
assert_eq!(a.info.name, b.info.name);
assert_eq!(a.seg.len(), b.seg.len());
// the route points come back as anchors, the other anchors as points of the path
assert_eq!(
anchors(&a.seg, ROUTE_POINT_MAX_ZOOM),
anchors(&b.seg, u8::MAX)
);
}
}
#[test]
fn test_anchors_shown_from_a_higher_zoom_are_points_of_the_path() {
let (file, categories) = read("simple");
let mut file = as_routes(file);
let segment = &mut file.rte[0].seg;
segment.update_all(|_, point| point.anchor = None);
let len = segment.len();
segment.set_anchor(5, ROUTE_POINT_MAX_ZOOM);
segment.set_anchor(10, ROUTE_POINT_MAX_ZOOM + 1);
let text = written_text(&file, &categories, ExportOptions::ALL);
// the ends, and the anchor shown from zoom 12
assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), len - 3);
}
#[test]
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
let (file, categories) = read("simple");
let mut file = as_routes(file);
file.rte[0].seg.update_all(|_, point| point.anchor = None);
let len = file.rte[0].seg.len();
let text = written_text(&file, &categories, ExportOptions::ALL);
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.rte[0].seg.splice(1, len, vec![]);
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rtept"), 1);
assert_eq!(count(&text, "<gpxx:rpt"), 0);
}
#[test]
fn test_routes_keep_their_style() {
let (file, categories) = read("with_style");
let file = as_routes(file);
let text = written_text(&file, &categories, ExportOptions::ALL);
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.rte[0].info.color, file.rte[0].info.color);
assert_eq!(again.rte[0].info.width, file.rte[0].info.width);
}
#[test]
fn test_routes_are_written_as_routes_before_the_tracks() {
let (mut file, categories) = read("with_tracks");
let (routes, _) = read("with_routes");
file.rte = routes.rte.clone();
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), routes.rte.len());
assert_eq!(count(&text, "<trk>"), file.trk.len());
assert!(text.rfind("</rte>").unwrap() < text.find("<trk>").unwrap());
let again = parse(text.as_bytes(), &mut categories.clone()).unwrap();
assert_eq!(again.rte.len(), file.rte.len());
assert_eq!(again.trk.len(), file.trk.len());
for (a, b) in file.rte.iter().zip(&again.rte) {
assert_eq!(a.info, b.info);
same_points(
&a.seg.iter().cloned().collect::<Vec<_>>(),
&b.seg.iter().cloned().collect::<Vec<_>>(),
);
}
same_points(&points(&file), &points(&again));
}
#[test]
fn test_an_empty_route_is_written_and_a_single_route_is_named_like_the_file() {
let mut file = File::default();
file.info.name = "file".into();
file.rte.push(crate::Route::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<rtept"), 0);
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.rte.len(), 1);
assert_eq!(again.rte[0].info.name.as_deref(), Some("file"));
// not when there are others
file.trk.push(Track::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
// the metadata only
assert_eq!(count(&text, "<name>file</name>"), 1);
}
#[test]
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
let (file, categories) = read("with_hr");
let file = as_routes(file);
let with_all = written_text(&file, &categories, ExportOptions::ALL);
assert!(with_all.contains("<gpxtpx:hr>"));
let without = written_text(
&file,
&categories,
ExportOptions {
hr: false,
time: false,
..ExportOptions::ALL
},
);
assert!(!without.contains("<gpxtpx:hr>"));
assert!(!without.contains("<time>"));
}
}
File renamed without changes.
@@ -3,6 +3,20 @@ use crate::{
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff, max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
}; };
/// Time between two trackpoints, to size the windows of a smoothing. It is unknown when one of
/// them has no time, which is longer than any known time span: the window does not go there.
#[derive(Clone, Copy, PartialEq, PartialOrd)]
enum TimeSpan {
Known(i64),
Unknown,
}
impl TimeSpan {
fn between(end: Option<i64>, start: Option<i64>) -> Self {
time_diff(end, start).map_or(Self::Unknown, Self::Known)
}
}
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct Statistics { pub struct Statistics {
pub global: GlobalStatistics, pub global: GlobalStatistics,
@@ -51,9 +65,10 @@ impl Statistics {
self.global.total_distance += dist; self.global.total_distance += dist;
if let Some(time) = time { let moving = time
let speed = speed(dist, time); .zip(time.and_then(|time| speed(dist, time)))
if (0.5..=1500.0).contains(&speed) { .filter(|(_, speed)| (0.5..=1500.0).contains(speed));
if let Some((time, _)) = moving {
self.global.moving_distance = self self.global.moving_distance = self
.global .global
.moving_distance .moving_distance
@@ -64,7 +79,6 @@ impl Statistics {
.map_or(Some(time), |t| Some(t + time)); .map_or(Some(time), |t| Some(t + time));
} }
} }
}
fn update_time_bounds(&mut self, time: Option<i64>) { fn update_time_bounds(&mut self, time: Option<i64>) {
if let Some(time) = time { if let Some(time) = time {
@@ -80,22 +94,35 @@ impl Statistics {
self.global.bounds.extend(coordinates); self.global.bounds.extend(coordinates);
} }
/// Speed over a window of 10 seconds on each side of the trackpoints that have a time (the
/// others have no speed). The windows do not extend to the trackpoints without time: if the
/// previous or next trackpoint has none, the window ends at the trackpoint itself on that side.
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) { fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
for_each_window!( for_each_window!(
trkseg, trkseg,
trkseg.first_index(), trkseg.first_index(),
trkseg.last_index(), trkseg.last_index(),
Some(10000), TimeSpan::Known(10_000),
|i, j| time_diff(trkseg[i].time, trkseg[j].time), |i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|i, left, right| { |i, left, right| {
self.local[i.flat].speed = if trkseg[i].time.is_some() {
time_diff(trkseg[right].time, trkseg[left].time).map(|t| { let timed = |index: TrackSegmentIndex| {
if trkseg[index].time.is_some() {
index
} else {
i
}
};
let (left, right) = (timed(left), timed(right));
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
.and_then(|time| {
speed( speed(
self.local[right.flat].total_distance self.local[right.flat].total_distance
- self.local[left.flat].total_distance, - self.local[left.flat].total_distance,
t, time,
) )
}); });
}
}, },
); );
} }
@@ -211,8 +238,8 @@ impl Statistics {
pub struct GlobalStatistics { pub struct GlobalStatistics {
pub total_distance: f64, pub total_distance: f64,
pub moving_distance: Option<f64>, pub moving_distance: Option<f64>,
pub total_time: Option<i32>, pub total_time: Option<i64>,
pub moving_time: Option<i32>, pub moving_time: Option<i64>,
pub elevation_gain: f64, pub elevation_gain: f64,
pub elevation_loss: f64, pub elevation_loss: f64,
pub start_time: Option<i64>, pub start_time: Option<i64>,
@@ -221,14 +248,17 @@ pub struct GlobalStatistics {
} }
impl GlobalStatistics { impl GlobalStatistics {
/// Average speed over the total time, unknown if that is not a positive duration (for
/// example a range with a single timestamp, or timestamps going backwards).
pub fn total_speed(&self) -> Option<f64> { pub fn total_speed(&self) -> Option<f64> {
self.total_time.map(|t| speed(self.total_distance, t)) self.total_time.and_then(|t| speed(self.total_distance, t))
} }
/// Average speed over the moving time, unknown if there is no moving time.
pub fn moving_speed(&self) -> Option<f64> { pub fn moving_speed(&self) -> Option<f64> {
self.moving_distance self.moving_distance
.zip(self.moving_time) .zip(self.moving_time)
.map(|(d, t)| speed(d, t)) .and_then(|(d, t)| speed(d, t))
} }
pub fn merge(&mut self, other: &GlobalStatistics) { pub fn merge(&mut self, other: &GlobalStatistics) {
@@ -248,8 +278,8 @@ impl GlobalStatistics {
pub struct TrackpointStatistics { pub struct TrackpointStatistics {
pub total_distance: f64, pub total_distance: f64,
pub moving_distance: Option<f64>, pub moving_distance: Option<f64>,
pub total_time: Option<i32>, pub total_time: Option<i64>,
pub moving_time: Option<i32>, pub moving_time: Option<i64>,
pub speed: Option<f64>, pub speed: Option<f64>,
pub elevation_gain: f64, pub elevation_gain: f64,
pub elevation_loss: f64, pub elevation_loss: f64,
@@ -293,7 +323,7 @@ mod tests {
let mut f = File::open("data/with_time.gpx").unwrap(); let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new(); let mut data = String::new();
let _ = f.read_to_string(&mut data); let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap(); let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let trkseg = &gpx.trk[0].trkseg[0]; let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg); let stats = Statistics::compute(trkseg);
@@ -306,11 +336,88 @@ mod tests {
} }
} }
/// The speeds of the segment of `data/with_time.gpx` (80 trackpoints at 20 km/h) once the
/// trackpoints at `holes` have lost their time.
fn speeds_with_holes(holes: &[usize]) -> Vec<Option<f64>> {
let gpx = load("data/with_time.gpx");
let mut segment = gpx.trk[0].trkseg[0].clone();
let points: Vec<_> = segment
.iter()
.enumerate()
.map(|(i, trkpt)| {
let mut trkpt = trkpt.clone();
if holes.contains(&i) {
trkpt.time = None;
}
trkpt
})
.collect();
segment.splice(0, points.len(), points);
Statistics::compute(&segment)
.local
.iter()
.map(|local| local.speed)
.collect()
}
/// Without time, there is no speed. With one, the speed is the one of the trackpoints around.
fn assert_speeds_with_holes(holes: &[usize]) {
let speeds = speeds_with_holes(holes);
assert_eq!(speeds.len(), 80);
for (i, speed) in speeds.iter().enumerate() {
if holes.contains(&i) {
assert_eq!(*speed, None, "trackpoint {i}");
} else {
let speed = speed.unwrap_or_else(|| panic!("no speed for trackpoint {i}"));
assert!((speed - 20.0).abs() < 0.1, "trackpoint {i}: {speed}");
}
}
}
#[test]
fn test_speed_without_holes() {
assert_speeds_with_holes(&[]);
}
#[test]
fn test_speed_with_holes_at_the_beginning() {
assert_speeds_with_holes(&[0, 1, 2, 3, 4]);
}
#[test]
fn test_speed_with_holes_at_the_end() {
assert_speeds_with_holes(&[75, 76, 77, 78, 79]);
}
#[test]
fn test_speed_with_holes_at_both_ends() {
assert_speeds_with_holes(&[0, 1, 2, 77, 78, 79]);
}
#[test]
fn test_speed_with_holes_in_the_middle() {
assert_speeds_with_holes(&[40]);
assert_speeds_with_holes(&[30, 31, 32, 33, 34]);
assert_speeds_with_holes(&(20..60).collect::<Vec<_>>());
}
#[test]
fn test_speed_with_scattered_holes() {
let holes: Vec<_> = (0..80).step_by(7).collect();
assert_speeds_with_holes(&holes);
}
#[test]
fn test_speed_without_any_time() {
let holes: Vec<_> = (0..80).collect();
assert!(speeds_with_holes(&holes).iter().all(Option::is_none));
}
fn load(path: &str) -> crate::File { fn load(path: &str) -> crate::File {
let mut f = File::open(path).unwrap(); let mut f = File::open(path).unwrap();
let mut data = String::new(); let mut data = String::new();
let _ = f.read_to_string(&mut data); let _ = f.read_to_string(&mut data);
parse(data.as_bytes()).unwrap() parse(data.as_bytes(), &mut Default::default()).unwrap()
} }
#[test] #[test]
+5
View File
@@ -0,0 +1,5 @@
mod compute;
mod utils;
pub use compute::*;
pub use utils::*;
@@ -18,13 +18,14 @@ pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
EARTH_RADIUS * c EARTH_RADIUS * c
} }
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i32> { pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
Some((a? - b?) as i32) Some(a? - b?)
} }
/// Computes the speed for a given distance in kilometers and a time in milliseconds /// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
pub fn speed(distance: f64, time: i32) -> f64 { /// Unknown if the time is not a positive duration.
distance / (time as f64 / 3_600_000.0) pub fn speed(distance: f64, time: i64) -> Option<f64> {
(time > 0).then(|| distance / (time as f64 / 3_600_000.0))
} }
pub fn slope(ele: f64, distance: f64) -> f64 { pub fn slope(ele: f64, distance: f64) -> f64 {
@@ -115,6 +116,19 @@ mod tests {
LngLat { lng, lat } 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] #[test]
fn test_distance() { fn test_distance() {
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0); assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
@@ -141,9 +155,13 @@ mod tests {
#[test] #[test]
fn test_speed() { fn test_speed() {
// 1 km in 1 h // 1 km in 1 h
assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12); assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
// 10 km in 30 min // 10 km in 30 min
assert!((speed(10.0, 1_800_000) - 20.0).abs() < 1e-12); assert!((speed(10.0, 1_800_000).unwrap() - 20.0).abs() < 1e-12);
// no duration, no speed
assert_eq!(speed(1.0, 0), None);
assert_eq!(speed(0.0, 0), None);
assert_eq!(speed(1.0, -1000), None);
} }
#[test] #[test]
+46
View File
@@ -0,0 +1,46 @@
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,
},
/// Names a file in all the states of the history, as if it had always been called so (see
/// [`crate::History::rename`]). Not undoable: for a file that is given a name from the outside,
/// like the name of the file it is written to.
Rename {
file_id: FileId,
name: String,
},
}
@@ -12,8 +12,6 @@ pub trait Apply {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError { pub enum CommandError {
/// The command is not implemented yet.
NotImplemented(&'static str),
/// The command would not change anything. /// The command would not change anything.
NothingToDo, NothingToDo,
/// The input data could not be parsed. /// The input data could not be parsed.
@@ -23,7 +21,6 @@ pub enum CommandError {
impl fmt::Display for CommandError { impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::NotImplemented(name) => write!(f, "{name} is not implemented yet"),
Self::NothingToDo => write!(f, "nothing to do"), Self::NothingToDo => write!(f, "nothing to do"),
Self::InvalidData(err) => write!(f, "invalid data: {err}"), Self::InvalidData(err) => write!(f, "invalid data: {err}"),
} }
+128
View File
@@ -0,0 +1,128 @@
use std::{collections::HashSet, rc::Rc};
use crate::{Apply, CommandError, FileId, Route, Selection, State, Track};
/// Converts the selected routes to tracks, or the selected tracks or segments to routes, in their
/// file:
///
/// - a route becomes a track with its segment. The new tracks go before the other tracks, right
/// after the routes;
/// - a track becomes one route per segment, as a route has a single segment, and goes away. A
/// selected segment becomes a route and leaves its track, which stays even if it has no segment
/// left. The routes have the information of their track, and go after the other routes.
///
/// The segments keep their ids, the new routes and tracks are selected. There is nothing to do
/// for the other selections, or for tracks without segments.
#[derive(Debug)]
pub struct Convert;
impl Apply for Convert {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
let next = match &selection {
Selection::Route { file_id, rte_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.rte)
.into_iter()
.partition::<Vec<_>, _>(|rte| rte_ids.contains(&rte.id));
file.rte = kept;
let tracks: Vec<Track> = converted
.into_iter()
.map(|rte| Track {
id: Default::default(),
info: rte.info,
trkseg: vec![rte.seg],
})
.collect();
let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect();
file.trk.splice(0..0, tracks);
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
Selection::Track { file_id, trk_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.trk)
.into_iter()
.partition::<Vec<_>, _>(|trk| trk_ids.contains(&trk.id));
file.trk = kept;
let routes = converted.into_iter().flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| Route {
id: Default::default(),
info: trk.info.clone(),
seg,
})
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let (converted, kept) = std::mem::take(&mut trk.trkseg)
.into_iter()
.partition::<Vec<_>, _>(|seg| trkseg_ids.contains(&seg.id));
trk.trkseg = kept;
let info = trk.info.clone();
let routes = converted.into_iter().map(|seg| Route {
id: Default::default(),
info: info.clone(),
seg,
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::Empty
| Selection::File { .. }
| Selection::Waypoints { .. }
| Selection::Waypoint { .. } => return Err(CommandError::NothingToDo),
};
let empty = match &next {
Selection::Route { rte_ids, .. } => rte_ids.is_empty(),
Selection::Track { trk_ids, .. } => trk_ids.is_empty(),
_ => true,
};
if empty {
// nothing was converted (the changed copy of the file is dropped)
return Err(CommandError::NothingToDo);
}
*state.selection = next;
Ok(())
}
}
/// Adds the routes after the other routes, and gives the selection of them.
fn add_routes(
rte: &mut Vec<Route>,
file_id: FileId,
routes: impl Iterator<Item = Route>,
) -> Selection {
let start = rte.len();
rte.extend(routes);
Selection::Route {
file_id,
rte_ids: rte[start..].iter().map(|rte| rte.id).collect(),
}
}
+178
View File
@@ -0,0 +1,178 @@
use crate::{
Apply, CommandError, Edit, Editor, File, Route, State, Track, TrackInfo, 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 Metadata<'_> {
fn set(&self, info: &mut TrackInfo) -> Edit {
info.name = Some(self.name.to_owned());
info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
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 route or track is named like the file
match (file.rte.as_mut_slice(), file.trk.as_mut_slice()) {
([route], []) => route.info.name = Some(self.name.to_owned()),
([], [track]) => track.info.name = Some(self.name.to_owned()),
_ => {}
}
Edit::Changed
}
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit {
self.set(&mut track.info)
}
}
#[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,
route: false,
}
.apply(&mut fx.state())
.unwrap();
New {
name: "second",
trackpoint: None,
route: false,
}
.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,
route: false,
}
.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 +1,13 @@
mod convert;
mod metadata; mod metadata;
mod new_route;
mod new_track; mod new_track;
mod new_track_segment; mod new_track_segment;
mod style; mod style;
pub use convert::*;
pub use metadata::*; pub use metadata::*;
pub use new_route::*;
pub use new_track::*; pub use new_track::*;
pub use new_track_segment::*; pub use new_track_segment::*;
pub use style::*; pub use style::*;
@@ -0,0 +1,55 @@
use crate::{Apply, CommandError, Edit, Editor, File, Route, State, update_selected};
use super::new_track::common_style;
/// Adds an empty route to the selected files, with the style that their routes and tracks agree
/// on (see [`crate::NewTrack`]).
#[derive(Debug)]
pub struct NewRoute;
impl Apply for NewRoute {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewRoute {
fn file(&mut self, file: &mut File) -> Edit {
file.rte.push(Route {
info: common_style(file),
..Default::default()
});
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_route_has_the_style_of_the_tracks_and_an_empty_segment() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
crate::NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
file.trk[0].info.color = Some("ff0000".into());
NewRoute.apply(&mut fx.state()).unwrap();
NewRoute.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[0].id, file.rte[1].id);
assert!(file.rte[0].seg.is_empty());
assert_eq!(file.rte[1].info.color.as_deref(), Some("ff0000"));
}
}
Loaded 100 of 539 files, more files were not shown because too many files have changed in this diff. Show more