24 Commits
Author SHA1 Message Date
vcoppe fde6bafc10 Merge branch 'dev' 2026-06-18 23:07:43 +02:00
vcoppe 4537ea4730 Merge branch 'dev' 2026-06-03 19:05:25 +02:00
vcoppe 1954bbd764 Merge branch 'dev' 2026-06-03 18:48:43 +02:00
vcoppe 9d2da80893 Merge branch 'dev' 2026-05-31 15:26:17 +02:00
vcoppe 043d368c74 Merge branch 'dev' 2026-05-29 08:37:39 +02:00
vcoppe bb11d3375b Merge branch 'dev' 2026-05-28 19:29:34 +02:00
vcoppe bfe5961e27 Merge branch 'dev' 2026-05-21 20:27:41 +02:00
vcoppe 17559913fd Merge branch 'dev' 2026-04-19 16:49:41 +02:00
vcoppe 55590d68a6 Merge branch 'dev' 2026-04-19 16:34:17 +02:00
vcoppe 8e9f16c460 Merge branch 'dev' 2026-04-19 14:47:35 +02:00
vcoppe c60b64f24f Merge branch 'dev' 2026-04-17 22:10:40 +02:00
vcoppe fdb6fe4e52 Merge branch 'dev' 2026-04-17 20:11:25 +02:00
vcoppe 0bf168e67e Merge branch 'dev' 2026-04-09 21:10:56 +02:00
vcoppe d762a45eb9 Merge branch 'dev' 2026-04-09 20:57:30 +02:00
vcoppe 548ab9a459 Merge branch 'dev' 2026-04-07 22:19:15 +02:00
vcoppe 315c1f6a61 Merge branch 'dev' 2026-04-07 22:02:14 +02:00
vcoppe 98257bee12 Merge branch 'dev' 2026-04-06 18:26:22 +02:00
vcoppe 5561b7d9fe Merge branch 'dev' 2026-04-06 14:26:56 +02:00
vcoppe 320887206c Merge branch 'dev' 2026-04-04 23:15:01 +02:00
vcoppe 04a1bf6a55 Merge branch 'dev' 2026-04-03 08:47:57 +02:00
vcoppe 0989371874 Merge branch 'dev' 2026-04-03 08:06:19 +02:00
vcoppe 3dcd6e52d3 Merge branch 'dev' 2026-04-02 22:35:46 +02:00
vcoppe 0f06d0d461 update 2026-04-02 22:23:34 +02:00
vcoppe db33310a10 revert some changes 2026-04-02 22:20:47 +02:00
145 changed files with 1582 additions and 22947 deletions
-2
View File
@@ -1,2 +0,0 @@
target/
pkg/
-398
View File
@@ -1,398 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "gpx-engine"
version = "0.1.0"
dependencies = [
"chrono",
"quick-xml",
"uuid",
]
[[package]]
name = "gpx-rs"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"gpx-engine",
"js-sys",
"uuid",
"wasm-bindgen",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
-6
View File
@@ -1,6 +0,0 @@
[workspace]
members = ["engine", "wasm"]
resolver = "3"
[profile.release]
debug = true
-3
View File
@@ -1,3 +0,0 @@
```
wasm-pack build wasm --target bundler --out-dir ../pkg
```
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "gpx-engine"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.45"
quick-xml = "0.42.0"
uuid = { version = "1.26.1", features = ["v4"] }
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="gpx.studio" xmlns="http://www.topografix.com/GPX/1/1"
xmlns:ext="https://example.com/ext/0.1">
<metadata>
<name>self closing points</name>
<desc>Points without children are self-closing elements.</desc>
<extensions>
<ext:version>2.0</ext:version>
<ext:paths/>
</extensions>
</metadata>
<wpt lat="50.0" lon="4.0"/>
<wpt lat="50.1" lon="4.1"><name>with children</name></wpt>
<wpt lat="50.2" lon="4.2"/>
<trk>
<name>track</name>
<trkseg>
<trkpt lat="50.00" lon="4.00"/>
<trkpt lat="50.01" lon="4.01"><ele>12.5</ele></trkpt>
<trkpt lat="50.02" lon="4.02"/>
</trkseg>
<trkseg>
<trkpt lat="51.00" lon="5.00"/>
</trkseg>
</trk>
</gpx>
-274
View File
@@ -1,274 +0,0 @@
<?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
@@ -1,660 +0,0 @@
<?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>
-62
View File
@@ -1,62 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_highway</name>
</metadata>
<trk>
<name>with_highway</name>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791000" lon="4.405100">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791200" lon="4.405300">
<ele>111.0</ele>
</trkpt>
<trkpt lat="50.791400" lon="4.405500">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<highway>track</highway>
<sac_scale>mountain_hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791600" lon="4.405700">
<ele>113.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<sac_scale>hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
-660
View File
@@ -1,660 +0,0 @@
<?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
@@ -1,500 +0,0 @@
<?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
@@ -1,665 +0,0 @@
<?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
@@ -1,253 +0,0 @@
<?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
@@ -1,253 +0,0 @@
<?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
@@ -1,267 +0,0 @@
<?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
@@ -1,820 +0,0 @@
<?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
@@ -1,660 +0,0 @@
<?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
@@ -1,340 +0,0 @@
<?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
@@ -1,257 +0,0 @@
<?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>
@@ -1,346 +0,0 @@
<?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
@@ -1,276 +0,0 @@
<?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
@@ -1,4 +0,0 @@
mod simplify;
mod smooth;
pub use simplify::*;
@@ -1,133 +0,0 @@
use crate::{
core::gpx::{TrackSegment, TrackSegmentIndex},
core::utils::crossarc,
};
pub fn ramer_douglas_peucker<F>(
trkseg: &TrackSegment,
mapping: &F,
epsilon: f64,
) -> Vec<TrackSegmentIndex>
where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
match trkseg.len() {
0 => vec![],
1 => vec![trkseg.first_index().unwrap()],
2 => vec![trkseg.first_index().unwrap(), trkseg.last_index().unwrap()],
_ => {
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
let mut indices = vec![first];
ramer_douglas_peucker_helper(trkseg, first, last, mapping, epsilon, &mut indices);
indices.push(last);
indices
}
}
}
fn ramer_douglas_peucker_helper<F>(
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
mapping: &F,
epsilon: f64,
indices: &mut Vec<TrackSegmentIndex>,
) where
F: Fn(TrackSegmentIndex) -> (f64, f64),
{
let mut max_idx = None;
let mut max_dist = 0.0;
let start_pt = mapping(start);
let end_pt = mapping(end);
let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let pt = mapping(idx);
let dist = crossarc(start_pt.0, start_pt.1, end_pt.0, end_pt.1, pt.0, pt.1);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
ramer_douglas_peucker_helper(trkseg, start, idx, mapping, epsilon, indices);
indices.push(idx);
ramer_douglas_peucker_helper(trkseg, idx, end, mapping, epsilon, indices);
}
}
#[cfg(test)]
mod tests {
use crate::{Trackpoint, TrackpointChunk};
use super::*;
fn segment(eles: &[f64]) -> TrackSegment {
let mut trkseg = TrackSegment::default();
// several small chunks, to also exercise the chunk boundaries
for part in eles.chunks(3) {
let mut chunk = TrackpointChunk::default();
for ele in part {
chunk.trkpt.push(Trackpoint {
ele: *ele,
..Default::default()
});
}
trkseg.push(chunk);
}
trkseg
}
fn simplify(eles: &[f64], epsilon: f64) -> Vec<usize> {
let trkseg = segment(eles);
ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon)
.iter()
.map(|idx| idx.flat)
.collect()
}
#[test]
fn test_empty_and_single_point() {
assert!(simplify(&[], 1.0).is_empty());
assert_eq!(simplify(&[5.0], 1.0), vec![0]);
}
#[test]
fn test_collinear_points_are_removed() {
let eles: Vec<f64> = (0..20).map(|i| i as f64 * 2.0).collect();
assert_eq!(simplify(&eles, 0.1), vec![0, 19]);
}
#[test]
fn test_peak_is_kept_when_above_epsilon() {
let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0];
assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]);
}
#[test]
fn test_peak_is_dropped_when_below_epsilon() {
let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0];
assert_eq!(simplify(&eles, 1.0), vec![0, 6]);
}
#[test]
fn test_indices_are_sorted_and_keep_extremities() {
let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0];
let kept = simplify(&eles, 0.5);
assert_eq!(kept.first(), Some(&0));
assert_eq!(kept.last(), Some(&9));
assert!(kept.windows(2).all(|w| w[0] < w[1]));
// a larger epsilon never keeps more points
assert!(simplify(&eles, 5.0).len() <= kept.len());
}
}
-141
View File
@@ -1,141 +0,0 @@
#[macro_export]
macro_rules! for_each_window {
(
$trkseg:expr,
$left:expr,
$right:expr,
$window:expr,
|$a:ident, $b:ident| $distance:expr,
|$i:ident, $l:ident, $r:ident| $body:block,
) => {{
let mut window_start = $left.unwrap_or_default();
let mut window_center = $left;
let mut window_end = $left.unwrap_or_default();
while let Some(idx) = window_center {
// advance left if needed
while window_start != idx && {
let $a = window_start;
let $b = idx;
$distance
} > $window {
if let Some(next) = $trkseg.next_index(Some(window_start)) {
if next == idx {
break;
}
window_start = next;
} else {
break;
}
}
// advance right if needed
window_end = window_end.max(idx);
while let Some(next) = $trkseg.next_index(Some(window_end)) {
if window_end != idx && {
let $a = idx;
let $b = next;
$distance
} > $window {
break;
}
window_end = next;
}
// apply body
let $i = idx;
let $l = window_start;
let $r = window_end;
$body
// go next
if window_center == $right {
break;
}
window_center = $trkseg.next_index(window_center);
}
}};
}
#[cfg(test)]
mod tests {
use crate::{TrackSegment, Trackpoint, TrackpointChunk};
fn segment(eles: &[f64]) -> TrackSegment {
let mut trkseg = TrackSegment::default();
// several small chunks, to also exercise the chunk boundaries
for part in eles.chunks(3) {
let mut chunk = TrackpointChunk::default();
for ele in part {
chunk.trkpt.push(Trackpoint {
ele: *ele,
..Default::default()
});
}
trkseg.push(chunk);
}
trkseg
}
/// Returns (center, left, right) flat indices for each visited center.
fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> {
let trkseg = segment(&vec![0.0; len]);
let mut visited = vec![];
for_each_window!(
trkseg,
trkseg.first_index(),
trkseg.last_index(),
window,
|a, b| (b.flat - a.flat) as f64,
|i, l, r| {
visited.push((i.flat, l.flat, r.flat));
},
);
visited
}
#[test]
fn test_window_bounds() {
let visited = windows(10, 2.0);
assert_eq!(visited.len(), 10);
for (i, l, r) in visited {
assert_eq!(l, i.saturating_sub(2), "left of {i}");
assert_eq!(r, (i + 2).min(9), "right of {i}");
}
}
#[test]
fn test_window_always_includes_neighbours() {
for (i, l, r) in windows(6, 0.0) {
assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5)));
}
}
#[test]
fn test_window_larger_than_segment() {
for (_, l, r) in windows(5, 100.0) {
assert_eq!((l, r), (0, 4));
}
}
#[test]
fn test_window_sub_range() {
let trkseg = segment(&[0.0; 10]);
let mut centers = vec![];
let mut first = trkseg.first_index();
for _ in 0..3 {
first = trkseg.next_index(first);
}
let mut last = first;
for _ in 0..2 {
last = trkseg.next_index(last);
}
for_each_window!(
trkseg,
first,
last,
1.0,
|a, b| (b.flat - a.flat) as f64,
|i, _l, _r| {
centers.push(i.flat);
},
);
assert_eq!(centers, vec![3, 4, 5]);
}
}
-96
View File
@@ -1,96 +0,0 @@
/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes,
/// so that the trackpoints only hold a `u8` instead of a string.
///
/// The codes are the positions of the names in the order of first appearance. The table only
/// grows: a code never changes meaning, so it stays valid in every state of the history.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Categories(Vec<String>);
impl Categories {
/// Maximum number of distinct values.
pub const CAPACITY: usize = u8::MAX as usize;
/// The code of `name`, which is added to the table if it is new. `None` if the table is full.
pub fn code(&mut self, name: &str) -> Option<u8> {
let index = match self.0.iter().position(|known| known == name) {
Some(index) => index,
None if self.0.len() < Self::CAPACITY => {
self.0.push(name.to_owned());
self.0.len() - 1
}
None => return None,
};
u8::try_from(index).ok()
}
/// The name of a code, `None` if it is unknown.
pub fn name(&self, code: u8) -> Option<&str> {
self.0.get(usize::from(code)).map(String::as_str)
}
/// All the names, in the order of their codes.
pub fn names(&self) -> &[String] {
&self.0
}
}
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
/// its files.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackpointCategories {
pub surface: Categories,
pub highway: Categories,
pub sac_scale: Categories,
pub mtb_scale: Categories,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_codes_follow_the_order_of_appearance() {
let mut categories = Categories::default();
assert_eq!(categories.code("asphalt"), Some(0));
assert_eq!(categories.code("gravel"), Some(1));
// a known name keeps its code
assert_eq!(categories.code("asphalt"), Some(0));
assert_eq!(categories.names(), ["asphalt", "gravel"]);
}
#[test]
fn test_names_of_codes() {
let mut categories = Categories::default();
let code = categories.code("asphalt").unwrap();
assert_eq!(categories.name(code), Some("asphalt"));
assert_eq!(categories.name(code + 1), None);
assert_eq!(Categories::default().name(0), None);
}
#[test]
fn test_a_full_table_gives_no_code_to_new_names() {
let mut categories = Categories::default();
for i in 0..Categories::CAPACITY {
assert_eq!(categories.code(&i.to_string()), Some(i as u8));
}
assert_eq!(categories.code("one too many"), None);
assert_eq!(categories.names().len(), Categories::CAPACITY);
// the known ones still have their code, the last one included
assert_eq!(categories.code("0"), Some(0));
assert_eq!(categories.code("254"), Some(254));
assert_eq!(categories.name(254), Some("254"));
}
#[test]
fn test_the_categories_are_independent() {
let mut categories = TrackpointCategories::default();
categories.surface.code("asphalt");
assert_eq!(categories.highway.code("path"), Some(0));
assert_eq!(categories.sac_scale.code("hiking"), Some(0));
assert_eq!(categories.mtb_scale.code("1"), Some(0));
assert_eq!(categories.surface.names(), ["asphalt"]);
assert_eq!(categories.highway.names(), ["path"]);
assert_eq!(categories.sac_scale.names(), ["hiking"]);
assert_eq!(categories.mtb_scale.names(), ["1"]);
}
}
-100
View File
@@ -1,100 +0,0 @@
use uuid::Uuid;
use crate::{Trackpoint, Waypoint};
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
#[derive(Debug, PartialEq, Eq)]
pub struct TrackpointChunkId(Uuid);
impl Default for TrackpointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default)]
pub struct TrackpointChunk {
pub id: TrackpointChunkId,
pub trkpt: Vec<Trackpoint>,
}
impl PartialEq for TrackpointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl TrackpointChunk {
pub fn is_full(&self) -> bool {
self.trkpt.len() == MAX_TRKPT_CHUNK_SIZE
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaypointChunkId(Uuid);
impl Default for WaypointChunkId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
const MAX_WPT_CHUNK_SIZE: usize = 128;
#[derive(Debug, Default)]
pub struct WaypointChunk {
pub id: WaypointChunkId,
pub wpt: Vec<Waypoint>,
}
impl PartialEq for WaypointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl WaypointChunk {
pub fn is_full(&self) -> bool {
self.wpt.len() == MAX_WPT_CHUNK_SIZE
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trackpoint_chunk_full() {
let mut chunk = TrackpointChunk::default();
assert!(!chunk.is_full());
for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 {
chunk.trkpt.push(Trackpoint::default());
}
assert!(!chunk.is_full());
chunk.trkpt.push(Trackpoint::default());
assert!(chunk.is_full());
}
#[test]
fn test_waypoint_chunk_full() {
let mut chunk = WaypointChunk::default();
assert!(!chunk.is_full());
for _ in 0..MAX_WPT_CHUNK_SIZE {
chunk.wpt.push(Waypoint::default());
}
assert!(chunk.is_full());
}
#[test]
fn test_chunk_equality_is_by_id() {
let a = TrackpointChunk::default();
let b = TrackpointChunk::default();
assert_ne!(a, b);
let c = TrackpointChunk {
id: TrackpointChunkId(a.id.0),
trkpt: vec![Trackpoint::default()],
};
assert_eq!(a, c);
}
}
-97
View File
@@ -1,97 +0,0 @@
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Link {
pub href: String,
pub text: Option<String>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LngLat {
pub lng: f64,
pub lat: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct LngLatBounds {
pub sw: LngLat,
pub ne: LngLat,
}
impl Default for LngLatBounds {
fn default() -> Self {
Self {
sw: LngLat {
lng: 180.0,
lat: 90.0,
},
ne: LngLat {
lng: -180.0,
lat: -90.0,
},
}
}
}
impl LngLatBounds {
pub fn extend(&mut self, coordinates: LngLat) {
self.sw.lng = self.sw.lng.min(coordinates.lng);
self.sw.lat = self.sw.lat.min(coordinates.lat);
self.ne.lng = self.ne.lng.max(coordinates.lng);
self.ne.lat = self.ne.lat.max(coordinates.lat);
}
pub fn merge(&mut self, other: &LngLatBounds) {
self.sw.lng = self.sw.lng.min(other.sw.lng);
self.sw.lat = self.sw.lat.min(other.sw.lat);
self.ne.lng = self.ne.lng.max(other.ne.lng);
self.ne.lat = self.ne.lat.max(other.ne.lat);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bounds_extend() {
let mut bounds = LngLatBounds::default();
bounds.extend(LngLat {
lng: 4.0,
lat: 50.0,
});
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0));
assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0));
bounds.extend(LngLat {
lng: 6.0,
lat: 45.0,
});
bounds.extend(LngLat {
lng: 5.0,
lat: 48.0,
});
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0));
assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0));
}
#[test]
fn test_bounds_merge() {
let mut a = LngLatBounds::default();
a.extend(LngLat { lng: 0.0, lat: 0.0 });
a.extend(LngLat { lng: 1.0, lat: 1.0 });
let mut b = LngLatBounds::default();
b.extend(LngLat {
lng: -2.0,
lat: 0.5,
});
b.extend(LngLat { lng: 0.5, lat: 3.0 });
a.merge(&b);
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
// merging an empty bounds changes nothing
a.merge(&LngLatBounds::default());
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
}
}
-49
View File
@@ -1,49 +0,0 @@
use std::rc::Rc;
use uuid::Uuid;
use crate::{Link, Track, WaypointChunk};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileId(pub Uuid);
impl Default for FileId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileWaypointsRevisionId(pub Uuid);
impl Default for FileWaypointsRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
pub trk: Vec<Track>,
pub wpt: Vec<Rc<WaypointChunk>>,
pub wpt_rev_id: FileWaypointsRevisionId,
// TODO routes
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
pub author: Option<Author>,
pub link: Option<Link>,
pub time: Option<i64>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Author {
pub name: Option<String>,
pub email: Option<String>,
pub link: Option<Link>,
}
-17
View File
@@ -1,17 +0,0 @@
mod categories;
mod chunk;
mod common;
mod file;
mod segment;
mod track;
mod trackpoint;
mod waypoint;
pub use categories::*;
pub use chunk::*;
pub use common::*;
pub use file::*;
pub use segment::*;
pub use track::*;
pub use trackpoint::*;
pub use waypoint::*;
-454
View File
@@ -1,454 +0,0 @@
use std::{ops::Index, rc::Rc};
use uuid::Uuid;
use crate::{Trackpoint, TrackpointChunk};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackSegmentId(pub Uuid);
impl Default for TrackSegmentId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrackSegmentRevisionId(pub Uuid);
impl Default for TrackSegmentRevisionId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackSegment {
pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId,
chunks: Vec<Rc<TrackpointChunk>>,
cumul_length: Vec<usize>,
}
impl TrackSegment {
pub fn push(&mut self, chunk: TrackpointChunk) {
self.push_shared(Rc::new(chunk));
}
fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
if chunk.trkpt.is_empty() {
return;
}
self.cumul_length
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.trkpt.len());
self.chunks.push(chunk);
}
/// Replaces the points in `start..end` by `points`. Panics if the range is out of bounds.
///
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the
/// range are copied and refilled.
pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
assert!(
start <= end && end <= self.len(),
"splice range out of bounds"
);
let old = std::mem::take(&mut self.chunks);
self.cumul_length.clear();
let mut pending = TrackpointChunk::default();
let mut inserted = false;
let mut offset = 0;
for chunk in old {
let (lo, hi) = (offset, offset + chunk.trkpt.len());
offset = hi;
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
if hi < start || (hi == start && chunk.is_full()) {
self.push_shared(chunk);
continue;
}
if !inserted {
self.fill(&mut pending, chunk.trkpt[..start - lo].iter().cloned());
self.fill(&mut pending, points.iter().cloned());
inserted = true;
}
if hi <= end {
continue;
}
if lo >= end {
self.flush(&mut pending);
self.push_shared(chunk);
} else {
self.fill(&mut pending, chunk.trkpt[end - lo..].iter().cloned());
}
}
if !inserted {
self.fill(&mut pending, points);
}
self.flush(&mut pending);
}
fn fill(
&mut self,
pending: &mut TrackpointChunk,
points: impl IntoIterator<Item = Trackpoint>,
) {
for trkpt in points {
pending.trkpt.push(trkpt);
if pending.is_full() {
self.flush(pending);
}
}
}
fn flush(&mut self, pending: &mut TrackpointChunk) {
self.push(std::mem::take(pending));
}
pub fn len(&self) -> usize {
self.cumul_length.last().copied().unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
pub fn iter(&self) -> TrackSegmentIterator<'_> {
TrackSegmentIterator::new(self)
}
pub fn first_index(&self) -> Option<TrackSegmentIndex> {
self.next_index(None)
}
pub fn last_index(&self) -> Option<TrackSegmentIndex> {
self.prev_index(None)
}
pub fn next_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut next = cur.map_or_default(|idx| TrackSegmentIndex {
chunk: idx.chunk,
pos: idx.pos + 1,
flat: idx.flat + 1,
});
loop {
if next.chunk >= self.chunks.len() {
return None;
}
if next.pos == self.chunks[next.chunk].trkpt.len() {
next.chunk += 1;
next.pos = 0;
} else {
return Some(next);
}
}
}
pub fn prev_index(&self, cur: Option<TrackSegmentIndex>) -> Option<TrackSegmentIndex> {
let mut prev = cur.unwrap_or(TrackSegmentIndex {
chunk: self.chunks.len(),
pos: 0,
flat: self.cumul_length.last().copied().unwrap_or_default(),
});
if prev.pos == 0 {
while prev.chunk > 0 {
prev.chunk -= 1;
if !self.chunks[prev.chunk].trkpt.is_empty() {
prev.pos = self.chunks[prev.chunk].trkpt.len() - 1;
prev.flat -= 1;
return Some(prev);
}
}
None
} else {
prev.pos -= 1;
prev.flat -= 1;
Some(prev)
}
}
fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> {
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
if chunk >= self.chunks.len() {
return None;
}
let pos = if chunk > 0 {
idx - self.cumul_length[chunk - 1]
} else {
idx
};
if pos >= self.chunks[chunk].trkpt.len() {
None
} else {
Some(TrackSegmentIndex {
chunk,
pos,
flat: idx,
})
}
}
}
impl Index<TrackSegmentIndex> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: TrackSegmentIndex) -> &Self::Output {
&self.chunks[idx.chunk].trkpt[idx.pos]
}
}
impl Index<usize> for TrackSegment {
type Output = Trackpoint;
fn index(&self, idx: usize) -> &Self::Output {
&self[self.locate(idx).unwrap()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct TrackSegmentIndex {
pub chunk: usize,
pub pos: usize,
pub flat: usize,
}
#[derive(Debug, Clone)]
pub struct TrackSegmentIterator<'a> {
trkseg: &'a TrackSegment,
idx: Option<TrackSegmentIndex>,
}
impl<'a> TrackSegmentIterator<'a> {
pub fn new(trkseg: &'a TrackSegment) -> Self {
Self {
trkseg,
idx: Default::default(),
}
}
}
impl<'a> Iterator for TrackSegmentIterator<'a> {
type Item = &'a Trackpoint;
fn next(&mut self) -> Option<Self::Item> {
self.idx = self.trkseg.next_index(self.idx);
self.idx.map(|idx| &self.trkseg[idx])
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let idx = self.idx.map_or_default(|idx| idx.flat) + n;
self.idx = self.trkseg.locate(idx);
self.idx.map(|idx| &self.trkseg[idx])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_track_segment(nb_chunks: usize) -> TrackSegment {
let mut trkseg = TrackSegment::default();
let mut count = 0;
for n in 1..=nb_chunks {
let mut chunk = TrackpointChunk::default();
for _ in 0..n {
chunk.trkpt.push(Trackpoint {
ele: count as f64,
..Default::default()
});
count += 1;
}
trkseg.push(chunk);
}
trkseg
}
fn eles(trkseg: &TrackSegment) -> Vec<f64> {
trkseg.iter().map(|p| p.ele).collect()
}
fn points(eles: &[f64]) -> Vec<Trackpoint> {
eles.iter()
.map(|&ele| Trackpoint {
ele,
..Default::default()
})
.collect()
}
#[test]
fn test_splice_replace_insert_delete_append() {
// chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14]
let mut trkseg = create_track_segment(5);
trkseg.splice(4, 8, points(&[-1.0, -2.0]));
let mut expected: Vec<f64> = (0..4).map(f64::from).collect();
expected.extend([-1.0, -2.0]);
expected.extend((8..15).map(f64::from));
assert_eq!(eles(&trkseg), expected);
assert_eq!(trkseg.len(), expected.len());
for i in 0..trkseg.len() {
assert_eq!(trkseg[i].ele, expected[i]);
}
trkseg.splice(2, 2, points(&[100.0]));
expected.insert(2, 100.0);
assert_eq!(eles(&trkseg), expected);
trkseg.splice(0, 3, vec![]);
expected.drain(..3);
assert_eq!(eles(&trkseg), expected);
let len = trkseg.len();
trkseg.splice(len, len, points(&[7.0, 8.0]));
expected.extend([7.0, 8.0]);
assert_eq!(eles(&trkseg), expected);
let len = trkseg.len();
trkseg.splice(0, len, vec![]);
assert_eq!(trkseg.len(), 0);
assert!(trkseg.first_index().is_none());
}
#[test]
fn test_splice_keeps_untouched_chunks_shared() {
let mut trkseg = create_track_segment(5);
let before = trkseg.chunks.clone();
// inside the third chunk only
trkseg.splice(4, 5, points(&[-1.0]));
assert!(Rc::ptr_eq(&trkseg.chunks[0], &before[0]));
assert!(Rc::ptr_eq(&trkseg.chunks[1], &before[1]));
assert!(Rc::ptr_eq(trkseg.chunks.last().unwrap(), &before[4]));
assert!(Rc::ptr_eq(
&trkseg.chunks[trkseg.chunks.len() - 2],
&before[3]
));
}
#[test]
fn test_splice_append_extends_last_chunk_and_respects_max_size() {
let mut trkseg = TrackSegment::default();
for i in 0..10_000 {
let len = trkseg.len();
trkseg.splice(len, len, points(&[i as f64]));
}
assert_eq!(trkseg.len(), 10_000);
assert!(trkseg.chunks.len() <= 3);
assert!(trkseg.chunks.iter().all(|c| c.trkpt.len() <= 4096));
assert_eq!(trkseg[9_999].ele, 9_999.0);
assert_eq!(*trkseg.cumul_length.last().unwrap(), 10_000);
}
#[test]
fn test_len() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
assert_eq!(trkseg.len(), nb_chunks * (nb_chunks + 1) / 2);
}
#[test]
fn test_locate() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for i in 0..trkseg.len() {
let idx = trkseg.locate(i);
assert!(idx.is_some());
assert_eq!(trkseg[idx.unwrap()].ele, i as f64);
}
assert!(trkseg.locate(trkseg.len()).is_none());
}
#[test]
fn test_index() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for i in 0..trkseg.len() {
assert_eq!(trkseg[i].ele, i as f64);
}
}
#[test]
#[should_panic]
fn test_index_out_of_bounds_1() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let _ = trkseg[trkseg.len()];
}
#[test]
#[should_panic]
fn test_index_out_of_bounds_2() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let _ = trkseg[TrackSegmentIndex {
chunk: trkseg.chunks.len(),
pos: 0,
flat: 0,
}];
}
#[test]
fn test_iter() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for (i, trkpt) in trkseg.iter().enumerate() {
assert!(std::ptr::eq(&trkseg[i], trkpt));
}
}
#[test]
fn test_iter_nth() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
let tenth = trkseg.iter().nth(10);
assert!(tenth.is_some());
assert!(std::ptr::eq(&trkseg[10], tenth.unwrap()));
}
#[test]
fn test_iter_skip() {
let nb_chunks = 10;
let trkseg = create_track_segment(nb_chunks);
for (i, trkpt) in trkseg.iter().enumerate().skip(5) {
assert!(std::ptr::eq(&trkseg[i], trkpt));
}
}
#[test]
fn test_first_last_index() {
assert!(TrackSegment::default().first_index().is_none());
assert!(TrackSegment::default().last_index().is_none());
let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5
let first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap();
assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0));
assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14));
assert_eq!(trkseg[last].ele, 14.0);
assert_eq!(trkseg.locate(14), Some(last));
assert!(trkseg.next_index(Some(last)).is_none());
assert!(trkseg.prev_index(Some(first)).is_none());
}
#[test]
fn test_prev_next_are_inverse() {
let trkseg = create_track_segment(6);
let mut idx = trkseg.last_index();
let mut visited = vec![];
while let Some(i) = idx {
assert_eq!(trkseg.locate(i.flat), Some(i));
visited.push(i.flat);
idx = trkseg.prev_index(Some(i));
}
assert_eq!(visited, (0..trkseg.len()).rev().collect::<Vec<_>>());
let mut idx = trkseg.first_index();
while let Some(i) = idx {
let next = trkseg.next_index(Some(i));
if let Some(n) = next {
assert_eq!(trkseg.prev_index(Some(n)), Some(i));
}
idx = next;
}
}
}
-32
View File
@@ -1,32 +0,0 @@
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>,
}
-20
View File
@@ -1,20 +0,0 @@
use crate::LngLat;
#[derive(Debug, Default, Clone)]
pub struct Trackpoint {
pub coordinates: LngLat,
pub ele: f64,
pub time: Option<i64>,
pub atemp: Option<i16>,
pub hr: Option<u16>,
pub cad: Option<u16>,
pub power: Option<u16>,
/// Code of the surface, see [`crate::TrackpointCategories`].
pub surface: Option<u8>,
/// Code of the highway, see [`crate::TrackpointCategories`].
pub highway: Option<u8>,
/// Code of the SAC hiking scale, see [`crate::TrackpointCategories`].
pub sac_scale: Option<u8>,
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
pub mtb_scale: Option<u8>,
}
-26
View File
@@ -1,26 +0,0 @@
use uuid::Uuid;
use crate::{Link, LngLat};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WaypointId(pub Uuid);
impl Default for WaypointId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Default, Clone)]
pub struct Waypoint {
pub id: WaypointId,
pub coordinates: LngLat,
pub ele: f64,
pub time: Option<i64>,
pub name: Option<String>,
pub cmt: Option<String>,
pub desc: Option<String>,
pub link: Option<Link>,
pub sym: Option<String>,
pub type_: Option<String>,
}
-3
View File
@@ -1,3 +0,0 @@
mod parse;
pub use parse::*;
-757
View File
@@ -1,757 +0,0 @@
use std::rc::Rc;
use crate::{
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
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,
Surface,
Highway,
SacScale,
MtbScale,
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
}
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
/// `categories`, which learns the values it does not know yet.
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
let mut reader = Reader::from_reader(data);
let mut buf = vec![];
let mut gpx = File::default();
let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default();
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),
e if e.ends_with("power") => stack.push(GPXElement::Power),
e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power),
"surface" => stack.push(GPXElement::Surface),
"highway" => stack.push(GPXElement::Highway),
"sac_scale" => stack.push(GPXElement::SacScale),
"mtb_scale" => stack.push(GPXElement::MtbScale),
"sym" => stack.push(GPXElement::Symbol),
"type" => stack.push(GPXElement::Type),
e if e.ends_with("color") => stack.push(GPXElement::Color),
e if e.ends_with("opacity") => stack.push(GPXElement::Opacity),
e if e.ends_with("width") => stack.push(GPXElement::Width),
_ => (),
},
// Self-closing points (`<trkpt lat=".." lon=".."/>`), which have no children
Ok(Event::Empty(e)) => match e.name().as_ref() {
"trkpt" => {
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
trkpt_chunk.trkpt.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"wpt" => {
wpt_chunk.wpt.push(Waypoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if wpt_chunk.is_full() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
_ => (),
},
Ok(Event::End(e)) => match e.name().as_ref() {
"gpx" => {
if !wpt_chunk.wpt.is_empty() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
"metadata" => {
stack.pop();
}
"author" => {
if let Some(GPXElement::Author(author)) = stack.pop() {
gpx.info.author = Some(author);
}
}
"link" => {
if let Some(GPXElement::Link(link)) = stack.pop() {
match stack.last_mut() {
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::Surface) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.surface = categories.surface.code(&e);
}
}
Some(GPXElement::Highway) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.highway = categories.highway.code(&e);
}
}
Some(GPXElement::SacScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.sac_scale = categories.sac_scale.code(&e);
}
}
Some(GPXElement::MtbScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.mtb_scale = categories.mtb_scale.code(&e);
}
}
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(e.to_string());
}
}
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(),
&mut Default::default(),
)
.unwrap()
}
#[test]
fn test_parse_self_closing_points() {
let gpx = parse_data("self_closing_points");
assert_eq!(gpx.info.name, "self closing points");
let wpt: Vec<_> = gpx.wpt.iter().flat_map(|chunk| &chunk.wpt).collect();
assert_eq!(wpt.len(), 3);
assert_eq!(wpt[0].coordinates.lat, 50.0);
assert_eq!(wpt[0].name, None);
assert_eq!(wpt[1].name.as_deref(), Some("with children"));
assert_eq!(wpt[2].coordinates.lng, 4.2);
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 2);
let seg = &trk.trkseg[0];
assert_eq!(seg.len(), 3);
assert_eq!(seg[0].coordinates.lat, 50.0);
assert_eq!(seg[0].ele, 0.0);
assert_eq!(seg[1].ele, 12.5);
assert_eq!(seg[2].coordinates.lng, 4.02);
assert_eq!(trk.trkseg[1].len(), 1);
}
#[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));
}
fn parse_data_with_categories(name: &str) -> (File, TrackpointCategories) {
let mut categories = TrackpointCategories::default();
let file = parse(
&std::fs::read(format!("data/{name}.gpx")).unwrap(),
&mut categories,
)
.unwrap();
(file, categories)
}
#[test]
fn test_parse_trackpoint_surface() {
let (gpx, categories) = parse_data_with_categories("with_surface");
let trkseg = &gpx.trk[0].trkseg[0];
assert_eq!(trkseg.len(), 80);
// the codes follow the order of appearance in the file
assert_eq!(categories.surface.names(), ["asphalt", "cobblestone"]);
let asphalt = Some(0);
let cobblestone = Some(1);
assert_eq!(trkseg.iter().filter(|p| p.surface == asphalt).count(), 79);
assert_eq!(
trkseg.iter().filter(|p| p.surface == cobblestone).count(),
1
);
// no highway in this file
assert!(trkseg.iter().all(|trkpt| trkpt.highway.is_none()));
assert!(categories.highway.names().is_empty());
}
#[test]
fn test_parse_trackpoint_surface_highway_and_scales() {
let (gpx, categories) = parse_data_with_categories("with_highway");
let trkseg = &gpx.trk[0].trkseg[0];
let names = |code: Option<u8>, names: &crate::Categories| {
code.and_then(|code| names.name(code)).map(str::to_owned)
};
let values: Vec<_> = trkseg
.iter()
.map(|trkpt| {
[
names(trkpt.surface, &categories.surface),
names(trkpt.highway, &categories.highway),
names(trkpt.sac_scale, &categories.sac_scale),
names(trkpt.mtb_scale, &categories.mtb_scale),
]
})
.collect();
let some = |s: &str| Some(s.to_owned());
assert_eq!(
values,
[
[some("asphalt"), some("residential"), None, None],
[some("asphalt"), some("residential"), None, None],
[None, None, None, None],
[
some("gravel"),
some("track"),
some("mountain_hiking"),
some("1")
],
[some("gravel"), None, some("hiking"), some("1")],
]
);
// the tables are separate
assert_eq!(categories.sac_scale.names(), ["mountain_hiking", "hiking"]);
assert_eq!(categories.mtb_scale.names(), ["1"]);
}
#[test]
fn test_parse_shares_the_categories_between_files() {
let mut categories = TrackpointCategories::default();
let mut parsed = |name: &str| {
parse(
&std::fs::read(format!("data/{name}.gpx")).unwrap(),
&mut categories,
)
.unwrap()
};
let first = parsed("with_highway");
let second = parsed("with_surface");
let code = |file: &File, i: usize| file.trk[0].trkseg[0][i].surface;
// "asphalt" is the same in both, "cobblestone" comes after "gravel"
assert_eq!(code(&first, 0), Some(0));
assert_eq!(code(&second, 0), Some(0));
assert_eq!(
categories.surface.names(),
["asphalt", "gravel", "cobblestone"]
);
}
#[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
@@ -1,11 +0,0 @@
mod algorithm;
pub(crate) mod gpx;
mod io;
pub(crate) mod statistics;
mod utils;
pub use algorithm::*;
pub use gpx::*;
pub use io::*;
pub use statistics::*;
pub use utils::*;
-5
View File
@@ -1,5 +0,0 @@
mod statistics;
mod utils;
pub use statistics::*;
pub use utils::*;
@@ -1,504 +0,0 @@
use crate::{
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
};
#[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.global.hr.add(cur.hr.map(f64::from));
self.global.cad.add(cur.cad.map(f64::from));
self.global.atemp.add(cur.atemp.map(f64::from));
self.global.power.add(cur.power.map(f64::from));
self.local
.push(TrackpointStatistics::from_partial_stats(self));
}
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(10_000),
|i, j| time_diff(trkseg[j].time, trkseg[i].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,
)
}
}
/// Average of an optional measure (heart rate, cadence...) over the trackpoints that have it.
/// Keeps the sum so that averages can be merged.
#[derive(Default, Debug, Clone, Copy, PartialEq)]
pub struct Average {
pub sum: f64,
pub count: u32,
}
impl Average {
pub fn add(&mut self, value: Option<f64>) {
if let Some(value) = value {
self.sum += value;
self.count += 1;
}
}
pub fn merge(&mut self, other: &Average) {
self.sum += other.sum;
self.count += other.count;
}
pub fn avg(&self) -> Option<f64> {
(self.count > 0).then(|| self.sum / self.count as f64)
}
}
#[derive(Default, Debug)]
pub struct GlobalStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
pub total_time: Option<i64>,
pub moving_time: Option<i64>,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub bounds: LngLatBounds,
pub hr: Average,
pub cad: Average,
pub atemp: Average,
pub power: Average,
}
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);
self.hr.merge(&other.hr);
self.cad.merge(&other.cad);
self.atemp.merge(&other.atemp);
self.power.merge(&other.power);
}
}
#[derive(Default, Debug)]
pub struct TrackpointStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
pub total_time: Option<i64>,
pub moving_time: Option<i64>,
pub speed: Option<f64>,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub slope: f64,
pub slope_segment: SlopeSegment,
}
impl TrackpointStatistics {
fn from_partial_stats(stats: &Statistics) -> Self {
Self {
total_distance: stats.global.total_distance,
moving_distance: stats.global.moving_distance,
total_time: stats.global.total_time,
moving_time: stats.global.moving_time,
// stats below are computed later
speed: None,
elevation_gain: Default::default(),
elevation_loss: Default::default(),
slope: Default::default(),
slope_segment: Default::default(),
}
}
}
#[derive(Default, Debug)]
pub struct SlopeSegment {
pub slope: f64,
pub distance: f64,
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::Read};
use crate::parse;
use super::*;
#[test]
fn test_compute_smoothed_speed() {
let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg);
assert_eq!(stats.local.len(), trkseg.len());
for trkpt_stats in stats.local.iter() {
assert!(trkpt_stats.speed.is_some());
let speed = trkpt_stats.speed.unwrap();
assert_ne!(speed, f64::NAN);
assert!((speed - 20.0).abs() < 0.1);
}
}
fn load(path: &str) -> crate::File {
let mut f = File::open(path).unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
parse(data.as_bytes(), &mut Default::default()).unwrap()
}
#[test]
fn test_compute_empty_segment() {
let stats = Statistics::compute(&TrackSegment::default());
assert!(stats.local.is_empty());
assert_eq!(stats.global.total_distance, 0.0);
}
#[test]
fn test_compute_distance_and_bounds() {
let gpx = load("data/simple.gpx");
let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg);
assert_eq!(stats.local.len(), trkseg.len());
assert_eq!(stats.local[0].total_distance, 0.0);
assert!(stats.global.total_distance > 0.0);
// cumulative distance never decreases and ends at the global distance
assert!(
stats
.local
.windows(2)
.all(|w| w[0].total_distance <= w[1].total_distance)
);
let last = stats.local.last().unwrap();
assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9);
assert!(stats.global.elevation_gain >= 0.0);
assert!(stats.global.elevation_loss >= 0.0);
// every point lies inside the bounds
let b = &stats.global.bounds;
for trkpt in trkseg.iter() {
assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng);
assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat);
}
}
#[test]
fn test_compute_time() {
let gpx = load("data/with_time.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
let total_time = stats.global.total_time.unwrap();
assert!(total_time > 0);
let speed = stats.global.total_speed().unwrap();
assert!((speed - 20.0).abs() < 0.5, "{speed}");
}
#[test]
fn test_no_time_without_timestamps() {
let gpx = load("data/simple.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
assert!(stats.global.total_time.is_none());
assert!(stats.global.total_speed().is_none());
assert!(stats.local.iter().all(|s| s.speed.is_none()));
}
#[test]
fn test_global_merge() {
let mut a = GlobalStatistics {
total_distance: 1.0,
elevation_gain: 10.0,
moving_time: Some(5),
..Default::default()
};
a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 });
let mut b = GlobalStatistics {
total_distance: 2.0,
elevation_loss: 4.0,
moving_distance: Some(1.5),
moving_time: Some(7),
..Default::default()
};
b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 });
a.merge(&b);
assert_eq!(a.total_distance, 3.0);
assert_eq!(a.elevation_gain, 10.0);
assert_eq!(a.elevation_loss, 4.0);
assert_eq!(a.moving_distance, Some(1.5));
assert_eq!(a.moving_time, Some(12));
assert_eq!(a.total_time, None);
let mut c = GlobalStatistics {
start_time: Some(1_000),
end_time: Some(4_000),
total_time: Some(3_000),
..Default::default()
};
let d = GlobalStatistics {
start_time: Some(10_000),
end_time: Some(12_000),
total_time: Some(2_000),
..Default::default()
};
c.merge(&d);
// 3 s + 2 s, the gap between the two is ignored
assert_eq!(c.total_time, Some(5_000));
let mut cumul = GlobalStatistics::default();
cumul.merge(&c);
cumul.merge(&d);
assert_eq!(cumul.start_time, Some(1_000));
assert_eq!(cumul.end_time, Some(12_000));
assert_eq!(cumul.total_time, Some(7_000));
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
}
#[test]
fn test_average() {
let mut average = Average::default();
assert_eq!(average.avg(), None);
average.add(None);
assert_eq!(average.avg(), None);
average.add(Some(100.0));
average.add(Some(110.0));
assert_eq!(average.avg(), Some(105.0));
let mut other = Average::default();
other.add(Some(130.0));
average.merge(&other);
assert_eq!(average.count, 3);
assert_eq!(average.avg(), Some(113.33333333333333));
}
#[test]
fn test_measures_of_the_trackpoints_are_averaged() {
for (path, measure) in [
("data/with_hr.gpx", 0),
("data/with_cad.gpx", 1),
("data/with_temp.gpx", 2),
("data/with_power_1.gpx", 3),
] {
let data = std::fs::read(path).unwrap();
let gpx = parse(&data, &mut Default::default()).unwrap();
let trkseg = &gpx.trk[0].trkseg[0];
let global = Statistics::compute(trkseg).global;
let values: Vec<f64> = trkseg
.iter()
.filter_map(|trkpt| match measure {
0 => trkpt.hr.map(f64::from),
1 => trkpt.cad.map(f64::from),
2 => trkpt.atemp.map(f64::from),
_ => trkpt.power.map(f64::from),
})
.collect();
let average = [global.hr, global.cad, global.atemp, global.power][measure];
assert!(!values.is_empty(), "{path}");
assert_eq!(average.count as usize, values.len(), "{path}");
let expected = values.iter().sum::<f64>() / values.len() as f64;
assert!((average.avg().unwrap() - expected).abs() < 1e-9, "{path}");
}
}
}
@@ -1,44 +0,0 @@
use std::ops::Add;
pub fn combine_options<T>(a: Option<T>, b: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
match (a, b) {
(Some(a), Some(b)) => Some(f(a, b)),
(Some(v), None) | (None, Some(v)) => Some(v),
(None, None) => None,
}
}
pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Add<Output = T>,
{
combine_options(a, b, |a, b| a + b)
}
pub fn min_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Ord,
{
combine_options(a, b, |a, b| a.min(b))
}
pub fn max_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where
T: Ord,
{
combine_options(a, b, |a, b| a.max(b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sum_options() {
assert_eq!(sum_options(Some(1), Some(2)), Some(3));
assert_eq!(sum_options(Some(1), None), Some(1));
assert_eq!(sum_options(None, Some(2)), Some(2));
assert_eq!(sum_options::<i32>(None, None), None);
assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75));
}
}
-203
View File
@@ -1,203 +0,0 @@
use std::f64::consts::PI;
use crate::LngLat;
const TO_RADIANS: f64 = PI / 180.0;
const EARTH_RADIUS: f64 = 6371.0088;
/// Computes the distance in kilometers between two coordinates using the Haversine formula
pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
let lat1 = p1.lat * TO_RADIANS;
let lat2 = p2.lat * TO_RADIANS;
let delta_lat = lat2 - lat1;
let delta_lng = (p2.lng - p1.lng) * TO_RADIANS;
let a =
(delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lng / 2.0).sin().powi(2);
let c = 2.0 * a.min(1.0).sqrt().asin();
EARTH_RADIUS * c
}
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
Some(a? - b?)
}
/// Computes the speed for a given distance in kilometers and a time in milliseconds
pub fn speed(distance: f64, time: i64) -> f64 {
distance / (time as f64 / 3_600_000.0)
}
pub fn slope(ele: f64, distance: f64) -> f64 {
if distance == 0.0 {
100.0
} else {
0.1 * ele / distance
}
}
const METERS_PER_LATITUDE_DEGREE: f64 = 111320.0;
/// Approximate planar projection, in meters, around the latitude of `origin` (ignores the
/// curvature of the earth).
struct Planar {
meters_per_longitude_degree: f64,
}
impl Planar {
fn around(origin: LngLat) -> Self {
Self {
meters_per_longitude_degree: (origin.lat * TO_RADIANS).cos()
* METERS_PER_LATITUDE_DEGREE,
}
}
fn to_meters(&self, p: LngLat) -> (f64, f64) {
(
p.lng * self.meters_per_longitude_degree,
p.lat * METERS_PER_LATITUDE_DEGREE,
)
}
fn to_degrees(&self, (x, y): (f64, f64)) -> LngLat {
LngLat {
lng: x / self.meters_per_longitude_degree,
lat: y / METERS_PER_LATITUDE_DEGREE,
}
}
}
/// Position, between 0 (at `(x1, y1)`) and 1 (at `(x2, y2)`), of the point of the segment that is
/// the closest to `(x3, y3)`. A degenerate segment (same ends) is a point.
fn closest_on_segment(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
let dx = x2 - x1;
let dy = y2 - y1;
let segment_length_squared = dx * dx + dy * dy;
if segment_length_squared == 0.0 {
0.0
} else {
(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared).clamp(0.0, 1.0)
}
}
/// Calculates the point on the line segment defined by p1 and p2 that is closest to the third
/// point, p3. Uses simple planar geometry (ignores earth curvature).
pub fn projected(p1: LngLat, p2: LngLat, p3: LngLat) -> LngLat {
let planar = Planar::around(p1);
let (x1, y1) = planar.to_meters(p1);
let (x2, y2) = planar.to_meters(p2);
let (x3, y3) = planar.to_meters(p3);
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
planar.to_degrees((x1 + t * (x2 - x1), y1 + t * (y2 - y1)))
}
/// Calculates the perpendicular distance in meters between a line segment (defined by p1 and p2)
/// and a third point, p3. Uses simple planar geometry (ignores earth curvature).
pub fn crossarc_lnglat(p1: LngLat, p2: LngLat, p3: LngLat) -> f64 {
let planar = Planar::around(p1);
let (x1, y1) = planar.to_meters(p1);
let (x2, y2) = planar.to_meters(p2);
let (x3, y3) = planar.to_meters(p3);
crossarc(x1, y1, x2, y2, x3, y3)
}
/// Distance from the point `(x3, y3)` to the segment `(x1, y1)`-`(x2, y2)`, in the units of the
/// coordinates (planar geometry).
pub fn crossarc(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
(x3 - (x1 + t * (x2 - x1))).hypot(y3 - (y1 + t * (y2 - y1)))
}
#[cfg(test)]
mod tests {
use super::*;
fn p(lng: f64, lat: f64) -> LngLat {
LngLat { lng, lat }
}
#[test]
fn test_time_diff_and_speed_with_long_durations() {
// 40 days in milliseconds, more than an i32 can hold
let forty_days = 40 * 24 * 3_600_000_i64;
assert!(forty_days > i64::from(i32::MAX));
assert_eq!(time_diff(Some(forty_days), Some(0)), Some(forty_days));
assert_eq!(time_diff(Some(0), Some(forty_days)), Some(-forty_days));
assert_eq!(time_diff(None, Some(1)), None);
assert_eq!(time_diff(Some(1), None), None);
// 1 km in one hour
assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12);
}
#[test]
fn test_distance() {
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
// one degree of latitude
let d = distance(p(0.0, 0.0), p(0.0, 1.0));
assert!((d - 111.195).abs() < 0.01, "{d}");
// symmetric
let a = p(4.40, 50.79);
let b = p(6.13, 45.90);
assert!((distance(a, b) - distance(b, a)).abs() < 1e-9);
// half the Earth's circumference, without NaN from rounding
let antipodal = distance(p(0.0, 0.0), p(180.0, 0.0));
assert!((antipodal - PI * EARTH_RADIUS).abs() < 1e-6);
}
#[test]
fn test_time_diff() {
assert_eq!(time_diff(Some(5000), Some(2000)), Some(3000));
assert_eq!(time_diff(Some(2000), Some(5000)), Some(-3000));
assert_eq!(time_diff(None, Some(1)), None);
assert_eq!(time_diff(Some(1), None), None);
}
#[test]
fn test_speed() {
// 1 km in 1 h
assert!((speed(1.0, 3_600_000) - 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);
}
}
-39
View File
@@ -1,39 +0,0 @@
use crate::{Command, FileId, SelectMode, Selection};
/// Everything the frontend can ask the engine to do.
///
/// Only [`Action::Edit`] changes the files (and records an undo step); the others act on the
/// history, on the selection or on the file order.
#[derive(Debug)]
pub enum Action<'a> {
Edit(Command<'a>),
Undo,
Redo,
/// Selects files, tracks, segments or waypoints (or the waypoints node of one file),
/// ignoring what does not exist: a selection left with nothing is empty, which deselects
/// everything. See [`SelectMode`] for how it combines with the current selection.
Select {
selection: Selection,
mode: SelectMode,
},
/// Selects all the elements of the same kind as the selected ones, in the same place (see
/// [`Selection::all_at_level`]). Does nothing when there is nothing more to select.
SelectAll,
/// Moves the selection to the next (`down`) or previous element of the same kind, like the
/// arrow keys do in the file list (see [`Selection::neighbour`]). With `add`, the element is
/// added to the selection (shift + arrow), otherwise it replaces it.
ArrowSelect {
down: bool,
add: bool,
},
/// Puts the selected elements in the clipboard, to be pasted by [`crate::Paste`]. Does
/// nothing when nothing is selected.
Copy,
/// Like `Copy`, but the elements are moved instead of copied when they are pasted.
Cut,
/// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder {
file_ids: Vec<FileId>,
index: usize,
},
}
-33
View File
@@ -1,33 +0,0 @@
use std::fmt;
use crate::State;
/// The effect of a command on the engine state.
///
/// Each command is a small struct holding its arguments, with its effect implemented in its
/// own file. Effects are built from the reusable patterns in `pattern/`.
pub trait Apply {
fn apply(self, state: &mut State) -> Result<(), CommandError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
/// The command 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 {}
@@ -1,72 +0,0 @@
use crate::{Apply, CommandError, State};
use crate::{
Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint, Elevation, Extract,
Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment,
NewWaypoint, Paste, Reverse, SpliceTrackpoints, Split, Style, Time,
};
/// A user action that edits the files. Each variant wraps the command's own struct, whose
/// effect is implemented in its own file (see `Apply`).
///
/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument
/// buffers, so a command is built without copying; the engine only copies what it keeps.
#[derive(Debug)]
pub enum Command<'a> {
New(New<'a>),
Load(Load<'a>),
LoadFiles(LoadFiles<'a>),
Duplicate(Duplicate),
Delete(Delete),
Paste(Paste),
Move(Move),
DeleteAll(DeleteAll),
DeleteWaypoint(DeleteWaypoint),
Metadata(Metadata<'a>),
Style(Style<'a>),
NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment),
Reverse(Reverse),
SpliceTrackpoints(SpliceTrackpoints<'a>),
NewWaypoint(NewWaypoint<'a>),
EditWaypoint(EditWaypoint<'a>),
MoveWaypoint(MoveWaypoint),
Crop(Crop),
Split(Split),
Time(Time),
Merge(Merge),
Extract(Extract),
Elevation(Elevation<'a>),
Clean(Clean),
}
impl Apply for Command<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match self {
Command::New(c) => c.apply(state),
Command::Load(c) => c.apply(state),
Command::LoadFiles(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state),
Command::Paste(c) => c.apply(state),
Command::Move(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state),
Command::DeleteWaypoint(c) => c.apply(state),
Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state),
Command::SpliceTrackpoints(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state),
Command::EditWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state),
Command::Crop(c) => c.apply(state),
Command::Split(c) => c.apply(state),
Command::Time(c) => c.apply(state),
Command::Merge(c) => c.apply(state),
Command::Extract(c) => c.apply(state),
Command::Elevation(c) => c.apply(state),
Command::Clean(c) => c.apply(state),
}
}
}
@@ -1,146 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
#[derive(Debug)]
pub struct Metadata<'a> {
pub name: &'a str,
pub desc: &'a str,
}
impl Apply for Metadata<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned());
// a single track is named like the file
if let [track] = file.trk.as_mut_slice() {
track.info.name = Some(self.name.to_owned());
}
Edit::Changed
}
fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some(self.name.to_owned());
track.info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_metadata_applies_to_selected_file() {
let mut fx = Fixture::default();
New { name: "first" }.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,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap().clone();
fx.selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([file.trk[0].id]),
};
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&file.id];
assert_eq!(after.info.name, file.info.name);
assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed"));
assert_eq!(after.trk[1].info, file.trk[1].info);
}
/// A new file with `nb_tracks` tracks (without name), selected.
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
New { name: "file" }.apply(&mut fx.state()).unwrap();
for _ in 0..nb_tracks {
NewTrack.apply(&mut fx.state()).unwrap();
}
}
fn rename(fx: &mut Fixture) {
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
}
#[test]
fn test_single_track_is_renamed_like_the_file() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_single_track_with_a_name_is_renamed_too() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
let id = *fx.files.keys().next().unwrap();
std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()).trk[0]
.info
.name = Some("track".to_owned());
rename(&mut fx);
let file = &fx.files[&id];
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_several_tracks_are_not_renamed() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 2);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert!(file.trk.iter().all(|trk| trk.info.name.is_none()));
}
}
@@ -1,9 +0,0 @@
mod metadata;
mod new_track;
mod new_track_segment;
mod style;
pub use metadata::*;
pub use new_track::*;
pub use new_track_segment::*;
pub use style::*;
@@ -1,65 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
#[derive(Debug)]
pub struct NewTrack;
impl Apply for NewTrack {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit {
let mut track = Track::default();
track.info.color = common(file, |t| &t.info.color);
track.info.opacity = common(file, |t| &t.info.opacity);
track.info.width = common(file, |t| &t.info.width);
file.trk.push(track);
Edit::Changed
}
}
/// Returns the value of a style attribute if all existing tracks agree on it.
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> {
let mut tracks = file.trk.iter();
let first = get(tracks.next()?);
tracks.all(|t| get(t) == first).then(|| first.clone())?
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track() {
let mut fx = Fixture::default();
New { name: "file" }.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);
}
}
@@ -1,36 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, TrackSegment, update_selected};
#[derive(Debug)]
pub struct NewTrackSegment;
impl Apply for NewTrackSegment {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrackSegment {
fn track(&mut self, track: &mut Track) -> Edit {
track.trkseg.push(TrackSegment::default());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, NewTrack, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track_segment_on_each_track_of_selected_file() {
let mut fx = Fixture::default();
New { name: "file" }.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrackSegment.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
}
}
@@ -1,71 +0,0 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected};
#[derive(Debug)]
pub struct Style<'a> {
pub color: Option<&'a str>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
impl Apply for Style<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for Style<'_> {
fn track(&mut self, track: &mut Track) -> Edit {
if let Some(color) = self.color {
track.info.color = Some(color.to_owned());
}
if self.opacity.is_some() {
track.info.opacity = self.opacity;
}
if self.width.is_some() {
track.info.width = self.width;
}
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_style.gpx").unwrap();
crate::Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = *fx.files.keys().next().unwrap();
let before = fx.files[&id].clone();
assert!(!before.trk.is_empty());
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Style {
color: Some("ff0000"),
opacity: None,
width: Some(7.0),
}
.apply(&mut fx.state())
.unwrap();
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
assert_eq!(a.info.color.as_deref(), Some("ff0000"));
assert_eq!(a.info.width, Some(7.0));
assert_eq!(a.info.opacity, b.info.opacity);
}
}
}
@@ -1,284 +0,0 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks,
};
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
/// are deleted instead, even if only a track or a waypoint is selected.
#[derive(Debug)]
pub struct Delete {
pub whole_files: bool,
}
impl Apply for Delete {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let files = state.selection.to_files();
let selection = if self.whole_files {
&files
} else {
&*state.selection
};
let next = match selection {
Selection::File { file_ids } => {
state.files.retain(|id, _| !file_ids.contains(id));
state.order.0.retain(|id| !file_ids.contains(id));
Selection::Empty
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let len = file.trk.len();
file.trk.retain(|trk| !trk_ids.contains(&trk.id));
if file.trk.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::File {
file_ids: [*file_id].into(),
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !trkseg_ids.contains(&seg.id));
if trk.trkseg.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
}
Selection::Waypoints { file_id } => delete_waypoints(state.files, *file_id, |_| true)?,
Selection::Waypoint { file_id, wpt_ids } => {
delete_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
pub(crate) fn delete_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = 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,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_delete_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_selected_files() {
let (mut fx, id) = loaded();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let keep = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
#[test]
fn test_delete_selected_tracks() {
let (mut fx, id) = loaded();
let before = fx.files[&id].trk.len();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before - 1);
assert!(file.trk.iter().all(|t| t.id != trk_id));
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_selected_segments() {
let (mut fx, id) = loaded();
let trk = &fx.files[&id].trk[0];
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: HashSet::from([seg_id]),
};
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before - 1);
assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
assert!(
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&trk_id))
);
}
fn with_waypoints(fx: &mut Fixture, id: FileId, n: usize) -> Vec<crate::WaypointId> {
let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect();
let ids = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = 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 { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let left: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
assert_eq!(left, vec![ids[0], ids[2]]);
assert_eq!(fx.selected_files(), HashSet::from([id]));
}
#[test]
fn test_delete_all_waypoints_of_file() {
let (mut fx, id) = loaded();
with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoints { file_id: id };
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert!(fx.files[&id].wpt.is_empty());
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_unknown_ids_is_nothing_to_do() {
let (mut fx, id) = loaded();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([Default::default()]),
};
assert_eq!(
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_whole_files_of_the_selected_elements() {
for selection in [
|id: FileId, fx: &Fixture| Selection::Track {
file_id: id,
trk_ids: HashSet::from([fx.files[&id].trk[0].id]),
},
|id: FileId, fx: &Fixture| Selection::TrackSegment {
file_id: id,
trk_id: fx.files[&id].trk[0].id,
trkseg_ids: HashSet::from([fx.files[&id].trk[0].trkseg[0].id]),
},
|id: FileId, _: &Fixture| Selection::Waypoints { file_id: id },
|id: FileId, _: &Fixture| Selection::File {
file_ids: HashSet::from([id]),
},
] {
let (mut fx, id) = loaded();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let keep = fx.order.0[1];
fx.selection = selection(id, &fx);
Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(!fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
}
#[test]
fn test_delete_whole_files_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete { whole_files: true }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -1,38 +0,0 @@
use crate::{Apply, CommandError, Selection, State};
#[derive(Debug)]
pub struct DeleteAll;
impl Apply for DeleteAll {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
if state.files.is_empty() {
return Err(CommandError::NothingToDo);
}
state.files.clear();
state.order.0.clear();
*state.selection = Selection::Empty;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_delete_all() {
let mut fx = Fixture::default();
assert_eq!(
DeleteAll.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
crate::New { name: "a" }.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));
}
}
@@ -1,121 +0,0 @@
use crate::{Apply, CommandError, FileId, Selection, State, WaypointId, delete_waypoints};
/// Deletes a waypoint of a file, whatever is selected (what the delete button of its popup does).
/// If it was the only selected element, its file gets selected.
#[derive(Debug)]
pub struct DeleteWaypoint {
pub file_id: FileId,
pub waypoint_id: WaypointId,
}
impl Apply for DeleteWaypoint {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
delete_waypoints(state.files, self.file_id, |wpt| wpt.id == self.waypoint_id)?;
if let Selection::Waypoint { file_id, wpt_ids } = &*state.selection
&& *file_id == self.file_id
&& wpt_ids.iter().all(|id| *id == self.waypoint_id)
{
*state.selection = Selection::File {
file_ids: [self.file_id].into(),
};
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
#[test]
fn test_deletes_one_waypoint_without_touching_the_selection() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk {
wpt: (0..3)
.map(|i| Waypoint {
name: Some(format!("w{i}")),
..Default::default()
})
.collect(),
..Default::default()
}));
let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
fx.selection = Selection::File {
file_ids: [id].into(),
};
DeleteWaypoint {
file_id: id,
waypoint_id: ids[1],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.name.clone().unwrap())
.collect();
assert_eq!(names, ["w0", "w2"]);
assert_eq!(
fx.selection,
Selection::File {
file_ids: [id].into()
}
);
}
#[test]
fn test_deleting_the_selected_waypoint_selects_its_file() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}));
let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: [ids[0]].into(),
};
DeleteWaypoint {
file_id: id,
waypoint_id: ids[0],
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(
fx.selection,
Selection::File {
file_ids: [id].into()
}
);
}
#[test]
fn test_unknown_waypoint_is_nothing_to_do() {
let mut fx = Fixture::default();
let file = File::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
let result = DeleteWaypoint {
file_id: id,
waypoint_id: WaypointId::default(),
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
}
}
@@ -1,311 +0,0 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
copy_track, copy_waypoint, insert_waypoints,
};
#[derive(Debug)]
pub struct Duplicate;
impl Apply for Duplicate {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection {
Selection::File { file_ids } => {
let mut copies = HashSet::new();
let mut order = Vec::new();
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let Some(file) = state.files.get(id) else {
continue;
};
let copy = copy_file(file);
order.push(copy.id);
copies.insert(copy.id);
state.files.insert(copy.id, Rc::new(copy));
}
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
let at = state
.order
.0
.iter()
.rposition(|id| file_ids.contains(id))
.map_or(state.order.0.len(), |i| i + 1);
state.order.0.splice(at..at, order);
Selection::File { file_ids: copies }
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let mut copies = HashSet::new();
append_copies(
&mut file.trk,
|trk| trk_ids.contains(&trk.id),
|trk| {
let copy = copy_track(trk);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::Track {
file_id: *file_id,
trk_ids: copies,
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let mut copies = HashSet::new();
append_copies(
&mut trk.trkseg,
|seg| trkseg_ids.contains(&seg.id),
|seg| {
let copy = copy_segment(seg);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: copies,
}
}
Selection::Waypoints { file_id } => {
duplicate_waypoints(state.files, *file_id, |_| true)?
}
Selection::Waypoint { file_id, wpt_ids } => {
duplicate_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
}
Selection::Empty => return Err(CommandError::NothingToDo),
};
*state.selection = next;
Ok(())
}
}
fn duplicate_waypoints(
files: &mut StackEntry,
file_id: FileId,
filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<&Waypoint> = file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| filter(wpt))
.collect();
let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
// The copies go right after the last selected waypoint.
let mut file = (**file).clone();
insert_waypoints(&mut file, Some(last), copies);
files.insert(file_id, Rc::new(file));
Ok(Selection::Waypoint {
file_id,
wpt_ids: copy_ids,
})
}
/// Inserts a copy of each item matching `filter`, as a block after the last matching item.
fn append_copies<T>(items: &mut Vec<T>, filter: impl Fn(&T) -> bool, copy: impl FnMut(&T) -> T) {
let Some(last) = items.iter().rposition(&filter) else {
return;
};
let copies: Vec<T> = items.iter().filter(|item| filter(item)).map(copy).collect();
items.splice(last + 1..last + 1, copies);
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use crate::{FileId, Load};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_duplicate_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Duplicate.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_duplicate_file() {
let (mut fx, id) = loaded();
Duplicate.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files.len(), 2);
let copy_id = fx.order.0[1];
assert_eq!(fx.selected_files(), [copy_id].into());
let (orig, copy) = (&fx.files[&id], &fx.files[&copy_id]);
assert_ne!(copy_id, id);
assert_eq!(copy.info.name, orig.info.name);
assert_eq!(copy.trk.len(), orig.trk.len());
for (a, b) in orig.trk.iter().zip(&copy.trk) {
assert_ne!(a.id, b.id);
assert_eq!(a.info, b.info);
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_ne!(a.id, b.id);
assert_eq!(a.len(), b.len());
}
}
}
#[test]
fn test_duplicate_files_go_after_last_selected() {
let mut fx = Fixture::default();
for name in ["a", "b", "c"] {
crate::New { name }.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());
}
}
@@ -1,220 +0,0 @@
use crate::{Apply, CommandError, File, State, TrackpointCategories, parse, produce};
#[derive(Debug)]
pub struct Load<'a> {
pub data: &'a [u8],
/// Name of the file when the data has none (typically the name of the file on disk, without
/// its extension).
pub name: &'a str,
}
impl Load<'_> {
fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
let mut file = parse(self.data, categories)
.map_err(|err| CommandError::InvalidData(err.to_string()))?;
if file.info.name.trim().is_empty() {
file.info.name = self.name.to_owned();
}
Ok(file)
}
}
impl Apply for Load<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
LoadFiles { files: vec![self] }.apply(state)
}
}
/// Loads several files at once, as a single command (one undo step): the files are added at the
/// end of the list, in the given order, and the first one is selected.
///
/// The files that cannot be read are skipped, unless none of them can: then nothing is loaded
/// and the error is the one of the first file.
#[derive(Debug)]
pub struct LoadFiles<'a> {
pub files: Vec<Load<'a>>,
}
impl Apply for LoadFiles<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let mut files = Vec::with_capacity(self.files.len());
let mut first_error = None;
for load in self.files {
match load.parse(state.categories) {
Ok(file) => files.push(file),
Err(error) => {
first_error.get_or_insert(error);
}
}
}
if files.is_empty() {
return Err(first_error.unwrap_or(CommandError::NothingToDo));
}
produce(state, |_| files);
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_load() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap();
assert!(
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.is_ok()
);
assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap();
assert!(!file.trk.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
}
#[test]
fn test_load_invalid_file_changes_nothing() {
let mut fx = Fixture::default();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let selected = fx.selected_files();
let result = Load {
data: b"<gpx><trk></gpx>",
name: "file",
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected);
}
fn load_with_name(data: &[u8]) -> String {
let mut fx = Fixture::default();
Load {
data,
name: "from disk",
}
.apply(&mut fx.state())
.unwrap();
fx.files.values().next().unwrap().info.name.clone()
}
#[test]
fn test_load_keeps_the_name_of_the_file() {
let data = br#"<gpx version="1.1"><metadata><name>in the file</name></metadata></gpx>"#;
assert_eq!(load_with_name(data), "in the file");
}
#[test]
fn test_load_without_name_uses_the_given_name() {
let without = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
assert_eq!(load_with_name(without), "from disk");
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
assert_eq!(load_with_name(empty), "from disk");
}
fn gpx(name: &str) -> Vec<u8> {
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
}
fn load<'a>(data: &'a [u8], name: &'a str) -> Load<'a> {
Load { data, name }
}
#[test]
fn test_load_several_files() {
let mut fx = Fixture::default();
crate::New { name: "before" }
.apply(&mut fx.state())
.unwrap();
let before = fx.order.0[0];
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(&b, "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
// added after the others, in the order they were given
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["before", "a", "b", "c"]);
assert_eq!(fx.order.0[0], before);
// the first one is selected
assert_eq!(fx.selected_files(), [fx.order.0[1]].into());
}
#[test]
fn test_load_several_files_skips_the_invalid_ones() {
let mut fx = Fixture::default();
let (a, c) = (gpx("a"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(b"<gpx><trk></gpx>", "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["a", "c"]);
}
#[test]
fn test_load_several_invalid_files_changes_nothing() {
let mut fx = Fixture::default();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let selected = fx.selected_files();
let result = LoadFiles {
files: vec![
load(b"<gpx><trk></gpx>", "x"),
load(b"<gpx><wpt></gpx>", "y"),
],
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected);
// nothing to load
assert_eq!(
LoadFiles { files: vec![] }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_each_file_without_name_gets_its_own_name() {
let mut fx = Fixture::default();
let unnamed = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
LoadFiles {
files: vec![load(unnamed, "first"), load(unnamed, "second")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["first", "second"]);
}
}
@@ -1,19 +0,0 @@
mod delete;
mod delete_all;
mod delete_waypoint;
mod duplicate;
mod load;
mod move_elements;
mod new;
mod paste;
mod transfer;
pub use delete::*;
pub use delete_all::*;
pub use delete_waypoint::*;
pub use duplicate::*;
pub use load::*;
pub use move_elements::*;
pub use new::*;
pub use paste::*;
pub use transfer::*;
@@ -1,447 +0,0 @@
use crate::{
Apply, Clipboard, CommandError, Destination, FileId, Place, Selection, State, TrackId, transfer,
};
/// Where moved elements go: a list of the file tree, and the position in it. The position is
/// counted among the elements of the list that are not moved, like the position of a dragged
/// element in the list it is dropped in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MoveTarget {
/// The list of files.
Files { index: usize },
/// The tracks of a file.
Tracks { file_id: FileId, index: usize },
/// The segments of a track.
Segments {
file_id: FileId,
trk_id: TrackId,
index: usize,
},
/// The waypoints of a file.
Waypoints { file_id: FileId, index: usize },
}
/// Moves elements to a place of the file tree (what drag and drop does), with their ids. The
/// moved elements are selected.
///
/// What can go where:
/// - files: among the files;
/// - tracks: among the files (each track becomes a file), or among the tracks of a file;
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each
/// segment becomes a track), or among the segments of a track;
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
/// file.
#[derive(Debug)]
pub struct Move {
pub what: Selection,
pub to: MoveTarget,
}
impl From<MoveTarget> for Destination {
fn from(target: MoveTarget) -> Self {
match target {
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
MoveTarget::Tracks { file_id, index } => {
Destination::Tracks(file_id, Place::Index(index))
}
MoveTarget::Segments {
file_id,
trk_id,
index,
} => Destination::Segments(file_id, trk_id, Place::Index(index)),
MoveTarget::Waypoints { file_id, index } => {
Destination::Waypoints(file_id, Place::Index(index))
}
}
}
}
impl Apply for Move {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let clipboard = Clipboard::new(&self.what, state.files, &state.order.0, true)
.ok_or(CommandError::NothingToDo)?;
*state.selection = transfer(state, &clipboard.content, true, self.to.into())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId,
engine::command::fixture::Fixture, new_file, waypoint_ids,
};
use super::*;
/// A file with the given tracks (name, number of segments) and number of waypoints.
fn file(name: &str, tracks: &[(&str, usize)], waypoints: usize) -> File {
let mut file = new_file(
name,
tracks
.iter()
.map(|(name, segments)| Track {
info: TrackInfo {
name: Some((*name).to_owned()),
..Default::default()
},
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
..Default::default()
})
.collect(),
);
if waypoints > 0 {
file.wpt = vec![Rc::new(WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default()
})];
}
file
}
fn add(fx: &mut Fixture, file: File) -> FileId {
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
id
}
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
}
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
}
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
waypoint_ids(&fx.files[&id]).collect()
}
fn do_move(fx: &mut Fixture, what: Selection, to: MoveTarget) -> Result<(), CommandError> {
Move { what, to }.apply(&mut fx.state())
}
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
}
}
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids.iter().copied().collect(),
}
}
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
Selection::Waypoint {
file_id,
wpt_ids: ids.iter().copied().collect(),
}
}
fn files(ids: &[FileId]) -> Selection {
Selection::File {
file_ids: ids.iter().copied().collect(),
}
}
#[test]
fn test_reorder_tracks_in_a_file() {
let build = || {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[("0", 1), ("1", 1), ("2", 1), ("3", 1)], 0),
);
let t = track_ids(&fx, a);
(fx, a, t)
};
let at = |a, index| MoveTarget::Tracks { file_id: a, index };
// the position counts the tracks that stay
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[0]]), at(a, 1)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[0], t[2], t[3]]);
assert_eq!(fx.selection, tracks(a, &[t[0]]));
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[3]]), at(a, 0)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[3], t[0], t[1], t[2]]);
// several at once, as a block, in their order; past the end is the end
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[1], t[0]]), at(a, 99)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[2], t[3], t[0], t[1]]);
assert_eq!(fx.selection, tracks(a, &[t[0], t[1]]));
}
#[test]
fn test_move_tracks_to_another_file_or_to_new_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t0", 1), ("t1", 1)], 0));
let b = add(&mut fx, file("b", &[("b0", 1), ("b1", 1)], 0));
let c = add(&mut fx, file("c", &[], 0));
let t = track_ids(&fx, a);
let b_tracks = track_ids(&fx, b);
do_move(
&mut fx,
tracks(a, &[t[0]]),
MoveTarget::Tracks {
file_id: b,
index: 1,
},
)
.unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![b_tracks[0], t[0], b_tracks[1]]);
assert_eq!(fx.selection, tracks(b, &[t[0]]));
// as a new file, at a position of the list of files
do_move(&mut fx, tracks(a, &[t[1]]), MoveTarget::Files { index: 1 }).unwrap();
assert!(track_ids(&fx, a).is_empty());
assert_eq!(fx.order.0.len(), 4);
let new = fx.order.0[1];
assert_eq!(fx.order.0[0], a);
assert_eq!(fx.order.0[2..], [b, c]);
assert_eq!(track_ids(&fx, new), vec![t[1]]);
assert_eq!(fx.files[&new].info.name, "t1");
assert_eq!(fx.selection, files(&[new]));
}
#[test]
fn test_move_segments() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t0", 3), ("t1", 1)], 0));
let t = track_ids(&fx, a);
(fx, a, t)
};
// within a track
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[2]]),
MoveTarget::Segments {
file_id: a,
trk_id: t[0],
index: 0,
},
)
.unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[2], s[0], s[1]]);
assert_eq!(fx.selection, segments(a, t[0], &[s[2]]));
// to another track
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
let other = segment_ids(&fx, a, 1);
do_move(
&mut fx,
segments(a, t[0], &[s[0], s[1]]),
MoveTarget::Segments {
file_id: a,
trk_id: t[1],
index: 0,
},
)
.unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
assert_eq!(segment_ids(&fx, a, 1), vec![s[0], s[1], other[0]]);
// as new tracks of a file
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[1]]),
MoveTarget::Tracks {
file_id: a,
index: 1,
},
)
.unwrap();
assert_eq!(fx.files[&a].trk.len(), 3);
assert_eq!(segment_ids(&fx, a, 1), vec![s[1]]);
assert_eq!(segment_ids(&fx, a, 0), vec![s[0], s[2]]);
assert_eq!(fx.files[&a].trk[0].id, t[0]);
assert_eq!(fx.files[&a].trk[2].id, t[1]);
// as new files
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[0], s[1]]),
MoveTarget::Files { index: 0 },
)
.unwrap();
assert_eq!(fx.order.0.len(), 3);
assert_eq!(fx.order.0[2], a);
assert_eq!(fx.files[&fx.order.0[0]].info.name, "t0");
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
}
#[test]
fn test_move_waypoints() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 4));
let b = add(&mut fx, file("b", &[], 2));
(fx, a, b)
};
let at = |file_id, index| MoveTarget::Waypoints { file_id, index };
// within a file
let (mut fx, a, _) = build();
let w = wpt_ids(&fx, a);
do_move(&mut fx, waypoints(a, &[w[3]]), at(a, 1)).unwrap();
assert_eq!(wpt_ids(&fx, a), vec![w[0], w[3], w[1], w[2]]);
assert_eq!(fx.selection, waypoints(a, &[w[3]]));
// to the start of another file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
do_move(&mut fx, waypoints(a, &[w[0], w[1]]), at(b, 0)).unwrap();
assert_eq!(wpt_ids(&fx, a), w[2..].to_vec());
assert_eq!(wpt_ids(&fx, b), [&w[..2], &before[..]].concat());
// all the waypoints of a file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
do_move(&mut fx, Selection::Waypoints { file_id: a }, at(b, 1)).unwrap();
assert!(wpt_ids(&fx, a).is_empty());
assert_eq!(
wpt_ids(&fx, b),
[&before[..1], &w[..], &before[1..]].concat()
);
}
#[test]
fn test_move_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
do_move(&mut fx, files(&[c]), MoveTarget::Files { index: 0 }).unwrap();
assert_eq!(fx.order.0, vec![c, a, b]);
do_move(&mut fx, files(&[c, a]), MoveTarget::Files { index: 1 }).unwrap();
assert_eq!(fx.order.0, vec![b, c, a]);
assert_eq!(fx.selection, files(&[c, a]));
}
#[test]
fn test_what_cannot_be_moved() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t", 1)], 1));
let t = track_ids(&fx, a);
let s = segment_ids(&fx, a, 0);
let w = wpt_ids(&fx, a);
let before = fx.files[&a].clone();
let selection = fx.selection.clone();
let invalid = [
// the wrong kind of list
(
files(&[a]),
MoveTarget::Tracks {
file_id: a,
index: 0,
},
),
(
tracks(a, &t),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(
tracks(a, &t),
MoveTarget::Segments {
file_id: a,
trk_id: t[0],
index: 0,
},
),
(
segments(a, t[0], &s),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(
waypoints(a, &w),
MoveTarget::Tracks {
file_id: a,
index: 0,
},
),
(waypoints(a, &w), MoveTarget::Files { index: 0 }),
(
Selection::Waypoints { file_id: a },
MoveTarget::Files { index: 0 },
),
(Selection::Empty, MoveTarget::Files { index: 0 }),
// elements that do not exist
(
tracks(a, &[TrackId::default()]),
MoveTarget::Files { index: 0 },
),
(
waypoints(a, &[WaypointId::default()]),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(files(&[FileId::default()]), MoveTarget::Files { index: 0 }),
// a place that does not exist
(
tracks(a, &t),
MoveTarget::Tracks {
file_id: FileId::default(),
index: 0,
},
),
(
segments(a, t[0], &s),
MoveTarget::Segments {
file_id: a,
trk_id: TrackId::default(),
index: 0,
},
),
(
waypoints(a, &w),
MoveTarget::Waypoints {
file_id: FileId::default(),
index: 0,
},
),
];
for (what, to) in invalid {
assert_eq!(
do_move(&mut fx, what.clone(), to),
Err(CommandError::NothingToDo),
"{what:?} to {to:?}"
);
// nothing changed
assert!(Rc::ptr_eq(&fx.files[&a], &before));
assert_eq!(fx.order.0, vec![a]);
assert_eq!(fx.selection, selection);
}
}
}
@@ -1,36 +0,0 @@
use crate::{Apply, CommandError, File, State, produce};
#[derive(Debug)]
pub struct New<'a> {
pub name: &'a str,
}
impl Apply for New<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
produce(state, |_| {
let mut file = File::default();
file.info.name = self.name.to_owned();
vec![file]
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use super::*;
#[test]
fn test_new() {
let mut fx = Fixture::default();
assert!(New { name: "new" }.apply(&mut fx.state()).is_ok());
assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "new");
assert!(file.trk.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
assert_eq!(fx.order.0, vec![file.id]);
}
}
@@ -1,790 +0,0 @@
use std::{collections::HashSet, hash::Hash};
use crate::{
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId,
TrackSegmentId, WaypointId, transfer, waypoint_ids,
};
/// Pastes what was copied or cut (see [`crate::Clipboard`]).
///
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
/// - nothing selected: the files are copied, and tracks and segments become new files (named
/// like their track, or like the file they come from);
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are
/// added to its own;
/// - a track: the segments are added to it, tracks go after it in its file;
/// - a segment: the segments go after it in its track;
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
///
/// Copies are made, with new ids, and the pasted elements are selected. What was cut is moved
/// instead, with its ids, the first time: what is still where it was is removed.
///
/// What is pasted is always the elements as they were when they were copied or cut, even if they
/// were changed or deleted since. The clipboard is kept so that it can be pasted again: after
/// what was cut was moved it is not cut anymore, and the next pastes make copies.
#[derive(Debug)]
pub struct Paste;
impl Apply for Paste {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let clipboard = state
.clipboard
.clone()
.filter(|clipboard| clipboard.can_paste(state.selection))
.ok_or(CommandError::NothingToDo)?;
let cut = clipboard.cut;
// always the elements as they were when they were copied or cut
let to = destination(state, &clipboard.content)?;
let next = transfer(state, &clipboard.content, cut, to)?;
if cut && let Some(clipboard) = state.clipboard.as_mut() {
// moved: the next pastes make copies
clipboard.cut = false;
}
*state.selection = next;
Ok(())
}
}
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
state
.files
.get(&file_id)
.map(|file| file.trk.iter().map(|trk| trk.id).collect())
.unwrap_or_default()
}
fn segment_ids_of(state: &State, file_id: FileId, trk_id: TrackId) -> Vec<TrackSegmentId> {
state
.files
.get(&file_id)
.and_then(|file| file.trk.iter().find(|trk| trk.id == trk_id))
.map(|trk| trk.trkseg.iter().map(|seg| seg.id).collect())
.unwrap_or_default()
}
fn waypoint_ids_of(state: &State, file_id: FileId) -> Vec<WaypointId> {
state
.files
.get(&file_id)
.map(|file| waypoint_ids(file).collect())
.unwrap_or_default()
}
/// Where the content goes, depending on what is selected.
fn destination(state: &State, content: &ClipboardContent) -> Result<Destination, CommandError> {
let nothing = CommandError::NothingToDo;
let last_file = |file_ids: &HashSet<FileId>| {
last_selected(state.order.0.iter().copied(), file_ids).ok_or(CommandError::NothingToDo)
};
Ok(match (content, &*state.selection) {
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => {
Destination::Files(Place::End)
}
(
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
Selection::File { file_ids },
) => Destination::Tracks(last_file(file_ids)?, Place::End),
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => {
let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Tracks(*file_id, Place::After(last))
}
(ClipboardContent::Segments(_), Selection::Track { file_id, trk_ids }) => {
let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Segments(*file_id, last, Place::End)
}
(
ClipboardContent::Segments(_),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
) => {
let last = last_selected(
segment_ids_of(state, *file_id, *trk_id).into_iter(),
trkseg_ids,
)
.ok_or(nothing)?;
Destination::Segments(*file_id, *trk_id, Place::After(last))
}
(ClipboardContent::Waypoints(_), Selection::File { file_ids }) => {
Destination::Waypoints(last_file(file_ids)?, Place::End)
}
(ClipboardContent::Waypoints(_), Selection::Waypoints { file_id }) => {
Destination::Waypoints(*file_id, Place::End)
}
(ClipboardContent::Waypoints(_), Selection::Waypoint { file_id, wpt_ids }) => {
let last = last_selected(waypoint_ids_of(state, *file_id).into_iter(), wpt_ids)
.ok_or(nothing)?;
Destination::Waypoints(*file_id, Place::After(last))
}
_ => return Err(nothing),
})
}
/// The last of the `selected` ids in the order of `all`: the element a paste goes after.
fn last_selected<T: Copy + Eq + Hash>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<T> {
all.filter(|id| selected.contains(id)).last()
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment,
TrackSegmentId, Waypoint, WaypointId, engine::command::fixture::Fixture, new_file,
};
use super::*;
/// A file with the given tracks (name, number of segments) and number of waypoints.
fn file(name: &str, tracks: &[(Option<&str>, usize)], waypoints: usize) -> File {
let mut file = new_file(
name,
tracks
.iter()
.map(|(name, segments)| Track {
info: TrackInfo {
name: name.map(str::to_owned),
..Default::default()
},
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
..Default::default()
})
.collect(),
);
if waypoints > 0 {
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default()
})];
}
file
}
fn add(fx: &mut Fixture, file: File) -> FileId {
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
id
}
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
}
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
}
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
waypoint_ids(&fx.files[&id]).collect()
}
fn names(fx: &Fixture) -> Vec<String> {
fx.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect()
}
fn copy(fx: &mut Fixture, selection: Selection, cut: bool) {
fx.clipboard = Clipboard::new(&selection, &fx.files, &fx.order.0, cut);
}
/// The ids of what is in the clipboard, and whether it is cut.
fn clipboard_state(fx: &Fixture) -> Option<(ClipboardIds, bool)> {
fx.clipboard.as_ref().map(|c| (c.content.ids(), c.cut))
}
fn paste(fx: &mut Fixture) -> Result<(), CommandError> {
Paste.apply(&mut fx.state())
}
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
}
}
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids.iter().copied().collect(),
}
}
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
Selection::Waypoint {
file_id,
wpt_ids: ids.iter().copied().collect(),
}
}
fn files(ids: &[FileId]) -> Selection {
Selection::File {
file_ids: ids.iter().copied().collect(),
}
}
#[test]
fn test_paste_copied_tracks_into_a_file() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("t0"), 1), (Some("t1"), 2), (None, 1)], 0),
);
let b = add(&mut fx, file("b", &[(Some("b0"), 1)], 0));
let source = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[source[0], source[1]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// the source is untouched, copies with new ids are added at the end
assert_eq!(track_ids(&fx, a), source);
let in_b = track_ids(&fx, b);
assert_eq!(in_b.len(), 3);
assert!(!source.contains(&in_b[1]) && !source.contains(&in_b[2]));
let names: Vec<_> = fx.files[&b]
.trk
.iter()
.map(|t| t.info.name.clone())
.collect();
assert_eq!(
names,
[Some("b0".into()), Some("t0".into()), Some("t1".into())]
);
// the segments are copies too
assert_eq!(fx.files[&b].trk[2].trkseg.len(), 2);
assert_ne!(segment_ids(&fx, b, 2), segment_ids(&fx, a, 1));
// the pasted tracks are selected, and the clipboard is still there
assert_eq!(fx.selection, tracks(b, &in_b[1..]));
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![source[0], source[1]]), false))
);
}
#[test]
fn test_paste_cut_tracks_moves_them() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let source = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[source[0]]), true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![source[1]]);
assert_eq!(track_ids(&fx, b), vec![source[0]]);
assert_eq!(fx.selection, tracks(b, &[source[0]]));
}
#[test]
fn test_paste_tracks_after_the_selected_track() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file(
"a",
&[
(Some("0"), 1),
(Some("1"), 1),
(Some("2"), 1),
(Some("3"), 1),
],
0,
),
);
let t = track_ids(&fx, a);
// copy: the block goes after the selected track
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
fx.selection = tracks(a, &[t[2]]);
paste(&mut fx).unwrap();
let names: Vec<_> = fx.files[&a]
.trk
.iter()
.map(|t| t.info.name.clone().unwrap())
.collect();
assert_eq!(names, ["0", "1", "2", "0", "1", "3"]);
// cut: moved after the selected track
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = tracks(a, &[t[2]]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
// the selected track is cut itself: the tracks go at the end
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = tracks(a, &[t[0]]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
}
#[test]
fn test_paste_tracks_as_new_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("source", &[(Some("named"), 1), (None, 1)], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
// named like the track, or like the file it comes from
assert_eq!(names(&fx), ["source", "named", "source"]);
assert_eq!(fx.files.len(), 3);
let new = &fx.order.0[1..];
assert!(
matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2 && new.iter().all(|id| file_ids.contains(id)))
);
assert_eq!(fx.files[&new[0]].trk.len(), 1);
// the source keeps its tracks
assert_eq!(track_ids(&fx, a), t);
}
#[test]
fn test_paste_cut_tracks_as_new_files() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("source", &[(Some("one"), 1), (Some("two"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1]]);
let new = fx.order.0[1];
assert_eq!(track_ids(&fx, new), vec![t[0]]);
}
#[test]
fn test_paste_segments() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 3), (Some("t1"), 1)], 0));
(fx, a)
};
// at the end of the selected track
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0]]), false);
fx.selection = tracks(a, &[t[1]]);
paste(&mut fx).unwrap();
assert_eq!(segment_ids(&fx, a, 0), s);
let in_t1 = segment_ids(&fx, a, 1);
assert_eq!(in_t1.len(), 2);
assert_ne!(in_t1[1], s[0]);
assert_eq!(fx.selection, segments(a, t[1], &in_t1[1..]));
// after the selected segment, moved
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0]]), true);
fx.selection = segments(a, t[0], &[s[1]]);
paste(&mut fx).unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[1], s[0], s[2]]);
// each segment becomes a track of the selected file
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
fx.selection = files(&[a]);
paste(&mut fx).unwrap();
let tracks_now = track_ids(&fx, a);
assert_eq!(tracks_now.len(), 4);
assert!(
fx.files[&a].trk[2..]
.iter()
.all(|trk| trk.trkseg.len() == 1)
);
assert_eq!(fx.selection, tracks(a, &tracks_now[2..]));
// each segment becomes a file, named like its track
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(names(&fx), ["a", "t0", "t0"]);
assert!(
fx.order.0[1..]
.iter()
.all(|id| fx.files[id].trk[0].trkseg.len() == 1)
);
}
#[test]
fn test_paste_waypoints() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 4));
let b = add(&mut fx, file("b", &[], 2));
(fx, a, b)
};
// to the end of another file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
copy(&mut fx, waypoints(a, &[w[1], w[2]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w);
let now = wpt_ids(&fx, b);
assert_eq!(now.len(), 4);
assert_eq!(&now[..2], &before[..]);
assert!(!w.contains(&now[2]));
assert_eq!(fx.selection, waypoints(b, &now[2..]));
// after the selected waypoint
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
copy(&mut fx, waypoints(a, &[w[0]]), true);
fx.selection = waypoints(b, &[before[0]]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w[1..].to_vec());
assert_eq!(wpt_ids(&fx, b), vec![before[0], w[0], before[1]]);
// within the file, after a waypoint
let (mut fx, a, _) = build();
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &[w[0], w[1]]), true);
fx.selection = waypoints(a, &[w[2]]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), vec![w[2], w[0], w[1], w[3]]);
}
#[test]
fn test_paste_all_the_waypoints_of_a_file() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 3));
let b = add(&mut fx, file("b", &[], 1));
let w = wpt_ids(&fx, a);
copy(&mut fx, Selection::Waypoints { file_id: a }, false);
fx.selection = Selection::Waypoints { file_id: b };
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w);
assert_eq!(wpt_ids(&fx, b).len(), 4);
// moved: the source has none left
copy(&mut fx, Selection::Waypoints { file_id: a }, true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert!(wpt_ids(&fx, a).is_empty());
assert_eq!(&wpt_ids(&fx, b)[4..], &w[..]);
}
#[test]
fn test_paste_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
copy(&mut fx, files(&[b, a]), false);
fx.selection = files(&[c]);
paste(&mut fx).unwrap();
// copies, in the order of the files, at the end
assert_eq!(names(&fx), ["a", "b", "c", "a", "b"]);
assert_eq!(fx.files.len(), 5);
assert_eq!(fx.files[&fx.order.0[3]].trk.len(), 1);
assert_ne!(track_ids(&fx, fx.order.0[3]), track_ids(&fx, a));
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2));
// cut: moved to the end, they keep their ids
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
copy(&mut fx, files(&[a]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(fx.order.0, vec![b, c, a]);
assert_eq!(fx.selection, files(&[a]));
}
#[test]
fn test_nothing_to_paste() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
let t = track_ids(&fx, a);
let w = wpt_ids(&fx, a);
fx.selection = files(&[a]);
// an empty clipboard
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
// a target that does not accept it (tracks cannot go to waypoints), the clipboard stays
copy(&mut fx, tracks(a, &t), false);
fx.selection = waypoints(a, &w);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
assert!(fx.clipboard.is_some());
assert_eq!(fx.selection, waypoints(a, &w));
// nothing to copy: what does not exist is not put in the clipboard
copy(&mut fx, tracks(a, &[TrackId::default()]), false);
fx.selection = files(&[a]);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
copy(&mut fx, waypoints(a, &[WaypointId::default()]), false);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
copy(&mut fx, files(&[FileId::default()]), false);
fx.selection = Selection::Empty;
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0, vec![a]);
}
#[test]
fn test_paste_a_copy_several_times() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 1));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
// each paste made a copy with new ids, and the source is untouched
let in_b = track_ids(&fx, b);
assert_eq!(in_b.len(), 3);
assert_eq!(in_b.iter().collect::<HashSet<_>>().len(), 3);
assert!(in_b.iter().all(|id| !t.contains(id)));
assert_eq!(track_ids(&fx, a), t);
assert!(fx.clipboard.is_some());
// the selection is the last copy: the next paste goes after it
assert_eq!(fx.selection, tracks(b, &[in_b[2]]));
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, b).len(), 4);
// the same with other elements
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &w), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, b).len(), 2);
assert_eq!(wpt_ids(&fx, a), w);
copy(&mut fx, files(&[b]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 4);
}
#[test]
fn test_paste_a_cut_moves_the_first_time_then_copies() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// moved with its id, and what is in the clipboard now is a copy of it
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![t[0]]), false))
);
fx.selection = files(&[c]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
// copies: the moved track stays where it was moved to
assert_eq!(track_ids(&fx, b), vec![t[0]]);
let in_c = track_ids(&fx, c);
assert_eq!(in_c.len(), 2);
assert!(!in_c.contains(&t[0]));
assert_eq!(track_ids(&fx, a), vec![t[1]]);
}
#[test]
fn test_paste_cut_files_several_times() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
copy(&mut fx, files(&[a]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(fx.order.0, vec![b, a]);
// the next pastes copy it
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 3);
assert_eq!(fx.order.0[..2], [b, a]);
}
/// Renames the first track of the file, as an edit would.
fn rename_track(fx: &mut Fixture, id: FileId, name: &str) {
let file = std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap());
file.trk[0].info.name = Some(name.to_owned());
}
#[test]
fn test_paste_the_elements_as_they_were_when_copied() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("original"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), false);
rename_track(&mut fx, a, "edited");
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
let pasted = &fx.files[&b].trk[0];
assert_eq!(pasted.info.name.as_deref(), Some("original"));
// the source keeps what was done to it
assert_eq!(fx.files[&a].trk[0].info.name.as_deref(), Some("edited"));
// and each time
paste(&mut fx).unwrap();
assert!(
fx.files[&b]
.trk
.iter()
.all(|trk| trk.info.name.as_deref() == Some("original"))
);
assert_eq!(fx.files[&b].trk.len(), 2);
}
#[test]
fn test_paste_a_cut_as_it_was_when_cut() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("original"), 1), (Some("other"), 1)], 0),
);
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
rename_track(&mut fx, a, "edited");
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// what is in the clipboard is moved, the edited version is not what is pasted
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(fx.files[&b].trk[0].info.name.as_deref(), Some("original"));
}
#[test]
fn test_paste_after_the_elements_were_deleted() {
let delete_file = |fx: &mut Fixture, id: FileId| {
fx.files.remove(&id);
fx.order.0.retain(|other| *other != id);
};
// copied tracks, waypoints, segments and files are still there to be pasted
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
let s = segment_ids(&fx, a, 0);
let w = wpt_ids(&fx, a);
copy(&mut fx, tracks(a, &t), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(fx.files[&b].trk.len(), 1);
assert_eq!(fx.files[&b].trk[0].trkseg.len(), 2);
// the copy of a track comes from a file that is gone: named like the track
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(names(&fx), ["b", "t"]);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let (t, s, w) = (track_ids(&fx, a), segment_ids(&fx, a, 0), wpt_ids(&fx, a));
copy(&mut fx, segments(a, t[0], &s), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(fx.files[&b].trk.len(), 2);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &w), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, b).len(), 2);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 0));
copy(&mut fx, files(&[a]), false);
fx.selection = Selection::Empty;
delete_file(&mut fx, a);
assert!(fx.files.is_empty());
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 2);
assert_eq!(names(&fx), ["a", "a"]);
let _ = (s, w);
}
#[test]
fn test_paste_a_cut_after_the_elements_were_deleted() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
// deleted before it is pasted
std::rc::Rc::make_mut(fx.files.get_mut(&a).unwrap())
.trk
.retain(|trk| trk.id != t[0]);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// nothing to remove, the track is where it was pasted, with its id
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![t[0]]), false))
);
}
}
@@ -1,362 +0,0 @@
//! Copying or moving elements to a place of the file tree: what paste and move are made of.
//! The elements come as the content of a clipboard, and a destination says where they go among
//! the elements already there.
use std::{collections::HashSet, rc::Rc};
use crate::{
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection,
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file,
copy_segment, copy_track, copy_waypoint, edit_waypoint_chunks, insert_waypoints_at,
};
/// Where elements are put, among the others of the same list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Place<T> {
/// After all of them.
End,
/// Right after the one with this id, or at the end if it is not there (anymore).
After(T),
/// At this position (at the end if it is past it).
Index(usize),
}
impl<T: Copy + PartialEq> Place<T> {
/// The position in the list of `ids`.
pub fn position(&self, ids: &[T]) -> usize {
match self {
Place::End => ids.len(),
Place::After(id) => ids
.iter()
.position(|other| other == id)
.map_or(ids.len(), |i| i + 1),
Place::Index(index) => (*index).min(ids.len()),
}
}
}
/// Where elements go. What can go where:
/// - files: among the files;
/// - tracks: among the files (each track becomes a file, named like the track, or like the file
/// it comes from), or among the tracks of a file;
/// - segments: among the files (each segment becomes a file with a track, named like its track,
/// or like its file), among the tracks of a file (each segment becomes a track), or among the
/// segments of a track;
/// - waypoints: among the waypoints of a file.
#[derive(Debug, Clone, Copy)]
pub enum Destination {
Files(Place<FileId>),
Tracks(FileId, Place<TrackId>),
Segments(FileId, TrackId, Place<TrackSegmentId>),
Waypoints(FileId, Place<WaypointId>),
}
pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
file.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
}
fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> {
state
.files
.get_mut(&id)
.map(Rc::make_mut)
.ok_or(CommandError::NothingToDo)
}
pub fn new_file(name: &str, trk: Vec<Track>) -> File {
let mut file = File {
trk,
..Default::default()
};
file.info.name = name.to_owned();
file
}
/// Adds the files to the state, at `place` in the order of the files.
fn add_files(state: &mut State, files: Vec<Rc<File>>, place: Place<FileId>) -> Selection {
let at = place.position(&state.order.0);
let ids: Vec<FileId> = files.iter().map(|file| file.id).collect();
for file in files {
state.files.insert(file.id, file);
}
state.order.0.splice(at..at, ids.iter().copied());
Selection::File {
file_ids: ids.into_iter().collect(),
}
}
/// Removes the elements of the content from the files, wherever they are.
pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
match content {
ClipboardContent::Files(files) => {
let ids: HashSet<FileId> = files.iter().map(|file| file.id).collect();
state.files.retain(|id, _| !ids.contains(id));
state.order.0.retain(|id| !ids.contains(id));
}
ClipboardContent::Tracks(tracks) => {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
for file in state.files.values_mut() {
if file.trk.iter().any(|trk| ids.contains(&trk.id)) {
Rc::make_mut(file).trk.retain(|trk| !ids.contains(&trk.id));
}
}
}
ClipboardContent::Segments(segments) => {
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.segment.id).collect();
for file in state.files.values_mut() {
let contains = |trk: &Track| trk.trkseg.iter().any(|seg| ids.contains(&seg.id));
if file.trk.iter().any(contains) {
for trk in Rc::make_mut(file).trk.iter_mut() {
trk.trkseg.retain(|seg| !ids.contains(&seg.id));
}
}
}
}
ClipboardContent::Waypoints(waypoints) => {
let ids: HashSet<WaypointId> = waypoints.iter().map(|wpt| wpt.id).collect();
for file in state.files.values_mut() {
if waypoint_ids(file).any(|id| ids.contains(&id)) {
edit_waypoint_chunks(
Rc::make_mut(file),
|wpt| ids.contains(&wpt.id),
|wpts| {
wpts.retain(|wpt| !ids.contains(&wpt.id));
true
},
);
}
}
}
}
}
/// Puts the elements of the content at the destination: copies of them, or the elements
/// themselves, with their ids, when `cut` (they are removed from where they are first). Returns
/// the selection of what was put.
pub fn transfer(
state: &mut State,
content: &ClipboardContent,
cut: bool,
to: Destination,
) -> Result<Selection, CommandError> {
// the destination has to be there, before anything is changed
let exists = match to {
Destination::Files(_) => true,
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id),
Destination::Segments(file_id, trk_id, _) => state
.files
.get(&file_id)
.is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)),
Destination::Waypoints(file_id, _) => state.files.contains_key(&file_id),
};
let valid = match (content, to) {
(ClipboardContent::Files(_), Destination::Files(_))
| (ClipboardContent::Tracks(_), Destination::Files(_) | Destination::Tracks(..))
| (
ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
)
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) => true,
_ => false,
};
let empty = match content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
};
if !exists || !valid || empty {
return Err(CommandError::NothingToDo);
}
if cut {
remove_elements(state, content);
}
let copy = !cut;
match (content, to) {
(ClipboardContent::Files(files), Destination::Files(place)) => {
let files = files
.iter()
.map(|file| {
if copy {
Rc::new(copy_file(file))
} else {
file.clone()
}
})
.collect();
Ok(add_files(state, files, place))
}
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
transfer_waypoints(state, waypoints, copy, file_id, place)
}
_ => Err(CommandError::NothingToDo),
}
}
fn transfer_tracks(
state: &mut State,
tracks: &[ClipboardTrack],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let tracks: Vec<(Track, &str)> = tracks
.iter()
.map(|clip| {
let track = if copy {
copy_track(&clip.track)
} else {
clip.track.clone()
};
(track, clip.file_name.as_str())
})
.collect();
match to {
Destination::Files(place) => {
let files = tracks
.into_iter()
.map(|(track, file_name)| {
let name = track
.info
.name
.clone()
.unwrap_or_else(|| file_name.to_owned());
Rc::new(new_file(&name, vec![track]))
})
.collect();
Ok(add_files(state, files, place))
}
Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect();
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
}
_ => Err(CommandError::NothingToDo),
}
}
fn transfer_segments(
state: &mut State,
segments: &[ClipboardSegment],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let segments: Vec<(TrackSegment, &str)> = segments
.iter()
.map(|clip| {
let segment = if copy {
copy_segment(&clip.segment)
} else {
clip.segment.clone()
};
(segment, clip.name.as_str())
})
.collect();
// a segment on its own needs a track
let own_track = |segment: TrackSegment| Track {
trkseg: vec![segment],
..Default::default()
};
match to {
Destination::Files(place) => {
let files = segments
.into_iter()
.map(|(segment, name)| Rc::new(new_file(name, vec![own_track(segment)])))
.collect();
Ok(add_files(state, files, place))
}
Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = segments
.into_iter()
.map(|(segment, _)| own_track(segment))
.collect();
let ids = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
}
Destination::Segments(file_id, trk_id, place) => {
let segments: Vec<TrackSegment> =
segments.into_iter().map(|(segment, _)| segment).collect();
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.id).collect();
let trk = file_mut(state, file_id)?
.trk
.iter_mut()
.find(|trk| trk.id == trk_id)
.ok_or(CommandError::NothingToDo)?;
let existing: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
let at = place.position(&existing);
trk.trkseg.splice(at..at, segments);
Ok(Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids,
})
}
Destination::Waypoints(..) => Err(CommandError::NothingToDo),
}
}
fn transfer_waypoints(
state: &mut State,
waypoints: &[Waypoint],
copy: bool,
file_id: FileId,
place: Place<WaypointId>,
) -> Result<Selection, CommandError> {
let waypoints: Vec<Waypoint> = waypoints
.iter()
.map(|wpt| {
if copy {
copy_waypoint(wpt)
} else {
wpt.clone()
}
})
.collect();
let ids = waypoints.iter().map(|wpt| wpt.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<WaypointId> = waypoint_ids(file).collect();
insert_waypoints_at(file, place.position(&existing), waypoints);
Ok(Selection::Waypoint {
file_id,
wpt_ids: ids,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_place_position() {
let ids = [1, 2, 3];
assert_eq!(Place::End.position(&ids), 3);
assert_eq!(Place::After(1).position(&ids), 1);
assert_eq!(Place::After(3).position(&ids), 3);
// not there: at the end
assert_eq!(Place::After(9).position(&ids), 3);
assert_eq!(Place::Index(0).position(&ids), 0);
assert_eq!(Place::Index(2).position(&ids), 2);
assert_eq!(Place::Index(10).position(&ids), 3);
assert_eq!(Place::<i32>::End.position(&[]), 0);
assert_eq!(Place::<i32>::Index(4).position(&[]), 0);
}
}
@@ -1,34 +0,0 @@
//! Shared helpers for the tests of the commands.
use std::collections::HashSet;
use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
#[derive(Default)]
pub struct Fixture {
pub files: StackEntry,
pub selection: Selection,
pub order: FileOrder,
pub clipboard: Option<Clipboard>,
pub categories: TrackpointCategories,
}
impl Fixture {
pub fn state(&mut self) -> State<'_> {
State {
categories: &mut self.categories,
files: &mut self.files,
selection: &mut self.selection,
order: &mut self.order,
clipboard: &mut self.clipboard,
}
}
/// Ids of the selected files, empty unless the selection is a file selection.
pub fn selected_files(&self) -> HashSet<FileId> {
match &self.selection {
Selection::File { file_ids } => file_ids.clone(),
_ => HashSet::new(),
}
}
}
-15
View File
@@ -1,15 +0,0 @@
mod apply;
mod command;
mod edit;
mod file;
#[cfg(test)]
pub(crate) mod fixture;
mod pattern;
mod tools;
pub use apply::*;
pub use command::*;
pub use edit::*;
pub use file::*;
pub use pattern::*;
pub use tools::*;
@@ -1,35 +0,0 @@
use crate::{File, Track, TrackSegment, Waypoint};
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
/// A copy of the segment, with a new id.
pub fn copy_segment(segment: &TrackSegment) -> TrackSegment {
let mut copy = segment.clone();
copy.id = Default::default();
copy
}
/// A copy of the track and of its segments, with new ids.
pub fn copy_track(track: &Track) -> Track {
Track {
id: Default::default(),
trkseg: track.trkseg.iter().map(copy_segment).collect(),
..track.clone()
}
}
/// A copy of the file and of its tracks and segments, with new ids.
pub fn copy_file(file: &File) -> File {
File {
id: Default::default(),
trk: file.trk.iter().map(copy_track).collect(),
..file.clone()
}
}
/// A copy of the waypoint, with a new id.
pub fn copy_waypoint(waypoint: &Waypoint) -> Waypoint {
let mut copy = waypoint.clone();
copy.id = Default::default();
copy
}
@@ -1,40 +0,0 @@
use std::rc::Rc;
use crate::{File, Waypoint, WaypointChunk};
/// Edits the waypoints of the chunks containing a waypoint accepted by `filter`.
///
/// `f` receives a copy of the chunk's waypoints and returns whether it changed them. A
/// changed chunk is replaced by a new one (with a new id, so that derived data is recomputed)
/// and chunks left empty are dropped. Returns whether anything changed.
pub fn edit_waypoint_chunks(
file: &mut File,
filter: impl Fn(&Waypoint) -> bool,
mut f: impl FnMut(&mut Vec<Waypoint>) -> bool,
) -> bool {
let mut changed = false;
let mut chunks = Vec::with_capacity(file.wpt.len());
for chunk in file.wpt.drain(..) {
if !chunk.wpt.iter().any(&filter) {
chunks.push(chunk);
continue;
}
let mut wpt = chunk.wpt.clone();
if !f(&mut wpt) {
chunks.push(chunk);
continue;
}
changed = true;
if !wpt.is_empty() {
chunks.push(Rc::new(WaypointChunk {
wpt,
..Default::default()
}));
}
}
file.wpt = chunks;
if changed {
file.wpt_rev_id = Default::default();
}
changed
}
@@ -1,218 +0,0 @@
use std::rc::Rc;
use crate::{File, Waypoint, WaypointChunk, WaypointId};
/// Inserts waypoints in the file, right after the waypoint `after`, or at the end if there is
/// none (or if it is not in the file).
///
/// See [`insert_waypoints_at`].
pub fn insert_waypoints(file: &mut File, after: Option<WaypointId>, waypoints: Vec<Waypoint>) {
let index = after
.and_then(|after| {
file.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.position(|wpt| wpt.id == after)
})
.map_or(usize::MAX, |i| i + 1);
insert_waypoints_at(file, index, waypoints);
}
/// Inserts waypoints in the file, so that the first one is at `index` among the waypoints of
/// the file (at the end if the index is past it).
///
/// Only the chunk holding the waypoint that was at `index` is cut (when the waypoints are not
/// inserted between two chunks), all the other chunks are kept as they are.
pub fn insert_waypoints_at(file: &mut File, index: usize, waypoints: Vec<Waypoint>) {
if waypoints.is_empty() {
return;
}
let mut inserted = Vec::new();
let mut chunk = WaypointChunk::default();
for wpt in waypoints {
chunk.wpt.push(wpt);
if chunk.is_full() {
inserted.push(Rc::new(std::mem::take(&mut chunk)));
}
}
if !chunk.wpt.is_empty() {
inserted.push(Rc::new(chunk));
}
// the chunk holding the waypoint at `index`, and the position of that waypoint in it
let mut start = 0;
let position = file.wpt.iter().enumerate().find_map(|(i, chunk)| {
let offset = index
.checked_sub(start)
.filter(|offset| *offset < chunk.wpt.len());
start += chunk.wpt.len();
offset.map(|offset| (i, offset))
});
match position {
None => file.wpt.extend(inserted),
Some((i, 0)) => {
file.wpt.splice(i..i, inserted);
}
Some((i, offset)) => {
let cut = &file.wpt[i];
let part = |wpt: &[Waypoint]| {
Rc::new(WaypointChunk {
wpt: wpt.to_vec(),
..Default::default()
})
};
let mut chunks = vec![part(&cut.wpt[..offset])];
chunks.extend(inserted);
chunks.push(part(&cut.wpt[offset..]));
file.wpt.splice(i..=i, chunks);
}
}
file.wpt_rev_id = Default::default();
}
#[cfg(test)]
mod tests {
use super::*;
fn waypoint() -> Waypoint {
Waypoint::default()
}
fn file_with(chunks: &[usize]) -> (File, Vec<WaypointId>) {
let mut file = File::default();
let mut ids = vec![];
for n in chunks {
let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect();
ids.extend(wpt.iter().map(|w| w.id));
file.wpt.push(Rc::new(WaypointChunk {
wpt,
..Default::default()
}));
}
(file, ids)
}
fn ids(file: &File) -> Vec<WaypointId> {
file.wpt
.iter()
.flat_map(|chunk| chunk.wpt.iter().map(|w| w.id))
.collect()
}
#[test]
fn test_insert_at_the_end() {
let (mut file, before) = file_with(&[2, 1]);
let rev = file.wpt_rev_id;
let new = vec![waypoint(), waypoint()];
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
insert_waypoints(&mut file, None, new);
assert_eq!(ids(&file), [before.clone(), new_ids].concat());
// the chunks that were there are kept
assert_eq!(file.wpt.len(), 3);
assert_ne!(file.wpt_rev_id, rev);
// an unknown waypoint is an insertion at the end too
let (mut file, before) = file_with(&[2]);
let one = waypoint();
let one_id = one.id;
insert_waypoints(&mut file, Some(WaypointId::default()), vec![one]);
assert_eq!(ids(&file), [before, vec![one_id]].concat());
}
#[test]
fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_chunks() {
let (mut file, before) = file_with(&[2, 2]);
let first = file.wpt[0].clone();
let one = waypoint();
let one_id = one.id;
insert_waypoints(&mut file, Some(before[1]), vec![one]);
assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat());
// 2 chunks + the inserted one, nothing was cut
assert_eq!(file.wpt.len(), 3);
assert!(Rc::ptr_eq(&file.wpt[0], &first));
}
#[test]
fn test_insert_in_the_middle_of_a_chunk_cuts_it() {
let (mut file, before) = file_with(&[3, 1]);
let last = file.wpt[1].clone();
let new = vec![waypoint(), waypoint()];
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
insert_waypoints(&mut file, Some(before[0]), new);
assert_eq!(
ids(&file),
[&before[..1], &new_ids[..], &before[1..]].concat()
);
// the cut chunk, the inserted one and the end of the cut one, then the untouched chunk
assert_eq!(file.wpt.len(), 4);
assert!(Rc::ptr_eq(&file.wpt[3], &last));
}
#[test]
fn test_insert_nothing() {
let (mut file, before) = file_with(&[2]);
let rev = file.wpt_rev_id;
insert_waypoints(&mut file, Some(before[0]), vec![]);
assert_eq!(ids(&file), before);
assert_eq!(file.wpt_rev_id, rev);
}
#[test]
fn test_insert_many_fills_chunks() {
let mut file = File::default();
let new: Vec<_> = (0..300).map(|_| waypoint()).collect();
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
insert_waypoints(&mut file, None, new);
assert_eq!(ids(&file), new_ids);
assert_eq!(file.wpt.len(), 3);
assert!(file.wpt[..2].iter().all(|chunk| chunk.is_full()));
}
#[test]
fn test_insert_at_an_index() {
// at the start
let (mut file, before) = file_with(&[2, 1]);
let first = file.wpt[0].clone();
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 0, vec![one]);
assert_eq!(ids(&file), [vec![one_id], before.clone()].concat());
// the existing chunks are kept
assert_eq!(file.wpt.len(), 3);
assert!(Rc::ptr_eq(&file.wpt[1], &first));
// between two chunks
let (mut file, before) = file_with(&[2, 1]);
let (first, last) = (file.wpt[0].clone(), file.wpt[1].clone());
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 2, vec![one]);
assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat());
assert_eq!(file.wpt.len(), 3);
assert!(Rc::ptr_eq(&file.wpt[0], &first) && Rc::ptr_eq(&file.wpt[2], &last));
// inside a chunk
let (mut file, before) = file_with(&[3]);
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 1, vec![one]);
assert_eq!(ids(&file), [&before[..1], &[one_id], &before[1..]].concat());
assert_eq!(file.wpt.len(), 3);
// at the end, or past it
for index in [3, 100] {
let (mut file, before) = file_with(&[3]);
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, index, vec![one]);
assert_eq!(ids(&file), [before, vec![one_id]].concat());
}
// in a file without waypoints
let mut file = File::default();
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 0, vec![one]);
assert_eq!(ids(&file), vec![one_id]);
}
}
@@ -1,13 +0,0 @@
mod copy;
mod edit_waypoint_chunks;
mod insert_waypoints;
mod produce;
mod update_selected;
mod update_waypoint;
pub use copy::*;
pub use edit_waypoint_chunks::*;
pub use insert_waypoints::*;
pub use produce::*;
pub use update_selected::*;
pub use update_waypoint::*;
@@ -1,81 +0,0 @@
use std::rc::Rc;
use crate::{File, Selection, State};
/// Pure producer: adds the files returned by `f` to the state and selects the first one.
pub fn produce<F>(state: &mut State, f: F)
where
F: FnOnce(&State) -> Vec<File>,
{
*state.selection = Selection::Empty;
for file in f(state) {
let file_id = file.id;
state.files.insert(file_id, Rc::new(file));
state.order.0.push(file_id);
if matches!(*state.selection, Selection::Empty) {
*state.selection = Selection::File {
file_ids: [file_id].into(),
};
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::engine::command::fixture::Fixture;
use super::*;
fn named(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_string();
file
}
#[test]
fn test_produce_adds_orders_and_selects_first() {
let mut fx = Fixture::default();
let (a, b) = (named("a"), named("b"));
let (id_a, id_b) = (a.id, b.id);
produce(&mut fx.state(), |_| vec![a, b]);
assert_eq!(fx.files.len(), 2);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_a]));
}
#[test]
fn test_produce_replaces_selection_and_keeps_order() {
let mut fx = Fixture::default();
let a = named("a");
let id_a = a.id;
produce(&mut fx.state(), |_| vec![a]);
let b = named("b");
let id_b = b.id;
produce(&mut fx.state(), |_| vec![b]);
assert_eq!(fx.order.0, vec![id_a, id_b]);
assert_eq!(fx.selected_files(), HashSet::from([id_b]));
}
#[test]
fn test_produce_nothing_clears_selection() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |_| vec![]);
assert!(matches!(fx.selection, Selection::Empty));
assert_eq!(fx.files.len(), 1);
}
#[test]
fn test_produce_sees_current_state() {
let mut fx = Fixture::default();
produce(&mut fx.state(), |_| vec![named("a")]);
produce(&mut fx.state(), |state| {
assert_eq!(state.files.len(), 1);
vec![]
});
}
}
@@ -1,340 +0,0 @@
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, &mut Default::default()).unwrap());
let id = file.id;
fx.files.insert(id, file);
(fx, id)
}
#[test]
fn test_only_selected_files_are_updated() {
let mut fx = Fixture::default();
let (a, b, c) = (File::default(), File::default(), File::default());
let ids = [a.id, b.id, c.id];
for file in [a, b, c] {
fx.files.insert(file.id, Rc::new(file));
}
let untouched = fx.files[&ids[2]].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([ids[0], ids[1]]),
};
update_selected(&mut fx.state(), &mut Rename);
assert_eq!(fx.files[&ids[0]].info.name, "!");
assert_eq!(fx.files[&ids[1]].info.name, "!");
assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched));
}
#[test]
fn test_unchanged_file_keeps_its_snapshot() {
let (mut fx, id) = fixture_with_tracks();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
let before = fx.files[&id].clone();
update_selected(&mut fx.state(), &mut Nothing);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_track_selection_uses_track_hook() {
let (mut fx, id) = fixture_with_tracks();
let trk_id = fx.files[&id].trk[0].id;
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
update_selected(&mut fx.state(), &mut Rename);
let file = &fx.files[&id];
assert_eq!(file.info.name, "with_tracks_and_segments");
assert_eq!(file.trk[0].info.name.as_deref(), Some("track"));
assert_ne!(file.trk[1].info.name.as_deref(), Some("track"));
}
#[test]
fn test_segment_selection_bumps_revision_id() {
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
let trk = &before.trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
};
update_selected(&mut fx.state(), &mut Rename);
let after = &fx.files[&id];
assert_ne!(after.trk[0].trkseg[0].rev_id, trk.trkseg[0].rev_id);
assert_eq!(after.trk[0].trkseg[1].rev_id, trk.trkseg[1].rev_id);
assert_eq!(
after.trk[1].trkseg[0].rev_id,
before.trk[1].trkseg[0].rev_id
);
}
#[test]
fn test_file_selection_cascades_to_segments() {
struct SegmentsOnly;
impl Editor for SegmentsOnly {
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let before = fx.files[&id].clone();
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
update_selected(&mut fx.state(), &mut SegmentsOnly);
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
for (b, a) in b.trkseg.iter().zip(a.trkseg.iter()) {
assert_ne!(b.rev_id, a.rev_id);
}
}
}
#[test]
fn test_waypoint_selection_edits_only_selected_waypoints() {
struct Name;
impl Editor for Name {
fn waypoint(&mut self, wpt: &mut Waypoint) -> Edit {
wpt.name = Some("x".into());
Edit::Changed
}
}
let (mut fx, id) = fixture_with_tracks();
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
let mut file = (*fx.files[&id]).clone();
file.wpt = 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));
}
}
}
@@ -1,30 +0,0 @@
use std::rc::Rc;
use crate::{CommandError, FileId, State, Waypoint, WaypointId, edit_waypoint_chunks};
/// Changes one waypoint of a file with `f`, whatever is selected. Nothing to do if the file or
/// the waypoint does not exist.
pub fn update_waypoint(
state: &mut State,
file_id: FileId,
waypoint_id: WaypointId,
f: impl Fn(&mut Waypoint),
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = edit_waypoint_chunks(
&mut file,
|wpt| wpt.id == waypoint_id,
|wpts| {
wpts.iter_mut()
.filter(|wpt| wpt.id == waypoint_id)
.for_each(&f);
true
},
);
if !changed {
return Err(CommandError::NothingToDo);
}
state.files.insert(file_id, Rc::new(file));
Ok(())
}
@@ -1,407 +0,0 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, edit_waypoint_chunks,
update_selected,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleanType {
Inside,
Outside,
}
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment
/// only trackpoints, selected waypoints only waypoints.
#[derive(Debug)]
pub struct Clean {
pub bounds: LngLatBounds,
pub type_: CleanType,
pub trkpt: bool,
pub wpt: bool,
}
impl Clean {
fn contains(&self, c: LngLat) -> bool {
(self.bounds.sw.lng..=self.bounds.ne.lng).contains(&c.lng)
&& (self.bounds.sw.lat..=self.bounds.ne.lat).contains(&c.lat)
}
fn removes(&self, c: LngLat) -> bool {
self.contains(c) == (self.type_ == CleanType::Inside)
}
}
impl Apply for Clean {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match &*state.selection {
Selection::Waypoint { file_id, .. } | Selection::Waypoints { file_id } => {
let file_id = *file_id;
if !self.wpt {
return Err(CommandError::NothingToDo);
}
let wpt_ids = match &*state.selection {
Selection::Waypoint { wpt_ids, .. } => Some(wpt_ids.clone()),
_ => None,
};
let selected =
|wpt: &Waypoint| wpt_ids.as_ref().is_none_or(|ids| ids.contains(&wpt.id));
*state.selection = delete_waypoints(state.files, file_id, |wpt| {
selected(wpt) && self.removes(wpt.coordinates)
})?;
Ok(())
}
Selection::Empty => Err(CommandError::NothingToDo),
_ => {
let mut cleaner = Cleaner {
clean: &self,
changed: false,
emptied: HashSet::new(),
wpt_files: HashSet::new(),
};
update_selected(state, &mut cleaner);
if cleaner.changed {
prune(state, &cleaner.emptied, &cleaner.wpt_files);
Ok(())
} else {
Err(CommandError::NothingToDo)
}
}
}
}
}
struct Cleaner<'a> {
clean: &'a Clean,
changed: bool,
/// Segments left without trackpoints by the clean.
emptied: HashSet<TrackSegmentId>,
/// Files that lost waypoints.
wpt_files: HashSet<FileId>,
}
impl Editor for Cleaner<'_> {
fn file(&mut self, file: &mut File) -> Edit {
let mut edit = Edit::Unchanged;
for trk in &mut file.trk {
if self.track(trk) == Edit::Changed {
edit = Edit::Changed;
}
}
if self.clean.wpt
&& edit_waypoint_chunks(
file,
|wpt| self.clean.removes(wpt.coordinates),
|wpts| {
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
true
},
)
{
edit = Edit::Changed;
self.wpt_files.insert(file.id);
}
self.changed |= edit == Edit::Changed;
edit
}
fn segment(&mut self, segment: &mut TrackSegment) -> Edit {
if !self.clean.trkpt || !segment.iter().any(|pt| self.clean.removes(pt.coordinates)) {
return Edit::Unchanged;
}
let kept: Vec<Trackpoint> = segment
.iter()
.filter(|pt| !self.clean.removes(pt.coordinates))
.cloned()
.collect();
if kept.is_empty() {
self.emptied.insert(segment.id);
}
segment.splice(0, segment.len(), kept);
self.changed = true;
Edit::Changed
}
}
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their
/// segments and the files that lost something and have nothing left. The selection falls back
/// to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state
.files
.iter()
.filter(|(id, file)| {
wpt_files.contains(id)
|| file
.trk
.iter()
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
})
.map(|(id, _)| *id)
.collect();
for id in ids {
let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
file.trk.retain_mut(|trk| {
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty()
});
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file));
}
}
*state.selection = fixed_selection(state);
}
fn fixed_selection(state: &State) -> Selection {
let files = &*state.files;
match &*state.selection {
Selection::File { file_ids } => {
let file_ids: HashSet<_> = file_ids
.iter()
.filter(|id| files.contains_key(id))
.copied()
.collect();
if file_ids.is_empty() {
Selection::Empty
} else {
Selection::File { file_ids }
}
}
Selection::Track { file_id, trk_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let trk_ids: HashSet<_> = file
.trk
.iter()
.map(|trk| trk.id)
.filter(|id| trk_ids.contains(id))
.collect();
if trk_ids.is_empty() {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let Some(trk) = file.trk.iter().find(|trk| trk.id == *trk_id) else {
return Selection::File {
file_ids: [*file_id].into(),
};
};
let trkseg_ids: HashSet<_> = trk
.trkseg
.iter()
.map(|seg| seg.id)
.filter(|id| trkseg_ids.contains(id))
.collect();
if trkseg_ids.is_empty() {
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
} else {
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids,
}
}
}
Selection::Waypoints { file_id } | Selection::Waypoint { file_id, .. } => {
if files.contains_key(file_id) {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Empty
}
}
Selection::Empty => Selection::Empty,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn bounds(w: f64, s: f64, e: f64, n: f64) -> LngLatBounds {
LngLatBounds {
sw: LngLat { lng: w, lat: s },
ne: LngLat { lng: e, lat: n },
}
}
fn clean(bounds: LngLatBounds, type_: CleanType) -> Clean {
Clean {
bounds,
type_,
trkpt: true,
wpt: true,
}
}
fn count(fx: &Fixture, id: FileId) -> usize {
fx.files
.get(&id)
.into_iter()
.flat_map(|file| &file.trk)
.flat_map(|trk| &trk.trkseg)
.map(|seg| seg.len())
.sum()
}
#[test]
fn test_inside_everything_removes_all_trackpoints() {
let (mut fx, id) = loaded();
assert!(count(&fx, id) > 0);
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(count(&fx, id), 0);
// nothing is left of the file
assert!(!fx.files.contains_key(&id));
assert!(fx.order.0.is_empty());
assert_eq!(fx.selection, Selection::Empty);
}
#[test]
fn test_emptied_segments_and_tracks_are_removed() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let seg = &before.trk[0].trkseg[0];
let pts: Vec<_> = seg.iter().map(|p| p.coordinates).collect();
let mut b = LngLatBounds::default();
pts.iter().for_each(|c| b.extend(*c));
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id];
assert!(after.trk.iter().all(|t| !t.trkseg.is_empty()));
assert!(
after
.trk
.iter()
.flat_map(|t| &t.trkseg)
.all(|s| !s.is_empty())
);
}
#[test]
fn test_outside_everything_changes_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let result =
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
assert_eq!(fx.files[&id], before);
}
#[test]
fn test_partial_clean_only_removes_points_inside_and_bumps_revision() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let first = before.trk[0].trkseg[0].iter().next().unwrap().coordinates;
let total = count(&fx, id);
// tiny rectangle around the first point
let b = bounds(
first.lng - 1e-9,
first.lat - 1e-9,
first.lng + 1e-9,
first.lat + 1e-9,
);
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let removed = total - count(&fx, id);
assert!(removed >= 1);
let seg = &fx.files[&id].trk[0].trkseg[0];
assert_ne!(seg.rev_id, before.trk[0].trkseg[0].rev_id);
assert!(seg.iter().all(|pt| !b_contains(&b, pt.coordinates)));
}
fn b_contains(b: &LngLatBounds, c: LngLat) -> bool {
c.lng >= b.sw.lng && c.lng <= b.ne.lng && c.lat >= b.sw.lat && c.lat <= b.ne.lat
}
#[test]
fn test_waypoints_flag_and_selection() {
let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![std::rc::Rc::new(crate::WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
})];
let ids: Vec<_> = file.wpt[0].wpt.iter().map(|w| w.id).collect();
fx.files.insert(id, std::rc::Rc::new(file));
let everywhere = bounds(-180.0, -90.0, 180.0, 90.0);
let wpt_count = |fx: &Fixture| {
fx.files
.get(&id)
.map_or(0, |f| f.wpt.iter().map(|c| c.wpt.len()).sum::<usize>())
};
// trackpoints only: waypoints are kept
Clean {
wpt: false,
..clean(everywhere, CleanType::Inside)
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 2);
// one selected waypoint
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[0]]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 1);
// whole file
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 0);
}
}
@@ -1,14 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Crop {
pub start: u32,
pub end: u32,
}
impl Apply for Crop {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("crop"))
}
}
@@ -1,106 +0,0 @@
use crate::{Apply, CommandError, FileId, State, WaypointId, set_waypoint_fields, update_waypoint};
/// Changes a waypoint of a file, whatever is selected (what the form of the waypoint tool does):
/// its name, description, icon, link, position and elevation. The strings that are empty remove
/// the field.
#[derive(Debug)]
pub struct EditWaypoint<'a> {
pub file_id: FileId,
pub waypoint_id: WaypointId,
pub lng: f64,
pub lat: f64,
pub ele: f64,
pub name: &'a str,
pub desc: &'a str,
pub icon: &'a str,
pub link: &'a str,
}
impl Apply for EditWaypoint<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
set_waypoint_fields(
wpt,
(self.lng, self.lat, self.ele),
self.name,
self.desc,
self.icon,
self.link,
)
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Link, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
#[test]
fn test_edit_the_given_waypoint() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![
Waypoint {
name: Some("old".into()),
desc: Some("old description".into()),
cmt: Some("old comment".into()),
sym: Some("Flag".into()),
link: Some(Link {
href: "https://old".into(),
text: None,
}),
..Default::default()
},
Waypoint {
name: Some("other".into()),
..Default::default()
},
],
..Default::default()
}));
let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
EditWaypoint {
file_id: id,
waypoint_id: ids[0],
lng: 4.5,
lat: 50.5,
ele: 12.0,
name: "new",
desc: "new description",
icon: "Summit",
link: "",
}
.apply(&mut fx.state())
.unwrap();
let wpts: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|c| c.wpt.iter())
.collect();
let edited = wpts[0];
assert_eq!(edited.id, ids[0]);
assert_eq!(edited.name.as_deref(), Some("new"));
assert_eq!(edited.desc.as_deref(), Some("new description"));
assert_eq!(edited.cmt.as_deref(), Some("new description"));
assert_eq!(edited.sym.as_deref(), Some("Summit"));
// an empty link removes it
assert!(edited.link.is_none());
assert_eq!(
(edited.coordinates.lng, edited.coordinates.lat, edited.ele),
(4.5, 50.5, 12.0)
);
// the other waypoint is as it was
assert_eq!(wpts[1].name.as_deref(), Some("other"));
}
}
@@ -1,13 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Elevation<'a> {
pub ele: &'a [f64],
}
impl Apply for Elevation<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("elevation"))
}
}
@@ -1,11 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Extract;
impl Apply for Extract {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("extract"))
}
}
@@ -1,19 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeType {
Connect,
Group,
}
#[derive(Debug)]
pub struct Merge {
pub type_: MergeType,
}
impl Apply for Merge {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("merge"))
}
}
@@ -1,25 +0,0 @@
mod clean;
mod crop;
mod edit_waypoint;
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 edit_waypoint::*;
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::*;
@@ -1,111 +0,0 @@
use crate::{Apply, CommandError, FileId, LngLat, State, WaypointId, update_waypoint};
/// Moves a waypoint of a file, whatever is selected (what a drag does): only its position and
/// elevation change.
#[derive(Debug)]
pub struct MoveWaypoint {
pub file_id: FileId,
pub waypoint_id: WaypointId,
pub lng: f64,
pub lat: f64,
pub ele: f64,
}
impl Apply for MoveWaypoint {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
wpt.coordinates = LngLat {
lng: self.lng,
lat: self.lat,
};
wpt.ele = self.ele;
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(Rc::new(WaypointChunk {
wpt: (0..3)
.map(|i| Waypoint {
name: Some(format!("w{i}")),
..Default::default()
})
.collect(),
..Default::default()
}));
let id = file.id;
let ids = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
(fx, id, ids)
}
#[test]
fn test_only_the_given_waypoint_moves_whatever_is_selected() {
let (mut fx, id, ids) = fixture();
let rev = fx.files[&id].wpt_rev_id;
MoveWaypoint {
file_id: id,
waypoint_id: ids[1],
lng: 4.0,
lat: 50.0,
ele: 42.0,
}
.apply(&mut fx.state())
.unwrap();
let wpts: Vec<_> = fx.files[&id]
.wpt
.iter()
.flat_map(|c| c.wpt.iter())
.collect();
assert_eq!(wpts.len(), 3);
assert_eq!(
(
wpts[1].coordinates.lng,
wpts[1].coordinates.lat,
wpts[1].ele
),
(4.0, 50.0, 42.0)
);
// the other fields and the other waypoints are as they were
assert_eq!(wpts[1].name.as_deref(), Some("w1"));
assert_eq!(wpts[1].id, ids[1]);
assert_eq!((wpts[0].coordinates.lng, wpts[0].ele), (0.0, 0.0));
assert_eq!((wpts[2].coordinates.lat, wpts[2].ele), (0.0, 0.0));
// the coordinates changed, which is noticed by what is derived from them
assert_ne!(fx.files[&id].wpt_rev_id, rev);
assert_eq!(fx.selection, Selection::Empty);
}
#[test]
fn test_unknown_waypoint_or_file_is_nothing_to_do() {
let (mut fx, id, ids) = fixture();
let before = fx.files[&id].clone();
for (file_id, waypoint_id) in [(id, WaypointId::default()), (FileId::default(), ids[0])] {
let result = MoveWaypoint {
file_id,
waypoint_id,
lng: 1.0,
lat: 1.0,
ele: 1.0,
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -1,239 +0,0 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint,
insert_waypoints_at,
};
/// Adds a waypoint at the end of the waypoints of each selected file (or of the file of the
/// selected elements). The strings are empty when the waypoint does not have the field.
#[derive(Debug)]
pub struct NewWaypoint<'a> {
pub lng: f64,
pub lat: f64,
pub ele: f64,
pub name: &'a str,
pub desc: &'a str,
pub icon: &'a str,
pub link: &'a str,
}
fn non_empty(text: &str) -> Option<String> {
(!text.is_empty()).then(|| text.to_owned())
}
/// Sets the fields of a waypoint: the strings that are empty remove the field.
pub fn set_waypoint_fields(
waypoint: &mut Waypoint,
position: (f64, f64, f64),
name: &str,
desc: &str,
icon: &str,
link: &str,
) {
let (lng, lat, ele) = position;
waypoint.coordinates = LngLat { lng, lat };
waypoint.ele = ele;
waypoint.name = non_empty(name);
waypoint.desc = non_empty(desc);
// the description is also the comment, as it is what the other applications show
waypoint.cmt = non_empty(desc);
waypoint.sym = non_empty(icon);
waypoint.link = non_empty(link).map(|href| Link { href, text: None });
}
impl Apply for NewWaypoint<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let file_ids: Vec<FileId> = match &*state.selection {
Selection::Empty => vec![],
Selection::File { file_ids } => state
.order
.0
.iter()
.filter(|id| file_ids.contains(id))
.copied()
.collect(),
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => vec![*file_id],
};
let file_ids: Vec<FileId> = file_ids
.into_iter()
.filter(|id| state.files.contains_key(id))
.collect();
if file_ids.is_empty() {
return Err(CommandError::NothingToDo);
}
for id in file_ids {
let mut waypoint = Waypoint::default();
set_waypoint_fields(
&mut waypoint,
(self.lng, self.lat, self.ele),
self.name,
self.desc,
self.icon,
self.link,
);
let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap());
insert_waypoints_at(file, usize::MAX, vec![waypoint]);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{New, TrackId, TrackSegmentId, engine::command::fixture::Fixture, waypoint_ids};
use super::*;
fn new_waypoint(name: &str) -> NewWaypoint<'_> {
NewWaypoint {
lng: 4.5,
lat: 50.25,
ele: 123.0,
name,
desc: "a description",
icon: "Summit",
link: "https://example.com",
}
}
fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> {
fx.files[&id]
.wpt
.iter()
.flat_map(|chunk| chunk.wpt.iter().cloned())
.collect()
}
#[test]
fn test_new_waypoint_in_the_selected_file() {
let mut fx = Fixture::default();
New { name: "a" }.apply(&mut fx.state()).unwrap();
New { name: "b" }.apply(&mut fx.state()).unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let rev = fx.files[&b].wpt_rev_id;
// b is selected
new_waypoint("peak").apply(&mut fx.state()).unwrap();
assert!(waypoints(&fx, a).is_empty());
let created = waypoints(&fx, b);
assert_eq!(created.len(), 1);
let wpt = &created[0];
assert_eq!(
(wpt.coordinates.lng, wpt.coordinates.lat, wpt.ele),
(4.5, 50.25, 123.0)
);
assert_eq!(wpt.name.as_deref(), Some("peak"));
assert_eq!(wpt.desc.as_deref(), Some("a description"));
assert_eq!(wpt.cmt.as_deref(), Some("a description"));
assert_eq!(wpt.sym.as_deref(), Some("Summit"));
assert_eq!(
wpt.link.as_ref().map(|l| l.href.as_str()),
Some("https://example.com")
);
assert_ne!(fx.files[&b].wpt_rev_id, rev);
// added after the others
let mut second = new_waypoint("second");
second.lng = 5.0;
second.apply(&mut fx.state()).unwrap();
let names: Vec<_> = waypoints(&fx, b)
.iter()
.map(|w| w.name.clone().unwrap())
.collect();
assert_eq!(names, ["peak", "second"]);
// and not selected
assert_eq!(fx.selected_files(), [b].into());
}
#[test]
fn test_empty_fields_are_not_set() {
let mut fx = Fixture::default();
New { name: "a" }.apply(&mut fx.state()).unwrap();
let a = fx.order.0[0];
NewWaypoint {
lng: 1.0,
lat: 2.0,
ele: 0.0,
name: "",
desc: "",
icon: "",
link: "",
}
.apply(&mut fx.state())
.unwrap();
let wpt = &waypoints(&fx, a)[0];
assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none());
assert!(wpt.sym.is_none() && wpt.link.is_none());
}
#[test]
fn test_each_selected_file_gets_its_own_waypoint() {
let mut fx = Fixture::default();
for name in ["a", "b", "c"] {
New { name }.apply(&mut fx.state()).unwrap();
}
let ids = fx.order.0.clone();
fx.selection = Selection::File {
file_ids: HashSet::from([ids[0], ids[2]]),
};
new_waypoint("w").apply(&mut fx.state()).unwrap();
assert_eq!(waypoints(&fx, ids[0]).len(), 1);
assert!(waypoints(&fx, ids[1]).is_empty());
assert_eq!(waypoints(&fx, ids[2]).len(), 1);
// not the same waypoint
assert_ne!(waypoints(&fx, ids[0])[0].id, waypoints(&fx, ids[2])[0].id);
}
#[test]
fn test_the_file_of_the_selected_elements_is_used() {
for selection in [
|id| Selection::Track {
file_id: id,
trk_ids: [TrackId::default()].into(),
},
|id| Selection::TrackSegment {
file_id: id,
trk_id: TrackId::default(),
trkseg_ids: [TrackSegmentId::default()].into(),
},
|id| Selection::Waypoints { file_id: id },
|id| Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::new(),
},
] {
let mut fx = Fixture::default();
New { name: "a" }.apply(&mut fx.state()).unwrap();
let a = fx.order.0[0];
fx.selection = selection(a);
new_waypoint("w").apply(&mut fx.state()).unwrap();
assert_eq!(waypoint_ids(&fx.files[&a]).count(), 1);
}
}
#[test]
fn test_nothing_selected_or_unknown_file() {
let mut fx = Fixture::default();
New { name: "a" }.apply(&mut fx.state()).unwrap();
fx.selection = Selection::Empty;
assert_eq!(
new_waypoint("w").apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints {
file_id: FileId::default(),
};
assert_eq!(
new_waypoint("w").apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert_eq!(fx.files.len(), 1);
}
}
@@ -1,11 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Reverse;
impl Apply for Reverse {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("reverse"))
}
}
@@ -1,315 +0,0 @@
use std::rc::Rc;
use crate::{Apply, CommandError, File, FileId, LngLat, Selection, State, Trackpoint};
/// Replaces the trackpoints `start..end` of the last track segment of the selection by the
/// given ones (a pure insertion when `start == end`, a pure removal when there are no new
/// points).
///
/// The selection can be made of several files, tracks or track segments: the indices are
/// meant for its very last segment, in file order, then track order, then segment order.
#[derive(Debug)]
pub struct SpliceTrackpoints<'a> {
pub start: u32,
pub end: u32,
pub lng: &'a [f64],
pub lat: &'a [f64],
pub ele: &'a [f64],
}
impl Apply for SpliceTrackpoints<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize);
if start > end || self.lng.len() != self.lat.len() || self.lat.len() != self.ele.len() {
return Err(CommandError::InvalidData(
"invalid trackpoints range".into(),
));
}
let (file_id, trk, seg) = last_segment(state).ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&file_id]).clone();
let segment = &mut file.trk[trk].trkseg[seg];
if end > segment.len() {
return Err(CommandError::InvalidData(
"trackpoints range out of bounds".into(),
));
}
if start == end && self.lng.is_empty() {
return Err(CommandError::NothingToDo);
}
let points = (0..self.lng.len())
.map(|i| Trackpoint {
coordinates: LngLat {
lng: self.lng[i],
lat: self.lat[i],
},
ele: self.ele[i],
..Default::default()
})
.collect();
segment.splice(start, end, points);
segment.rev_id = Default::default();
state.files.insert(file_id, Rc::new(file));
Ok(())
}
}
/// Position (file, track index, segment index) of the last segment of the selection.
fn last_segment(state: &State) -> Option<(FileId, usize, usize)> {
let last_of_track = |file: &File, trk: usize| file.trk[trk].trkseg.len().checked_sub(1);
let last_of_file = |file: &File, filter: &dyn Fn(usize) -> bool| {
(0..file.trk.len())
.rev()
.filter(|&trk| filter(trk))
.find_map(|trk| last_of_track(file, trk).map(|seg| (trk, seg)))
};
match &*state.selection {
Selection::File { file_ids } => state
.order
.0
.iter()
.rev()
.filter(|id| file_ids.contains(id))
.find_map(|id| {
let (trk, seg) = last_of_file(state.files.get(id)?, &|_| true)?;
Some((*id, trk, seg))
}),
Selection::Track { file_id, trk_ids } => {
let file = state.files.get(file_id)?;
let (trk, seg) = last_of_file(file, &|trk| trk_ids.contains(&file.trk[trk].id))?;
Some((*file_id, trk, seg))
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = state.files.get(file_id)?;
let trk = file.trk.iter().position(|trk| trk.id == *trk_id)?;
let seg = file.trk[trk]
.trkseg
.iter()
.rposition(|seg| trkseg_ids.contains(&seg.id))?;
Some((*file_id, trk, seg))
}
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
let trk = &fx.files[&id].trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
};
(fx, id)
}
fn splice<'a>(
start: u32,
end: u32,
lng: &'a [f64],
lat: &'a [f64],
ele: &'a [f64],
) -> SpliceTrackpoints<'a> {
SpliceTrackpoints {
start,
end,
lng,
lat,
ele,
}
}
#[test]
fn test_splice_replaces_points_of_selected_segment_only() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let len = before.trk[0].trkseg[0].len();
assert!(len >= 3);
splice(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0])
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]);
assert_eq!(new.len(), len + 1);
assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng);
assert_eq!(new[1].coordinates.lng, 1.0);
assert_eq!(new[3].coordinates.lat, 6.0);
assert_eq!(new[3].ele, 9.0);
assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng);
assert_ne!(new.rev_id, old.rev_id);
assert_eq!(new.id, old.id);
// other segments and tracks keep their revision
for (b, a) in before.trk.iter().zip(&after.trk) {
for (b, a) in b.trkseg.iter().zip(&a.trkseg) {
if a.id != new.id {
assert_eq!(a.rev_id, b.rev_id);
}
}
}
}
#[test]
fn test_splice_append_and_remove() {
let (mut fx, id) = loaded();
let len = fx.files[&id].trk[0].trkseg[0].len() as u32;
splice(len, len, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let seg = &fx.files[&id].trk[0].trkseg[0];
assert_eq!(seg.len(), len as usize + 1);
assert_eq!(seg[len as usize].ele, 3.0);
splice(0, len + 1, &[], &[], &[])
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.files[&id].trk[0].trkseg[0].len(), 0);
}
#[test]
fn test_splice_invalid_arguments_change_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let len = before.trk[0].trkseg[0].len() as u32;
let results = [
splice(2, 1, &[], &[], &[]).apply(&mut fx.state()),
splice(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
splice(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
];
assert!(
results
.iter()
.all(|r| matches!(r, Err(CommandError::InvalidData(_))))
);
assert_eq!(
splice(0, 0, &[], &[], &[]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_splice_needs_a_segment_selection() {
let (mut fx, _) = loaded();
fx.selection = Selection::Empty;
assert_eq!(
splice(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_splice_targets_last_segment_of_the_selection() {
let (mut fx, id) = loaded();
let file = fx.files[&id].clone();
assert!(file.trk.len() >= 2);
let last_trk = file.trk.len() - 1;
let last_seg = file.trk[last_trk].trkseg.len() - 1;
// whole file selected: last segment of its last track
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
for (t, (b, a)) in file.trk.iter().zip(&after.trk).enumerate() {
for (s, (b, a)) in b.trkseg.iter().zip(&a.trkseg).enumerate() {
let target = (t, s) == (last_trk, last_seg);
assert_eq!(a.len(), b.len() + usize::from(target));
assert_eq!(a.rev_id != b.rev_id, target);
}
}
// several tracks selected: last selected one (in file order, whatever the set order)
let ids: Vec<_> = file.trk.iter().map(|t| t.id).collect();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([ids[1], ids[0]]),
};
let len = |fx: &Fixture, t: usize| {
let trk = &fx.files[&id].trk[t];
trk.trkseg.last().unwrap().len()
};
let (l0, l1) = (len(&fx, 0), len(&fx, 1));
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(len(&fx, 0), l0);
assert_eq!(len(&fx, 1), l1 + 1);
// several segments selected
let trk = &fx.files[&id].trk[0];
let seg_ids: Vec<_> = trk.trkseg.iter().map(|s| s.id).collect();
if seg_ids.len() >= 2 {
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: seg_ids.iter().copied().collect(),
};
let lens: Vec<_> = trk.trkseg.iter().map(|s| s.len()).collect();
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let after: Vec<_> = fx.files[&id].trk[0]
.trkseg
.iter()
.map(|s| s.len())
.collect();
let n = lens.len();
assert_eq!(after[..n - 1], lens[..n - 1]);
assert_eq!(after[n - 1], lens[n - 1] + 1);
}
}
#[test]
fn test_splice_on_multiple_files_uses_last_in_file_order() {
let (mut fx, first) = loaded();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let second = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([second, first]),
};
let total = |fx: &Fixture, id: FileId| -> usize {
fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.len())
.sum()
};
let (a, b) = (total(&fx, first), total(&fx, second));
splice(0, 0, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(total(&fx, first), a);
assert_eq!(total(&fx, second), b + 1);
}
}
@@ -1,13 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Split {
pub at: u32,
}
impl Apply for Split {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("split"))
}
}
@@ -1,11 +0,0 @@
use crate::{Apply, CommandError, State};
#[derive(Debug)]
pub struct Time;
impl Apply for Time {
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
// TODO
Err(CommandError::NotImplemented("time"))
}
}
@@ -1,157 +0,0 @@
use std::collections::{HashMap, HashSet};
use crate::{
FileId, FileWaypointsRevisionId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId,
};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
/// of its file) changes, so editing a segment does not touch the buffers of the others.
#[derive(Debug, Default)]
pub struct CoordinatesCache {
segments: HashMap<TrackSegmentId, (TrackSegmentRevisionId, Vec<f64>)>,
waypoints: HashMap<FileId, (FileWaypointsRevisionId, Vec<f64>)>,
}
impl CoordinatesCache {
pub fn update(&mut self, files: Option<&Files>) {
let mut segments = HashSet::new();
let mut waypoints = HashSet::new();
for file in files.into_iter().flat_map(|files| files.values()) {
waypoints.insert(file.id);
if self
.waypoints
.get(&file.id)
.is_none_or(|(r, _)| *r != file.wpt_rev_id)
{
let mut coordinates = Vec::new();
for wpt in file.wpt.iter().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,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_segment_buffers_match_trackpoints() {
let (fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
for seg in fx.files[&id].trk.iter().flat_map(|t| &t.trkseg) {
let buffer = cache.segment(&seg.id);
assert_eq!(buffer.len(), seg.len() * 2);
for (i, p) in seg.iter().enumerate() {
assert_eq!(buffer[2 * i], p.coordinates.lng);
assert_eq!(buffer[2 * i + 1], p.coordinates.lat);
}
}
assert!(cache.segment(&TrackSegmentId::default()).is_empty());
}
#[test]
fn test_only_changed_buffers_are_rebuilt_and_stale_ones_dropped() {
let (mut fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
let ids: Vec<_> = fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.id)
.collect();
assert!(ids.len() >= 2);
let ptr = |c: &CoordinatesCache, i: usize| c.segment(&ids[i]).as_ptr();
let (p0, p1) = (ptr(&cache, 0), ptr(&cache, 1));
let mut file = (*fx.files[&id]).clone();
let seg = &mut file.trk[0].trkseg[0];
seg.splice(0, 0, vec![Default::default()]);
seg.rev_id = Default::default();
let len = seg.len();
fx.files.insert(id, Rc::new(file));
cache.update(Some(&fx.files));
assert_eq!(cache.segment(&ids[0]).len(), len * 2);
assert_ne!(ptr(&cache, 0), p0);
assert_eq!(ptr(&cache, 1), p1);
cache.update(None);
assert!(cache.segment(&ids[0]).is_empty());
assert!(cache.waypoints(&id).is_empty());
}
#[test]
fn test_waypoint_buffer() {
let (mut fx, id) = loaded();
let wpt = |lng, lat| Waypoint {
coordinates: LngLat { lng, lat },
..Default::default()
};
let mut file = (*fx.files[&id]).clone();
file.wpt_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]);
}
}
@@ -1,158 +0,0 @@
use crate::{
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId,
WaypointId,
};
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints,
/// with the ids that reference every element. Coordinates are not part of it, they are read
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
/// a segment).
#[derive(Debug, Clone, PartialEq)]
pub struct FileStructure {
pub id: FileId,
pub name: String,
pub desc: Option<String>,
pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change.
pub wpt_rev_id: FileWaypointsRevisionId,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub name: Option<String>,
pub desc: Option<String>,
/// Style of the track, only when the file defines it.
pub color: Option<String>,
pub opacity: Option<f64>,
pub width: Option<f64>,
pub segments: Vec<SegmentNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentNode {
pub id: TrackSegmentId,
/// Changes whenever the trackpoints of the segment (hence their buffer) change.
pub rev_id: TrackSegmentRevisionId,
pub len: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WaypointNode {
pub id: WaypointId,
pub name: Option<String>,
pub sym: Option<String>,
}
impl FileStructure {
pub fn new(file: &File) -> Self {
Self {
id: file.id,
name: file.info.name.clone(),
desc: file.info.desc.clone(),
tracks: file
.trk
.iter()
.map(|trk| TrackNode {
id: trk.id,
name: trk.info.name.clone(),
desc: trk.info.desc.clone(),
color: trk.info.color.clone(),
opacity: trk.info.opacity,
width: trk.info.width,
segments: trk
.trkseg
.iter()
.map(|seg| SegmentNode {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
})
.collect(),
})
.collect(),
waypoints: file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| WaypointNode {
id: wpt.id,
name: wpt.name.clone(),
sym: wpt.sym.clone(),
})
.collect(),
wpt_rev_id: file.wpt_rev_id,
}
}
}
#[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,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&fx.order.0[0]];
let node = FileStructure::new(file);
assert_eq!(
(node.id, &node.name, &node.desc),
(file.id, &file.info.name, &file.info.desc)
);
assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc));
assert_eq!(
(&n.color, n.opacity, n.width),
(&t.info.color, t.info.opacity, t.info.width)
);
assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
}
}
}
#[test]
fn test_waypoints_in_buffer_order_and_revision() {
let mut file = crate::File::default();
let wpt = |n: &str| Waypoint {
name: Some(n.to_string()),
..Default::default()
};
let before = file.wpt_rev_id;
file.wpt_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);
}
}
@@ -1,92 +0,0 @@
use std::collections::HashMap;
use crate::{Diff, FileId, FileStructure, StackEntry as Files};
/// The structure of each file, only recomputed for the files that changed according to the
/// [`Diff`] of the stack.
#[derive(Debug, Default)]
pub struct FileStructureCache {
map: HashMap<FileId, FileStructure>,
}
impl FileStructureCache {
/// Brings the cache in line with `files`, given what changed since the last update.
pub fn update(&mut self, files: Option<&Files>, diff: Option<&Diff>) {
if let Some(diff) = diff {
for id in diff.removed.iter() {
self.map.remove(id);
}
for id in diff.added.iter().chain(&diff.modified) {
if let Some(file) = files.and_then(|files| files.get(id)) {
self.map.insert(*id, FileStructure::new(file));
}
}
}
}
pub fn get(&self, id: &FileId) -> Option<&FileStructure> {
self.map.get(id)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, Load, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_only_files_of_the_diff_are_recomputed() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let mut cache = FileStructureCache::default();
cache.update(
Some(&fx.files),
Some(&Diff {
added: vec![a, b],
..Default::default()
}),
);
let before = cache.get(&b).unwrap() as *const FileStructure;
let mut file = (*fx.files[&a]).clone();
file.info.name = "renamed".into();
fx.files.insert(a, Rc::new(file));
cache.update(
Some(&fx.files),
Some(&Diff {
modified: vec![a],
..Default::default()
}),
);
assert_eq!(cache.get(&a).unwrap().name, "renamed");
assert!(std::ptr::eq(cache.get(&b).unwrap(), before));
fx.files.remove(&b);
cache.update(
Some(&fx.files),
Some(&Diff {
removed: vec![b],
..Default::default()
}),
);
assert!(cache.get(&b).is_none());
assert!(cache.get(&a).is_some());
}
}
-11
View File
@@ -1,11 +0,0 @@
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::*;
@@ -1,482 +0,0 @@
use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options};
/// Value of the timestamps of the trackpoints that have none.
pub const NO_TIME: i64 = i64::MIN;
/// Flattened statistics of the selected segments, one entry per trackpoint, in selection order.
///
/// The distance, time, moving and elevation values are cumulative over the whole selection (the
/// second segment continues where the first one ended), so the statistics of any range of
/// trackpoints are the difference between its two ends: see [`StatisticsBuffer::slice`].
/// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are
/// [`NO_TIME`], missing measures are NaN.
#[derive(Debug, Default)]
pub struct StatisticsBuffer {
/// Statistics of the whole selection.
pub global: GlobalStatistics,
pub total_distance: Vec<f64>,
pub moving_distance: Vec<f64>,
pub total_time: Vec<i64>,
pub moving_time: Vec<i64>,
pub speed: Vec<f64>,
pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>,
pub slope_segment_slope: Vec<f64>,
pub slope_segment_distance: Vec<f64>,
pub lng: Vec<f64>,
pub lat: Vec<f64>,
pub ele: Vec<f64>,
/// Timestamps, in milliseconds since the epoch.
pub time: Vec<i64>,
pub hr: Vec<f64>,
pub cad: Vec<f64>,
pub atemp: Vec<f64>,
pub power: Vec<f64>,
/// Surface of the trackpoints: 0 when unknown, else 1 + its code in the categories of the
/// engine (so that it is never 0 for a known surface).
pub surface: Vec<u8>,
/// Highway of the trackpoints, as the surface.
pub highway: Vec<u8>,
/// SAC hiking scale of the trackpoints, as the surface.
pub sac_scale: Vec<u8>,
/// Mountain biking scale of the trackpoints, as the surface.
pub mtb_scale: Vec<u8>,
}
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
/// fits.
fn unknown_or_next(code: Option<u8>) -> u8 {
code.and_then(|code| code.checked_add(1)).unwrap_or(0)
}
impl StatisticsBuffer {
pub fn len(&self) -> usize {
self.total_distance.len()
}
pub fn is_empty(&self) -> bool {
self.total_distance.is_empty()
}
/// `selected`: the segments and their statistics, in order.
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
self.global = GlobalStatistics::default();
self.total_distance.clear();
self.moving_distance.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();
self.lng.clear();
self.lat.clear();
self.ele.clear();
self.time.clear();
self.hr.clear();
self.cad.clear();
self.atemp.clear();
self.power.clear();
self.surface.clear();
self.highway.clear();
self.sac_scale.clear();
self.mtb_scale.clear();
let optional = |value: Option<f64>| value.unwrap_or(f64::NAN);
for (segment, stats) in selected {
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
let cumul_stats = &self.global;
self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
self.moving_distance.push(
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance)
.unwrap_or_default(),
);
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);
self.lng.push(trkpt.coordinates.lng);
self.lat.push(trkpt.coordinates.lat);
self.ele.push(trkpt.ele);
self.time.push(trkpt.time.unwrap_or(NO_TIME));
self.hr.push(optional(trkpt.hr.map(f64::from)));
self.cad.push(optional(trkpt.cad.map(f64::from)));
self.atemp.push(optional(trkpt.atemp.map(f64::from)));
self.power.push(optional(trkpt.power.map(f64::from)));
self.surface.push(unknown_or_next(trkpt.surface));
self.highway.push(unknown_or_next(trkpt.highway));
self.sac_scale.push(unknown_or_next(trkpt.sac_scale));
self.mtb_scale.push(unknown_or_next(trkpt.mtb_scale));
}
self.global.merge(&stats.global);
}
}
/// Statistics of the trackpoints from `start` to `end` (both included) of the selection, for
/// example the part of an elevation profile the user dragged over. `None` if the range is
/// not inside the selection.
///
/// It is done in constant time, from the cumulative values at both ends: the bounds and
/// the averages are the ones of the whole selection, they are not computed for the range.
pub fn slice(&self, start: usize, end: usize) -> Option<GlobalStatistics> {
if start > end || end >= self.len() {
return None;
}
let delta = |values: &[f64]| values[end] - values[start];
let delta_time = |values: &[i64]| values[end] - values[start];
let time = |i: usize| (self.time[i] != NO_TIME).then_some(self.time[i]);
Some(GlobalStatistics {
total_distance: delta(&self.total_distance),
moving_distance: self
.global
.moving_distance
.map(|_| delta(&self.moving_distance)),
total_time: self.global.total_time.map(|_| delta_time(&self.total_time)),
moving_time: self
.global
.moving_time
.map(|_| delta_time(&self.moving_time)),
elevation_gain: delta(&self.elevation_gain),
elevation_loss: delta(&self.elevation_loss),
start_time: time(start),
end_time: time(end),
bounds: self.global.bounds,
hr: self.global.hr,
cad: self.global.cad,
atemp: self.global.atemp,
power: self.global.power,
})
}
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn computed(path: &str) -> (TrackSegment, Statistics) {
let data = std::fs::read(path).unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let stats = Statistics::compute(&segment);
(segment, stats)
}
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
vec![
buffer.total_distance.len(),
buffer.moving_distance.len(),
buffer.total_time.len(),
buffer.moving_time.len(),
buffer.speed.len(),
buffer.elevation_gain.len(),
buffer.elevation_loss.len(),
buffer.slope.len(),
buffer.slope_segment_slope.len(),
buffer.slope_segment_distance.len(),
buffer.lng.len(),
buffer.lat.len(),
buffer.ele.len(),
buffer.time.len(),
buffer.hr.len(),
buffer.cad.len(),
buffer.atemp.len(),
buffer.power.len(),
buffer.surface.len(),
buffer.highway.len(),
buffer.sac_scale.len(),
buffer.mtb_scale.len(),
]
}
#[test]
fn test_empty() {
let mut buffer = StatisticsBuffer::default();
buffer.update(&[]);
assert!(buffer.is_empty());
assert!(buffer.speed.is_empty());
assert_eq!(buffer.global.total_distance, 0.0);
assert!(buffer.slice(0, 0).is_none());
}
#[test]
fn test_single_segment_matches_local_stats() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = s.local.len();
assert!(all_lengths(&buffer).iter().all(|len| *len == n));
assert_eq!(buffer.len(), n);
for (i, local) in s.local.iter().enumerate() {
assert_eq!(buffer.total_distance[i], local.total_distance);
assert_eq!(buffer.slope[i], local.slope);
}
// the trackpoints
for (i, trkpt) in segment.iter().enumerate() {
assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
assert_eq!(buffer.lat[i], trkpt.coordinates.lat);
assert_eq!(buffer.ele[i], trkpt.ele);
}
}
#[test]
fn test_global_is_the_merge_of_the_segments() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let global = &buffer.global;
assert!((global.total_distance - 2.0 * s.global.total_distance).abs() < 1e-9);
assert!((global.elevation_gain - 2.0 * s.global.elevation_gain).abs() < 1e-9);
assert!((global.elevation_loss - 2.0 * s.global.elevation_loss).abs() < 1e-9);
// and it is the last cumulative value of the points
assert_eq!(
global.total_distance,
*buffer.total_distance.last().unwrap()
);
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.global.total_distance, s.global.total_distance);
buffer.update(&[]);
assert_eq!(buffer.global.total_distance, 0.0);
}
#[test]
fn test_distances_accumulate_over_segments() {
let (segment, s) = computed("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.total_distance.len(), 2 * n);
// the second segment starts where the first one ended
assert_eq!(buffer.total_distance[n], s.global.total_distance);
assert!((buffer.total_distance[2 * n - 1] - 2.0 * s.global.total_distance).abs() < 1e-9);
assert!(buffer.total_distance.windows(2).all(|w| w[0] <= w[1]));
assert!((buffer.elevation_gain[2 * n - 1] - 2.0 * s.global.elevation_gain).abs() < 1e-9);
}
#[test]
fn test_total_time_accumulates_over_segments() {
let (segment, s) = computed("data/with_time.gpx");
let n = s.local.len();
let duration = s.global.total_time.unwrap();
assert!(duration > 0);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.total_time[n - 1], duration);
assert_eq!(buffer.total_time[n], duration);
assert_eq!(buffer.total_time[2 * n - 1], 2 * duration);
assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1]));
assert!(buffer.time.iter().all(|t| *t != NO_TIME));
}
#[test]
fn test_missing_times_and_measures_are_nan() {
let (segment, s) = computed("data/simple.gpx");
assert!(segment.iter().all(|trkpt| trkpt.time.is_none()));
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert!(buffer.time.iter().all(|t| *t == NO_TIME));
assert!(buffer.hr.iter().all(|v| v.is_nan()));
assert_eq!(buffer.global.hr.count, 0);
assert_eq!(buffer.global.total_time, None);
}
#[test]
fn test_surface_and_highway_codes() {
let (segment, s) = computed("data/with_highway.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
// 0 for the trackpoints that have none, else the code of the engine plus one
assert_eq!(buffer.surface, [1, 1, 0, 2, 2]);
assert_eq!(buffer.highway, [1, 1, 0, 2, 0]);
assert_eq!(buffer.sac_scale, [0, 0, 0, 1, 2]);
assert_eq!(buffer.mtb_scale, [0, 0, 0, 1, 1]);
assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
}
#[test]
fn test_codes_of_the_trackpoints_are_used_as_they_are() {
let (mut segment, _) = computed("data/simple.gpx");
let mut point = segment[0].clone();
point.surface = Some(7);
point.highway = Some(254);
point.sac_scale = Some(0);
point.mtb_scale = Some(3);
segment.splice(0, 1, vec![point]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert_eq!((buffer.surface[0], buffer.highway[0]), (8, 255));
assert_eq!((buffer.sac_scale[0], buffer.mtb_scale[0]), (1, 4));
assert_eq!((buffer.surface[1], buffer.highway[1]), (0, 0));
assert_eq!((buffer.sac_scale[1], buffer.mtb_scale[1]), (0, 0));
}
#[test]
fn test_no_codes_without_surface_and_highway() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.surface.len(), s.local.len());
assert!(buffer.surface.iter().all(|c| *c == 0));
assert!(buffer.highway.iter().all(|c| *c == 0));
assert!(buffer.sac_scale.iter().all(|c| *c == 0));
assert!(buffer.mtb_scale.iter().all(|c| *c == 0));
// and they are reset by the next update
buffer.update(&[]);
assert!(buffer.surface.is_empty() && buffer.highway.is_empty());
assert!(buffer.sac_scale.is_empty() && buffer.mtb_scale.is_empty());
}
#[test]
fn test_update_replaces_previous_content() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.total_distance.len(), s.local.len());
assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len()));
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
assert!(all_lengths(&buffer).iter().all(|len| *len == 0));
}
#[test]
fn test_slice_of_the_whole_selection_is_the_global_statistics() {
let (segment, s) = computed("data/with_time.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let slice = buffer.slice(0, buffer.len() - 1).unwrap();
let global = &buffer.global;
assert!((slice.total_distance - global.total_distance).abs() < 1e-9);
assert!((slice.elevation_gain - global.elevation_gain).abs() < 1e-9);
assert!((slice.elevation_loss - global.elevation_loss).abs() < 1e-9);
assert_eq!(slice.total_time, global.total_time);
assert_eq!(slice.moving_time, global.moving_time);
assert_eq!(slice.start_time, global.start_time);
assert_eq!(slice.end_time, global.end_time);
}
#[test]
fn test_slice_of_a_range() {
let (segment, s) = computed("data/with_time.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let (start, end) = (n / 4, n / 2);
let slice = buffer.slice(start, end).unwrap();
assert!(
(slice.total_distance - (buffer.total_distance[end] - buffer.total_distance[start]))
.abs()
< 1e-12
);
assert_eq!(
slice.total_time,
Some(buffer.total_time[end] - buffer.total_time[start])
);
assert_eq!(
slice.moving_time,
Some(buffer.moving_time[end] - buffer.moving_time[start])
);
assert_eq!(slice.start_time, Some(buffer.time[start]));
assert_eq!(slice.end_time, Some(buffer.time[end]));
assert!(slice.total_distance < buffer.global.total_distance);
assert!(slice.total_speed().is_some());
// a single point has nothing between its ends
let point = buffer.slice(start, start).unwrap();
assert_eq!(point.total_distance, 0.0);
assert_eq!(point.total_time, Some(0));
}
#[test]
fn test_slice_keeps_the_bounds_and_averages_of_the_selection() {
let data = std::fs::read("data/with_hr.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert!(buffer.global.hr.count > 0);
let slice = buffer.slice(1, 3).unwrap();
let (global, bounds) = (&buffer.global, &slice.bounds);
assert_eq!(slice.hr, global.hr);
assert_eq!(slice.cad, global.cad);
assert_eq!(slice.atemp, global.atemp);
assert_eq!(slice.power, global.power);
assert_eq!(
(bounds.sw.lng, bounds.sw.lat),
(global.bounds.sw.lng, global.bounds.sw.lat)
);
assert_eq!(
(bounds.ne.lng, bounds.ne.lat),
(global.bounds.ne.lng, global.bounds.ne.lat)
);
}
#[test]
fn test_durations_are_not_limited_to_24_days() {
// 30 days of recording, in milliseconds
let (segment, mut s) = computed("data/with_time.gpx");
let month = 30 * 24 * 3_600_000_i64;
s.global.total_time = Some(month);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.global.total_time, Some(2 * month));
assert!(2 * month > i64::from(i32::MAX));
}
#[test]
fn test_slice_across_segments() {
let (segment, s) = computed("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
// from the middle of the first segment to the middle of the second one
let slice = buffer.slice(n / 2, n + n / 2).unwrap();
let expected = buffer.total_distance[n + n / 2] - buffer.total_distance[n / 2];
assert!((slice.total_distance - expected).abs() < 1e-9);
assert_eq!(slice.total_time, None);
}
#[test]
fn test_slice_out_of_range() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = buffer.len();
assert!(buffer.slice(0, n).is_none());
assert!(buffer.slice(n, n).is_none());
assert!(buffer.slice(5, 4).is_none());
assert!(buffer.slice(0, n - 1).is_some());
}
}
@@ -1,299 +0,0 @@
use std::collections::{HashMap, HashSet, hash_map::Entry};
use crate::{
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
TrackSegmentRevisionId,
};
/// A segment and its statistics.
pub type SelectedSegment<'a> = (&'a TrackSegment, &'a Statistics);
#[derive(Debug, Default)]
pub struct StatisticsCache {
map: HashMap<TrackSegmentRevisionId, Statistics>,
}
impl StatisticsCache {
pub fn update(&mut self, state: Option<&StackEntry>) {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
for trk in file.trk.iter() {
for trkseg in trk.trkseg.iter() {
match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id);
}
Entry::Vacant(e) => {
e.insert(Statistics::compute(trkseg));
}
}
}
}
}
}
for id in stale.iter() {
self.map.remove(id);
}
}
/// Statistics of the segments of the selection.
pub fn get<'a>(
&'a self,
state: Option<&'a StackEntry>,
selection: &Selection,
) -> Vec<&'a Statistics> {
self.selected(state, selection, &[])
.into_iter()
.map(|(_, stats)| stats)
.collect()
}
/// The segments of the selection with their statistics. The segments of several selected
/// files follow `order` (the files missing from it come last).
pub fn selected<'a>(
&'a self,
state: Option<&'a StackEntry>,
selection: &Selection,
order: &[FileId],
) -> Vec<SelectedSegment<'a>> {
let mut selected = vec![];
if let Some(state) = state {
match selection {
Selection::Empty => (),
Selection::File { file_ids } => {
let ordered = order
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
if let Some(file) = state.get(id) {
self.add_file_stats(file, &mut selected);
}
}
}
Selection::Track { file_id, trk_ids } => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if trk_ids.contains(&trk.id) {
self.add_track_stats(trk, &mut selected);
}
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if *trk_id == trk.id {
for trkseg in trk.trkseg.iter() {
if trkseg_ids.contains(&trkseg.id) {
self.add_segment_stats(trkseg, &mut selected);
}
}
break;
}
}
}
}
Selection::Waypoints { file_id } => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut selected);
}
}
Selection::Waypoint {
file_id,
wpt_ids: _,
} => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut selected);
}
}
}
}
selected
}
/// Global statistics of a file: its segments merged, in order.
pub fn file_global(&self, file: &File) -> GlobalStatistics {
let mut selected = vec![];
self.add_file_stats(file, &mut selected);
let mut global = GlobalStatistics::default();
for (_, stats) in selected {
global.merge(&stats.global);
}
global
}
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
for trk in file.trk.iter() {
self.add_track_stats(trk, selected);
}
}
fn add_track_stats<'a>(&'a self, trk: &'a Track, selected: &mut Vec<SelectedSegment<'a>>) {
for trkseg in trk.trkseg.iter() {
self.add_segment_stats(trkseg, selected);
}
}
fn add_segment_stats<'a>(
&'a self,
trkseg: &'a TrackSegment,
selected: &mut Vec<SelectedSegment<'a>>,
) {
if let Some(stats) = self.map.get(&trkseg.rev_id) {
selected.push((trkseg, stats));
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashSet, rc::Rc};
use crate::{FileId, parse};
use super::*;
fn state(path: &str) -> (StackEntry, Rc<File>) {
let data = std::fs::read(path).unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
let mut state = StackEntry::default();
state.insert(file.id, file.clone());
(state, file)
}
fn file_selection(id: FileId) -> Selection {
Selection::File {
file_ids: HashSet::from([id]),
}
}
#[test]
fn test_update_and_select_file() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let nb_segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
assert_eq!(cache.map.len(), nb_segments);
assert_eq!(
cache.get(Some(&state), &file_selection(file.id)).len(),
nb_segments
);
assert!(cache.get(Some(&state), &Selection::Empty).is_empty());
assert!(cache.get(None, &file_selection(file.id)).is_empty());
// unknown file
assert!(
cache
.get(Some(&state), &file_selection(FileId::default()))
.is_empty()
);
}
#[test]
fn test_select_track_and_segment() {
let (state, file) = state("data/with_tracks_and_segments.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let trk = &file.trk[0];
let selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([trk.id]),
};
assert_eq!(cache.get(Some(&state), &selection).len(), trk.trkseg.len());
let trkseg = &trk.trkseg[1];
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trkseg.id]),
};
let stats = cache.get(Some(&state), &selection);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].local.len(), trkseg.len());
// a segment id under the wrong track selects nothing
let selection = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[1].id,
trkseg_ids: HashSet::from([trkseg.id]),
};
assert!(cache.get(Some(&state), &selection).is_empty());
}
#[test]
fn test_unchanged_segments_are_not_recomputed() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
// a new snapshot of the file that shares its segments (same revision ids)
let mut next = StackEntry::default();
let renamed = {
let mut f = (*file).clone();
f.info.name = "other".to_string();
Rc::new(f)
};
next.insert(renamed.id, renamed);
cache.update(Some(&next));
let after: *const Statistics = cache.get(Some(&next), &file_selection(file.id))[0];
assert!(std::ptr::eq(before, after));
}
#[test]
fn test_modified_segment_is_recomputed_and_stale_entries_dropped() {
let (state, file) = state("data/simple.gpx");
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let old_rev = file.trk[0].trkseg[0].rev_id;
assert!(cache.map.contains_key(&old_rev));
let mut f = (*file).clone();
f.trk[0].trkseg[0].rev_id = Default::default();
let new_rev = f.trk[0].trkseg[0].rev_id;
let mut next = StackEntry::default();
next.insert(f.id, Rc::new(f));
cache.update(Some(&next));
assert!(cache.map.contains_key(&new_rev));
assert!(!cache.map.contains_key(&old_rev));
cache.update(None);
assert!(cache.map.is_empty());
}
#[test]
fn test_segments_of_several_files_follow_the_given_order() {
let (mut state, first) = state("data/simple.gpx");
let (other, second) = {
let data = std::fs::read("data/with_time.gpx").unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
(file.clone(), file)
};
state.insert(other.id, other);
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let selection = Selection::File {
file_ids: HashSet::from([first.id, second.id]),
};
let ids = |order: &[FileId]| -> Vec<_> {
cache
.selected(Some(&state), &selection, order)
.iter()
.map(|(segment, _)| segment.id)
.collect()
};
let (a, b) = (first.trk[0].trkseg[0].id, second.trk[0].trkseg[0].id);
assert_eq!(ids(&[first.id, second.id]), vec![a, b]);
assert_eq!(ids(&[second.id, first.id]), vec![b, a]);
// the files that are not in the order come last
assert_eq!(ids(&[second.id]), vec![b, a]);
}
}
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
mod action;
mod command;
mod derived;
mod engine;
mod state;
pub use action::*;
pub use command::*;
pub use derived::*;
pub use engine::*;
pub use state::*;
-479
View File
@@ -1,479 +0,0 @@
use std::rc::Rc;
use uuid::Uuid;
use crate::{
File, FileId, Selection, StackEntry, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
WaypointId,
};
/// A track in the clipboard, with the name of the file it comes from (the name of the new file
/// when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardTrack {
pub track: Track,
pub file_name: String,
}
/// A segment in the clipboard, with the name of its track, or of its file when the track has
/// none (the name of the new file when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardSegment {
pub segment: TrackSegment,
pub name: String,
}
/// The elements that were copied or cut, in the order of the files.
///
/// It holds the elements themselves, not references to them: they are cheap to keep (files are
/// shared, and so are the trackpoints of tracks and segments), and the clipboard stays valid
/// when the elements are changed or deleted.
#[derive(Debug, Clone)]
pub enum ClipboardContent {
Files(Vec<Rc<File>>),
Tracks(Vec<ClipboardTrack>),
Segments(Vec<ClipboardSegment>),
Waypoints(Vec<Waypoint>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClipboardId(Uuid);
impl Default for ClipboardId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
/// What was copied or cut, to be pasted, as many times as needed.
///
/// What is pasted is always the elements as they were when they were copied or cut, whatever
/// happened to them since, even if they were deleted. Pasting what was copied makes copies, with
/// new ids. What was cut is moved, with its ids, the first time it is pasted (the elements that
/// are still there are removed): the clipboard is not cut anymore after that, and the next
/// pastes make copies.
#[derive(Debug, Clone)]
pub struct Clipboard {
id: ClipboardId,
pub content: ClipboardContent,
pub cut: bool,
}
/// Two clipboards are the same when they come from the same copy, and are cut the same way.
impl PartialEq for Clipboard {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.cut == other.cut
}
}
impl Clipboard {
/// The clipboard for the selected elements, `None` if nothing is selected (or if what is
/// selected does not exist).
pub fn new(
selection: &Selection,
files: &StackEntry,
order: &[FileId],
cut: bool,
) -> Option<Clipboard> {
let content = match selection {
Selection::Empty => return None,
Selection::File { file_ids } => ClipboardContent::Files(
order
.iter()
.filter(|id| file_ids.contains(id))
.filter_map(|id| files.get(id).cloned())
.collect(),
),
Selection::Track { file_id, trk_ids } => {
let file = files.get(file_id)?;
ClipboardContent::Tracks(
file.trk
.iter()
.filter(|trk| trk_ids.contains(&trk.id))
.map(|trk| ClipboardTrack {
track: trk.clone(),
file_name: file.info.name.clone(),
})
.collect(),
)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = files.get(file_id)?;
let trk = file.trk.iter().find(|trk| trk.id == *trk_id)?;
let name = trk
.info
.name
.clone()
.unwrap_or_else(|| file.info.name.clone());
ClipboardContent::Segments(
trk.trkseg
.iter()
.filter(|seg| trkseg_ids.contains(&seg.id))
.map(|seg| ClipboardSegment {
segment: seg.clone(),
name: name.clone(),
})
.collect(),
)
}
Selection::Waypoints { file_id } => ClipboardContent::Waypoints(
files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| chunk.wpt.iter().cloned())
.collect(),
),
Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints(
files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| wpt_ids.contains(&wpt.id))
.cloned()
.collect(),
),
};
let clipboard = Clipboard {
id: ClipboardId::default(),
content,
cut,
};
(!clipboard.is_empty()).then_some(clipboard)
}
pub fn is_empty(&self) -> bool {
match &self.content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
}
}
/// Whether the content can be pasted when `target` is selected: files and tracks can be
/// pasted anywhere above them in the file tree, and nothing selected stands for the list of
/// files.
///
/// | copied | pasted when selected |
/// |-----------|------------------------------------------|
/// | files | nothing, a file |
/// | tracks | nothing, a file, a track |
/// | segments | nothing, a file, a track, a segment |
/// | waypoints | a file, the waypoints, a waypoint |
pub fn can_paste(&self, target: &Selection) -> bool {
use ClipboardContent as C;
use Selection::*;
matches!(
(&self.content, target),
(C::Files(_), Empty | File { .. })
| (C::Tracks(_), Empty | File { .. } | Track { .. })
| (
C::Segments(_),
Empty | File { .. } | Track { .. } | TrackSegment { .. }
)
| (
C::Waypoints(_),
File { .. } | Waypoints { .. } | Waypoint { .. }
)
)
}
}
/// The ids of the elements of the content, by kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClipboardIds {
Files(Vec<FileId>),
Tracks(Vec<TrackId>),
Segments(Vec<TrackSegmentId>),
Waypoints(Vec<WaypointId>),
}
impl ClipboardContent {
pub fn ids(&self) -> ClipboardIds {
match self {
ClipboardContent::Files(files) => {
ClipboardIds::Files(files.iter().map(|file| file.id).collect())
}
ClipboardContent::Tracks(tracks) => {
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
}
ClipboardContent::Segments(segments) => {
ClipboardIds::Segments(segments.iter().map(|seg| seg.segment.id).collect())
}
ClipboardContent::Waypoints(waypoints) => {
ClipboardIds::Waypoints(waypoints.iter().map(|wpt| wpt.id).collect())
}
}
}
}
#[cfg(test)]
mod tests {
use crate::{TrackInfo, WaypointChunk};
use super::*;
/// A file with 3 tracks (named "t0", "t1", "t2") of 2 segments and 3 waypoints.
fn file(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_owned();
for i in 0..3 {
file.trk.push(Track {
info: TrackInfo {
name: (i < 2).then(|| format!("t{i}")),
..Default::default()
},
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}));
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
}));
file
}
fn files_of(files: Vec<File>) -> StackEntry {
files
.into_iter()
.map(|file| (file.id, Rc::new(file)))
.collect()
}
fn order_of(files: &StackEntry) -> Vec<FileId> {
files.keys().copied().collect()
}
fn tracks(file: &File, ids: &[usize]) -> Selection {
Selection::Track {
file_id: file.id,
trk_ids: ids.iter().map(|i| file.trk[*i].id).collect(),
}
}
#[test]
fn test_the_clipboard_holds_what_is_selected() {
let file = file("source");
let (t0, t2) = (file.trk[0].id, file.trk[2].id);
let (s0, s1) = (file.trk[1].trkseg[0].id, file.trk[1].trkseg[1].id);
let wpt: Vec<_> = file
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
let files = files_of(vec![file.clone()]);
// tracks, in the order of the file, with the name of their file
let clipboard =
Clipboard::new(&tracks(&file, &[2, 0]), &files, &order_of(&files), false).unwrap();
assert!(!clipboard.cut);
match &clipboard.content {
ClipboardContent::Tracks(copied) => {
assert_eq!(
copied.iter().map(|c| c.track.id).collect::<Vec<_>>(),
vec![t0, t2]
);
assert!(copied.iter().all(|c| c.file_name == "source"));
}
other => panic!("{other:?}"),
}
// segments, with the name of their track, or else of their file
let segments = |trk: usize, ids: &[TrackSegmentId]| Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[trk].id,
trkseg_ids: ids.iter().copied().collect(),
};
match Clipboard::new(&segments(1, &[s1, s0]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => {
assert_eq!(
copied.iter().map(|c| c.segment.id).collect::<Vec<_>>(),
vec![s0, s1]
);
assert!(copied.iter().all(|c| c.name == "t1"));
}
other => panic!("{other:?}"),
}
let unnamed = file.trk[2].trkseg[0].id;
match Clipboard::new(&segments(2, &[unnamed]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => assert_eq!(copied[0].name, "source"),
other => panic!("{other:?}"),
}
// waypoints: some of them, or all of them
let some = Selection::Waypoint {
file_id: file.id,
wpt_ids: [wpt[2], wpt[0]].into(),
};
assert_eq!(
Clipboard::new(&some, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(vec![wpt[0], wpt[2]])
);
assert_eq!(
Clipboard::new(
&Selection::Waypoints { file_id: file.id },
&files,
&order_of(&files),
false
)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(wpt)
);
// files
let selection = Selection::File {
file_ids: [file.id].into(),
};
assert_eq!(
Clipboard::new(&selection, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Files(vec![file.id])
);
}
#[test]
fn test_nothing_to_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
assert!(Clipboard::new(&Selection::Empty, &files, &order_of(&files), false).is_none());
// elements that do not exist
let unknown = Selection::Track {
file_id: FileId::default(),
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
let unknown = Selection::Track {
file_id: file.id,
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
assert!(
Clipboard::new(
&Selection::File {
file_ids: [FileId::default()].into()
},
&files,
&order_of(&files),
false
)
.is_none()
);
// a file without waypoints
let empty = files_of(vec![File::default()]);
let id = *empty.keys().next().unwrap();
assert!(
Clipboard::new(
&Selection::Waypoints { file_id: id },
&empty,
&order_of(&empty),
true
)
.is_none()
);
}
#[test]
fn test_two_clipboards_are_the_same_when_they_come_from_the_same_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection = tracks(&file, &[0]);
let clipboard = Clipboard::new(&selection, &files, &order_of(&files), false).unwrap();
assert_eq!(clipboard, clipboard.clone());
// the same elements copied again make another clipboard
assert_ne!(
clipboard,
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
);
// and it is not the same once it is not cut
let mut copy = clipboard.clone();
copy.cut = true;
assert_ne!(clipboard, copy);
}
#[test]
fn test_the_clipboard_survives_what_happens_to_the_files() {
let file = file("f");
let mut files = files_of(vec![file.clone()]);
let clipboard =
Clipboard::new(&tracks(&file, &[0, 1]), &files, &order_of(&files), true).unwrap();
files.clear();
assert!(!clipboard.is_empty());
assert_eq!(
clipboard.content.ids(),
ClipboardIds::Tracks(vec![file.trk[0].id, file.trk[1].id])
);
}
#[test]
fn test_where_things_can_be_pasted() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection_of = |selection: Selection| {
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
};
let file_selection = Selection::File {
file_ids: [file.id].into(),
};
let track = tracks(&file, &[0]);
let segment = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[0].id,
trkseg_ids: [file.trk[0].trkseg[0].id].into(),
};
let waypoints = Selection::Waypoints { file_id: file.id };
let waypoint = Selection::Waypoint {
file_id: file.id,
wpt_ids: [file.wpt[0].wpt[0].id].into(),
};
let targets = [
Selection::Empty,
file_selection.clone(),
track.clone(),
segment.clone(),
waypoints.clone(),
waypoint.clone(),
];
let expected = [
// nothing, file, track, segment, waypoints, waypoint
(file_selection, [true, true, false, false, false, false]),
(track, [true, true, true, false, false, false]),
(segment, [true, true, true, true, false, false]),
(waypoints, [false, true, false, false, true, true]),
(waypoint, [false, true, false, false, true, true]),
];
for (copied, allowed) in expected {
let clipboard = selection_of(copied.clone());
for (target, allowed) in targets.iter().zip(allowed) {
assert_eq!(
clipboard.can_paste(target),
allowed,
"{copied:?} on {target:?}"
);
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
mod clipboard;
mod order;
mod selection;
mod stack;
pub use clipboard::*;
pub use order::*;
pub use selection::*;
pub use stack::*;
pub struct State<'a> {
pub categories: &'a mut crate::TrackpointCategories,
pub files: &'a mut StackEntry,
pub selection: &'a mut Selection,
pub order: &'a mut FileOrder,
pub clipboard: &'a mut Option<Clipboard>,
}
-97
View File
@@ -1,97 +0,0 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry as Files};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileOrder(pub Vec<FileId>);
impl FileOrder {
/// Moves the given files, as a block and in the given order, so that they start at `index`
/// among the other files (clamped to the end). Unknown ids are ignored. Returns whether the
/// order changed.
pub fn move_files(&mut self, file_ids: &[FileId], index: usize) -> bool {
let mut seen = HashSet::new();
let moved: Vec<FileId> = file_ids
.iter()
.copied()
.filter(|id| self.0.contains(id) && seen.insert(*id))
.collect();
let mut order: Vec<FileId> = self
.0
.iter()
.copied()
.filter(|id| !seen.contains(id))
.collect();
let index = index.min(order.len());
order.splice(index..index, moved);
let changed = order != self.0;
self.0 = order;
changed
}
/// Drops the files that do not exist anymore and appends the new ones at the end.
pub fn sync(&mut self, files: &Files) {
self.0.retain(|id| files.contains_key(id));
let known: HashSet<FileId> = self.0.iter().copied().collect();
self.0
.extend(files.keys().filter(|id| !known.contains(id)).copied());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> [FileId; 3] {
[FileId::default(), FileId::default(), FileId::default()]
}
#[test]
fn test_move_files_as_block_in_given_order() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c, a], 1));
// index counts among the files that are not moved
assert_eq!(order.0, vec![b, c, a]);
}
#[test]
fn test_move_files_start_and_clamped_end() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c], 0));
assert_eq!(order.0, vec![c, a, b]);
assert!(order.move_files(&[c], 100));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_move_files_ignores_unknown_and_duplicate_ids() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[FileId::default(), b, b], 0));
assert_eq!(order.0, vec![b, a, c]);
}
#[test]
fn test_move_files_unchanged() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(!order.move_files(&[], 0));
assert!(!order.move_files(&[a, b], 0));
assert!(!order.move_files(&[FileId::default()], 0));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_sync_drops_missing_and_appends_new_at_the_end() {
let [a, b, c] = ids();
let mut files = Files::default();
for id in [b, c] {
files.insert(id, Default::default());
}
let mut order = FileOrder(vec![a, b]);
order.sync(&files);
assert_eq!(order.0, vec![b, c]);
}
}
-759
View File
@@ -1,759 +0,0 @@
use std::collections::HashSet;
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
/// How a new selection combines with the current one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectMode {
/// The new selection replaces the current one.
Replace,
/// The elements are added when they are of the same kind and in the same place as the
/// current selection (see [`Selection::extend`]), otherwise they replace it.
Add,
/// Like `Add`, but the elements already selected are removed instead (see
/// [`Selection::toggle`]).
Toggle,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Selection {
#[default]
Empty,
File {
file_ids: HashSet<FileId>,
},
Track {
file_id: FileId,
trk_ids: HashSet<TrackId>,
},
TrackSegment {
file_id: FileId,
trk_id: TrackId,
trkseg_ids: HashSet<TrackSegmentId>,
},
Waypoints {
file_id: FileId,
},
Waypoint {
file_id: FileId,
wpt_ids: HashSet<WaypointId>,
},
}
impl Selection {
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
pub fn retain_existing(&mut self, files: &StackEntry) {
let empty = match self {
Selection::Empty => false,
Selection::File { file_ids } => {
file_ids.retain(|id| files.contains_key(id));
file_ids.is_empty()
}
Selection::Track { file_id, trk_ids } => match files.get(file_id) {
Some(file) => {
trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id));
trk_ids.is_empty()
}
None => true,
},
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => match files
.get(file_id)
.and_then(|file| file.trk.iter().find(|trk| trk.id == *trk_id))
{
Some(trk) => {
trkseg_ids.retain(|id| trk.trkseg.iter().any(|seg| seg.id == *id));
trkseg_ids.is_empty()
}
None => true,
},
Selection::Waypoints { file_id } => !files.contains_key(file_id),
Selection::Waypoint { file_id, wpt_ids } => match files.get(file_id) {
Some(file) => {
wpt_ids.retain(|id| {
file.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.any(|wpt| wpt.id == *id)
});
wpt_ids.is_empty()
}
None => true,
},
};
if empty {
*self = Selection::Empty;
}
}
/// Adds `other` to the selection: the elements are merged when they are of the same kind and
/// in the same place (same file, same track for segments), otherwise `other` replaces the
/// selection.
pub fn extend(&mut self, other: Selection) {
match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
file_ids.extend(other)
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => trk_ids.extend(other),
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => trkseg_ids.extend(other),
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other => {}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => wpt_ids.extend(other),
(selection, other) => *selection = other,
}
}
/// Like [`Selection::extend`], but the elements of `other` that are already selected are
/// removed from the selection instead. A selection left with nothing is empty.
pub fn toggle(&mut self, other: Selection) {
fn toggle_ids<T: std::hash::Hash + Eq>(ids: &mut HashSet<T>, other: HashSet<T>) {
for id in other {
if !ids.remove(&id) {
ids.insert(id);
}
}
}
let empty = match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
toggle_ids(file_ids, other);
file_ids.is_empty()
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => {
toggle_ids(trk_ids, other);
trk_ids.is_empty()
}
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => {
toggle_ids(trkseg_ids, other);
trkseg_ids.is_empty()
}
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other =>
{
true
}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => {
toggle_ids(wpt_ids, other);
wpt_ids.is_empty()
}
(selection, other) => {
*selection = other;
false
}
};
if empty {
*self = Selection::Empty;
}
}
}
/// Position of the first and last of the `selected` ids among the `all` ids.
fn selected_range<T: PartialEq>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<(usize, usize)>
where
T: std::hash::Hash + Eq,
{
let mut range: Option<(usize, usize)> = None;
for (i, id) in all.enumerate() {
if selected.contains(&id) {
range = Some(range.map_or((i, i), |(first, _)| (first, i)));
}
}
range
}
/// The id before or after the selected ones among `ids`, without wrapping around.
fn neighbour<T: Copy + PartialEq + std::hash::Hash + Eq>(
ids: &[T],
selected: &HashSet<T>,
down: bool,
) -> Option<T> {
let (first, last) = selected_range(ids.iter().copied(), selected)?;
if down {
ids.get(last + 1).copied()
} else {
first.checked_sub(1).map(|i| ids[i])
}
}
impl Selection {
/// The files holding the selected elements, as a selection of files.
pub fn to_files(&self) -> Selection {
match self {
Selection::Empty => Selection::Empty,
Selection::File { .. } => self.clone(),
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => Selection::File {
file_ids: [*file_id].into(),
},
}
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
/// of the file. All the files when nothing is selected. `None` when there is nothing to
/// select (the waypoints node, or a file that does not exist).
pub fn all_at_level(&self, files: &StackEntry, order: &[FileId]) -> Option<Selection> {
match self {
Selection::Empty | Selection::File { .. } => Some(Selection::File {
file_ids: order.iter().copied().collect(),
}),
Selection::Track { file_id, .. } => Some(Selection::Track {
file_id: *file_id,
trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(),
}),
Selection::TrackSegment {
file_id, trk_id, ..
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: trk.trkseg.iter().map(|seg| seg.id).collect(),
})
}
Selection::Waypoints { .. } => None,
Selection::Waypoint { file_id, .. } => Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
.collect(),
}),
}
}
/// The element next to the selected ones, as a selection of that one element: the next one
/// (`down`) or the previous one among the elements of the same kind, in the same place.
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
/// the ones that are already selected. `None` when there is no such element.
pub fn neighbour(&self, files: &StackEntry, order: &[FileId], down: bool) -> Option<Selection> {
match self {
Selection::Empty | Selection::Waypoints { .. } => None,
Selection::File { file_ids } => {
let (first, last) = selected_range(order.iter(), &file_ids.iter().collect())?;
let limit = if down { last } else { first };
let n = order.len();
let mut i = limit;
loop {
i = if down { (i + 1) % n } else { (i + n - 1) % n };
if i == limit {
return None;
}
if !file_ids.contains(&order[i]) {
return Some(Selection::File {
file_ids: [order[i]].into(),
});
}
}
}
Selection::Track { file_id, trk_ids } => {
let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect();
Some(Selection::Track {
file_id: *file_id,
trk_ids: [neighbour(&ids, trk_ids, down)?].into(),
})
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
let ids: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: [neighbour(&ids, trkseg_ids, down)?].into(),
})
}
Selection::Waypoint { file_id, wpt_ids } => {
let ids: Vec<WaypointId> = files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
.collect();
Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: [neighbour(&ids, wpt_ids, down)?].into(),
})
}
}
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{File, Track, TrackSegment, Waypoint, WaypointChunk};
use super::*;
#[test]
fn test_retain_existing() {
let file = File::default();
let (kept, gone) = (file.id, FileId::default());
let mut files = StackEntry::default();
files.insert(kept, Rc::new(file));
let mut selection = Selection::File {
file_ids: [kept, gone].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::File { file_ids } if file_ids.len() == 1 && file_ids.contains(&kept))
);
let mut selection = Selection::File {
file_ids: [gone].into(),
};
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
let mut selection = Selection::Waypoints { file_id: kept };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Waypoints { .. }));
let mut selection = Selection::Waypoints { file_id: gone };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
}
#[test]
fn test_retain_existing_elements() {
let mut file = File::default();
file.trk.push(Track {
trkseg: vec![TrackSegment::default()],
..Default::default()
});
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
}));
let (file_id, trk_id) = (file.id, file.trk[0].id);
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].wpt[0].id);
let mut files = StackEntry::default();
files.insert(file_id, Rc::new(file));
let mut selection = Selection::Track {
file_id,
trk_ids: [trk_id, TrackId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(
selection,
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
);
let mut selection = Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg_id, TrackSegmentId::default()].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::TrackSegment { trkseg_ids, .. } if trkseg_ids.len() == 1)
);
// a segment under the wrong track
let mut selection = Selection::TrackSegment {
file_id,
trk_id: TrackId::default(),
trkseg_ids: [seg_id].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [wpt_id, WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert!(matches!(&selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids.len() == 1));
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
}
#[test]
fn test_extend() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: merged
let mut selection = tracks(&[a]);
selection.extend(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
let other = Selection::Track {
file_id: FileId::default(),
trk_ids: [b].into(),
};
selection.extend(other.clone());
assert_eq!(selection, other);
selection.extend(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// segments of another track: replaced
let seg = TrackSegmentId::default();
let segments = |trk_id| Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg].into(),
};
let mut selection = segments(a);
selection.extend(segments(b));
assert_eq!(selection, segments(b));
}
#[test]
fn test_toggle() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: toggled one by one
let mut selection = tracks(&[a]);
selection.toggle(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
selection.toggle(tracks(&[a]));
assert_eq!(selection, tracks(&[b]));
selection.toggle(tracks(&[a, b]));
assert_eq!(selection, tracks(&[a]));
// nothing left: empty
selection.toggle(tracks(&[a]));
assert_eq!(selection, Selection::Empty);
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// the waypoints node toggles itself off
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Empty);
// files
let (f1, f2) = (FileId::default(), FileId::default());
let files = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let mut selection = files(&[f1]);
selection.toggle(files(&[f2]));
assert_eq!(selection, files(&[f1, f2]));
selection.toggle(files(&[f1]));
assert_eq!(selection, files(&[f2]));
}
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
struct Tree {
files: StackEntry,
order: Vec<FileId>,
file: FileId,
tracks: Vec<TrackId>,
segments: Vec<Vec<TrackSegmentId>>,
waypoints: Vec<WaypointId>,
}
fn tree() -> Tree {
let mut file = File::default();
for _ in 0..3 {
file.trk.push(Track {
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}));
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
}));
let tree = Tree {
file: file.id,
tracks: file.trk.iter().map(|t| t.id).collect(),
segments: file
.trk
.iter()
.map(|t| t.trkseg.iter().map(|s| s.id).collect())
.collect(),
waypoints: file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|w| w.id)
.collect(),
order: vec![file.id],
files: StackEntry::from([(file.id, Rc::new(file))]),
};
tree
}
#[test]
fn test_all_at_level() {
let t = tree();
let other = FileId::default();
let order = [other, t.file];
let all = |selection: &Selection| selection.all_at_level(&t.files, &order);
// the files, in the order
let all_files = Selection::File {
file_ids: [other, t.file].into(),
};
assert_eq!(all(&Selection::Empty), Some(all_files.clone()));
assert_eq!(
all(&Selection::File {
file_ids: [t.file].into()
}),
Some(all_files)
);
// the siblings of the selected elements
assert_eq!(
all(&Selection::Track {
file_id: t.file,
trk_ids: [t.tracks[1]].into()
}),
Some(Selection::Track {
file_id: t.file,
trk_ids: t.tracks.iter().copied().collect()
})
);
assert_eq!(
all(&Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: [t.segments[2][0]].into()
}),
Some(Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: t.segments[2].iter().copied().collect()
})
);
assert_eq!(
all(&Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[0]].into()
}),
Some(Selection::Waypoint {
file_id: t.file,
wpt_ids: t.waypoints.iter().copied().collect()
})
);
// nothing to select
assert_eq!(all(&Selection::Waypoints { file_id: t.file }), None);
let unknown = FileId::default();
assert_eq!(
all(&Selection::Track {
file_id: unknown,
trk_ids: HashSet::new()
}),
None
);
}
#[test]
fn test_neighbour_of_tracks_stops_at_the_ends() {
let t = tree();
let tracks = |ids: &[TrackId]| Selection::Track {
file_id: t.file,
trk_ids: ids.iter().copied().collect(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
assert_eq!(
next(&tracks(&[t.tracks[1]]), true),
Some(tracks(&[t.tracks[2]]))
);
assert_eq!(
next(&tracks(&[t.tracks[1]]), false),
Some(tracks(&[t.tracks[0]]))
);
assert_eq!(next(&tracks(&[t.tracks[2]]), true), None);
assert_eq!(next(&tracks(&[t.tracks[0]]), false), None);
// from the end of the selected range, whatever its holes
let range = tracks(&[t.tracks[0], t.tracks[1]]);
assert_eq!(next(&range, true), Some(tracks(&[t.tracks[2]])));
assert_eq!(next(&range, false), None);
let holes = tracks(&[t.tracks[0], t.tracks[2]]);
assert_eq!(next(&holes, true), None);
assert_eq!(next(&holes, false), None);
}
#[test]
fn test_neighbour_of_segments_and_waypoints() {
let t = tree();
let segments = |trk: usize, seg: usize| Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[trk],
trkseg_ids: [t.segments[trk][seg]].into(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
// the segments stay in their track
assert_eq!(next(&segments(1, 0), true), Some(segments(1, 1)));
assert_eq!(next(&segments(1, 1), true), None);
assert_eq!(next(&segments(1, 1), false), Some(segments(1, 0)));
assert_eq!(next(&segments(1, 0), false), None);
// the waypoints are in the order of the file, across its chunks
let waypoint = |i: usize| Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[i]].into(),
};
assert_eq!(next(&waypoint(1), true), Some(waypoint(2)));
assert_eq!(next(&waypoint(2), true), None);
assert_eq!(next(&waypoint(0), false), None);
assert_eq!(next(&waypoint(2), false), Some(waypoint(1)));
assert_eq!(next(&Selection::Waypoints { file_id: t.file }, true), None);
assert_eq!(next(&Selection::Empty, true), None);
}
#[test]
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
let files = StackEntry::default();
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
let order = [a, b, c];
let select = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let next = |ids: &[FileId], down| select(ids).neighbour(&files, &order, down);
assert_eq!(next(&[a], true), Some(select(&[b])));
assert_eq!(next(&[c], true), Some(select(&[a])));
assert_eq!(next(&[a], false), Some(select(&[c])));
assert_eq!(next(&[b], false), Some(select(&[a])));
// from the end of the selection, skipping the files already selected
assert_eq!(next(&[a, b], true), Some(select(&[c])));
assert_eq!(next(&[a, b], false), Some(select(&[c])));
assert_eq!(next(&[c, a], true), Some(select(&[b])));
// everything is selected
assert_eq!(next(&[a, b, c], true), None);
// none of them is in the order
assert_eq!(next(&[FileId::default()], true), None);
}
#[test]
fn test_to_files() {
let (file_id, trk_id) = (FileId::default(), TrackId::default());
let file = Selection::File {
file_ids: [file_id].into(),
};
assert_eq!(Selection::Empty.to_files(), Selection::Empty);
assert_eq!(file.to_files(), file);
assert_eq!(
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
.to_files(),
file
);
assert_eq!(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: HashSet::new()
}
.to_files(),
file
);
assert_eq!(Selection::Waypoints { file_id }.to_files(), file);
assert_eq!(
Selection::Waypoint {
file_id,
wpt_ids: HashSet::new()
}
.to_files(),
file
);
let several = Selection::File {
file_ids: [file_id, FileId::default()].into(),
};
assert_eq!(several.to_files(), several);
}
}
-252
View File
@@ -1,252 +0,0 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{File, FileId};
#[derive(Debug, Default)]
pub struct Stack {
entries: Vec<StackEntry>,
index: Option<usize>,
}
impl Stack {
pub fn current(&self) -> Option<&StackEntry> {
self.index.map_or_default(|i| self.get(i))
}
pub fn create_and_push_next<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut StackEntry) -> Result<(), String>,
{
self.record_diff(|stack| {
let mut next = stack.current().map_or_default(|c| c.clone());
if f(&mut next).is_ok() {
stack.push(next);
}
})
}
pub fn undo(&mut self) -> Option<Diff> {
self.record_diff(|stack| {
if let Some(i) = stack.index {
if i == 0 {
stack.index = None;
} else {
stack.index = Some(i - 1);
}
}
})
}
pub fn redo(&mut self) -> Option<Diff> {
self.record_diff(|stack| match stack.index {
Some(i) => {
if i + 1 < stack.entries.len() {
stack.index = Some(i + 1);
}
}
None => {
if !stack.entries.is_empty() {
stack.index = Some(0);
}
}
})
}
pub fn can_undo(&self) -> bool {
self.index.is_some()
}
pub fn can_redo(&self) -> bool {
match self.index {
Some(i) => i + 1 < self.entries.len(),
None => !self.entries.is_empty(),
}
}
fn get(&self, index: usize) -> Option<&StackEntry> {
self.entries.get(index)
}
fn push(&mut self, entry: StackEntry) {
if let Some(i) = self.index {
self.entries.truncate(i + 1);
}
self.entries.push(entry);
self.index = Some(self.entries.len() - 1);
}
fn record_diff<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut Self),
{
let prev = self.index;
f(self);
let cur = self.index;
if prev == cur {
return None;
}
let prev = prev.map_or_default(|i| self.get(i));
let cur = cur.map_or_default(|i| self.get(i));
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
let mut modified = vec![];
for id in prev_ids.intersection(&cur_ids) {
let before = prev.map_or_default(|e| e.get(id));
let after = cur.map_or_default(|e| e.get(id));
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) && before != after {
modified.push(*id);
}
}
Some(Diff {
added: cur_ids.difference(&prev_ids).copied().collect(),
removed: prev_ids.difference(&cur_ids).copied().collect(),
modified,
})
}
}
pub type StackEntry = HashMap<FileId, Rc<File>>;
/// Which files were added, removed or modified by a change of the current stack entry.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Diff {
pub added: Vec<FileId>,
pub removed: Vec<FileId>,
pub modified: Vec<FileId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_file() {
let mut stack = Stack::default();
assert!(!stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_none());
let diff = stack.create_and_push_next(|e| {
let file = Rc::new(File::default());
e.insert(file.id, file);
Ok(())
});
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.current().is_some());
assert!(diff.is_some());
let diff = diff.unwrap();
assert_eq!(diff.added.len(), 1);
assert!(diff.removed.is_empty());
assert!(diff.modified.is_empty());
}
fn add_file(stack: &mut Stack) -> FileId {
let file = Rc::new(File::default());
let id = file.id;
stack
.create_and_push_next(|e| {
e.insert(id, file);
Ok(())
})
.unwrap();
id
}
#[test]
fn test_failed_command_pushes_nothing() {
let mut stack = Stack::default();
let diff = stack.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default()));
Err("nope".to_string())
});
assert!(diff.is_none());
assert!(!stack.can_undo());
assert!(stack.current().is_none());
}
#[test]
fn test_undo_redo() {
let mut stack = Stack::default();
assert!(stack.undo().is_none());
assert!(stack.redo().is_none());
let id = add_file(&mut stack);
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty());
assert!(stack.current().is_none());
assert!(!stack.can_undo());
assert!(stack.can_redo());
assert!(stack.undo().is_none());
let diff = stack.redo().unwrap();
assert_eq!(diff.added, vec![id]);
assert!(stack.current().unwrap().contains_key(&id));
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
}
#[test]
fn test_modified_and_removed() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
let diff = stack
.create_and_push_next(|e| {
let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty());
// an unchanged file is not reported as modified
let diff = stack.create_and_push_next(|_| Ok(())).unwrap();
assert!(diff.modified.is_empty());
let diff = stack
.create_and_push_next(|e| {
e.remove(&id);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(stack.current().unwrap().is_empty());
// going back restores the renamed file
stack.undo();
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_push_after_undo_drops_redo_branch() {
let mut stack = Stack::default();
let first = add_file(&mut stack);
let second = add_file(&mut stack);
stack.undo();
assert!(stack.can_redo());
assert!(!stack.current().unwrap().contains_key(&second));
let third = add_file(&mut stack);
assert!(!stack.can_redo());
assert!(stack.redo().is_none());
let current = stack.current().unwrap();
assert!(current.contains_key(&first));
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
}
-5
View File
@@ -1,5 +0,0 @@
mod core;
mod engine;
pub use core::*;
pub use engine::*;
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "gpx-rs"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1.7"
gpx-engine = { path = "../engine" }
js-sys = "0.3.106"
uuid = { version = "1.26.1", features = ["js"] }
wasm-bindgen = "0.2"
File diff suppressed because it is too large Load Diff
+1 -21
View File
@@ -17,7 +17,6 @@
"dexie": "^4.0.11", "dexie": "^4.0.11",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"gpx": "file:../gpx", "gpx": "file:../gpx",
"gpx-rs": "file:../gpx-rs/pkg",
"immer": "^10.1.1", "immer": "^10.1.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
@@ -71,8 +70,7 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7", "vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5", "vite": "^6.3.5"
"vite-plugin-wasm": "^3.6.0"
} }
}, },
"../gpx": { "../gpx": {
@@ -91,10 +89,6 @@
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
}, },
"../gpx-rs/pkg": {
"name": "gpx-rs",
"version": "0.1.0"
},
"../gpx/node_modules/@cspotcode/source-map-support": { "../gpx/node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"dev": true, "dev": true,
@@ -5498,10 +5492,6 @@
"resolved": "../gpx", "resolved": "../gpx",
"link": true "link": true
}, },
"node_modules/gpx-rs": {
"resolved": "../gpx-rs/pkg",
"link": true
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"dev": true, "dev": true,
@@ -7726,16 +7716,6 @@
"node": ">=18.0.0" "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": { "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+1 -4
View File
@@ -9,7 +9,6 @@
"postbuild": "npx tsx src/lib/scripts/sitemap.ts", "postbuild": "npx tsx src/lib/scripts/sitemap.ts",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node --import tsx --test \"src/**/*.test.ts\"",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .", "lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
@@ -60,8 +59,7 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7", "vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5", "vite": "^6.3.5"
"vite-plugin-wasm": "^3.6.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -74,7 +72,6 @@
"dexie": "^4.0.11", "dexie": "^4.0.11",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"gpx": "file:../gpx", "gpx": "file:../gpx",
"gpx-rs": "file:../gpx-rs/pkg",
"immer": "^10.1.1", "immer": "^10.1.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
-49
View File
@@ -1,49 +0,0 @@
import { readable } from 'svelte/store';
import { engine, FileStateCollectionObserver, type FileState, type Selection } from '$lib/engine';
import { isSelectionHidden } from '$lib/selection-helpers';
/**
* Whether all the selected elements are hidden. It follows the selection and, through the
* observer of the files, the state of every file (hiding, editing).
*/
export const allHidden = readable(false, (set) => {
let selection: Selection = { type: 'empty' };
const states = new Map<string, FileState>();
const unsubscribes = new Map<string, () => void>();
const update = () => set(isSelectionHidden(selection, states));
const unsubscribeSelection = engine.selection.subscribe(($selection) => {
selection = $selection;
update();
});
const observer = new FileStateCollectionObserver(
(files) => {
files.forEach((store, fileId) => {
unsubscribes.set(
fileId,
store.subscribe(($file) => {
states.set(fileId, $file);
update();
})
);
});
},
(fileId) => {
unsubscribes.get(fileId)?.();
unsubscribes.delete(fileId);
states.delete(fileId);
update();
},
() => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
unsubscribes.clear();
states.clear();
}
);
return () => {
unsubscribeSelection();
observer.destroy();
};
});
-29
View File
@@ -1,29 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { closestPointIndex } from './closest-point';
describe('closestPointIndex', () => {
const line = [0, 0, 1, 0, 2, 0, 2, 1];
it('has no point on an empty polyline', () => {
assert.equal(closestPointIndex([], { lng: 0, lat: 0 }), undefined);
});
it('is the only point of a polyline of one point', () => {
assert.equal(closestPointIndex([3, 4], { lng: 0, lat: 0 }), 0);
});
it('is the nearest end of the nearest segment', () => {
assert.equal(closestPointIndex(line, { lng: 0.2, lat: 0.1 }), 0);
assert.equal(closestPointIndex(line, { lng: 0.8, lat: 0.1 }), 1);
assert.equal(closestPointIndex(line, { lng: 1.6, lat: -0.1 }), 2);
assert.equal(closestPointIndex(line, { lng: 2.1, lat: 0.9 }), 3);
});
it('uses the distance to the segment, not to its ends', () => {
// far from every point but right next to the long segment
const coordinates = [0, 0, 10, 0, 10, 5];
assert.equal(closestPointIndex(coordinates, { lng: 7, lat: 0.01 }), 1);
assert.equal(closestPointIndex(coordinates, { lng: 3, lat: 0.01 }), 0);
});
});

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