38 Commits
Author SHA1 Message Date
vcoppe f1904bc12f cleaning 2026-10-03 20:54:05 +02:00
vcoppe 9afba53e5d progress 2026-10-03 19:50:38 +02:00
vcoppe fd76e0fa46 progress 2026-10-03 19:17:58 +02:00
vcoppe 438a87f06f progress 2026-10-03 19:08:56 +02:00
vcoppe a994e16f51 splice 2026-10-03 18:03:13 +02:00
vcoppe 639e7491eb merge methods into 1 2026-10-03 17:47:04 +02:00
vcoppe 891a08a3cc progress 2026-10-03 17:42:26 +02:00
vcoppe 0c07164dec remove (copy) in name 2026-10-03 17:26:53 +02:00
vcoppe f133d55889 progress 2026-10-03 17:21:16 +02:00
vcoppe fbb636cb06 progress 2026-10-03 16:52:21 +02:00
vcoppe bddceb28aa keep style info 2026-10-03 16:43:53 +02:00
vcoppe bff84fa24c progress 2026-10-03 16:40:17 +02:00
vcoppe 64914f9aa0 progress 2026-10-03 16:16:47 +02:00
vcoppe 2abf46c747 progress 2026-10-03 15:33:44 +02:00
vcoppe 888f41e979 refactoring 2026-10-03 13:56:16 +02:00
vcoppe 17533586ef progress 2026-10-03 13:41:58 +02:00
vcoppe c6ad088947 fix 2026-10-03 13:40:49 +02:00
vcoppe 87d6d5f30e change type 2026-10-03 11:49:10 +02:00
vcoppe dbe894b3a3 reorganize 2026-10-03 10:03:22 +02:00
vcoppe a921d4b9cd fixes 2026-10-02 18:50:10 +02:00
vcoppe b7db73cf77 progress 2026-10-02 18:06:45 +02:00
vcoppe 5868595a6c progress 2026-10-01 18:42:31 +02:00
vcoppe eb8e09b835 progress 2026-09-30 19:19:23 +02:00
vcoppe 41dc853f65 progress 2026-09-25 12:09:58 +02:00
vcoppe 27c0a57cc1 progress 2026-09-24 20:55:30 +02:00
vcoppe 497ac120ec progress 2026-09-23 19:53:39 +02:00
vcoppe a486016d96 progress 2026-09-21 23:49:57 +02:00
vcoppe cc5f5653ef progress 2026-09-20 13:58:27 +02:00
vcoppe 20222c450d parsing continued 2026-09-18 09:50:04 +02:00
vcoppe 69049e1345 parsing start 2026-09-18 09:03:51 +02:00
vcoppe ed6264444f wasm first steps 2026-07-14 18:36:30 +02:00
vcoppe 86f4fc3394 New Crowdin updates (#355)
* New translations map-controls.mdx (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations elevation.mdx (Chinese Simplified)

[ci skip]

* New translations settings.mdx (Dutch)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations settings.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations routing.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations faq.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations edit.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations edit.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations edit.mdx (Chinese Simplified)

[ci skip]

* New translations files-and-stats.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations getting-started.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations file.mdx (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]
2026-06-18 23:07:16 +02:00
vcoppe 565589fc94 improve railway station query 2026-06-18 23:06:38 +02:00
vcoppe 4ef92e5abd update readme 2026-06-06 16:43:38 +02:00
vcoppe 7f8751fa38 turn wikipedia tags into links, closes #354 2026-06-03 19:05:15 +02:00
vcoppe befcba00b4 hopefully more reliable initial fitBounds 2026-06-03 18:48:07 +02:00
vcoppe 26e4b637a2 New Crowdin updates (#353)
* New translations en.json (Dutch)

[ci skip]

* New translations map-controls.mdx (Dutch)

[ci skip]

* New translations elevation.mdx (Dutch)

[ci skip]
2026-05-31 15:25:59 +02:00
vcoppe 75e3bc6f93 use self-hosted overpass api 2026-05-31 15:25:10 +02:00
112 changed files with 14056 additions and 571 deletions
+3 -2
View File
@@ -70,8 +70,9 @@ This project has been made possible thanks to the following open source projects
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping:
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
- [GraphHopper](https://github.com/graphhopper/graphhopper) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine
- [GraphHopper](https://github.com/graphhopper/graphhopper) — powerful routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — open map data used by most of the map layers, and by the routing engine
- [Mapterhorn](https://github.com/mapterhorn/mapterhorn) — high-quality open terrain data used by some map layers (including for 3D), and by the routing engine
- Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
+2
View File
@@ -0,0 +1,2 @@
target/
pkg/
+398
View File
@@ -0,0 +1,398 @@
# 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",
]
+6
View File
@@ -0,0 +1,6 @@
[workspace]
members = ["engine", "wasm"]
resolver = "3"
[profile.release]
debug = true
+3
View File
@@ -0,0 +1,3 @@
```
wasm-pack build wasm --target bundler --out-dir ../pkg
```
+9
View File
@@ -0,0 +1,9 @@
[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"] }
+274
View File
@@ -0,0 +1,274 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1"
xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
creator="https://gpx.studio">
<metadata>
<name>simple</name>
<desc>description</desc>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio">
<text>link text</text>
</link>
</author>
</metadata>
<trk>
<name>track name</name>
<cmt>track comment</cmt>
<desc>track description</desc>
<src>track source</src>
<link href="https://gpx.studio">
<text>track link text</text>
</link>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</trkpt>
</trkseg>
</trk>
</gpx>
+660
View File
@@ -0,0 +1,660 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_cad</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_cad</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>80</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:cad>90</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+660
View File
@@ -0,0 +1,660 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_hr</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_hr</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>150</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:hr>160</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+500
View File
@@ -0,0 +1,500 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_power</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_power</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<extensions>
<power>200</power>
</extensions>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<extensions>
<power>210</power>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+665
View File
@@ -0,0 +1,665 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1"
xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxpx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
creator="https://gpx.studio">
<metadata>
<name>with_power</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_power</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>200</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<extensions>
<gpxpx:PowerExtension>
<gpxpx:PowerInWatts>210</gpxpx:PowerInWatts>
</gpxpx:PowerExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+253
View File
@@ -0,0 +1,253 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_routes</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<rte>
<name>route 1</name>
<type>Cycling</type>
<rtept lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</rtept>
<rtept lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</rtept>
<rtept lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</rtept>
<rtept lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</rtept>
<rtept lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</rtept>
<rtept lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</rtept>
<rtept lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</rtept>
<rtept lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</rtept>
<rtept lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</rtept>
<rtept lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</rtept>
<rtept lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</rtept>
<rtept lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</rtept>
<rtept lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</rtept>
<rtept lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</rtept>
<rtept lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</rtept>
<rtept lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</rtept>
<rtept lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</rtept>
<rtept lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</rtept>
<rtept lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</rtept>
<rtept lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</rtept>
<rtept lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</rtept>
<rtept lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</rtept>
<rtept lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</rtept>
<rtept lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</rtept>
<rtept lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</rtept>
<rtept lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</rtept>
<rtept lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</rtept>
<rtept lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</rtept>
<rtept lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</rtept>
<rtept lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</rtept>
<rtept lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</rtept>
<rtept lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</rtept>
<rtept lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</rtept>
<rtept lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</rtept>
<rtept lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</rtept>
<rtept lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</rtept>
<rtept lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</rtept>
<rtept lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</rtept>
<rtept lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</rtept>
<rtept lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</rtept>
<rtept lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</rtept>
<rtept lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</rtept>
<rtept lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</rtept>
<rtept lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</rtept>
<rtept lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</rtept>
<rtept lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</rtept>
</rte>
<rte>
<name>route 2</name>
<type>Cycling</type>
<rtept lat="50.782212" lon="4.406377">
<ele>115.5</ele>
</rtept>
<rtept lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</rtept>
<rtept lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</rtept>
<rtept lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</rtept>
<rtept lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</rtept>
<rtept lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</rtept>
<rtept lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</rtept>
<rtept lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</rtept>
<rtept lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</rtept>
<rtept lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</rtept>
<rtept lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</rtept>
<rtept lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</rtept>
<rtept lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</rtept>
<rtept lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</rtept>
<rtept lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</rtept>
<rtept lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</rtept>
<rtept lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</rtept>
<rtept lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</rtept>
<rtept lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</rtept>
<rtept lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</rtept>
<rtept lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</rtept>
<rtept lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</rtept>
<rtept lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</rtept>
<rtept lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</rtept>
<rtept lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</rtept>
<rtept lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</rtept>
<rtept lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</rtept>
<rtept lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</rtept>
</rte>
</gpx>
+253
View File
@@ -0,0 +1,253 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_segments</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_segments</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</trkpt>
</trkseg>
<trkseg>
<trkpt lat="50.782212" lon="4.406377">
<ele>115.5</ele>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</trkpt>
</trkseg>
</trk>
</gpx>
+267
View File
@@ -0,0 +1,267 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_style</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_style</name>
<type>Cycling</type>
<extensions>
<gpx_style:line>
<gpx_style:color>2d3ee9</gpx_style:color>
<gpx_style:opacity>0.5</gpx_style:opacity>
<gpx_style:width>6</gpx_style:width>
</gpx_style:line>
</extensions>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</trkpt>
</trkseg>
</trk>
</gpx>
+820
View File
@@ -0,0 +1,820 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_surface</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_surface</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>cobblestone</surface>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+660
View File
@@ -0,0 +1,660 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_temp</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_temp</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>21</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:atemp>22</gpxtpx:atemp>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+340
View File
@@ -0,0 +1,340 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_time</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>with_time</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<time>2023-12-31T23:00:00.000Z</time>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<time>2023-12-31T23:00:03.180Z</time>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<time>2023-12-31T23:00:11.254Z</time>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<time>2023-12-31T23:00:14.795Z</time>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<time>2023-12-31T23:00:17.957Z</time>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<time>2023-12-31T23:00:21.759Z</time>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<time>2023-12-31T23:00:29.948Z</time>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<time>2023-12-31T23:00:36.098Z</time>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<time>2023-12-31T23:00:42.396Z</time>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<time>2023-12-31T23:00:45.636Z</time>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<time>2023-12-31T23:00:48.827Z</time>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<time>2023-12-31T23:00:55.249Z</time>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<time>2023-12-31T23:01:00.794Z</time>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<time>2023-12-31T23:01:03.708Z</time>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<time>2023-12-31T23:01:06.542Z</time>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<time>2023-12-31T23:01:11.014Z</time>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<time>2023-12-31T23:01:14.108Z</time>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<time>2023-12-31T23:01:17.026Z</time>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<time>2023-12-31T23:01:19.742Z</time>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<time>2023-12-31T23:01:28.110Z</time>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<time>2023-12-31T23:01:31.328Z</time>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<time>2023-12-31T23:01:35.306Z</time>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<time>2023-12-31T23:01:36.830Z</time>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<time>2023-12-31T23:01:38.701Z</time>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<time>2023-12-31T23:01:40.022Z</time>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<time>2023-12-31T23:01:41.920Z</time>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<time>2023-12-31T23:01:48.333Z</time>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<time>2023-12-31T23:01:52.195Z</time>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<time>2023-12-31T23:01:55.766Z</time>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<time>2023-12-31T23:01:58.740Z</time>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<time>2023-12-31T23:02:04.091Z</time>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<time>2023-12-31T23:02:12.713Z</time>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<time>2023-12-31T23:02:14.875Z</time>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<time>2023-12-31T23:02:16.638Z</time>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<time>2023-12-31T23:02:18.384Z</time>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<time>2023-12-31T23:02:25.735Z</time>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<time>2023-12-31T23:02:27.404Z</time>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<time>2023-12-31T23:02:29.642Z</time>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<time>2023-12-31T23:02:31.271Z</time>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<time>2023-12-31T23:02:32.974Z</time>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<time>2023-12-31T23:02:34.725Z</time>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<time>2023-12-31T23:02:36.057Z</time>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<time>2023-12-31T23:02:37.639Z</time>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<time>2023-12-31T23:02:39.280Z</time>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<time>2023-12-31T23:02:41.205Z</time>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<time>2023-12-31T23:02:43.731Z</time>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<time>2023-12-31T23:02:54.830Z</time>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<time>2023-12-31T23:02:59.232Z</time>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<time>2023-12-31T23:03:01.951Z</time>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
<time>2023-12-31T23:03:03.598Z</time>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
<time>2023-12-31T23:03:05.119Z</time>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
<time>2023-12-31T23:03:07.291Z</time>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
<time>2023-12-31T23:03:11.741Z</time>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<time>2023-12-31T23:03:17.114Z</time>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<time>2023-12-31T23:03:28.573Z</time>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<time>2023-12-31T23:03:32.796Z</time>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<time>2023-12-31T23:03:38.750Z</time>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<time>2023-12-31T23:03:41.922Z</time>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<time>2023-12-31T23:03:45.133Z</time>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<time>2023-12-31T23:03:48.867Z</time>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<time>2023-12-31T23:03:56.536Z</time>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<time>2023-12-31T23:04:00.561Z</time>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<time>2023-12-31T23:04:03.346Z</time>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<time>2023-12-31T23:04:06.761Z</time>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<time>2023-12-31T23:04:14.339Z</time>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<time>2023-12-31T23:04:23.699Z</time>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<time>2023-12-31T23:04:27.973Z</time>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<time>2023-12-31T23:04:32.307Z</time>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<time>2023-12-31T23:04:44.408Z</time>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<time>2023-12-31T23:04:48.146Z</time>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<time>2023-12-31T23:05:00.532Z</time>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<time>2023-12-31T23:05:11.852Z</time>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<time>2023-12-31T23:05:23.300Z</time>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<time>2023-12-31T23:05:41.944Z</time>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<time>2023-12-31T23:05:49.538Z</time>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<time>2023-12-31T23:05:57.109Z</time>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<time>2023-12-31T23:06:03.116Z</time>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<time>2023-12-31T23:06:14.336Z</time>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<time>2023-12-31T23:06:26.353Z</time>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<time>2023-12-31T23:06:40.567Z</time>
</trkpt>
</trkseg>
</trk>
</gpx>
+257
View File
@@ -0,0 +1,257 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_tracks</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>track 1</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</trkpt>
</trkseg>
</trk>
<trk>
<name>track 2</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.782212" lon="4.406377">
<ele>115.5</ele>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</trkpt>
</trkseg>
</trk>
</gpx>
@@ -0,0 +1,346 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_tracks_and_segments</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<trk>
<name>track 1</name>
<type>Running</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<time>2023-12-31T23:00:00.000Z</time>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
<time>2023-12-31T23:00:03.180Z</time>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
<time>2023-12-31T23:00:11.254Z</time>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
<time>2023-12-31T23:00:14.795Z</time>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
<time>2023-12-31T23:00:17.957Z</time>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
<time>2023-12-31T23:00:21.759Z</time>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
<time>2023-12-31T23:00:29.948Z</time>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
<time>2023-12-31T23:00:36.098Z</time>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
<time>2023-12-31T23:00:42.396Z</time>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
<time>2023-12-31T23:00:45.636Z</time>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
<time>2023-12-31T23:00:48.827Z</time>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
<time>2023-12-31T23:00:55.249Z</time>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
<time>2023-12-31T23:01:00.794Z</time>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
<time>2023-12-31T23:01:03.708Z</time>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
<time>2023-12-31T23:01:06.542Z</time>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
<time>2023-12-31T23:01:11.014Z</time>
</trkpt>
</trkseg>
<trkseg>
<trkpt lat="50.78727108169855" lon="4.406133681127736">
<ele>115.0</ele>
<time>2023-12-31T23:01:14.708Z</time>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
<time>2023-12-31T23:01:15.462Z</time>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
<time>2023-12-31T23:01:18.380Z</time>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
<time>2023-12-31T23:01:21.096Z</time>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
<time>2023-12-31T23:01:29.464Z</time>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
<time>2023-12-31T23:01:32.682Z</time>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
<time>2023-12-31T23:01:36.660Z</time>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
<time>2023-12-31T23:01:38.184Z</time>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
<time>2023-12-31T23:01:40.055Z</time>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
<time>2023-12-31T23:01:41.376Z</time>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
<time>2023-12-31T23:01:43.274Z</time>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
<time>2023-12-31T23:01:49.687Z</time>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
<time>2023-12-31T23:01:53.549Z</time>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
<time>2023-12-31T23:01:57.120Z</time>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
<time>2023-12-31T23:02:00.094Z</time>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
<time>2023-12-31T23:02:05.445Z</time>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
<time>2023-12-31T23:02:14.067Z</time>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
<time>2023-12-31T23:02:16.229Z</time>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
<time>2023-12-31T23:02:17.992Z</time>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
<time>2023-12-31T23:02:19.738Z</time>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
<time>2023-12-31T23:02:27.089Z</time>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
<time>2023-12-31T23:02:28.758Z</time>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
<time>2023-12-31T23:02:30.996Z</time>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
<time>2023-12-31T23:02:32.625Z</time>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
<time>2023-12-31T23:02:34.328Z</time>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
<time>2023-12-31T23:02:36.079Z</time>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
<time>2023-12-31T23:02:37.411Z</time>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
<time>2023-12-31T23:02:38.993Z</time>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
<time>2023-12-31T23:02:40.634Z</time>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
<time>2023-12-31T23:02:42.559Z</time>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
<time>2023-12-31T23:02:45.085Z</time>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
<time>2023-12-31T23:02:56.184Z</time>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
<time>2023-12-31T23:03:00.586Z</time>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
<time>2023-12-31T23:03:03.305Z</time>
</trkpt>
</trkseg>
</trk>
<trk>
<name>track 2</name>
<type>Running</type>
<trkseg>
<trkpt lat="50.782212" lon="4.406377">
<ele>115.5</ele>
<time>2023-12-31T23:03:17.151Z</time>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
<time>2023-12-31T23:03:18.020Z</time>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
<time>2023-12-31T23:03:29.479Z</time>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
<time>2023-12-31T23:03:33.702Z</time>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
<time>2023-12-31T23:03:39.656Z</time>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
<time>2023-12-31T23:03:42.828Z</time>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
<time>2023-12-31T23:03:46.039Z</time>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
<time>2023-12-31T23:03:49.773Z</time>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
<time>2023-12-31T23:03:57.442Z</time>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
<time>2023-12-31T23:04:01.467Z</time>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
<time>2023-12-31T23:04:04.252Z</time>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
<time>2023-12-31T23:04:07.667Z</time>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
<time>2023-12-31T23:04:15.245Z</time>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
<time>2023-12-31T23:04:24.605Z</time>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
<time>2023-12-31T23:04:28.879Z</time>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
<time>2023-12-31T23:04:33.213Z</time>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
<time>2023-12-31T23:04:45.314Z</time>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
<time>2023-12-31T23:04:49.052Z</time>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
<time>2023-12-31T23:05:01.438Z</time>
</trkpt>
</trkseg>
<trkseg>
<trkpt lat="50.77906316558724" lon="4.412547477922485">
<ele>133.3</ele>
<time>2023-12-31T23:05:03.324Z</time>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
<time>2023-12-31T23:05:12.804Z</time>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
<time>2023-12-31T23:05:24.252Z</time>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
<time>2023-12-31T23:05:42.896Z</time>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
<time>2023-12-31T23:05:50.490Z</time>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
<time>2023-12-31T23:05:58.061Z</time>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
<time>2023-12-31T23:06:04.068Z</time>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
<time>2023-12-31T23:06:15.288Z</time>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
<time>2023-12-31T23:06:27.305Z</time>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
<time>2023-12-31T23:06:41.519Z</time>
</trkpt>
</trkseg>
</trk>
</gpx>
+276
View File
@@ -0,0 +1,276 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1"
xsi:schemaLocation="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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
creator="https://gpx.studio">
<metadata>
<name>with_waypoint</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<wpt lat="50.7836710064975" lon="4.410764082658738">
<ele>122.0</ele>
<name>waypoint name</name>
<cmt>waypoint comment</cmt>
<desc>waypoint description</desc>
<link href="https://gpx.studio">
<text>waypoint link text</text>
</link>
<sym>Bike Trail</sym>
<type>Bike Trail</type>
</wpt>
<trk>
<name>with_waypoint</name>
<type>Cycling</type>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</trkpt>
<trkpt lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</trkpt>
<trkpt lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</trkpt>
<trkpt lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</trkpt>
<trkpt lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</trkpt>
<trkpt lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</trkpt>
<trkpt lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</trkpt>
<trkpt lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</trkpt>
<trkpt lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</trkpt>
<trkpt lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</trkpt>
<trkpt lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</trkpt>
<trkpt lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</trkpt>
<trkpt lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</trkpt>
<trkpt lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</trkpt>
<trkpt lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</trkpt>
<trkpt lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</trkpt>
<trkpt lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</trkpt>
<trkpt lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</trkpt>
<trkpt lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</trkpt>
<trkpt lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</trkpt>
<trkpt lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</trkpt>
<trkpt lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</trkpt>
<trkpt lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</trkpt>
<trkpt lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</trkpt>
<trkpt lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</trkpt>
<trkpt lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</trkpt>
<trkpt lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</trkpt>
<trkpt lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</trkpt>
<trkpt lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</trkpt>
<trkpt lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</trkpt>
<trkpt lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</trkpt>
<trkpt lat="50.78279" lon="4.406021">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.782714" lon="4.406018">
<ele>113.5</ele>
</trkpt>
<trkpt lat="50.782607" lon="4.406047">
<ele>113.8</ele>
</trkpt>
<trkpt lat="50.782405" lon="4.406194">
<ele>114.8</ele>
</trkpt>
<trkpt lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</trkpt>
<trkpt lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</trkpt>
<trkpt lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</trkpt>
<trkpt lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</trkpt>
<trkpt lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</trkpt>
<trkpt lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</trkpt>
<trkpt lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</trkpt>
<trkpt lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</trkpt>
<trkpt lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</trkpt>
<trkpt lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</trkpt>
<trkpt lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</trkpt>
<trkpt lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</trkpt>
<trkpt lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</trkpt>
<trkpt lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</trkpt>
<trkpt lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</trkpt>
<trkpt lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</trkpt>
<trkpt lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</trkpt>
<trkpt lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</trkpt>
<trkpt lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</trkpt>
<trkpt lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</trkpt>
<trkpt lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</trkpt>
<trkpt lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</trkpt>
<trkpt lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</trkpt>
<trkpt lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</trkpt>
</trkseg>
</trk>
</gpx>
+4
View File
@@ -0,0 +1,4 @@
mod simplify;
mod smooth;
pub use simplify::*;
@@ -0,0 +1,133 @@
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());
}
}
+141
View File
@@ -0,0 +1,141 @@
#[macro_export]
macro_rules! for_each_window {
(
$trkseg:expr,
$left:expr,
$right:expr,
$window:expr,
|$a:ident, $b:ident| $distance:expr,
|$i:ident, $l:ident, $r:ident| $body:block,
) => {{
let mut window_start = $left.unwrap_or_default();
let mut window_center = $left;
let mut window_end = $left.unwrap_or_default();
while let Some(idx) = window_center {
// advance left if needed
while window_start != idx && {
let $a = window_start;
let $b = idx;
$distance
} > $window {
if let Some(next) = $trkseg.next_index(Some(window_start)) {
if next == idx {
break;
}
window_start = next;
} else {
break;
}
}
// advance right if needed
window_end = window_end.max(idx);
while let Some(next) = $trkseg.next_index(Some(window_end)) {
if window_end != idx && {
let $a = idx;
let $b = next;
$distance
} > $window {
break;
}
window_end = next;
}
// apply body
let $i = idx;
let $l = window_start;
let $r = window_end;
$body
// go next
if window_center == $right {
break;
}
window_center = $trkseg.next_index(window_center);
}
}};
}
#[cfg(test)]
mod tests {
use crate::{TrackSegment, Trackpoint, TrackpointChunk};
fn segment(eles: &[f64]) -> TrackSegment {
let mut trkseg = TrackSegment::default();
// several small chunks, to also exercise the chunk boundaries
for part in eles.chunks(3) {
let mut chunk = TrackpointChunk::default();
for ele in part {
chunk.trkpt.push(Trackpoint {
ele: *ele,
..Default::default()
});
}
trkseg.push(chunk);
}
trkseg
}
/// Returns (center, left, right) flat indices for each visited center.
fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> {
let trkseg = segment(&vec![0.0; len]);
let mut visited = vec![];
for_each_window!(
trkseg,
trkseg.first_index(),
trkseg.last_index(),
window,
|a, b| (b.flat - a.flat) as f64,
|i, l, r| {
visited.push((i.flat, l.flat, r.flat));
},
);
visited
}
#[test]
fn test_window_bounds() {
let visited = windows(10, 2.0);
assert_eq!(visited.len(), 10);
for (i, l, r) in visited {
assert_eq!(l, i.saturating_sub(2), "left of {i}");
assert_eq!(r, (i + 2).min(9), "right of {i}");
}
}
#[test]
fn test_window_always_includes_neighbours() {
for (i, l, r) in windows(6, 0.0) {
assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5)));
}
}
#[test]
fn test_window_larger_than_segment() {
for (_, l, r) in windows(5, 100.0) {
assert_eq!((l, r), (0, 4));
}
}
#[test]
fn test_window_sub_range() {
let trkseg = segment(&[0.0; 10]);
let mut centers = vec![];
let mut first = trkseg.first_index();
for _ in 0..3 {
first = trkseg.next_index(first);
}
let mut last = first;
for _ in 0..2 {
last = trkseg.next_index(last);
}
for_each_window!(
trkseg,
first,
last,
1.0,
|a, b| (b.flat - a.flat) as f64,
|i, _l, _r| {
centers.push(i.flat);
},
);
assert_eq!(centers, vec![3, 4, 5]);
}
}
+100
View File
@@ -0,0 +1,100 @@
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);
}
}
+97
View File
@@ -0,0 +1,97 @@
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Link {
pub href: String,
pub text: Option<String>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LngLat {
pub lng: f64,
pub lat: f64,
}
#[derive(Debug)]
pub struct LngLatBounds {
pub sw: LngLat,
pub ne: LngLat,
}
impl Default for LngLatBounds {
fn default() -> Self {
Self {
sw: LngLat {
lng: 180.0,
lat: 90.0,
},
ne: LngLat {
lng: -180.0,
lat: -90.0,
},
}
}
}
impl LngLatBounds {
pub fn extend(&mut self, coordinates: LngLat) {
self.sw.lng = self.sw.lng.min(coordinates.lng);
self.sw.lat = self.sw.lat.min(coordinates.lat);
self.ne.lng = self.ne.lng.max(coordinates.lng);
self.ne.lat = self.ne.lat.max(coordinates.lat);
}
pub fn merge(&mut self, other: &LngLatBounds) {
self.sw.lng = self.sw.lng.min(other.sw.lng);
self.sw.lat = self.sw.lat.min(other.sw.lat);
self.ne.lng = self.ne.lng.max(other.ne.lng);
self.ne.lat = self.ne.lat.max(other.ne.lat);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bounds_extend() {
let mut bounds = LngLatBounds::default();
bounds.extend(LngLat {
lng: 4.0,
lat: 50.0,
});
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0));
assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0));
bounds.extend(LngLat {
lng: 6.0,
lat: 45.0,
});
bounds.extend(LngLat {
lng: 5.0,
lat: 48.0,
});
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0));
assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0));
}
#[test]
fn test_bounds_merge() {
let mut a = LngLatBounds::default();
a.extend(LngLat { lng: 0.0, lat: 0.0 });
a.extend(LngLat { lng: 1.0, lat: 1.0 });
let mut b = LngLatBounds::default();
b.extend(LngLat {
lng: -2.0,
lat: 0.5,
});
b.extend(LngLat { lng: 0.5, lat: 3.0 });
a.merge(&b);
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
// merging an empty bounds changes nothing
a.merge(&LngLatBounds::default());
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
}
}
+49
View File
@@ -0,0 +1,49 @@
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>,
}
+15
View File
@@ -0,0 +1,15 @@
mod chunk;
mod common;
mod file;
mod segment;
mod track;
mod trackpoint;
mod waypoint;
pub use chunk::*;
pub use common::*;
pub use file::*;
pub use segment::*;
pub use track::*;
pub use trackpoint::*;
pub use waypoint::*;
+454
View File
@@ -0,0 +1,454 @@
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;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
use uuid::Uuid;
use crate::{Link, TrackSegment};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackId(pub Uuid);
impl Default for TrackId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Track {
pub id: TrackId,
pub info: TrackInfo,
pub trkseg: Vec<TrackSegment>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackInfo {
pub name: Option<String>,
pub cmt: Option<String>,
pub desc: Option<String>,
pub src: Option<String>,
pub link: Option<Link>,
pub type_: Option<String>,
pub color: Option<String>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
+13
View File
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,26 @@
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>,
}
+3
View File
@@ -0,0 +1,3 @@
mod parse;
pub use parse::*;
+579
View File
@@ -0,0 +1,579 @@
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));
}
}
+11
View File
@@ -0,0 +1,11 @@
mod algorithm;
pub(crate) mod gpx;
mod io;
pub(crate) mod statistics;
mod utils;
pub use algorithm::*;
pub use gpx::*;
pub use io::*;
pub use statistics::*;
pub use utils::*;
+5
View File
@@ -0,0 +1,5 @@
mod statistics;
mod utils;
pub use statistics::*;
pub use utils::*;
@@ -0,0 +1,420 @@
use crate::{
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
};
#[derive(Default, Debug)]
pub struct Statistics {
pub global: GlobalStatistics,
pub local: Vec<TrackpointStatistics>,
}
impl Statistics {
pub fn total_speed(&self) -> Option<f64> {
self.global.total_speed()
}
pub fn moving_speed(&self) -> Option<f64> {
self.global.moving_speed()
}
pub fn compute(trkseg: &TrackSegment) -> Self {
let mut stats = Self::default();
if trkseg.is_empty() {
return stats;
}
let mut prev = &trkseg[0];
for cur in trkseg.iter() {
stats.accumulate(prev, cur);
prev = cur;
}
stats.compute_smoothed_speed(trkseg);
stats.compute_smoothed_elevation_segments(trkseg);
stats.compute_smoothed_slope(trkseg);
stats
}
fn accumulate(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
self.accumulate_distance_and_time(prev, cur);
self.update_time_bounds(cur.time);
self.update_bounds(cur.coordinates);
self.local
.push(TrackpointStatistics::from_partial_stats(self));
}
fn accumulate_distance_and_time(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
let dist = distance(prev.coordinates, cur.coordinates);
let time = time_diff(cur.time, prev.time);
self.global.total_distance += dist;
if let Some(time) = time {
let speed = speed(dist, time);
if (0.5..=1500.0).contains(&speed) {
self.global.moving_distance = self
.global
.moving_distance
.map_or(Some(dist), |d| Some(d + dist));
self.global.moving_time = self
.global
.moving_time
.map_or(Some(time), |t| Some(t + time));
}
}
}
fn update_time_bounds(&mut self, time: Option<i64>) {
if let Some(time) = time {
if self.global.start_time.is_none() {
self.global.start_time = Some(time);
}
self.global.end_time = Some(time);
self.global.total_time = time_diff(self.global.end_time, self.global.start_time);
}
}
fn update_bounds(&mut self, coordinates: LngLat) {
self.global.bounds.extend(coordinates);
}
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
for_each_window!(
trkseg,
trkseg.first_index(),
trkseg.last_index(),
Some(10000),
|i, j| time_diff(trkseg[i].time, trkseg[j].time),
|i, left, right| {
self.local[i.flat].speed =
time_diff(trkseg[right].time, trkseg[left].time).map(|t| {
speed(
self.local[right.flat].total_distance
- self.local[left.flat].total_distance,
t,
)
});
},
);
}
fn compute_smoothed_elevation_segments(&mut self, trkseg: &TrackSegment) {
let simplified = self.get_elevation_extremas(trkseg);
for i in 0..(simplified.len() - 1) {
let start = simplified[i];
let end = simplified[i + 1];
let last = i + 1 == simplified.len() - 1;
self.compute_smoothed_elevation_gain(trkseg, start, end, last);
let segment_dist =
self.local[end.flat].total_distance - self.local[start.flat].total_distance;
let segment_ele = trkseg[end].ele - trkseg[start].ele;
let segment_slope = slope(segment_ele, segment_dist);
for k in start.flat..(end.flat + last as usize) {
self.local[k].slope_segment = SlopeSegment {
slope: segment_slope,
distance: segment_dist,
};
}
}
}
fn compute_smoothed_elevation_gain(
&mut self,
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
last: bool,
) {
let mut cumul_ele = 0.0;
let mut current_left = start;
let mut current_right = Some(start);
let mut prev_smoothed_ele = trkseg[start].ele;
for_each_window!(
trkseg,
Some(start),
Some(end),
0.1,
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|i, left, right| {
while current_left != left {
cumul_ele -= trkseg[current_left].ele;
current_left = trkseg.next_index(Some(current_left)).unwrap();
}
while let Some(current) = current_right {
if current > right {
break;
}
cumul_ele += trkseg[current].ele;
current_right = trkseg.next_index(current_right);
}
let smoothed_ele = if i == start || i == end {
trkseg[i].ele
} else {
cumul_ele / (right.flat - left.flat + 1) as f64
};
let delta = smoothed_ele - prev_smoothed_ele;
if delta > 0.0 {
self.global.elevation_gain += delta;
} else if delta < 0.0 {
self.global.elevation_loss -= delta;
}
if i < end || last {
self.local[i.flat].elevation_gain = self.global.elevation_gain;
self.local[i.flat].elevation_loss = self.global.elevation_loss;
}
prev_smoothed_ele = smoothed_ele;
},
);
}
fn compute_smoothed_slope(&mut self, trkseg: &TrackSegment) {
for_each_window!(
trkseg,
trkseg.first_index(),
trkseg.last_index(),
0.05,
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|i, left, right| {
let dist =
self.local[right.flat].total_distance - self.local[left.flat].total_distance;
let ele = trkseg[right].ele - trkseg[left].ele;
self.local[i.flat].slope = slope(ele, dist);
},
);
}
fn get_elevation_extremas(&self, trkseg: &TrackSegment) -> Vec<TrackSegmentIndex> {
ramer_douglas_peucker(
trkseg,
&|idx| {
(
self.local[idx.flat].total_distance * 1000.0,
trkseg[idx].ele,
)
},
20.0,
)
}
}
#[derive(Default, Debug)]
pub struct GlobalStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
pub total_time: Option<i32>,
pub moving_time: Option<i32>,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub bounds: LngLatBounds,
}
impl GlobalStatistics {
pub fn total_speed(&self) -> Option<f64> {
self.total_time.map(|t| speed(self.total_distance, t))
}
pub fn moving_speed(&self) -> Option<f64> {
self.moving_distance
.zip(self.moving_time)
.map(|(d, t)| speed(d, t))
}
pub fn merge(&mut self, other: &GlobalStatistics) {
self.total_distance += other.total_distance;
self.moving_distance = sum_options(self.moving_distance, other.moving_distance);
self.total_time = sum_options(self.total_time, other.total_time);
self.moving_time = sum_options(self.moving_time, other.moving_time);
self.elevation_gain += other.elevation_gain;
self.elevation_loss += other.elevation_loss;
self.start_time = min_options(self.start_time, other.start_time);
self.end_time = max_options(self.end_time, other.end_time);
self.bounds.merge(&other.bounds);
}
}
#[derive(Default, Debug)]
pub struct TrackpointStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
pub total_time: Option<i32>,
pub moving_time: Option<i32>,
pub speed: Option<f64>,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub slope: f64,
pub slope_segment: SlopeSegment,
}
impl TrackpointStatistics {
fn from_partial_stats(stats: &Statistics) -> Self {
Self {
total_distance: stats.global.total_distance,
moving_distance: stats.global.moving_distance,
total_time: stats.global.total_time,
moving_time: stats.global.moving_time,
// stats below are computed later
speed: None,
elevation_gain: Default::default(),
elevation_loss: Default::default(),
slope: Default::default(),
slope_segment: Default::default(),
}
}
}
#[derive(Default, Debug)]
pub struct SlopeSegment {
pub slope: f64,
pub distance: f64,
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::Read};
use crate::parse;
use super::*;
#[test]
fn test_compute_smoothed_speed() {
let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg);
assert_eq!(stats.local.len(), trkseg.len());
for trkpt_stats in stats.local.iter() {
assert!(trkpt_stats.speed.is_some());
let speed = trkpt_stats.speed.unwrap();
assert_ne!(speed, f64::NAN);
assert!((speed - 20.0).abs() < 0.1);
}
}
fn load(path: &str) -> crate::File {
let mut f = File::open(path).unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
parse(data.as_bytes()).unwrap()
}
#[test]
fn test_compute_empty_segment() {
let stats = Statistics::compute(&TrackSegment::default());
assert!(stats.local.is_empty());
assert_eq!(stats.global.total_distance, 0.0);
}
#[test]
fn test_compute_distance_and_bounds() {
let gpx = load("data/simple.gpx");
let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg);
assert_eq!(stats.local.len(), trkseg.len());
assert_eq!(stats.local[0].total_distance, 0.0);
assert!(stats.global.total_distance > 0.0);
// cumulative distance never decreases and ends at the global distance
assert!(
stats
.local
.windows(2)
.all(|w| w[0].total_distance <= w[1].total_distance)
);
let last = stats.local.last().unwrap();
assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9);
assert!(stats.global.elevation_gain >= 0.0);
assert!(stats.global.elevation_loss >= 0.0);
// every point lies inside the bounds
let b = &stats.global.bounds;
for trkpt in trkseg.iter() {
assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng);
assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat);
}
}
#[test]
fn test_compute_time() {
let gpx = load("data/with_time.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
let total_time = stats.global.total_time.unwrap();
assert!(total_time > 0);
let speed = stats.global.total_speed().unwrap();
assert!((speed - 20.0).abs() < 0.5, "{speed}");
}
#[test]
fn test_no_time_without_timestamps() {
let gpx = load("data/simple.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
assert!(stats.global.total_time.is_none());
assert!(stats.global.total_speed().is_none());
assert!(stats.local.iter().all(|s| s.speed.is_none()));
}
#[test]
fn test_global_merge() {
let mut a = GlobalStatistics {
total_distance: 1.0,
elevation_gain: 10.0,
moving_time: Some(5),
..Default::default()
};
a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 });
let mut b = GlobalStatistics {
total_distance: 2.0,
elevation_loss: 4.0,
moving_distance: Some(1.5),
moving_time: Some(7),
..Default::default()
};
b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 });
a.merge(&b);
assert_eq!(a.total_distance, 3.0);
assert_eq!(a.elevation_gain, 10.0);
assert_eq!(a.elevation_loss, 4.0);
assert_eq!(a.moving_distance, Some(1.5));
assert_eq!(a.moving_time, Some(12));
assert_eq!(a.total_time, None);
let mut c = GlobalStatistics {
start_time: Some(1_000),
end_time: Some(4_000),
total_time: Some(3_000),
..Default::default()
};
let d = GlobalStatistics {
start_time: Some(10_000),
end_time: Some(12_000),
total_time: Some(2_000),
..Default::default()
};
c.merge(&d);
// 3 s + 2 s, the gap between the two is ignored
assert_eq!(c.total_time, Some(5_000));
let mut cumul = GlobalStatistics::default();
cumul.merge(&c);
cumul.merge(&d);
assert_eq!(cumul.start_time, Some(1_000));
assert_eq!(cumul.end_time, Some(12_000));
assert_eq!(cumul.total_time, Some(7_000));
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
}
}
@@ -0,0 +1,44 @@
use std::ops::Add;
pub fn combine_options<T>(a: Option<T>, b: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
match (a, b) {
(Some(a), Some(b)) => Some(f(a, b)),
(Some(v), None) | (None, Some(v)) => Some(v),
(None, None) => None,
}
}
pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Add<Output = T>,
{
combine_options(a, b, |a, b| a + b)
}
pub fn min_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Ord,
{
combine_options(a, b, |a, b| a.min(b))
}
pub fn max_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Ord,
{
combine_options(a, b, |a, b| a.max(b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sum_options() {
assert_eq!(sum_options(Some(1), Some(2)), Some(3));
assert_eq!(sum_options(Some(1), None), Some(1));
assert_eq!(sum_options(None, Some(2)), Some(2));
assert_eq!(sum_options::<i32>(None, None), None);
assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75));
}
}
+190
View File
@@ -0,0 +1,190 @@
use std::f64::consts::PI;
use crate::LngLat;
const TO_RADIANS: f64 = PI / 180.0;
const EARTH_RADIUS: f64 = 6371.0088;
/// Computes the distance in kilometers between two coordinates using the Haversine formula
pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
let lat1 = p1.lat * TO_RADIANS;
let lat2 = p2.lat * TO_RADIANS;
let delta_lat = lat2 - lat1;
let delta_lng = (p2.lng - p1.lng) * TO_RADIANS;
let a =
(delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lng / 2.0).sin().powi(2);
let c = 2.0 * a.min(1.0).sqrt().asin();
EARTH_RADIUS * c
}
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i32> {
Some((a? - b?) as i32)
}
/// Computes the speed for a given distance in kilometers and a time in milliseconds
pub fn speed(distance: f64, time: i32) -> f64 {
distance / (time as f64 / 3_600_000.0)
}
pub fn slope(ele: f64, distance: f64) -> f64 {
if distance == 0.0 {
100.0
} else {
0.1 * ele / distance
}
}
const METERS_PER_LATITUDE_DEGREE: f64 = 111320.0;
/// Approximate planar projection, in meters, around the latitude of `origin` (ignores the
/// curvature of the earth).
struct Planar {
meters_per_longitude_degree: f64,
}
impl Planar {
fn around(origin: LngLat) -> Self {
Self {
meters_per_longitude_degree: (origin.lat * TO_RADIANS).cos()
* METERS_PER_LATITUDE_DEGREE,
}
}
fn to_meters(&self, p: LngLat) -> (f64, f64) {
(
p.lng * self.meters_per_longitude_degree,
p.lat * METERS_PER_LATITUDE_DEGREE,
)
}
fn to_degrees(&self, (x, y): (f64, f64)) -> LngLat {
LngLat {
lng: x / self.meters_per_longitude_degree,
lat: y / METERS_PER_LATITUDE_DEGREE,
}
}
}
/// Position, between 0 (at `(x1, y1)`) and 1 (at `(x2, y2)`), of the point of the segment that is
/// the closest to `(x3, y3)`. A degenerate segment (same ends) is a point.
fn closest_on_segment(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
let dx = x2 - x1;
let dy = y2 - y1;
let segment_length_squared = dx * dx + dy * dy;
if segment_length_squared == 0.0 {
0.0
} else {
(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared).clamp(0.0, 1.0)
}
}
/// Calculates the point on the line segment defined by p1 and p2 that is closest to the third
/// point, p3. Uses simple planar geometry (ignores earth curvature).
pub fn projected(p1: LngLat, p2: LngLat, p3: LngLat) -> LngLat {
let planar = Planar::around(p1);
let (x1, y1) = planar.to_meters(p1);
let (x2, y2) = planar.to_meters(p2);
let (x3, y3) = planar.to_meters(p3);
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
planar.to_degrees((x1 + t * (x2 - x1), y1 + t * (y2 - y1)))
}
/// Calculates the perpendicular distance in meters between a line segment (defined by p1 and p2)
/// and a third point, p3. Uses simple planar geometry (ignores earth curvature).
pub fn crossarc_lnglat(p1: LngLat, p2: LngLat, p3: LngLat) -> f64 {
let planar = Planar::around(p1);
let (x1, y1) = planar.to_meters(p1);
let (x2, y2) = planar.to_meters(p2);
let (x3, y3) = planar.to_meters(p3);
crossarc(x1, y1, x2, y2, x3, y3)
}
/// Distance from the point `(x3, y3)` to the segment `(x1, y1)`-`(x2, y2)`, in the units of the
/// coordinates (planar geometry).
pub fn crossarc(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
(x3 - (x1 + t * (x2 - x1))).hypot(y3 - (y1 + t * (y2 - y1)))
}
#[cfg(test)]
mod tests {
use super::*;
fn p(lng: f64, lat: f64) -> LngLat {
LngLat { lng, lat }
}
#[test]
fn test_distance() {
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
// one degree of latitude
let d = distance(p(0.0, 0.0), p(0.0, 1.0));
assert!((d - 111.195).abs() < 0.01, "{d}");
// symmetric
let a = p(4.40, 50.79);
let b = p(6.13, 45.90);
assert!((distance(a, b) - distance(b, a)).abs() < 1e-9);
// half the Earth's circumference, without NaN from rounding
let antipodal = distance(p(0.0, 0.0), p(180.0, 0.0));
assert!((antipodal - PI * EARTH_RADIUS).abs() < 1e-6);
}
#[test]
fn test_time_diff() {
assert_eq!(time_diff(Some(5000), Some(2000)), Some(3000));
assert_eq!(time_diff(Some(2000), Some(5000)), Some(-3000));
assert_eq!(time_diff(None, Some(1)), None);
assert_eq!(time_diff(Some(1), None), None);
}
#[test]
fn test_speed() {
// 1 km in 1 h
assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12);
// 10 km in 30 min
assert!((speed(10.0, 1_800_000) - 20.0).abs() < 1e-12);
}
#[test]
fn test_slope() {
// 10 m of elevation over 100 m (distance is expressed in km, hence the 0.1 factor)
assert!((slope(10.0, 0.1) - 10.0).abs() < 1e-12);
assert!((slope(-5.0, 0.1) + 5.0).abs() < 1e-12);
assert_eq!(slope(0.0, 1.0), 0.0);
assert_eq!(slope(10.0, 0.0), 100.0);
}
#[test]
fn test_crossarc() {
// perpendicular distance to the segment (0,0)-(10,0)
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 5.0, 3.0) - 3.0).abs() < 1e-12);
// beyond the ends: distance to the closest endpoint
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 13.0, 4.0) - 5.0).abs() < 1e-12);
assert!((crossarc(0.0, 0.0, 10.0, 0.0, -3.0, 4.0) - 5.0).abs() < 1e-12);
// point on the segment
assert_eq!(crossarc(0.0, 0.0, 10.0, 0.0, 4.0, 0.0), 0.0);
// degenerate segment
assert!((crossarc(1.0, 1.0, 1.0, 1.0, 4.0, 5.0) - 5.0).abs() < 1e-12);
}
#[test]
fn test_projected() {
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
assert!((proj.lng - 0.5).abs() < 1e-9);
assert!(proj.lat.abs() < 1e-9);
// clamped to the segment
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(2.0, 1.0));
assert!((proj.lng - 1.0).abs() < 1e-9);
// degenerate segment
let proj = projected(p(3.0, 4.0), p(3.0, 4.0), p(5.0, 6.0));
assert_eq!((proj.lng, proj.lat), (3.0, 4.0));
}
#[test]
fn test_crossarc_lnglat() {
// about 1 degree of latitude away from an east-west segment on the equator
let d = crossarc_lnglat(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
assert!((d - METERS_PER_LATITUDE_DEGREE).abs() < 1e-6);
}
}
+24
View File
@@ -0,0 +1,24 @@
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,
},
}
+33
View File
@@ -0,0 +1,33 @@
use std::fmt;
use crate::State;
/// The effect of a command on the engine state.
///
/// Each command is a small struct holding its arguments, with its effect implemented in its
/// own file. Effects are built from the reusable patterns in `pattern/`.
pub trait Apply {
fn apply(self, state: &mut State) -> Result<(), CommandError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
/// The command is not implemented yet.
NotImplemented(&'static str),
/// The command would not change anything.
NothingToDo,
/// The input data could not be parsed.
InvalidData(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotImplemented(name) => write!(f, "{name} is not implemented yet"),
Self::NothingToDo => write!(f, "nothing to do"),
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
}
}
}
impl std::error::Error for CommandError {}
@@ -0,0 +1,62 @@
use crate::{Apply, CommandError, State};
use crate::{
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata,
MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Reverse, SpliceTrackpoints, Split,
Style, Time,
};
/// A user action that edits the files. Each variant wraps the command's own struct, whose
/// effect is implemented in its own file (see `Apply`).
///
/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument
/// buffers, so a command is built without copying; the engine only copies what it keeps.
#[derive(Debug)]
pub enum Command<'a> {
New(New<'a>),
Load(Load<'a>),
Duplicate(Duplicate),
Delete(Delete),
DeleteAll(DeleteAll),
Metadata(Metadata<'a>),
Style(Style<'a>),
NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment),
Reverse(Reverse),
SpliceTrackpoints(SpliceTrackpoints<'a>),
NewWaypoint(NewWaypoint<'a>),
MoveWaypoint(MoveWaypoint),
Crop(Crop),
Split(Split),
Time(Time),
Merge(Merge),
Extract(Extract),
Elevation(Elevation<'a>),
Clean(Clean),
}
impl Apply for Command<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match self {
Command::New(c) => c.apply(state),
Command::Load(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state),
Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state),
Command::SpliceTrackpoints(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state),
Command::Crop(c) => c.apply(state),
Command::Split(c) => c.apply(state),
Command::Time(c) => c.apply(state),
Command::Merge(c) => c.apply(state),
Command::Extract(c) => c.apply(state),
Command::Elevation(c) => c.apply(state),
Command::Clean(c) => c.apply(state),
}
}
}
@@ -0,0 +1,86 @@
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);
}
}
@@ -0,0 +1,9 @@
mod metadata;
mod new_track;
mod new_track_segment;
mod style;
pub use metadata::*;
pub use new_track::*;
pub use new_track_segment::*;
pub use style::*;
@@ -0,0 +1,65 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
#[derive(Debug)]
pub struct NewTrack;
impl Apply for NewTrack {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit {
let mut track = Track::default();
track.info.color = common(file, |t| &t.info.color);
track.info.opacity = common(file, |t| &t.info.opacity);
track.info.width = common(file, |t| &t.info.width);
file.trk.push(track);
Edit::Changed
}
}
/// Returns the value of a style attribute if all existing tracks agree on it.
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> {
let mut tracks = file.trk.iter();
let first = get(tracks.next()?);
tracks.all(|t| get(t) == first).then(|| first.clone())?
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track() {
let mut fx = Fixture::default();
New { name: "file" }.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.trk.len(), 2);
assert_ne!(file.trk[0].id, file.trk[1].id);
}
#[test]
fn test_new_track_inherits_agreeing_style_only() {
let mut fx = Fixture::default();
New { name: "file" }.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
for (i, trk) in file.trk.iter_mut().enumerate() {
trk.info.color = Some("ff0000".into());
trk.info.opacity = Some(0.5 + i as f64 * 0.1);
}
NewTrack.apply(&mut fx.state()).unwrap();
let info = &fx.files.values().next().unwrap().trk[2].info;
assert_eq!(info.color.as_deref(), Some("ff0000"));
assert_eq!(info.opacity, None);
assert_eq!(info.width, None);
}
}
@@ -0,0 +1,36 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, TrackSegment, update_selected};
#[derive(Debug)]
pub struct NewTrackSegment;
impl Apply for NewTrackSegment {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrackSegment {
fn track(&mut self, track: &mut Track) -> Edit {
track.trkseg.push(TrackSegment::default());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, NewTrack, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track_segment_on_each_track_of_selected_file() {
let mut fx = Fixture::default();
New { name: "file" }.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrackSegment.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
}
}
@@ -0,0 +1,66 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected};
#[derive(Debug)]
pub struct Style<'a> {
pub color: Option<&'a str>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
impl Apply for Style<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for Style<'_> {
fn track(&mut self, track: &mut Track) -> Edit {
if let Some(color) = self.color {
track.info.color = Some(color.to_owned());
}
if self.opacity.is_some() {
track.info.opacity = self.opacity;
}
if self.width.is_some() {
track.info.width = self.width;
}
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_style.gpx").unwrap();
crate::Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = *fx.files.keys().next().unwrap();
let before = fx.files[&id].clone();
assert!(!before.trk.is_empty());
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Style {
color: Some("ff0000"),
opacity: None,
width: Some(7.0),
}
.apply(&mut fx.state())
.unwrap();
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
assert_eq!(a.info.color.as_deref(), Some("ff0000"));
assert_eq!(a.info.width, Some(7.0));
assert_eq!(a.info.opacity, b.info.opacity);
}
}
}
@@ -0,0 +1,222 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks,
};
#[derive(Debug)]
pub struct Delete;
impl Apply for Delete {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection {
Selection::File { file_ids } => {
state.files.retain(|id, _| !file_ids.contains(id));
state.order.0.retain(|id| !file_ids.contains(id));
Selection::Empty
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let len = file.trk.len();
file.trk.retain(|trk| !trk_ids.contains(&trk.id));
if file.trk.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::File {
file_ids: [*file_id].into(),
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !trkseg_ids.contains(&seg.id));
if trk.trkseg.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
}
Selection::Waypoints { file_id } => delete_waypoints(state.files, *file_id, |_| true)?,
Selection::Waypoint { file_id, wpt_ids } => {
delete_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
fn delete_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = edit_waypoint_chunks(&mut file, &filter, |wpts| {
wpts.retain(|wpt| !filter(wpt));
true
});
if !changed {
return Err(CommandError::NothingToDo);
}
files.insert(file_id, Rc::new(file));
Ok(Selection::File {
file_ids: [file_id].into(),
})
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use crate::{FileId, Load};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_delete_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_selected_files() {
let (mut fx, id) = loaded();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let keep = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Delete.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
#[test]
fn test_delete_selected_tracks() {
let (mut fx, id) = loaded();
let before = fx.files[&id].trk.len();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
Delete.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before - 1);
assert!(file.trk.iter().all(|t| t.id != trk_id));
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_selected_segments() {
let (mut fx, id) = loaded();
let trk = &fx.files[&id].trk[0];
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: HashSet::from([seg_id]),
};
Delete.apply(&mut fx.state()).unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before - 1);
assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
assert!(
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&trk_id))
);
}
fn with_waypoints(fx: &mut Fixture, id: FileId, n: usize) -> Vec<crate::WaypointId> {
let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect();
let ids = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: wpts,
..Default::default()
})];
fx.files.insert(id, Rc::new(file));
ids
}
#[test]
fn test_delete_selected_waypoints() {
let (mut fx, id) = loaded();
let ids = with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[1]]),
};
let rev = fx.files[&id].wpt_rev_id;
Delete.apply(&mut fx.state()).unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let left: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
assert_eq!(left, vec![ids[0], ids[2]]);
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_all_waypoints_of_file() {
let (mut fx, id) = loaded();
with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoints { file_id: id };
Delete.apply(&mut fx.state()).unwrap();
assert!(fx.files[&id].wpt.is_empty());
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
Delete.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_unknown_ids_is_nothing_to_do() {
let (mut fx, id) = loaded();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([Default::default()]),
};
assert_eq!(
Delete.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -0,0 +1,38 @@
use crate::{Apply, CommandError, Selection, State};
#[derive(Debug)]
pub struct DeleteAll;
impl Apply for DeleteAll {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
if state.files.is_empty() {
return Err(CommandError::NothingToDo);
}
state.files.clear();
state.order.0.clear();
*state.selection = Selection::Empty;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_delete_all() {
let mut fx = Fixture::default();
assert_eq!(
DeleteAll.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
crate::New { name: "a" }.apply(&mut fx.state()).unwrap();
crate::New { name: "b" }.apply(&mut fx.state()).unwrap();
assert!(DeleteAll.apply(&mut fx.state()).is_ok());
assert!(fx.files.is_empty());
assert!(fx.order.0.is_empty());
assert!(matches!(fx.selection, Selection::Empty));
}
}
@@ -0,0 +1,366 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint,
WaypointChunk,
};
#[derive(Debug)]
pub struct Duplicate;
impl Apply for Duplicate {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection {
Selection::File { file_ids } => {
let mut copies = HashSet::new();
let mut order = Vec::new();
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let Some(file) = state.files.get(id) else {
continue;
};
let copy = copy_file(file);
order.push(copy.id);
copies.insert(copy.id);
state.files.insert(copy.id, Rc::new(copy));
}
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
let at = state
.order
.0
.iter()
.rposition(|id| file_ids.contains(id))
.map_or(state.order.0.len(), |i| i + 1);
state.order.0.splice(at..at, order);
Selection::File { file_ids: copies }
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let mut copies = HashSet::new();
append_copies(
&mut file.trk,
|trk| trk_ids.contains(&trk.id),
|trk| {
let copy = copy_track(trk);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: copies,
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let mut copies = HashSet::new();
append_copies(
&mut trk.trkseg,
|seg| trkseg_ids.contains(&seg.id),
|seg| {
let copy = copy_segment(seg);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: copies,
}
}
Selection::Waypoints { file_id } => {
duplicate_waypoints(state.files, *file_id, |_| true)?
}
Selection::Waypoint { file_id, wpt_ids } => {
duplicate_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
fn duplicate_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let last_chunk = file
.wpt
.iter()
.rposition(|chunk| chunk.wpt.iter().any(&filter))
.ok_or(CommandError::NothingToDo)?;
let mut copies = HashSet::new();
let mut inserted = Vec::new();
let mut chunk = WaypointChunk::default();
for wpt in file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| filter(wpt))
{
let mut copy = wpt.clone();
copy.id = Default::default();
copies.insert(copy.id);
chunk.wpt.push(copy);
if chunk.is_full() {
inserted.push(Rc::new(std::mem::take(&mut chunk)));
}
}
if !chunk.wpt.is_empty() {
inserted.push(Rc::new(chunk));
}
// The copies go right after the last selected waypoint: only the chunk holding it is cut
// (when waypoints follow it), all the other chunks are kept as they are.
let cut = &file.wpt[last_chunk];
let split = cut.wpt.iter().rposition(&filter).unwrap() + 1;
let replacement = if split == cut.wpt.len() {
let mut chunks = vec![cut.clone()];
chunks.extend(inserted);
chunks
} else {
let part = |wpt: &[Waypoint]| {
Rc::new(WaypointChunk {
wpt: wpt.to_vec(),
..Default::default()
})
};
let mut chunks = vec![part(&cut.wpt[..split])];
chunks.extend(inserted);
chunks.push(part(&cut.wpt[split..]));
chunks
};
file.wpt.splice(last_chunk..=last_chunk, replacement);
file.wpt_rev_id = Default::default();
files.insert(file_id, Rc::new(file));
Ok(Selection::Waypoint {
file_id,
wpt_ids: copies,
})
}
/// Inserts a copy of each item matching `filter`, as a block after the last matching item.
fn append_copies<T>(items: &mut Vec<T>, filter: impl Fn(&T) -> bool, copy: impl FnMut(&T) -> T) {
let Some(last) = items.iter().rposition(&filter) else {
return;
};
let copies: Vec<T> = items.iter().filter(|item| filter(item)).map(copy).collect();
items.splice(last + 1..last + 1, copies);
}
// Track points are shared chunks, so copies are cheap.
fn copy_segment(segment: &TrackSegment) -> TrackSegment {
let mut copy = segment.clone();
copy.id = Default::default();
copy
}
fn copy_track(track: &Track) -> Track {
Track {
id: Default::default(),
trkseg: track.trkseg.iter().map(copy_segment).collect(),
..track.clone()
}
}
fn copy_file(file: &File) -> File {
File {
id: Default::default(),
trk: file.trk.iter().map(copy_track).collect(),
..file.clone()
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use crate::{FileId, Load};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_duplicate_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Duplicate.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_duplicate_file() {
let (mut fx, id) = loaded();
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files.len(), 2);
let copy_id = fx.order.0[1];
assert_eq!(fx.selected_files(), [copy_id].into());
let (orig, copy) = (&fx.files[&id], &fx.files[&copy_id]);
assert_ne!(copy_id, id);
assert_eq!(copy.info.name, orig.info.name);
assert_eq!(copy.trk.len(), orig.trk.len());
for (a, b) in orig.trk.iter().zip(&copy.trk) {
assert_ne!(a.id, b.id);
assert_eq!(a.info, b.info);
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_ne!(a.id, b.id);
assert_eq!(a.len(), b.len());
}
}
}
#[test]
fn test_duplicate_files_go_after_last_selected() {
let mut fx = Fixture::default();
for name in ["a", "b", "c"] {
crate::New { name }.apply(&mut fx.state()).unwrap();
}
let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]];
fx.selection = Selection::File {
file_ids: [a, b].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0.len(), 5);
assert_eq!(fx.order.0[..2], [a, b]);
assert_eq!(fx.order.0[4], c);
assert_eq!(
fx.selected_files(),
fx.order.0[2..4].iter().copied().collect()
);
}
#[test]
fn test_duplicate_track_appends_copy() {
let (mut fx, id) = loaded();
let before = fx.files[&id].trk.len();
let (trk_id, second_id) = (fx.files[&id].trk[0].id, fx.files[&id].trk[1].id);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [trk_id].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before + 1);
assert_eq!(file.trk[0].id, trk_id);
assert_ne!(file.trk[1].id, trk_id);
// copies go right after the selected track, not at the end
assert_eq!(file.trk[2].id, second_id);
assert!(
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&file.trk[1].id))
);
}
#[test]
fn test_duplicate_selected_waypoints() {
let (mut fx, id) = loaded();
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: wpts,
..Default::default()
})];
fx.files.insert(id, Rc::new(file));
let original_chunk = fx.files[&id].wpt[0].clone();
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[0]]),
};
let rev = fx.files[&id].wpt_rev_id;
Duplicate.apply(&mut fx.state()).unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let all: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
assert_eq!(all.len(), 4);
assert!(
matches!(&fx.selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids == &HashSet::from([all[1]]))
);
// the chunk is cut after the selected waypoint: [w0] [copy] [w1 w2]
assert_eq!(all[..], [ids[0], all[1], ids[1], ids[2]]);
assert_eq!(fx.files[&id].wpt.len(), 3);
assert_ne!(fx.files[&id].wpt[0].id, original_chunk.id);
// selecting the last waypoint of a chunk does not cut it
let last = fx.files[&id].wpt.last().unwrap().wpt.last().unwrap().id;
let chunks = fx.files[&id].wpt.clone();
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([last]),
};
Duplicate.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.wpt.len(), 4);
assert!(
file.wpt[..3]
.iter()
.zip(&chunks)
.all(|(a, b)| Rc::ptr_eq(a, b))
);
fx.selection = Selection::Waypoints { file_id: id };
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(
fx.files[&id].wpt.iter().map(|c| c.wpt.len()).sum::<usize>(),
10
);
}
#[test]
fn test_duplicate_segment_appends_copy() {
let (mut fx, id) = loaded();
let trk = &fx.files[&id].trk[0];
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: [seg_id].into(),
};
Duplicate.apply(&mut fx.state()).unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before + 1);
assert_eq!(trk.trkseg[0].id, seg_id);
assert_ne!(trk.trkseg[1].id, seg_id);
assert_eq!(trk.trkseg[0].len(), trk.trkseg[1].len());
}
}
@@ -0,0 +1,48 @@
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);
}
}
@@ -0,0 +1,11 @@
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::*;
@@ -0,0 +1,36 @@
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]);
}
}
@@ -0,0 +1,30 @@
//! Shared helpers for the tests of the commands.
use std::collections::HashSet;
use crate::{FileId, FileOrder, Selection, StackEntry, State};
#[derive(Default)]
pub struct Fixture {
pub files: StackEntry,
pub selection: Selection,
pub order: FileOrder,
}
impl Fixture {
pub fn state(&mut self) -> State<'_> {
State {
files: &mut self.files,
selection: &mut self.selection,
order: &mut self.order,
}
}
/// Ids of the selected files, empty unless the selection is a file selection.
pub fn selected_files(&self) -> HashSet<FileId> {
match &self.selection {
Selection::File { file_ids } => file_ids.clone(),
_ => HashSet::new(),
}
}
}
+15
View File
@@ -0,0 +1,15 @@
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::*;
@@ -0,0 +1,40 @@
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
}
@@ -0,0 +1,7 @@
mod edit_waypoint_chunks;
mod produce;
mod update_selected;
pub use edit_waypoint_chunks::*;
pub use produce::*;
pub use update_selected::*;
@@ -0,0 +1,81 @@
use std::rc::Rc;
use crate::{File, Selection, State};
/// Pure producer: adds the files returned by `f` to the state and selects the first one.
pub fn produce<F>(state: &mut State, f: F)
where
F: FnOnce(&State) -> Vec<File>,
{
*state.selection = Selection::Empty;
for file in f(state) {
let file_id = file.id;
state.files.insert(file_id, Rc::new(file));
state.order.0.push(file_id);
if matches!(*state.selection, Selection::Empty) {
*state.selection = Selection::File {
file_ids: [file_id].into(),
};
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use super::*;
fn named(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_string();
file
}
#[test]
fn test_produce_adds_orders_and_selects_first() {
let mut fx = Fixture::default();
let (a, b) = (named("a"), named("b"));
let (id_a, id_b) = (a.id, b.id);
produce(&mut fx.state(), |_| vec![a, b]);
assert_eq!(fx.files.len(), 2);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_a]));
}
#[test]
fn test_produce_replaces_selection_and_keeps_order() {
let mut fx = Fixture::default();
let a = named("a");
let id_a = a.id;
produce(&mut fx.state(), |_| vec![a]);
let b = named("b");
let id_b = b.id;
produce(&mut fx.state(), |_| vec![b]);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_b]));
}
#[test]
fn test_produce_nothing_clears_selection() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |_| vec![]);
assert!(matches!(fx.selection, Selection::Empty));
assert_eq!(fx.files.len(), 1);
}
#[test]
fn test_produce_sees_current_state() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |state| {
assert_eq!(state.files.len(), 1);
vec![]
});
}
}
@@ -0,0 +1,340 @@
use std::rc::Rc;
use crate::{
File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint, edit_waypoint_chunks,
};
/// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Edit {
Unchanged,
Changed,
}
impl Edit {
fn merge(self, other: Edit) -> Edit {
if self == Edit::Changed || other == Edit::Changed {
Edit::Changed
} else {
Edit::Unchanged
}
}
}
/// Independent edit of each selected element, without needing the rest of the state.
///
/// A selected file, track or segment is passed to the hook of its own level. By default a
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment).
/// Waypoints are only edited when selected, never through their file.
pub trait Editor {
fn file(&mut self, file: &mut File) -> Edit {
edit_each(&mut file.trk, |trk| self.track(trk))
}
fn track(&mut self, track: &mut Track) -> Edit {
edit_each(&mut track.trkseg, |seg| edit_segment(self, seg))
}
fn segment(&mut self, _segment: &mut TrackSegment) -> Edit {
Edit::Unchanged
}
fn waypoint(&mut self, _waypoint: &mut Waypoint) -> Edit {
Edit::Unchanged
}
}
/// Calls the segment hook and gives the segment a new revision id if it changed, so that
/// derived data (statistics) is recomputed.
fn edit_segment<E: Editor + ?Sized>(editor: &mut E, segment: &mut TrackSegment) -> Edit {
let edit = editor.segment(segment);
if edit == Edit::Changed {
segment.rev_id = Default::default();
}
edit
}
fn edit_each<T>(items: &mut [T], f: impl FnMut(&mut T) -> Edit) -> Edit {
edit_where(items, |_| true, f)
}
fn edit_where<T>(
items: &mut [T],
filter: impl Fn(&T) -> bool,
mut f: impl FnMut(&mut T) -> Edit,
) -> Edit {
items
.iter_mut()
.filter(|item| filter(item))
.fold(Edit::Unchanged, |acc, item| acc.merge(f(item)))
}
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
/// new snapshot only if something changed.
fn update_file(files: &mut StackEntry, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
let Some(current) = files.get(&id) else {
return;
};
let mut file = (**current).clone();
if f(&mut file) == Edit::Changed {
files.insert(id, Rc::new(file));
}
}
fn edit_waypoints<E: Editor + ?Sized>(
file: &mut File,
filter: impl Fn(&Waypoint) -> bool,
editor: &mut E,
) -> Edit {
let changed = edit_waypoint_chunks(file, &filter, |wpts| {
edit_where(wpts, &filter, |wpt| editor.waypoint(wpt)) == Edit::Changed
});
if changed {
Edit::Changed
} else {
Edit::Unchanged
}
}
/// Applies the editor to every selected file, track or segment.
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
match &*state.selection {
Selection::File { file_ids } => {
for id in file_ids {
update_file(state.files, *id, |file| editor.file(file));
}
}
Selection::Track { file_id, trk_ids } => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.trk,
|trk| trk_ids.contains(&trk.id),
|trk| editor.track(trk),
)
});
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.trk,
|trk| trk.id == *trk_id,
|trk| {
edit_where(
&mut trk.trkseg,
|seg| trkseg_ids.contains(&seg.id),
|seg| edit_segment(editor, seg),
)
},
)
});
}
Selection::Waypoints { file_id } => {
update_file(state.files, *file_id, |file| {
edit_waypoints(file, |_| true, editor)
});
}
Selection::Waypoint { file_id, wpt_ids } => {
update_file(state.files, *file_id, |file| {
edit_waypoints(file, |wpt| wpt_ids.contains(&wpt.id), editor)
});
}
Selection::Empty => {}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use super::*;
struct Rename;
impl Editor for Rename {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name.push('!');
Edit::Changed
}
fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some("track".to_string());
Edit::Changed
}
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
Edit::Changed
}
}
struct Nothing;
impl Editor for Nothing {}
fn fixture_with_tracks() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
let file = Rc::new(crate::parse(&data).unwrap());
let id = file.id;
fx.files.insert(id, file);
(fx, id)
}
#[test]
fn test_only_selected_files_are_updated() {
let mut fx = Fixture::default();
let (a, b, c) = (File::default(), File::default(), File::default());
let ids = [a.id, b.id, c.id];
for file in [a, b, c] {
fx.files.insert(file.id, Rc::new(file));
}
let untouched = fx.files[&ids[2]].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([ids[0], ids[1]]),
};
update_selected(&mut fx.state(), &mut Rename);
assert_eq!(fx.files[&ids[0]].info.name, "!");
assert_eq!(fx.files[&ids[1]].info.name, "!");
assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched));
}
#[test]
fn test_unchanged_file_keeps_its_snapshot() {
let (mut fx, id) = fixture_with_tracks();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
let before = fx.files[&id].clone();
update_selected(&mut fx.state(), &mut Nothing);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_track_selection_uses_track_hook() {
let (mut fx, id) = fixture_with_tracks();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
update_selected(&mut fx.state(), &mut Rename);
let file = &fx.files[&id];
assert_eq!(file.info.name, "with_tracks_and_segments");
assert_eq!(file.trk[0].info.name.as_deref(), Some("track"));
assert_ne!(file.trk[1].info.name.as_deref(), Some("track"));
}
#[test]
fn test_segment_selection_bumps_revision_id() {
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
let trk = &before.trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
};
update_selected(&mut fx.state(), &mut Rename);
let after = &fx.files[&id];
assert_ne!(after.trk[0].trkseg[0].rev_id, trk.trkseg[0].rev_id);
assert_eq!(after.trk[0].trkseg[1].rev_id, trk.trkseg[1].rev_id);
assert_eq!(
after.trk[1].trkseg[0].rev_id,
before.trk[1].trkseg[0].rev_id
);
}
#[test]
fn test_file_selection_cascades_to_segments() {
struct SegmentsOnly;
impl Editor for SegmentsOnly {
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
update_selected(&mut fx.state(), &mut SegmentsOnly);
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
for (b, a) in b.trkseg.iter().zip(a.trkseg.iter()) {
assert_ne!(b.rev_id, a.rev_id);
}
}
}
#[test]
fn test_waypoint_selection_edits_only_selected_waypoints() {
struct Name;
impl Editor for Name {
fn waypoint(&mut self, wpt: &mut Waypoint) -> Edit {
wpt.name = Some("x".into());
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: wpts,
..Default::default()
})];
fx.files.insert(id, Rc::new(file));
let chunk_id = fx.files[&id].wpt[0].id;
// A file selection never touches waypoints.
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
update_selected(&mut fx.state(), &mut Name);
assert!(fx.files[&id].wpt[0].wpt.iter().all(|w| w.name.is_none()));
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[1]]),
};
let rev = fx.files[&id].wpt_rev_id;
update_selected(&mut fx.state(), &mut Name);
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let chunk = &fx.files[&id].wpt[0];
assert_ne!(chunk.id, chunk_id);
let named: Vec<_> = chunk.wpt.iter().map(|w| w.name.is_some()).collect();
assert_eq!(named, [false, true, false]);
fx.selection = Selection::Waypoints { file_id: id };
update_selected(&mut fx.state(), &mut Name);
assert!(fx.files[&id].wpt[0].wpt.iter().all(|w| w.name.is_some()));
}
#[test]
fn test_missing_files_and_other_selections_are_ignored() {
let mut fx = Fixture::default();
let file = File::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
let before = fx.files[&id].clone();
for selection in [
Selection::File {
file_ids: HashSet::from([FileId::default()]),
},
Selection::Empty,
] {
fx.selection = selection;
update_selected(&mut fx.state(), &mut Rename);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
}
@@ -0,0 +1,22 @@
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"))
}
}
@@ -0,0 +1,14 @@
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"))
}
}
@@ -0,0 +1,13 @@
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"))
}
}
@@ -0,0 +1,11 @@
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"))
}
}
@@ -0,0 +1,19 @@
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"))
}
}
@@ -0,0 +1,23 @@
mod clean;
mod crop;
mod elevation;
mod extract;
mod merge;
mod move_waypoint;
mod new_waypoint;
mod reverse;
mod splice_trackpoints;
mod split;
mod time;
pub use clean::*;
pub use crop::*;
pub use elevation::*;
pub use extract::*;
pub use merge::*;
pub use move_waypoint::*;
pub use new_waypoint::*;
pub use reverse::*;
pub use splice_trackpoints::*;
pub use split::*;
pub use time::*;
@@ -0,0 +1,15 @@
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"))
}
}
@@ -0,0 +1,19 @@
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"))
}
}
@@ -0,0 +1,11 @@
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"))
}
}
@@ -0,0 +1,305 @@
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);
}
}
@@ -0,0 +1,13 @@
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"))
}
}
@@ -0,0 +1,11 @@
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"))
}
}
@@ -0,0 +1,152 @@
use std::collections::{HashMap, HashSet};
use crate::{
FileId, FileWaypointsRevisionId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId,
};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
/// of its file) changes, so editing a segment does not touch the buffers of the others.
#[derive(Debug, Default)]
pub struct CoordinatesCache {
segments: HashMap<TrackSegmentId, (TrackSegmentRevisionId, Vec<f64>)>,
waypoints: HashMap<FileId, (FileWaypointsRevisionId, Vec<f64>)>,
}
impl CoordinatesCache {
pub fn update(&mut self, files: Option<&Files>) {
let mut segments = HashSet::new();
let mut waypoints = HashSet::new();
for file in files.into_iter().flat_map(|files| files.values()) {
waypoints.insert(file.id);
if self
.waypoints
.get(&file.id)
.is_none_or(|(r, _)| *r != file.wpt_rev_id)
{
let mut coordinates = Vec::new();
for wpt in file.wpt.iter().flat_map(|chunk| &chunk.wpt) {
coordinates.extend([wpt.coordinates.lng, wpt.coordinates.lat]);
}
self.waypoints
.insert(file.id, (file.wpt_rev_id, coordinates));
}
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
segments.insert(seg.id);
if self
.segments
.get(&seg.id)
.is_none_or(|(r, _)| *r != seg.rev_id)
{
let mut coordinates = Vec::with_capacity(seg.len() * 2);
for trkpt in seg.iter() {
coordinates.extend([trkpt.coordinates.lng, trkpt.coordinates.lat]);
}
self.segments.insert(seg.id, (seg.rev_id, coordinates));
}
}
}
self.segments.retain(|id, _| segments.contains(id));
self.waypoints.retain(|id, _| waypoints.contains(id));
}
/// Coordinates of the trackpoints of a segment, empty if it does not exist.
pub fn segment(&self, id: &TrackSegmentId) -> &[f64] {
self.segments.get(id).map_or(&[], |(_, c)| c)
}
/// Coordinates of the waypoints of a file, empty if it does not exist.
pub fn waypoints(&self, id: &FileId) -> &[f64] {
self.waypoints.get(id).map_or(&[], |(_, c)| c)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, LngLat, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_segment_buffers_match_trackpoints() {
let (fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
for seg in fx.files[&id].trk.iter().flat_map(|t| &t.trkseg) {
let buffer = cache.segment(&seg.id);
assert_eq!(buffer.len(), seg.len() * 2);
for (i, p) in seg.iter().enumerate() {
assert_eq!(buffer[2 * i], p.coordinates.lng);
assert_eq!(buffer[2 * i + 1], p.coordinates.lat);
}
}
assert!(cache.segment(&TrackSegmentId::default()).is_empty());
}
#[test]
fn test_only_changed_buffers_are_rebuilt_and_stale_ones_dropped() {
let (mut fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
let ids: Vec<_> = fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.id)
.collect();
assert!(ids.len() >= 2);
let ptr = |c: &CoordinatesCache, i: usize| c.segment(&ids[i]).as_ptr();
let (p0, p1) = (ptr(&cache, 0), ptr(&cache, 1));
let mut file = (*fx.files[&id]).clone();
let seg = &mut file.trk[0].trkseg[0];
seg.splice(0, 0, vec![Default::default()]);
seg.rev_id = Default::default();
let len = seg.len();
fx.files.insert(id, Rc::new(file));
cache.update(Some(&fx.files));
assert_eq!(cache.segment(&ids[0]).len(), len * 2);
assert_ne!(ptr(&cache, 0), p0);
assert_eq!(ptr(&cache, 1), p1);
cache.update(None);
assert!(cache.segment(&ids[0]).is_empty());
assert!(cache.waypoints(&id).is_empty());
}
#[test]
fn test_waypoint_buffer() {
let (mut fx, id) = loaded();
let wpt = |lng, lat| Waypoint {
coordinates: LngLat { lng, lat },
..Default::default()
};
let mut file = (*fx.files[&id]).clone();
file.wpt_rev_id = Default::default();
file.wpt = vec![
Rc::new(WaypointChunk {
wpt: vec![wpt(1.0, 2.0)],
..Default::default()
}),
Rc::new(WaypointChunk {
wpt: vec![wpt(3.0, 4.0)],
..Default::default()
}),
];
fx.files.insert(id, Rc::new(file));
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
assert_eq!(cache.waypoints(&id), [1.0, 2.0, 3.0, 4.0]);
}
}
@@ -0,0 +1,133 @@
use crate::{
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId,
WaypointId,
};
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints,
/// with the ids that reference every element. Coordinates are not part of it, they are read
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
/// a segment).
#[derive(Debug, Clone, PartialEq)]
pub struct FileStructure {
pub id: FileId,
pub name: String,
pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change.
pub wpt_rev_id: FileWaypointsRevisionId,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub name: Option<String>,
pub segments: Vec<SegmentNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentNode {
pub id: TrackSegmentId,
/// Changes whenever the trackpoints of the segment (hence their buffer) change.
pub rev_id: TrackSegmentRevisionId,
pub len: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WaypointNode {
pub id: WaypointId,
pub name: Option<String>,
}
impl FileStructure {
pub fn new(file: &File) -> Self {
Self {
id: file.id,
name: file.info.name.clone(),
tracks: file
.trk
.iter()
.map(|trk| TrackNode {
id: trk.id,
name: trk.info.name.clone(),
segments: trk
.trkseg
.iter()
.map(|seg| SegmentNode {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
})
.collect(),
})
.collect(),
waypoints: file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| WaypointNode {
id: wpt.id,
name: wpt.name.clone(),
})
.collect(),
wpt_rev_id: file.wpt_rev_id,
}
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_structure_of_a_file() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let file = &fx.files[&fx.order.0[0]];
let node = FileStructure::new(file);
assert_eq!((node.id, &node.name), (file.id, &file.info.name));
assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name), (t.id, &t.info.name));
assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
}
}
}
#[test]
fn test_waypoints_in_buffer_order_and_revision() {
let mut file = crate::File::default();
let wpt = |n: &str| Waypoint {
name: Some(n.to_string()),
..Default::default()
};
let before = file.wpt_rev_id;
file.wpt_rev_id = Default::default();
file.wpt = vec![
Rc::new(WaypointChunk {
wpt: vec![wpt("a"), wpt("b")],
..Default::default()
}),
Rc::new(WaypointChunk {
wpt: vec![wpt("c")],
..Default::default()
}),
];
let node = FileStructure::new(&file);
let names: Vec<_> = node
.waypoints
.iter()
.map(|w| w.name.clone().unwrap())
.collect();
assert_eq!(names, ["a", "b", "c"]);
assert_ne!(node.wpt_rev_id, before);
assert_eq!(node.wpt_rev_id, file.wpt_rev_id);
}
}
@@ -0,0 +1,82 @@
use std::collections::HashMap;
use crate::{Diff, FileId, FileStructure, StackEntry as Files};
/// The structure of each file, only recomputed for the files that changed according to the
/// [`Diff`] of the stack.
#[derive(Debug, Default)]
pub struct FileStructureCache {
map: HashMap<FileId, FileStructure>,
}
impl FileStructureCache {
/// Brings the cache in line with `files`, given what changed since the last update.
pub fn update(&mut self, files: Option<&Files>, diff: Option<&Diff>) {
if let Some(diff) = diff {
for id in diff.removed.iter() {
self.map.remove(id);
}
for id in diff.added.iter().chain(&diff.modified) {
if let Some(file) = files.and_then(|files| files.get(id)) {
self.map.insert(*id, FileStructure::new(file));
}
}
}
}
pub fn get(&self, id: &FileId) -> Option<&FileStructure> {
self.map.get(id)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, Load, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_only_files_of_the_diff_are_recomputed() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let mut cache = FileStructureCache::default();
cache.update(
Some(&fx.files),
Some(&Diff {
added: vec![a, b],
..Default::default()
}),
);
let before = cache.get(&b).unwrap() as *const FileStructure;
let mut file = (*fx.files[&a]).clone();
file.info.name = "renamed".into();
fx.files.insert(a, Rc::new(file));
cache.update(
Some(&fx.files),
Some(&Diff {
modified: vec![a],
..Default::default()
}),
);
assert_eq!(cache.get(&a).unwrap().name, "renamed");
assert!(std::ptr::eq(cache.get(&b).unwrap(), before));
fx.files.remove(&b);
cache.update(
Some(&fx.files),
Some(&Diff {
removed: vec![b],
..Default::default()
}),
);
assert!(cache.get(&b).is_none());
assert!(cache.get(&a).is_some());
}
}
+11
View File
@@ -0,0 +1,11 @@
mod coordinates_cache;
mod file_structure;
mod file_structure_cache;
mod statistics_buffer;
mod statistics_cache;
pub use coordinates_cache::*;
pub use file_structure::*;
pub use file_structure_cache::*;
pub use statistics_buffer::*;
pub use statistics_cache::*;
@@ -0,0 +1,149 @@
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());
}
}
@@ -0,0 +1,233 @@
use std::{
collections::{HashMap, HashSet, hash_map::Entry},
rc::Rc,
};
use crate::{File, Selection, StackEntry, Statistics, Track, TrackSegment, TrackSegmentRevisionId};
#[derive(Debug, Default)]
pub struct StatisticsCache {
map: HashMap<TrackSegmentRevisionId, Statistics>,
}
impl StatisticsCache {
pub fn update(&mut self, state: Option<&StackEntry>) {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
for trk in file.trk.iter() {
for trkseg in trk.trkseg.iter() {
match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id);
}
Entry::Vacant(e) => {
e.insert(Statistics::compute(trkseg));
}
}
}
}
}
}
for id in stale.iter() {
self.map.remove(id);
}
}
pub fn get<'a>(
&'a self,
state: Option<&StackEntry>,
selection: &Selection,
) -> Vec<&'a Statistics> {
let mut stats = vec![];
if let Some(state) = state {
match selection {
Selection::Empty => (),
Selection::File { file_ids } => {
for id in file_ids.iter() {
if let Some(file) = state.get(id) {
self.add_file_stats(file, &mut stats);
}
}
}
Selection::Track { file_id, trk_ids } => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if trk_ids.contains(&trk.id) {
self.add_track_stats(trk, &mut stats);
}
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if *trk_id == trk.id {
for trkseg in trk.trkseg.iter() {
if trkseg_ids.contains(&trkseg.id) {
self.add_segment_stats(trkseg, &mut stats);
}
}
break;
}
}
}
}
Selection::Waypoints { file_id } => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut stats);
}
}
Selection::Waypoint {
file_id,
wpt_ids: _,
} => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut stats);
}
}
}
}
stats
}
fn add_file_stats<'a>(&'a self, file: &Rc<File>, stats: &mut Vec<&'a Statistics>) {
for trk in file.trk.iter() {
self.add_track_stats(trk, stats);
}
}
fn add_track_stats<'a>(&'a self, trk: &Track, stats: &mut Vec<&'a Statistics>) {
for trkseg in trk.trkseg.iter() {
self.add_segment_stats(trkseg, stats);
}
}
fn add_segment_stats<'a>(&'a self, trkseg: &TrackSegment, stats: &mut Vec<&'a Statistics>) {
if let Some(s) = self.map.get(&trkseg.rev_id) {
stats.push(s);
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, parse};
use super::*;
fn state(path: &str) -> (StackEntry, Rc<File>) {
let data = std::fs::read(path).unwrap();
let file = Rc::new(parse(&data).unwrap());
let mut state = StackEntry::default();
state.insert(file.id, file.clone());
(state, file)
}
fn file_selection(id: FileId) -> Selection {
Selection::File {
file_ids: HashSet::from([id]),
}
}
#[test]
fn test_update_and_select_file() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let nb_segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
assert_eq!(cache.map.len(), nb_segments);
assert_eq!(
cache.get(Some(&state), &file_selection(file.id)).len(),
nb_segments
);
assert!(cache.get(Some(&state), &Selection::Empty).is_empty());
assert!(cache.get(None, &file_selection(file.id)).is_empty());
// unknown file
assert!(
cache
.get(Some(&state), &file_selection(FileId::default()))
.is_empty()
);
}
#[test]
fn test_select_track_and_segment() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let trk = &file.trk[0];
let selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([trk.id]),
};
assert_eq!(cache.get(Some(&state), &selection).len(), trk.trkseg.len());
let trkseg = &trk.trkseg[1];
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trkseg.id]),
};
let stats = cache.get(Some(&state), &selection);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].local.len(), trkseg.len());
// a segment id under the wrong track selects nothing
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[1].id,
trkseg_ids: HashSet::from([trkseg.id]),
};
assert!(cache.get(Some(&state), &selection).is_empty());
}
#[test]
fn test_unchanged_segments_are_not_recomputed() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
// a new snapshot of the file that shares its segments (same revision ids)
let mut next = StackEntry::default();
let renamed = {
let mut f = (*file).clone();
f.info.name = "other".to_string();
Rc::new(f)
};
next.insert(renamed.id, renamed);
cache.update(Some(&next));
let after: *const Statistics = cache.get(Some(&next), &file_selection(file.id))[0];
assert!(std::ptr::eq(before, after));
}
#[test]
fn test_modified_segment_is_recomputed_and_stale_entries_dropped() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let old_rev = file.trk[0].trkseg[0].rev_id;
assert!(cache.map.contains_key(&old_rev));
let mut f = (*file).clone();
f.trk[0].trkseg[0].rev_id = Default::default();
let new_rev = f.trk[0].trkseg[0].rev_id;
let mut next = StackEntry::default();
next.insert(f.id, Rc::new(f));
cache.update(Some(&next));
assert!(cache.map.contains_key(&new_rev));
assert!(!cache.map.contains_key(&old_rev));
cache.update(None);
assert!(cache.map.is_empty());
}
}
+449
View File
@@ -0,0 +1,449 @@
// 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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,13 @@
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,
}
+97
View File
@@ -0,0 +1,97 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry as Files};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileOrder(pub Vec<FileId>);
impl FileOrder {
/// Moves the given files, as a block and in the given order, so that they start at `index`
/// among the other files (clamped to the end). Unknown ids are ignored. Returns whether the
/// order changed.
pub fn move_files(&mut self, file_ids: &[FileId], index: usize) -> bool {
let mut seen = HashSet::new();
let moved: Vec<FileId> = file_ids
.iter()
.copied()
.filter(|id| self.0.contains(id) && seen.insert(*id))
.collect();
let mut order: Vec<FileId> = self
.0
.iter()
.copied()
.filter(|id| !seen.contains(id))
.collect();
let index = index.min(order.len());
order.splice(index..index, moved);
let changed = order != self.0;
self.0 = order;
changed
}
/// Drops the files that do not exist anymore and appends the new ones at the end.
pub fn sync(&mut self, files: &Files) {
self.0.retain(|id| files.contains_key(id));
let known: HashSet<FileId> = self.0.iter().copied().collect();
self.0
.extend(files.keys().filter(|id| !known.contains(id)).copied());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> [FileId; 3] {
[FileId::default(), FileId::default(), FileId::default()]
}
#[test]
fn test_move_files_as_block_in_given_order() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c, a], 1));
// index counts among the files that are not moved
assert_eq!(order.0, vec![b, c, a]);
}
#[test]
fn test_move_files_start_and_clamped_end() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c], 0));
assert_eq!(order.0, vec![c, a, b]);
assert!(order.move_files(&[c], 100));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_move_files_ignores_unknown_and_duplicate_ids() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[FileId::default(), b, b], 0));
assert_eq!(order.0, vec![b, a, c]);
}
#[test]
fn test_move_files_unchanged() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(!order.move_files(&[], 0));
assert!(!order.move_files(&[a, b], 0));
assert!(!order.move_files(&[FileId::default()], 0));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_sync_drops_missing_and_appends_new_at_the_end() {
let [a, b, c] = ids();
let mut files = Files::default();
for id in [b, c] {
files.insert(id, Default::default());
}
let mut order = FileOrder(vec![a, b]);
order.sync(&files);
assert_eq!(order.0, vec![b, c]);
}
}
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,252 @@
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
@@ -0,0 +1,5 @@
mod core;
mod engine;
pub use core::*;
pub use engine::*;
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "gpx-rs"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1.7"
gpx-engine = { path = "../engine" }
js-sys = "0.3.106"
uuid = { version = "1.26.1", features = ["js"] }
wasm-bindgen = "0.2"
+552
View File
@@ -0,0 +1,552 @@
//! Frontier between the SvelteKit frontend and the engine.
//!
//! Conventions, chosen to keep calls cheap:
//! - Coordinates and elevations cross as `Float64Array` (`&[f64]`): one memcpy into linear
//! memory, no per-point calls. `lng`/`lat`/`ele` arrays must have the same length.
//! - File bytes cross as `Uint8Array` (`&[u8]`), strings as `&str`.
//! - File ids cross as one flat `Uint8Array` of concatenated 16-byte UUIDs (no `Array<string>`).
//! - Rectangles cross as four numbers instead of an object.
//! - Ids read from the file tree are hyphenated UUID strings; the functions reading buffers take
//! them as such.
//! - Every function returns `false` when its arguments are invalid or the command did nothing.
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds};
use js_sys::{Array, Float64Array, Int32Array, Object, Reflect};
#[wasm_bindgen]
#[derive(Clone, Copy)]
pub enum MergeType {
Connect,
Group,
}
#[wasm_bindgen]
#[derive(Clone, Copy)]
pub enum CleanType {
Inside,
Outside,
}
impl From<MergeType> for engine::MergeType {
fn from(t: MergeType) -> Self {
match t {
MergeType::Connect => Self::Connect,
MergeType::Group => Self::Group,
}
}
}
impl From<CleanType> for engine::CleanType {
fn from(t: CleanType) -> Self {
match t {
CleanType::Inside => Self::Inside,
CleanType::Outside => Self::Outside,
}
}
}
thread_local! {
static ENGINE: RefCell<Option<Engine>> = const { RefCell::new(None) };
}
fn execute(action: Action) -> bool {
ENGINE.with(|engine| {
engine
.borrow_mut()
.as_mut()
.is_some_and(|engine| engine.execute(action))
})
}
fn edit(command: Command) -> bool {
execute(Action::Edit(command))
}
/// Decodes concatenated 16-byte UUIDs.
fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> {
let (chunks, rest) = bytes.as_chunks::<16>();
rest.is_empty().then(|| {
chunks
.iter()
.map(|c| FileId(uuid::Uuid::from_bytes(*c)))
.collect()
})
}
fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool {
a.len() == b.len() && b.len() == c.len()
}
#[wasm_bindgen]
pub fn start() {
console_error_panic_hook::set_once();
ENGINE.with(|engine| {
*engine.borrow_mut() = Some(Engine::default());
});
}
// Statistics buffers
//
// These are views into wasm memory: they are invalidated by the next command (and any
// allocation), so read or copy them right away.
fn with_stats<T>(f: impl FnOnce(&engine::StatisticsBuffer) -> T) -> Option<T> {
ENGINE.with(|engine| engine.borrow().as_ref().map(|e| f(e.statistics())))
}
macro_rules! stats_getter {
($name:ident, $array:ident) => {
#[wasm_bindgen]
pub fn $name() -> $array {
with_stats(|s| unsafe { $array::view(&s.$name) })
.unwrap_or_else(|| $array::new_with_length(0))
}
};
}
stats_getter!(total_distance, Float64Array);
stats_getter!(moving_distance, Float64Array);
stats_getter!(total_time, Int32Array);
stats_getter!(moving_time, Int32Array);
stats_getter!(speed, Float64Array);
stats_getter!(elevation_gain, Float64Array);
stats_getter!(elevation_loss, Float64Array);
stats_getter!(slope, Float64Array);
stats_getter!(slope_segment_slope, Float64Array);
stats_getter!(slope_segment_distance, Float64Array);
// File commands
#[wasm_bindgen]
pub fn new_file(name: &str) -> bool {
edit(Command::New(engine::New { name }))
}
#[wasm_bindgen]
pub fn load_file(data: &[u8]) -> bool {
edit(Command::Load(engine::Load { data }))
}
#[wasm_bindgen]
pub fn duplicate() -> bool {
edit(Command::Duplicate(engine::Duplicate))
}
#[wasm_bindgen]
pub fn delete() -> bool {
edit(Command::Delete(engine::Delete))
}
#[wasm_bindgen]
pub fn delete_all() -> bool {
edit(Command::DeleteAll(engine::DeleteAll))
}
/// Moves the files to `index` among the other files, in the given order (not undoable).
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
#[wasm_bindgen]
pub fn reorder(file_ids_bytes: &[u8], index: usize) -> bool {
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::Reorder { file_ids, index }))
}
// Edit commands
#[wasm_bindgen]
pub fn metadata(name: &str, desc: &str) -> bool {
edit(Command::Metadata(engine::Metadata { name, desc }))
}
#[wasm_bindgen]
pub fn style(color: Option<String>, opacity: Option<f64>, width: Option<f64>) -> bool {
edit(Command::Style(engine::Style {
color: color.as_deref(),
opacity,
width,
}))
}
#[wasm_bindgen]
pub fn new_track() -> bool {
edit(Command::NewTrack(engine::NewTrack))
}
#[wasm_bindgen]
pub fn new_track_segment() -> bool {
edit(Command::NewTrackSegment(engine::NewTrackSegment))
}
// Tools
#[wasm_bindgen]
pub fn reverse() -> bool {
edit(Command::Reverse(engine::Reverse))
}
#[wasm_bindgen]
pub fn splice_trackpoints(start: u32, end: u32, lng: &[f64], lat: &[f64], ele: &[f64]) -> bool {
start <= end
&& same_len(lng, lat, ele)
&& edit(Command::SpliceTrackpoints(engine::SpliceTrackpoints {
start,
end,
lng,
lat,
ele,
}))
}
#[wasm_bindgen]
pub fn new_waypoint(
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> bool {
edit(Command::NewWaypoint(engine::NewWaypoint {
lng,
lat,
ele,
name,
desc,
icon,
link,
}))
}
#[wasm_bindgen]
pub fn move_waypoint(lng: f64, lat: f64, ele: f64) -> bool {
edit(Command::MoveWaypoint(engine::MoveWaypoint {
lng,
lat,
ele,
}))
}
#[wasm_bindgen]
pub fn crop(start: u32, end: u32) -> bool {
start <= end && edit(Command::Crop(engine::Crop { start, end }))
}
#[wasm_bindgen]
pub fn split(at: u32) -> bool {
edit(Command::Split(engine::Split { at }))
}
#[wasm_bindgen]
pub fn time() -> bool {
edit(Command::Time(engine::Time))
}
#[wasm_bindgen]
pub fn merge(type_: MergeType) -> bool {
edit(Command::Merge(engine::Merge {
type_: type_.into(),
}))
}
#[wasm_bindgen]
pub fn extract() -> bool {
edit(Command::Extract(engine::Extract))
}
#[wasm_bindgen]
pub fn elevation(ele: &[f64]) -> bool {
edit(Command::Elevation(engine::Elevation { ele }))
}
#[wasm_bindgen]
pub fn clean(
west: f64,
south: f64,
east: f64,
north: f64,
type_: CleanType,
trkpt: bool,
wpt: bool,
) -> bool {
edit(Command::Clean(engine::Clean {
bounds: LngLatBounds {
sw: LngLat {
lng: west,
lat: south,
},
ne: LngLat {
lng: east,
lat: north,
},
},
type_: type_.into(),
trkpt,
wpt,
}))
}
// Undo-redo
#[wasm_bindgen]
pub fn undo() -> bool {
execute(Action::Undo)
}
#[wasm_bindgen]
pub fn redo() -> bool {
execute(Action::Redo)
}
// Selection
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
#[wasm_bindgen]
pub fn select(file_ids_bytes: &[u8]) -> bool {
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::Select { file_ids }))
}
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
#[wasm_bindgen]
pub fn add_select(file_ids_bytes: &[u8]) -> bool {
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::AddSelect { file_ids }))
}
#[wasm_bindgen]
pub fn select_all() -> bool {
execute(Action::SelectAll)
}
// File order and structures
//
// The files are described by one ordered list of ids and one structure object per file, so that
// the UI can react to what changed only: after each action, `last_update` tells which
// structures to (re)read or drop and whether the order changed. Ids are UUID strings. The
// trackpoints of a segment and the waypoints of a file are in the same order as the coordinates
// of their buffers (see below).
#[wasm_bindgen(typescript_custom_section)]
const FILE_STRUCTURE_TS: &str = r#"
export type Selection =
| { type: 'empty' }
| { type: 'file'; fileIds: string[] }
| { type: 'track'; fileId: string; trackIds: string[] }
| { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
| { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointIds: string[] };
export interface FilesUpdate {
orderChanged: boolean;
selectionChanged: boolean;
/** Files to read the structure of. */
added: string[];
/** Files whose structure changed: reread it. */
modified: string[];
/** Files that do not exist anymore: drop their structure. */
removed: string[];
}
export interface FileStructure {
id: string;
name: string;
tracks: TrackNode[];
waypoints: WaypointNode[];
/** Changes when the waypoints of the file change: refetch their coordinates. */
waypointsRev: string;
}
export interface TrackNode {
id: string;
name?: string;
segments: SegmentNode[];
}
export interface SegmentNode {
id: string;
/** Changes when the trackpoints of the segment change: refetch their coordinates. */
rev: string;
/** Number of trackpoints. */
length: number;
}
export interface WaypointNode {
id: string;
name?: string;
}
"#;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "Selection")]
pub type Selection;
#[wasm_bindgen(typescript_type = "FilesUpdate")]
pub type FilesUpdate;
#[wasm_bindgen(typescript_type = "FileStructure | undefined")]
pub type FileStructure;
#[wasm_bindgen(typescript_type = "string[]")]
pub type FileOrder;
}
fn set(object: &Object, key: &str, value: impl Into<JsValue>) {
Reflect::set(object, &key.into(), &value.into()).unwrap();
}
fn array<T>(items: &[T], f: impl Fn(&T) -> JsValue) -> Array {
items.iter().map(f).collect()
}
fn ids(items: &[FileId]) -> Array {
array(items, |id| id.0.to_string().into())
}
fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object {
let node = Object::new();
set(&node, "id", id.to_string());
if let Some(name) = name {
set(&node, "name", name);
}
node
}
fn structure_object(file: &engine::FileStructure) -> Object {
let node = named_node(file.id.0, Some(&file.name));
set(
&node,
"tracks",
array(&file.tracks, |trk| {
let node = named_node(trk.id.0, trk.name.as_deref());
set(
&node,
"segments",
array(&trk.segments, |seg| {
let node = named_node(seg.id.0, None);
set(&node, "rev", seg.rev_id.0.to_string());
set(&node, "length", seg.len as f64);
node.into()
}),
);
node.into()
}),
);
set(
&node,
"waypoints",
array(&file.waypoints, |wpt| {
named_node(wpt.id.0, wpt.name.as_deref()).into()
}),
);
set(&node, "waypointsRev", file.wpt_rev_id.0.to_string());
node
}
fn with_engine<T>(f: impl FnOnce(&Engine) -> T) -> Option<T> {
ENGINE.with(|engine| engine.borrow().as_ref().map(f))
}
/// Ids of the files in display order.
#[wasm_bindgen]
pub fn file_order() -> FileOrder {
let order = with_engine(|e| ids(e.order())).unwrap_or_default();
order.unchecked_into()
}
/// Structure of a file, `undefined` if the id is unknown.
#[wasm_bindgen]
pub fn file_structure(file_id: &str) -> FileStructure {
let structure = uuid::Uuid::parse_str(file_id)
.ok()
.and_then(|id| {
with_engine(|e| e.file_structure(&FileId(id)).map(structure_object)).flatten()
})
.map_or(JsValue::UNDEFINED, JsValue::from);
structure.unchecked_into()
}
fn uuids<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid) -> Array {
items
.into_iter()
.map(|item| JsValue::from(uuid(item).to_string()))
.collect()
}
/// The current selection (ids are UUID strings).
#[wasm_bindgen]
pub fn selection() -> Selection {
use engine::Selection as S;
let object = Object::new();
with_engine(|e| match e.selection() {
S::Empty => set(&object, "type", "empty"),
S::File { file_ids } => {
set(&object, "type", "file");
set(&object, "fileIds", uuids(file_ids, |id| id.0));
}
S::Track { file_id, trk_ids } => {
set(&object, "type", "track");
set(&object, "fileId", file_id.0.to_string());
set(&object, "trackIds", uuids(trk_ids, |id| id.0));
}
S::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
set(&object, "type", "segment");
set(&object, "fileId", file_id.0.to_string());
set(&object, "trackId", trk_id.0.to_string());
set(&object, "segmentIds", uuids(trkseg_ids, |id| id.0));
}
S::Waypoints { file_id } => {
set(&object, "type", "waypoints");
set(&object, "fileId", file_id.0.to_string());
}
S::Waypoint { file_id, wpt_ids } => {
set(&object, "type", "waypoint");
set(&object, "fileId", file_id.0.to_string());
set(&object, "waypointIds", uuids(wpt_ids, |id| id.0));
}
});
object.unchecked_into()
}
/// What the last action changed. Read it right after each action.
#[wasm_bindgen]
pub fn last_update() -> FilesUpdate {
let update = Object::new();
with_engine(|e| {
let diff = e.last_diff().cloned().unwrap_or_default();
set(&update, "orderChanged", e.order_changed());
set(&update, "selectionChanged", e.selection_changed());
set(&update, "added", ids(&diff.added));
set(&update, "modified", ids(&diff.modified));
set(&update, "removed", ids(&diff.removed));
});
update.unchecked_into()
}
// Coordinates buffers
//
// Like the statistics buffers, these are views into wasm memory, invalidated by the next
// command (and any allocation): read or copy them right away. They are flat `[lng, lat, ...]`
// arrays. Compare `rev` / `waypointsRev` of the file structures with the previous ones to know which
// buffers actually changed.
fn coordinates_view(f: impl FnOnce(&Engine) -> Option<&[f64]>) -> Float64Array {
ENGINE.with(|engine| match engine.borrow().as_ref().and_then(f) {
Some(coordinates) => unsafe { Float64Array::view(coordinates) },
None => Float64Array::new_with_length(0),
})
}
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
#[wasm_bindgen]
pub fn segment_coordinates(segment_id: &str) -> Float64Array {
match uuid::Uuid::parse_str(segment_id) {
Ok(id) => coordinates_view(|e| Some(e.segment_coordinates(&engine::TrackSegmentId(id)))),
Err(_) => Float64Array::new_with_length(0),
}
}
/// Coordinates of the waypoints of a file (empty if the id is unknown).
#[wasm_bindgen]
pub fn waypoint_coordinates(file_id: &str) -> Float64Array {
match uuid::Uuid::parse_str(file_id) {
Ok(id) => coordinates_view(|e| Some(e.waypoint_coordinates(&FileId(id)))),
Err(_) => Float64Array::new_with_length(0),
}
}
+21 -1
View File
@@ -17,6 +17,7 @@
"dexie": "^4.0.11",
"file-saver": "^2.0.5",
"gpx": "file:../gpx",
"gpx-rs": "file:../gpx-rs/pkg",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"mapillary-js": "^4.1.2",
@@ -70,7 +71,8 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-wasm": "^3.6.0"
}
},
"../gpx": {
@@ -89,6 +91,10 @@
"typescript": "^5.6.2"
}
},
"../gpx-rs/pkg": {
"name": "gpx-rs",
"version": "0.1.0"
},
"../gpx/node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"dev": true,
@@ -5492,6 +5498,10 @@
"resolved": "../gpx",
"link": true
},
"node_modules/gpx-rs": {
"resolved": "../gpx-rs/pkg",
"link": true
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"dev": true,
@@ -7716,6 +7726,16 @@
"node": ">=18.0.0"
}
},
"node_modules/vite-plugin-wasm": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+3 -1
View File
@@ -59,7 +59,8 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-wasm": "^3.6.0"
},
"type": "module",
"dependencies": {
@@ -72,6 +73,7 @@
"dexie": "^4.0.11",
"file-saver": "^2.0.5",
"gpx": "file:../gpx",
"gpx-rs": "file:../gpx-rs/pkg",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"mapillary-js": "^4.1.2",
+1 -1
View File
@@ -1449,7 +1449,7 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
color: 'DarkBlue',
},
tags: {
railway: 'station',
railway: ['station', 'halt'],
},
symbol: 'Ground Transportation',
},
@@ -45,26 +45,6 @@
settings.initialize();
function applyOptions() {
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
(url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
});
if (allowedEmbeddingBasemaps.includes(options.basemap)) {
$currentBasemap = options.basemap;
}
@@ -90,6 +70,27 @@
].filter((dataset) => dataset !== null)
);
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
(url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
});
}
$effect(() => {
@@ -88,6 +88,14 @@
<span class="font-mono">{key}</span>
{#if key === 'website' || key.startsWith('website:') || key.endsWith(':website') || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-link underline">{value}</a>
{:else if key === 'wikipedia' || key.startsWith('wikipedia:') || key.endsWith(':wikipedia')}
<a
href="https://wikipedia.org/wiki/{value}"
target="_blank"
class="text-link underline"
>
{value}
</a>
{:else if key === 'phone' || key === 'contact:phone'}
<a href={'tel:' + value} class="text-link underline">{value}</a>
{:else if key === 'email' || key === 'contact:email'}
@@ -24,7 +24,7 @@ liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
});
export class OverpassLayer {
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
+1 -1
View File
@@ -59,7 +59,7 @@ Met de kaartlagen knop kun je wisselen tussen verschillende basiskaarten, en kaa
<DocsLayers />
<span class="text-sm text-center mt-2">
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the topographic basemap.
Beweeg over de kaart om de <a href="https://hiking.waymarkedtrails.org" target="_blank">Gemarkeerde sporen wandelen</a> overlay te tonen bovenop de topografische basiskaart.
</span>
</div>
+2 -2
View File
@@ -13,9 +13,9 @@ title: Instellingen
Wijzig de eenheden die worden gebruikt om afstanden in de interface weer te geven.
### <Zap size="16" class="inline-block" style="margin-bottom: 2px" /> Snelheids eenheden
### <Zap size="16" class="inline-block" style="margin-bottom: 2px" /> Snelheidseenheden
Verander de eenheden die worden gebruikt om velocities in de interface weer te geven.
Verander de eenheden die worden gebruikt om snelheden in de interface weer te geven.
Je kunt kiezen tussen afstand per uur of minuten per afstand, wat geschikter kan zijn voor het uitvoeren van activiteiten.
### <Thermometer size="16" class="inline-block" style="margin-bottom: 2px" /> Temperatuureenheden
@@ -18,7 +18,7 @@ Met dit gereedschap kun je hoogtegegevens toevoegen aan sporen en [interessante
<DocsNote>
Elevation data is provided by <a href="https://mapterhorn.com" target="_blank">Mapterhorn</a>.
You can learn more about its origin and accuracy in the <a href="https://mapterhorn.com/attribution/" target="_blank">documentation</a>.
Hoogtegegevens worden verstrekt door <a href="https://maptiler.com" target="_blank">Mapterhorn</a>.
Je kunt meer leren over de oorsprong en nauwkeurigheid in de <a href="https://mapterhorn.com/attribution/" target="_blank">documentatie</a>.
</DocsNote>
+2 -2
View File
@@ -1,5 +1,5 @@
---
title: FAQ
title: 常見問題
---
<script>
@@ -8,7 +8,7 @@ title: FAQ
# { title }
### Do I need to donate to use the website?
### 需要捐贈才能使用這網站嗎?
No.
The website is free to use and always will be (as long as it is financially sustainable).
@@ -1,5 +1,5 @@
---
title: Files and statistics
title: 檔案和統計
---
<script lang="ts">
@@ -19,7 +19,7 @@ title: Files and statistics
# { title }
## File list
## 檔案列表
Once you have [opened](./menu/file) files, they will be shown as tabs in the file list located at the bottom of the map.
You can reorder them by dragging and dropping the tabs.
@@ -21,7 +21,7 @@ Before we dive into the details of each section, let's have a quick overview of
At the top of the interface, you will find the [main menu](./menu).
This is where you can access common actions such as opening, closing, and exporting files, undoing and redoing actions, and adjusting the application settings.
## Files and statistics
## 檔案和統計
At the bottom of the interface, you will find the list of files currently open in the application.
You can click on a file to select it and display its statistics below the list.
+35 -35
View File
@@ -10,87 +10,87 @@ title: Edit actions
# { title }
與文件操作不同,編輯操作可能會修改目前選定文件的內容。
Moreover, when the tree layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, and points of interest](../gpx).
Therefore, we will refer to the elements that can be modified by these actions as _file items_.
Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items.
若啟用檔案 [樹形佈局](../files-and-stats),操作也可作用於相關 [軌跡、路段和興趣點](../gpx)。
在文件中,所有可通過以上操作修改的項目被稱為 _檔案項_。
注意,除復原和重做外,其餘操作也可通過右鍵點選檔案項來喚出操作菜單。
### <Undo2 size="16" class="inline-block" style="margin-bottom: 2px" /><Redo2 size="16" class="inline-block" style="margin-bottom: 2px" /> Undo and redo
### <Undo2 size="16" class="inline-block" style="margin-bottom: 2px" /><Redo2 size="16" class="inline-block" style="margin-bottom: 2px" /> 復原和重做
Using these buttons, you can undo or redo the last actions you performed.
This applies to all actions of the interface but not to view options, application settings, or map navigation.
復原或重做上一次執行的操作。
只適用於在介面上直接進行的所有操作,不適用於顯示選項、應用設定或地圖導航。
### <Info size="16" class="inline-block" style="margin-bottom: 2px" /> Info...
### <Info size="16" class="inline-block" style="margin-bottom: 2px" /> 資訊…
Open the information dialog of the currently selected file item, where you can see and edit its name and description.
用於查看或編輯所選檔案項的名稱和說明資訊。用於查看或編輯所選檔案項的名稱和說明資訊。
### <PaintBucket size="16" class="inline-block" style="margin-bottom: 2px" /> Appearance...
### <PaintBucket size="16" class="inline-block" style="margin-bottom: 2px" /> 外觀
Open the appearance dialog, where you can change the color, opacity, and width of the selected file items on the map.
用於調整所選檔案項在地圖上顯示的軌跡顏色、不透明度和闊度。
### <EyeOff size="16" class="inline-block" style="margin-bottom: 2px" /> Hide/unhide
### <EyeOff size="16" class="inline-block" style="margin-bottom: 2px" /> 隱藏/顯示
Toggle the visibility of the selected file items on the map.
切換所選檔案項在地圖上的可見性。
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New track
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> 新建軌跡
Create a new track in the selected file.
在所選檔案項中建立一條新軌跡。
<DocsNote>
This action is only available when the tree layout of the files list is enabled.
Additionally, the selection must be a single file.
此操作僅在啟用樹形佈局時可用,
所選內容必須是一個檔案。
</DocsNote>
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New segment
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> 新建路段
Create a new segment in the selected track.
在所選軌跡中建立一個新路段。
<DocsNote>
This action is only available when the tree layout of the files list is enabled.
Additionally, the selection must be a single track.
此操作僅在啟用樹形佈局時可用,
且所選內容必須是單條軌跡。
</DocsNote>
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Select all
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> 全選
Add all file items in the current hierarchy level to the selection.
加入當前檔案層級中所有內容到選取中。
### <Maximize size="16" class="inline-block" style="margin-bottom: 2px" /> Center
### <Maximize size="16" class="inline-block" style="margin-bottom: 2px" /> 置中
Center the map on the selected file items.
根據所選檔案項調整地圖顯示中心。
### <ClipboardCopy size="16" class="inline-block" style="margin-bottom: 2px" /> Copy
### <ClipboardCopy size="16" class="inline-block" style="margin-bottom: 2px" /> 複製
Copy the selected file items to the clipboard.
複製所選檔案項到剪貼簿。
<DocsNote>
This action is only available when the tree layout of the files list is enabled.
此操作僅在啟用樹形佈局時可用,
</DocsNote>
### <Scissors size="16" class="inline-block" style="margin-bottom: 2px" /> Cut
### <Scissors size="16" class="inline-block" style="margin-bottom: 2px" /> 剪下
Cut the selected file items to the clipboard.
剪下所選檔案項到剪貼簿。
<DocsNote>
This action is only available when the tree layout of the files list is enabled.
此操作僅在啟用樹形佈局時可用,
</DocsNote>
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Paste
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> 貼上
Paste the file items from the clipboard to the current hierarchy level if they are compatible with it.
如果剪貼簿中的內容與當前檔案層級相容,進行貼上操作。
<DocsNote>
This action is only available when the tree layout of the files list is enabled.
此操作僅在啟用樹形佈局時可用,
</DocsNote>
### <Trash2 size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
### <Trash2 size="16" class="inline-block" style="margin-bottom: 2px" /> 刪除
Delete the selected file items.
刪除所選檔案項。
+18 -18
View File
@@ -1,5 +1,5 @@
---
title: File actions
title: 檔案
---
<script lang="ts">
@@ -9,44 +9,44 @@ title: File actions
# { title }
The file actions menu contains a set of pretty self-explanatory file operations.
該菜單中的操作非常清晰。
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> 新增
Create a new empty file.
創造一個新的空白檔案。
### <FolderOpen size="16" class="inline-block" style="margin-bottom: 2px" /> Open...
### <FolderOpen size="16" class="inline-block" style="margin-bottom: 2px" /> 開啟…
Open files from your computer.
開啟本地檔案。
<DocsNote>
You can also drag and drop files directly from your file system into the window.
支援將檔案直接拖放到窗口中。
</DocsNote>
### <Copy size="16" class="inline-block" style="margin-bottom: 2px" /> Duplicate
### <Copy size="16" class="inline-block" style="margin-bottom: 2px" /> 複製
Create a copy of the currently selected files.
創造當前選中檔案的副本。
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 刪除
Delete the currently selected files.
刪除當前選中的檔案。
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 全部刪除
Delete all files.
刪除全部檔案。
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export...
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> 匯出...
Open the export dialog to save the currently selected files to your computer.
保存當前選取檔案到本地。
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export all...
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> 匯出所有…
Open the export dialog to save all files to your computer.
保存所有檔案到本地。
<DocsNote type="warning">
If your download does not start after clicking the download button, please check your browser settings to allow downloads from <b>gpx.studio</b>.
點選下載按鈕後若無反應,請檢查瀏覽器設定以允許其從 <b>gpx.studio</b> 下載。
</DocsNote>
+2 -2
View File
@@ -1,5 +1,5 @@
---
title: Settings
title: 設定
---
<script lang="ts">
@@ -9,7 +9,7 @@ title: Settings
# { title }
### <Ruler size="16" class="inline-block" style="margin-bottom: 2px" /> Distance units
### <Ruler size="16" class="inline-block" style="margin-bottom: 2px" /> 距離單位
Change the units used to display distances in the interface.

Some files were not shown because too many files have changed in this diff Show More