From 67a4c090bd1b7bff5d118313ca96692c013337dc Mon Sep 17 00:00:00 2001 From: vcoppe Date: Thu, 8 Oct 2026 18:34:19 +0200 Subject: [PATCH] progress --- gpx-rs/Cargo.lock | 245 +++ gpx-rs/engine/Cargo.toml | 4 +- gpx-rs/engine/data/with_route_extensions.gpx | 23 + gpx-rs/engine/src/core/gpx/categories.rs | 4 +- gpx-rs/engine/src/core/gpx/chunk.rs | 40 +- gpx-rs/engine/src/core/gpx/chunked.rs | 2 +- gpx-rs/engine/src/core/gpx/common.rs | 4 +- gpx-rs/engine/src/core/gpx/file.rs | 9 +- gpx-rs/engine/src/core/gpx/segment.rs | 4 +- gpx-rs/engine/src/core/gpx/track.rs | 6 +- gpx-rs/engine/src/core/gpx/trackpoint.rs | 2 +- gpx-rs/engine/src/core/gpx/waypoint.rs | 4 +- gpx-rs/engine/src/core/gpx/waypoints.rs | 8 +- gpx-rs/engine/src/core/io/parse.rs | 118 +- .../engine/src/engine/command/tools/clean.rs | 3 +- .../engine/src/engine/command/tools/reduce.rs | 4 +- .../engine/src/engine/command/tools/split.rs | 9 +- gpx-rs/engine/src/engine/engine.rs | 79 +- gpx-rs/engine/src/engine/state/stack.rs | 148 +- gpx-rs/engine/src/lib.rs | 2 + gpx-rs/engine/src/storage/codec.rs | 130 ++ gpx-rs/engine/src/storage/memory.rs | 105 ++ gpx-rs/engine/src/storage/mod.rs | 85 + gpx-rs/engine/src/storage/persister.rs | 485 +++++ gpx-rs/wasm/Cargo.toml | 2 + gpx-rs/wasm/src/lib.rs | 162 +- gpx-rs/wasm/src/storage.rs | 234 +++ website/package-lock.json | 1571 +---------------- website/package.json | 4 +- website/src/lib/assets/example-data.ts | 330 ++++ website/src/lib/assets/example.gpx | 1 + website/src/lib/assets/example.test.ts | 48 + website/src/lib/assets/example.ts | 67 +- website/src/lib/cached-fetch.test.ts | 143 ++ website/src/lib/cached-fetch.ts | 89 + .../elevation-profile/ElevationProfile.svelte | 2 +- .../elevation-profile/elevation-profile.ts | 4 +- .../lib/components/embedding/Embedding.svelte | 4 +- .../embedding/EmbeddingPlayground.svelte | 8 +- .../file-list/FileListNodeContent.svelte | 13 +- .../file-list/FileListNodeLabel.svelte | 18 +- .../src/lib/components/file-list/file-list.ts | 300 ---- .../file-list/sortable-file-list.ts | 65 +- .../lib/components/map/gpx-layer/gpx-layer.ts | 4 +- .../map/gpx-layer/start-end-markers.ts | 5 +- .../map/layer-control/OverpassPopup.svelte | 2 +- .../map/layer-control/overpass-layer.ts | 93 +- .../components/map/map-layer-event-manager.ts | 21 +- .../components/toolbar/tools/Elevation.svelte | 2 +- .../toolbar/tools/routing/routing-controls.ts | 30 +- .../toolbar/tools/routing/routing.ts | 14 +- .../toolbar/tools/routing/simplify.ts | 51 - .../toolbar/tools/waypoint/Waypoint.svelte | 2 +- website/src/lib/db.ts | 37 - website/src/lib/docs/be/files-and-stats.mdx | 14 +- website/src/lib/docs/ca/files-and-stats.mdx | 14 +- website/src/lib/docs/cs/files-and-stats.mdx | 14 +- website/src/lib/docs/da/files-and-stats.mdx | 14 +- website/src/lib/docs/de/files-and-stats.mdx | 14 +- website/src/lib/docs/el/files-and-stats.mdx | 14 +- website/src/lib/docs/en/files-and-stats.mdx | 14 +- website/src/lib/docs/es/files-and-stats.mdx | 14 +- website/src/lib/docs/eu/files-and-stats.mdx | 14 +- website/src/lib/docs/fi/files-and-stats.mdx | 14 +- website/src/lib/docs/fr/files-and-stats.mdx | 14 +- website/src/lib/docs/he/files-and-stats.mdx | 14 +- website/src/lib/docs/hu/files-and-stats.mdx | 14 +- website/src/lib/docs/id/files-and-stats.mdx | 14 +- website/src/lib/docs/it/files-and-stats.mdx | 14 +- website/src/lib/docs/ko/files-and-stats.mdx | 14 +- website/src/lib/docs/lt/files-and-stats.mdx | 14 +- website/src/lib/docs/lv/files-and-stats.mdx | 14 +- website/src/lib/docs/nl/files-and-stats.mdx | 14 +- website/src/lib/docs/no/files-and-stats.mdx | 14 +- website/src/lib/docs/pl/files-and-stats.mdx | 14 +- .../src/lib/docs/pt-BR/files-and-stats.mdx | 14 +- website/src/lib/docs/pt/files-and-stats.mdx | 14 +- website/src/lib/docs/ro/files-and-stats.mdx | 14 +- website/src/lib/docs/ru/files-and-stats.mdx | 14 +- website/src/lib/docs/sr/files-and-stats.mdx | 14 +- website/src/lib/docs/sv/files-and-stats.mdx | 14 +- website/src/lib/docs/th/files-and-stats.mdx | 14 +- website/src/lib/docs/tr/files-and-stats.mdx | 14 +- website/src/lib/docs/uk/files-and-stats.mdx | 14 +- website/src/lib/docs/vi/files-and-stats.mdx | 14 +- .../src/lib/docs/zh-HK/files-and-stats.mdx | 14 +- website/src/lib/docs/zh/files-and-stats.mdx | 14 +- website/src/lib/engine.ts | 103 +- website/src/lib/file-visibility.test.ts | 53 + website/src/lib/file-visibility.ts | 52 + website/src/lib/geo.ts | 19 + website/src/lib/legacy-import.ts | 211 +++ website/src/lib/logic/file-action-manager.ts | 253 --- website/src/lib/logic/file-actions.ts | 43 - website/src/lib/logic/file-state.ts | 208 --- website/src/lib/logic/hidden.ts | 67 - website/src/lib/logic/selection-statistics.ts | 6 +- website/src/lib/logic/selection-tree.ts | 140 -- website/src/lib/logic/selection.ts | 424 ----- website/src/lib/logic/settings.ts | 123 +- website/src/lib/logic/statistics-tree.ts | 74 - website/src/lib/logic/statistics.ts | 89 - website/src/lib/scripts/example-statistics.ts | 88 + website/src/lib/utils.ts | 93 +- website/src/routes/[[language]]/+page.svelte | 14 +- .../src/routes/[[language]]/app/+page.svelte | 48 +- website/vite.config.ts | 3 - 107 files changed, 3474 insertions(+), 3948 deletions(-) create mode 100644 gpx-rs/engine/data/with_route_extensions.gpx create mode 100644 gpx-rs/engine/src/storage/codec.rs create mode 100644 gpx-rs/engine/src/storage/memory.rs create mode 100644 gpx-rs/engine/src/storage/mod.rs create mode 100644 gpx-rs/engine/src/storage/persister.rs create mode 100644 gpx-rs/wasm/src/storage.rs create mode 100644 website/src/lib/assets/example-data.ts create mode 100644 website/src/lib/assets/example.gpx create mode 100644 website/src/lib/assets/example.test.ts create mode 100644 website/src/lib/cached-fetch.test.ts create mode 100644 website/src/lib/cached-fetch.ts delete mode 100644 website/src/lib/components/file-list/file-list.ts delete mode 100644 website/src/lib/components/toolbar/tools/routing/simplify.ts delete mode 100644 website/src/lib/db.ts create mode 100644 website/src/lib/geo.ts create mode 100644 website/src/lib/legacy-import.ts delete mode 100644 website/src/lib/logic/file-action-manager.ts delete mode 100644 website/src/lib/logic/file-state.ts delete mode 100644 website/src/lib/logic/hidden.ts delete mode 100644 website/src/lib/logic/selection-tree.ts delete mode 100644 website/src/lib/logic/selection.ts delete mode 100644 website/src/lib/logic/statistics-tree.ts delete mode 100644 website/src/lib/logic/statistics.ts create mode 100644 website/src/lib/scripts/example-statistics.ts diff --git a/gpx-rs/Cargo.lock b/gpx-rs/Cargo.lock index 624382ae3..9acce6566 100644 --- a/gpx-rs/Cargo.lock +++ b/gpx-rs/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "libc", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -23,6 +32,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cc" version = "1.4.6" @@ -52,6 +67,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -68,6 +92,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -114,7 +162,9 @@ name = "gpx-engine" version = "0.1.0" dependencies = [ "chrono", + "postcard", "quick-xml", + "serde", "uuid", ] @@ -124,9 +174,40 @@ version = "0.1.0" dependencies = [ "console_error_panic_hook", "gpx-engine", + "idb", "js-sys", "uuid", "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", ] [[package]] @@ -153,6 +234,31 @@ dependencies = [ "cc", ] +[[package]] +name = "idb" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828" +dependencies = [ + "indexmap", + "js-sys", + "num-traits", + "thiserror", + "tokio", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -170,6 +276,15 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -203,6 +318,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -236,12 +364,63 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "shlex" version = "2.0.1" @@ -254,6 +433,21 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.118" @@ -276,6 +470,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -290,6 +513,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -306,6 +530,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -338,6 +573,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/gpx-rs/engine/Cargo.toml b/gpx-rs/engine/Cargo.toml index 2edb870f9..f6e72066f 100644 --- a/gpx-rs/engine/Cargo.toml +++ b/gpx-rs/engine/Cargo.toml @@ -5,5 +5,7 @@ edition = "2024" [dependencies] chrono = "0.4.45" +postcard = { version = "1.1.3", features = ["use-std"] } quick-xml = "0.42.0" -uuid = { version = "1.26.1", features = ["v4"] } +serde = { version = "1.0.229", features = ["derive"] } +uuid = { version = "1.26.1", features = ["serde", "v4"] } diff --git a/gpx-rs/engine/data/with_route_extensions.gpx b/gpx-rs/engine/data/with_route_extensions.gpx new file mode 100644 index 000000000..9236a8246 --- /dev/null +++ b/gpx-rs/engine/data/with_route_extensions.gpx @@ -0,0 +1,23 @@ + + + + with extensions + + 10 + + + + + + + + + 20 + + + + + + empty + + diff --git a/gpx-rs/engine/src/core/gpx/categories.rs b/gpx-rs/engine/src/core/gpx/categories.rs index ad0f3097d..fc88378eb 100644 --- a/gpx-rs/engine/src/core/gpx/categories.rs +++ b/gpx-rs/engine/src/core/gpx/categories.rs @@ -3,7 +3,7 @@ /// /// 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)] +#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Categories(Vec); impl Categories { @@ -36,7 +36,7 @@ impl Categories { /// The categories of the data of the trackpoints. There is one table per engine, shared by all /// its files. -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TrackpointCategories { pub surface: Categories, pub highway: Categories, diff --git a/gpx-rs/engine/src/core/gpx/chunk.rs b/gpx-rs/engine/src/core/gpx/chunk.rs index bdf1e981f..f727e2429 100644 --- a/gpx-rs/engine/src/core/gpx/chunk.rs +++ b/gpx-rs/engine/src/core/gpx/chunk.rs @@ -5,8 +5,8 @@ use crate::{Trackpoint, Waypoint}; /// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk /// that is not touched by an edit is not copied. /// -/// Chunks are equal when they have the same identity, which is cheap to compare and changes -/// whenever the content does (a modified chunk is a new one). +/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new +/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is. pub trait Chunk { type Item: Clone; @@ -27,8 +27,8 @@ pub trait Chunk { const MAX_TRKPT_CHUNK_SIZE: usize = 4096; -#[derive(Debug, PartialEq, Eq)] -pub struct TrackpointChunkId(Uuid); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct TrackpointChunkId(pub Uuid); impl Default for TrackpointChunkId { fn default() -> Self { @@ -42,12 +42,6 @@ pub struct TrackpointChunk { pub trkpt: Vec, } -impl PartialEq for TrackpointChunk { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - } -} - impl Chunk for TrackpointChunk { type Item = Trackpoint; const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE; @@ -68,8 +62,8 @@ impl Chunk for TrackpointChunk { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WaypointChunkId(Uuid); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct WaypointChunkId(pub Uuid); impl Default for WaypointChunkId { fn default() -> Self { @@ -85,12 +79,6 @@ pub struct WaypointChunk { pub wpt: Vec, } -impl PartialEq for WaypointChunk { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - } -} - impl Chunk for WaypointChunk { type Item = Waypoint; const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE; @@ -141,20 +129,8 @@ mod tests { fn test_new_chunks_have_their_own_identity() { let a = TrackpointChunk::new(vec![Trackpoint::default()]); let b = TrackpointChunk::new(vec![Trackpoint::default()]); - assert_ne!(a, b); + assert_ne!(a.id, b.id); assert_eq!(a.items().len(), 1); - assert_ne!(WaypointChunk::new(vec![]), WaypointChunk::new(vec![])); - } - - #[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); + assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id); } } diff --git a/gpx-rs/engine/src/core/gpx/chunked.rs b/gpx-rs/engine/src/core/gpx/chunked.rs index 116d1142c..4e72d6824 100644 --- a/gpx-rs/engine/src/core/gpx/chunked.rs +++ b/gpx-rs/engine/src/core/gpx/chunked.rs @@ -6,7 +6,7 @@ use crate::Chunk; /// copies the chunks around the change, so that a version costs little more than the change. /// /// There are no empty chunks. -#[derive(Debug, PartialEq)] +#[derive(Debug)] pub struct Chunked { chunks: Vec>, cumul_length: Vec, diff --git a/gpx-rs/engine/src/core/gpx/common.rs b/gpx-rs/engine/src/core/gpx/common.rs index 62b00f049..0e10bf026 100644 --- a/gpx-rs/engine/src/core/gpx/common.rs +++ b/gpx-rs/engine/src/core/gpx/common.rs @@ -1,10 +1,10 @@ -#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Link { pub href: String, pub text: Option, } -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)] pub struct LngLat { pub lng: f64, pub lat: f64, diff --git a/gpx-rs/engine/src/core/gpx/file.rs b/gpx-rs/engine/src/core/gpx/file.rs index 3d2960a48..99dcc6954 100644 --- a/gpx-rs/engine/src/core/gpx/file.rs +++ b/gpx-rs/engine/src/core/gpx/file.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::{Link, Track, Waypoints}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct FileId(pub Uuid); impl Default for FileId { @@ -11,16 +11,15 @@ impl Default for FileId { } } -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone)] pub struct File { pub id: FileId, pub info: FileInfo, pub trk: Vec, pub wpt: Waypoints, - // TODO routes } -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FileInfo { pub name: String, pub desc: Option, @@ -29,7 +28,7 @@ pub struct FileInfo { pub time: Option, } -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Author { pub name: Option, pub email: Option, diff --git a/gpx-rs/engine/src/core/gpx/segment.rs b/gpx-rs/engine/src/core/gpx/segment.rs index 34136a5cb..02ad441ec 100644 --- a/gpx-rs/engine/src/core/gpx/segment.rs +++ b/gpx-rs/engine/src/core/gpx/segment.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct TrackSegmentId(pub Uuid); impl Default for TrackSegmentId { @@ -22,7 +22,7 @@ impl Default for TrackSegmentRevisionId { } } -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone)] pub struct TrackSegment { pub id: TrackSegmentId, pub rev_id: TrackSegmentRevisionId, diff --git a/gpx-rs/engine/src/core/gpx/track.rs b/gpx-rs/engine/src/core/gpx/track.rs index 5fe10454a..f9bdebb38 100644 --- a/gpx-rs/engine/src/core/gpx/track.rs +++ b/gpx-rs/engine/src/core/gpx/track.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::{Link, TrackSegment}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct TrackId(pub Uuid); impl Default for TrackId { @@ -11,14 +11,14 @@ impl Default for TrackId { } } -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone)] pub struct Track { pub id: TrackId, pub info: TrackInfo, pub trkseg: Vec, } -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TrackInfo { pub name: Option, pub cmt: Option, diff --git a/gpx-rs/engine/src/core/gpx/trackpoint.rs b/gpx-rs/engine/src/core/gpx/trackpoint.rs index 625acb652..7b859d1f3 100644 --- a/gpx-rs/engine/src/core/gpx/trackpoint.rs +++ b/gpx-rs/engine/src/core/gpx/trackpoint.rs @@ -1,6 +1,6 @@ use crate::LngLat; -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] pub struct Trackpoint { pub coordinates: LngLat, pub ele: f64, diff --git a/gpx-rs/engine/src/core/gpx/waypoint.rs b/gpx-rs/engine/src/core/gpx/waypoint.rs index bd35617ed..409e46ccc 100644 --- a/gpx-rs/engine/src/core/gpx/waypoint.rs +++ b/gpx-rs/engine/src/core/gpx/waypoint.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::{Link, LngLat}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct WaypointId(pub Uuid); impl Default for WaypointId { @@ -11,7 +11,7 @@ impl Default for WaypointId { } } -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] pub struct Waypoint { pub id: WaypointId, pub coordinates: LngLat, diff --git a/gpx-rs/engine/src/core/gpx/waypoints.rs b/gpx-rs/engine/src/core/gpx/waypoints.rs index 81ff1189f..361de4d2a 100644 --- a/gpx-rs/engine/src/core/gpx/waypoints.rs +++ b/gpx-rs/engine/src/core/gpx/waypoints.rs @@ -19,7 +19,7 @@ impl Default for FileWaypointsRevisionId { /// Everything that changes the waypoints changes the revision too, which is why there is no /// mutable access to the list itself: the revision tells what is derived from the waypoints /// (coordinates for the map...) that it is out of date. -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone)] pub struct Waypoints { chunks: Chunked, /// Changes when the waypoints do. @@ -50,6 +50,12 @@ impl Waypoints { self.rev_id = Default::default(); } + /// Adds a chunk that is shared with something else after the last waypoint. + pub fn push_shared(&mut self, chunk: std::rc::Rc) { + self.chunks.push_shared(chunk); + self.rev_id = Default::default(); + } + /// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`]. pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec) { self.chunks.splice(start, end, waypoints); diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index b5b62bf77..eac38d977 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -21,6 +21,7 @@ enum GPXElement { Track(Track), Segment(TrackSegment), Trackpoint(Trackpoint), + RoutePoint(LngLat), Waypoint(Waypoint), Elevation, Time, @@ -51,6 +52,11 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat { coordinates } +/// Whether the element is a point of the detailed path of a route point (`gpxx:rpt`). +fn is_route_point_extension(name: &str) -> bool { + name == "rpt" || name.ends_with(":rpt") +} + /// Whether the element holds a value, which is its text. fn holds_text(element: Option<&GPXElement>) -> bool { matches!( @@ -262,6 +268,10 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result = vec![]; let mut trkpt_chunk = TrackpointChunk::default(); let mut wpt_chunk = WaypointChunk::default(); + // Routes are read as tracks of a single segment, which is what `rte_segment` collects the points of. A route + // point that comes with the detailed path of the route to the next one (`gpxx:rpt`) is replaced by it. + let mut rte_segment = TrackSegment::default(); + let mut rpt_points: Vec = vec![]; // the text of the element at the top of the stack, up to now let mut text = String::new(); loop { @@ -283,11 +293,11 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result stack.push(GPXElement::Text), - "trk" => stack.push(GPXElement::Track(Track::default())), + "trk" | "rte" => stack.push(GPXElement::Track(Track::default())), "trkseg" => { stack.push(GPXElement::Segment(TrackSegment::default())); } - "trkpt" => { + "trkpt" | "rtept" => { stack.push(GPXElement::Trackpoint(Trackpoint { coordinates: parse_coordinates(e.attributes()), ..Default::default() @@ -299,6 +309,9 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { + stack.push(GPXElement::RoutePoint(parse_coordinates(e.attributes()))); + } "ele" => stack.push(GPXElement::Elevation), "time" => stack.push(GPXElement::Time), e if e.ends_with("atemp") => stack.push(GPXElement::Temperature), @@ -330,6 +343,25 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { + if let Some(GPXElement::Track(_)) = stack.last() { + trkpt_chunk.trkpt.push(Trackpoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + }); + if trkpt_chunk.is_full() { + rte_segment.push(std::mem::take(&mut trkpt_chunk)); + } + } + } + name if is_route_point_extension(name) => { + if let Some(GPXElement::Trackpoint(_)) = stack.last() { + rpt_points.push(Trackpoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + }); + } + } "wpt" => { wpt_chunk.wpt.push(Waypoint { coordinates: parse_coordinates(e.attributes()), @@ -385,6 +417,42 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { + if let Some(GPXElement::Track(mut trk)) = stack.pop() { + rte_segment.push(std::mem::take(&mut trkpt_chunk)); + if !rte_segment.is_empty() { + let mut trkseg = std::mem::take(&mut rte_segment); + trkseg.compute_anchors(); + trk.trkseg.push(trkseg); + } + gpx.trk.push(trk); + } + } + "rtept" => { + if let Some(GPXElement::Trackpoint(rtept)) = stack.pop() + && let Some(GPXElement::Track(_)) = stack.last() + { + let points = if rpt_points.is_empty() { + vec![rtept] + } else { + std::mem::take(&mut rpt_points) + }; + for point in points { + trkpt_chunk.trkpt.push(point); + if trkpt_chunk.is_full() { + rte_segment.push(std::mem::take(&mut trkpt_chunk)); + } + } + } + } + name if is_route_point_extension(name) => { + if let Some(GPXElement::RoutePoint(coordinates)) = stack.pop() { + rpt_points.push(Trackpoint { + coordinates, + ..Default::default() + }); + } + } "trkseg" => { if let Some(GPXElement::Segment(mut trkseg)) = stack.pop() && let Some(GPXElement::Track(trk)) = stack.last_mut() @@ -571,6 +639,52 @@ mod tests { assert_eq!(trkpt.ele, 115.5); } + #[test] + fn test_parse_routes_as_tracks() { + let gpx = parse_data("with_routes"); + + assert_eq!(gpx.trk.len(), 2); + let trk = &gpx.trk[0]; + assert_eq!(trk.info.name.as_deref(), Some("route 1")); + assert_eq!(trk.info.type_.as_deref(), Some("Cycling")); + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert_eq!(trkseg.len(), 49); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.790867); + assert_eq!(trkpt.coordinates.lng, 4.404968); + assert_eq!(trkpt.ele, 109.0); + + let trk = &gpx.trk[1]; + assert_eq!(trk.info.name.as_deref(), Some("route 2")); + assert_eq!(trk.trkseg[0].len(), 28); + } + + #[test] + fn test_parse_route_point_extensions() { + let gpx = parse_data("with_route_extensions"); + + // a route without points has no segment + assert_eq!(gpx.trk.len(), 2); + assert!(gpx.trk[1].trkseg.is_empty()); + + let trk = &gpx.trk[0]; + assert_eq!(trk.info.name.as_deref(), Some("with extensions")); + assert_eq!(trk.trkseg.len(), 1); + let points: Vec<_> = trk.trkseg[0].iter().collect(); + // the detailed path replaces the route point it comes with + let coordinates: Vec<_> = points + .iter() + .map(|p| (p.coordinates.lat, p.coordinates.lng)) + .collect(); + assert_eq!( + coordinates, + [(1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)] + ); + assert_eq!(points[2].ele, 20.0); + assert!(points[2].time.is_some()); + } + #[test] fn test_parse_segments() { let gpx = parse_data("with_segments"); diff --git a/gpx-rs/engine/src/engine/command/tools/clean.rs b/gpx-rs/engine/src/engine/command/tools/clean.rs index 6cfde55d6..c543d6ebb 100644 --- a/gpx-rs/engine/src/engine/command/tools/clean.rs +++ b/gpx-rs/engine/src/engine/command/tools/clean.rs @@ -329,7 +329,8 @@ mod tests { 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); + // the file was not even copied + assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before)); } #[test] diff --git a/gpx-rs/engine/src/engine/command/tools/reduce.rs b/gpx-rs/engine/src/engine/command/tools/reduce.rs index e6fb1f314..20612424d 100644 --- a/gpx-rs/engine/src/engine/command/tools/reduce.rs +++ b/gpx-rs/engine/src/engine/command/tools/reduce.rs @@ -1,6 +1,6 @@ use std::rc::Rc; -use crate::{Apply, CommandError, File, State, Trackpoint, reduce_indices, reduction_distances}; +use crate::{Apply, CommandError, File, State, Trackpoint, reduce_indices}; /// Removes the trackpoints of the selected segments that are less than `tolerance` meters away /// from the line of their neighbours (Ramer-Douglas-Peucker, see [`reduce_indices`]). The ends of the segments are @@ -56,7 +56,7 @@ impl Apply for Reduce { mod tests { use std::collections::HashSet; - use crate::{FileId, Load, Selection, engine::command::fixture::Fixture}; + use crate::{FileId, Load, Selection, engine::command::fixture::Fixture, reduction_distances}; use super::*; diff --git a/gpx-rs/engine/src/engine/command/tools/split.rs b/gpx-rs/engine/src/engine/command/tools/split.rs index f245ec7f1..73f7e93bb 100644 --- a/gpx-rs/engine/src/engine/command/tools/split.rs +++ b/gpx-rs/engine/src/engine/command/tools/split.rs @@ -245,7 +245,14 @@ mod tests { // the segments before are in the first track, the ones after in the second one assert_eq!(a.trkseg.len(), seg + 1); assert_eq!(b.trkseg.len(), before.trk[trk].trkseg.len() - seg); - assert_eq!(a.trkseg[..seg], before.trk[trk].trkseg[..seg]); + // the segments before are the same ones, with the same trackpoints + for (kept, original) in a.trkseg[..seg].iter().zip(&before.trk[trk].trkseg[..seg]) { + assert_eq!((kept.id, kept.rev_id), (original.id, original.rev_id)); + let chunks = |segment: &TrackSegment| -> Vec<_> { + segment.chunks().iter().map(|chunk| chunk.id).collect() + }; + assert_eq!(chunks(kept), chunks(original)); + } // new ids for everything that moved, so that they are unique let ids: HashSet = after .trk diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 25c44f2db..78927a1ac 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -2,9 +2,10 @@ use crate::{ Action, Apply, Clipboard, Command, CoordinatesCache, Diff, ExportOptions, FileId, FileOrder, - FileStructure, FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection, - Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint, - TrackpointCategories, Waypoint, WaypointId, reduction_distances, write, + FileStructure, FileStructureCache, GlobalStatistics, Restored, RoutingBuffer, SelectMode, + Selection, Snapshot, Stack, StackEntry, State, StatisticsBuffer, StatisticsCache, + TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, WaypointId, reduction_distances, + write, }; #[derive(Debug, Default)] @@ -118,6 +119,40 @@ impl Engine { .collect() } + /// What there is to keep of the engine, see [`crate::Persister`]. + pub fn snapshot(&self) -> Snapshot { + Snapshot { + files: self.stack.current().cloned().unwrap_or_default(), + order: self.order.0.clone(), + categories: self.categories.clone(), + } + } + + /// Puts files in an engine that has none, typically the ones read from the storage. They are + /// the state to start from: they cannot be undone. The files that were added are reported as + /// the last diff, and the order as changed. + pub fn restore(&mut self, restored: Restored) { + let files: StackEntry = restored + .files + .into_iter() + .map(|file| (file.id, file)) + .collect(); + self.categories = restored.categories; + self.order = FileOrder(restored.order); + self.selection = Selection::Empty; + self.clipboard = None; + self.diff = Some(Diff { + added: files.keys().copied().collect(), + ..Default::default() + }); + self.stack = Stack::restored(files); + self.refresh(); + self.sort_diff(); + self.order_changed = true; + self.selection_changed = true; + self.clipboard_changed = true; + } + /// A file as GPX (UTF-8), `None` if it does not exist. pub fn export(&self, id: &FileId, options: ExportOptions) -> Option> { let file = self.stack.current()?.get(id)?; @@ -243,6 +278,7 @@ impl Engine { || self.clipboard != clipboard_before; if changed { self.refresh(); + self.sort_diff(); } // after the refresh, which also syncs the order and selection with the files (undo, redo) self.order_changed = self.order.0 != order_before; @@ -273,6 +309,16 @@ impl Engine { }) } + /// Puts the files of the last diff in the order of the files (the stack does not know it), so + /// that whatever is done with them one after the other, like giving them colors, follows it. + fn sort_diff(&mut self) { + let position = |id: &FileId| self.order.0.iter().position(|other| other == id); + if let Some(diff) = &mut self.diff { + diff.added.sort_by_key(position); + diff.modified.sort_by_key(position); + } + } + /// Brings everything derived from the files back in line with the current stack entry. fn refresh(&mut self) { let current = self.stack.current(); @@ -1166,6 +1212,33 @@ mod tests { assert_eq!(track_ids(&engine, a), tracks); } + #[test] + fn test_added_files_are_reported_in_the_order_of_the_files() { + use crate::LoadFiles; + + let mut engine = Engine::default(); + let datas: Vec> = (0..12) + .map(|i| { + format!(r#"{i}"#) + .into_bytes() + }) + .collect(); + let loads = datas + .iter() + .map(|data| Load { data, name: "x" }) + .collect::>(); + assert!(edit( + &mut engine, + Command::LoadFiles(LoadFiles { files: loads }) + )); + assert_eq!(engine.last_diff().unwrap().added, engine.order()); + + // files that come back with an undo too + assert!(engine.execute(Action::Undo)); + assert!(engine.execute(Action::Redo)); + assert_eq!(engine.last_diff().unwrap().added, engine.order()); + } + #[test] fn test_load_several_files_is_one_edit() { use crate::LoadFiles; diff --git a/gpx-rs/engine/src/engine/state/stack.rs b/gpx-rs/engine/src/engine/state/stack.rs index e14cce69e..354b7dc65 100644 --- a/gpx-rs/engine/src/engine/state/stack.rs +++ b/gpx-rs/engine/src/engine/state/stack.rs @@ -9,9 +9,21 @@ use crate::{File, FileId}; pub struct Stack { entries: Vec, index: Option, + /// Whether the first entry is the state the stack started from (files restored from the + /// storage), which is not undone. + restored: bool, } impl Stack { + /// A stack whose first entry is `entry`, from which nothing can be undone. + pub fn restored(entry: StackEntry) -> Self { + Self { + entries: vec![entry], + index: Some(0), + restored: true, + } + } + pub fn current(&self) -> Option<&StackEntry> { self.index.map_or_default(|i| self.get(i)) } @@ -32,7 +44,9 @@ impl Stack { self.record_diff(|stack| { if let Some(i) = stack.index { if i == 0 { - stack.index = None; + if !stack.restored { + stack.index = None; + } } else { stack.index = Some(i - 1); } @@ -56,7 +70,7 @@ impl Stack { } pub fn can_undo(&self) -> bool { - self.index.is_some() + self.index.is_some_and(|i| i > 0 || !self.restored) } pub fn can_redo(&self) -> bool { @@ -97,7 +111,7 @@ impl Stack { 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 { + if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) { modified.push(*id); } } @@ -231,6 +245,34 @@ mod tests { assert_eq!(stack.current().unwrap()[&id].info.name, "renamed"); } + #[test] + fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() { + let mut stack = Stack::default(); + let id = add_file(&mut stack); + + // a copy, even if it has the same content, is another file for the stack: files are + // told apart by their identity, comparing them would read all of them + let diff = stack + .create_and_push_next(|e| { + let copy = (**e.get(&id).unwrap()).clone(); + e.insert(id, Rc::new(copy)); + Ok(()) + }) + .unwrap(); + assert_eq!(diff.modified, vec![id]); + + // and the files that were not replaced are not + let other = add_file(&mut stack); + let diff = stack + .create_and_push_next(|e| { + let copy = (**e.get(&other).unwrap()).clone(); + e.insert(other, Rc::new(copy)); + Ok(()) + }) + .unwrap(); + assert_eq!(diff.modified, vec![other]); + } + #[test] fn test_push_after_undo_drops_redo_branch() { let mut stack = Stack::default(); @@ -249,4 +291,104 @@ mod tests { assert!(current.contains_key(&third)); assert!(!current.contains_key(&second)); } + + fn restored_entry() -> (StackEntry, FileId) { + let file = Rc::new(File::default()); + let id = file.id; + (StackEntry::from([(id, file)]), id) + } + + #[test] + fn test_restored_stack_starts_from_its_entry() { + let (entry, id) = restored_entry(); + let mut stack = Stack::restored(entry); + + assert!(stack.current().unwrap().contains_key(&id)); + // there is nothing to go back to, nor forward to + assert!(!stack.can_undo()); + assert!(!stack.can_redo()); + assert!(stack.undo().is_none()); + assert!(stack.redo().is_none()); + assert!(stack.current().unwrap().contains_key(&id)); + } + + #[test] + fn test_restored_stack_cannot_undo_an_empty_entry_either() { + let mut stack = Stack::restored(StackEntry::new()); + + assert!(stack.current().is_some_and(|files| files.is_empty())); + assert!(!stack.can_undo()); + assert!(stack.undo().is_none()); + // the stack is not the one of an engine that has done nothing + assert!(stack.current().is_some()); + } + + #[test] + fn test_restored_stack_undoes_what_comes_after_it_and_no_further() { + let (entry, restored) = restored_entry(); + let mut stack = Stack::restored(entry); + + let added = add_file(&mut stack); + assert!(stack.can_undo()); + assert!(!stack.can_redo()); + assert_eq!(stack.current().unwrap().len(), 2); + + // back to the restored files + let diff = stack.undo().unwrap(); + assert_eq!(diff.removed, vec![added]); + assert!(diff.added.is_empty() && diff.modified.is_empty()); + assert_eq!(stack.current().unwrap().len(), 1); + assert!(stack.current().unwrap().contains_key(&restored)); + assert!(!stack.can_undo()); + assert!(stack.can_redo()); + assert!(stack.undo().is_none()); + assert!(stack.current().unwrap().contains_key(&restored)); + + let diff = stack.redo().unwrap(); + assert_eq!(diff.added, vec![added]); + assert!(stack.can_undo()); + assert!(!stack.can_redo()); + + // and again + assert!(stack.undo().is_some()); + assert!(stack.undo().is_none()); + assert!(stack.current().unwrap().contains_key(&restored)); + } + + #[test] + fn test_restored_stack_removing_its_files_can_be_undone() { + let (entry, restored) = restored_entry(); + let mut stack = Stack::restored(entry); + + let diff = stack + .create_and_push_next(|e| { + e.remove(&restored); + Ok(()) + }) + .unwrap(); + assert_eq!(diff.removed, vec![restored]); + assert!(stack.current().unwrap().is_empty()); + + let diff = stack.undo().unwrap(); + assert_eq!(diff.added, vec![restored]); + assert!(stack.current().unwrap().contains_key(&restored)); + assert!(!stack.can_undo()); + } + + #[test] + fn test_restored_stack_push_after_undo_drops_redo_branch() { + let (entry, restored) = restored_entry(); + let mut stack = Stack::restored(entry); + add_file(&mut stack); + stack.undo().unwrap(); + assert!(stack.can_redo()); + + let other = add_file(&mut stack); + assert!(!stack.can_redo()); + assert!(stack.can_undo()); + let diff = stack.undo().unwrap(); + assert_eq!(diff.removed, vec![other]); + assert!(stack.current().unwrap().contains_key(&restored)); + assert!(!stack.can_undo()); + } } diff --git a/gpx-rs/engine/src/lib.rs b/gpx-rs/engine/src/lib.rs index d6ea07f75..8fef69875 100644 --- a/gpx-rs/engine/src/lib.rs +++ b/gpx-rs/engine/src/lib.rs @@ -1,5 +1,7 @@ mod core; mod engine; +mod storage; pub use core::*; pub use engine::*; +pub use storage::*; diff --git a/gpx-rs/engine/src/storage/codec.rs b/gpx-rs/engine/src/storage/codec.rs new file mode 100644 index 000000000..ec4aa8b7e --- /dev/null +++ b/gpx-rs/engine/src/storage/codec.rs @@ -0,0 +1,130 @@ +//! The binary form of what is stored: a version byte, then the data (postcard). + +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + File, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Trackpoint, + TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk, WaypointChunkId, Waypoints, +}; + +/// Changes when the form of the records does. +const VERSION: u8 = 1; + +pub fn encode(value: &T) -> Vec { + postcard::to_extend(value, vec![VERSION]).expect("serializing to memory") +} + +pub fn decode<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Option { + match bytes.split_first() { + Some((&VERSION, data)) => postcard::from_bytes(data).ok(), + _ => None, + } +} + +/// A file without its trackpoints and waypoints, which are in chunks. +#[derive(Serialize, Deserialize)] +pub struct FileRecord { + info: FileInfo, + tracks: Vec, + waypoints: Vec, +} + +#[derive(Serialize, Deserialize)] +struct TrackRecord { + id: TrackId, + info: TrackInfo, + segments: Vec, +} + +#[derive(Serialize, Deserialize)] +struct SegmentRecord { + id: TrackSegmentId, + chunks: Vec, +} + +impl FileRecord { + pub fn new(file: &File) -> Self { + Self { + info: file.info.clone(), + tracks: file + .trk + .iter() + .map(|track| TrackRecord { + id: track.id, + info: track.info.clone(), + segments: track + .trkseg + .iter() + .map(|segment| SegmentRecord { + id: segment.id, + chunks: segment.chunks().iter().map(|chunk| chunk.id).collect(), + }) + .collect(), + }) + .collect(), + waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id).collect(), + } + } + + /// The file made of the record and its chunks, `None` if one is missing. Chunks that several + /// files use stay shared. + pub fn build( + self, + id: crate::FileId, + trackpoint_chunks: &impl Fn(&TrackpointChunkId) -> Option>, + waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option>, + ) -> Option { + let mut file = File { + id, + info: self.info, + ..Default::default() + }; + for track in self.tracks { + let mut trkseg = Vec::with_capacity(track.segments.len()); + for segment in track.segments { + let mut built = TrackSegment::default(); + built.id = segment.id; + for chunk in &segment.chunks { + built.push_shared(trackpoint_chunks(chunk)?); + } + trkseg.push(built); + } + file.trk.push(Track { + id: track.id, + info: track.info, + trkseg, + }); + } + let mut wpt = Waypoints::default(); + for chunk in &self.waypoints { + wpt.push_shared(waypoint_chunks(chunk)?); + } + file.wpt = wpt; + Some(file) + } +} + +pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec { + encode(&chunk.trkpt) +} + +pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Option { + Some(TrackpointChunk { + id: TrackpointChunkId(id), + trkpt: decode::>(bytes)?, + }) +} + +pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec { + encode(&chunk.wpt) +} + +pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Option { + Some(WaypointChunk { + id: WaypointChunkId(id), + wpt: decode::>(bytes)?, + }) +} diff --git a/gpx-rs/engine/src/storage/memory.rs b/gpx-rs/engine/src/storage/memory.rs new file mode 100644 index 000000000..ce9986684 --- /dev/null +++ b/gpx-rs/engine/src/storage/memory.rs @@ -0,0 +1,105 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::{Batch, FileId, Storage, StorageError, StoredData}; + +/// A storage that lives as long as the session. It is what an engine without a host storage uses, +/// and what the tests use. +#[derive(Debug, Default)] +pub struct MemoryStorage { + state: RefCell, +} + +#[derive(Debug, Default)] +struct State { + files: HashMap>, + trackpoint_chunks: HashMap>, + waypoint_chunks: HashMap>, + order: Option>, + categories: Option>, + settings: HashMap, +} + +impl MemoryStorage { + /// How many files and chunks (trackpoints, waypoints) are stored. + pub fn counts(&self) -> (usize, usize, usize) { + let state = self.state.borrow(); + ( + state.files.len(), + state.trackpoint_chunks.len(), + state.waypoint_chunks.len(), + ) + } +} + +impl Storage for MemoryStorage { + async fn load(&self) -> Result { + let state = self.state.borrow(); + Ok(StoredData { + files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(), + trackpoint_chunks: state + .trackpoint_chunks + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(), + waypoint_chunks: state + .waypoint_chunks + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(), + order: state.order.clone(), + categories: state.categories.clone(), + }) + } + + async fn commit(&self, batch: &Batch) -> Result<(), StorageError> { + let mut state = self.state.borrow_mut(); + state.files.extend(batch.put_files.iter().cloned()); + for id in &batch.delete_files { + state.files.remove(id); + } + state + .trackpoint_chunks + .extend(batch.put_trackpoint_chunks.iter().cloned()); + for id in &batch.delete_trackpoint_chunks { + state.trackpoint_chunks.remove(id); + } + state + .waypoint_chunks + .extend(batch.put_waypoint_chunks.iter().cloned()); + for id in &batch.delete_waypoint_chunks { + state.waypoint_chunks.remove(id); + } + if let Some(order) = &batch.order { + state.order = Some(order.clone()); + } + if let Some(categories) = &batch.categories { + state.categories = Some(categories.clone()); + } + Ok(()) + } + + async fn load_settings(&self) -> Result, StorageError> { + let state = self.state.borrow(); + Ok(state + .settings + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect()) + } + + async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> { + self.state + .borrow_mut() + .settings + .insert(key.to_owned(), value.to_owned()); + Ok(()) + } + + async fn delete_setting(&self, key: &str) -> Result<(), StorageError> { + self.state.borrow_mut().settings.remove(key); + Ok(()) + } +} diff --git a/gpx-rs/engine/src/storage/mod.rs b/gpx-rs/engine/src/storage/mod.rs new file mode 100644 index 000000000..e2344e95d --- /dev/null +++ b/gpx-rs/engine/src/storage/mod.rs @@ -0,0 +1,85 @@ +//! Keeping the files and the settings between sessions. +//! +//! The engine does not know where the data goes: the host (the browser, a desktop app...) +//! implements [`Storage`], and a [`Persister`] works out what to write after each change. +//! +//! The files are not stored as a whole. Their trackpoints and waypoints are stored in chunks (see +//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record +//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the +//! files that changed; the chunks that nothing refers to anymore are deleted at the same time. + +mod codec; +mod memory; +mod persister; + +pub use memory::*; +pub use persister::*; + +use uuid::Uuid; + +use crate::FileId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageError(pub String); + +impl std::fmt::Display for StorageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "storage error: {}", self.0) + } +} + +impl std::error::Error for StorageError {} + +/// Everything that is stored about the files, as opaque bytes (see the module documentation). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct StoredData { + /// The record of each file. + pub files: Vec<(FileId, Vec)>, + pub trackpoint_chunks: Vec<(Uuid, Vec)>, + pub waypoint_chunks: Vec<(Uuid, Vec)>, + /// The order of the files. + pub order: Option>, + /// The categories of the trackpoints, which their chunks refer to. + pub categories: Option>, +} + +/// A change of the stored data, to be applied all at once: a stored state is always one that the +/// engine had. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Batch { + pub put_files: Vec<(FileId, Vec)>, + pub delete_files: Vec, + pub put_trackpoint_chunks: Vec<(Uuid, Vec)>, + pub delete_trackpoint_chunks: Vec, + pub put_waypoint_chunks: Vec<(Uuid, Vec)>, + pub delete_waypoint_chunks: Vec, + pub order: Option>, + pub categories: Option>, +} + +impl Batch { + pub fn is_empty(&self) -> bool { + self == &Self::default() + } +} + +/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that +/// keeps nothing after the session. +/// +/// The methods are async because storage often is (IndexedDB), the engine itself is not: it +/// changes its files at once and the changes are written afterwards, see [`Persister`]. +#[allow(async_fn_in_trait)] +pub trait Storage { + /// Reads the files. + async fn load(&self) -> Result; + + /// Applies a batch: either all of it is stored, or none. + async fn commit(&self, batch: &Batch) -> Result<(), StorageError>; + + /// Reads the settings, as `(key, value)`. The engine does not look into them. + async fn load_settings(&self) -> Result, StorageError>; + + async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError>; + + async fn delete_setting(&self, key: &str) -> Result<(), StorageError>; +} diff --git a/gpx-rs/engine/src/storage/persister.rs b/gpx-rs/engine/src/storage/persister.rs new file mode 100644 index 000000000..89a89f9ab --- /dev/null +++ b/gpx-rs/engine/src/storage/persister.rs @@ -0,0 +1,485 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use uuid::Uuid; + +use super::codec::{ + FileRecord, decode, decode_trackpoints, decode_waypoints, encode, encode_trackpoints, + encode_waypoints, +}; +use crate::{ + Batch, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories, TrackpointChunk, + WaypointChunk, +}; + +/// What there is to keep of the engine: the files of the current state, their order, and the +/// categories their trackpoints refer to. See [`crate::Engine::snapshot`]. +#[derive(Debug, Default, Clone)] +pub struct Snapshot { + pub files: StackEntry, + pub order: Vec, + pub categories: TrackpointCategories, +} + +/// What was read from the storage, to start an engine with (see [`crate::Engine::restore`]). +#[derive(Debug, Default)] +pub struct Restored { + pub files: Vec>, + pub order: Vec, + pub categories: TrackpointCategories, + /// The files that could not be read: they are dropped at the next save. + pub unreadable: usize, +} + +/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it +/// already holds. +/// +/// A file whose `Rc` is the saved one has not changed, which is what makes a save cheap: only the +/// files that changed are looked at to find the chunks to write, and the others are only counted +/// to find the chunks that nothing uses anymore. +#[derive(Debug, Default)] +pub struct Persister { + /// The files as they are saved. + files: HashMap>, + /// The files that have a record, including the ones that could not be read. + stored_files: HashSet, + trackpoint_chunks: HashSet, + waypoint_chunks: HashSet, + order: Option>, + categories: Option, +} + +/// What a save writes, and what is stored once it is written. +struct Plan { + batch: Batch, + snapshot: Snapshot, + trackpoint_chunks: HashSet, + waypoint_chunks: HashSet, +} + +impl Persister { + /// Reads the files of the storage. The persister then knows that they are saved. + pub async fn restore(&mut self, storage: &S) -> Result { + let data = storage.load().await?; + *self = Self::default(); + + let categories: Option = data.categories.as_deref().and_then(decode); + let stored_order: Option> = data.order.as_deref().and_then(decode); + + let trackpoint_bytes: HashMap> = data.trackpoint_chunks.into_iter().collect(); + let waypoint_bytes: HashMap> = data.waypoint_chunks.into_iter().collect(); + // chunks are decoded once, whatever the number of files that use them + let trackpoint_cache: RefCell>>> = + Default::default(); + let waypoint_cache: RefCell>>> = Default::default(); + let trackpoint_chunk = |id: &crate::TrackpointChunkId| { + trackpoint_cache + .borrow_mut() + .entry(id.0) + .or_insert_with(|| { + let bytes = trackpoint_bytes.get(&id.0)?; + decode_trackpoints(id.0, bytes).map(Rc::new) + }) + .clone() + }; + let waypoint_chunk = |id: &crate::WaypointChunkId| { + waypoint_cache + .borrow_mut() + .entry(id.0) + .or_insert_with(|| { + let bytes = waypoint_bytes.get(&id.0)?; + decode_waypoints(id.0, bytes).map(Rc::new) + }) + .clone() + }; + + let mut restored = Restored { + categories: categories.clone().unwrap_or_default(), + ..Default::default() + }; + let mut files: HashMap> = HashMap::new(); + for (id, bytes) in &data.files { + self.stored_files.insert(*id); + let file = decode::(bytes) + .and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk)); + match file { + Some(file) => { + files.insert(*id, Rc::new(file)); + } + None => restored.unreadable += 1, + } + } + + restored.order = stored_order + .iter() + .flatten() + .filter(|id| files.contains_key(id)) + .copied() + .collect(); + let ordered: HashSet = restored.order.iter().copied().collect(); + restored + .order + .extend(files.keys().filter(|id| !ordered.contains(id)).copied()); + restored.files = restored.order.iter().map(|id| files[id].clone()).collect(); + + self.files = files; + self.trackpoint_chunks = trackpoint_bytes.into_keys().collect(); + self.waypoint_chunks = waypoint_bytes.into_keys().collect(); + self.order = stored_order; + self.categories = categories; + Ok(restored) + } + + /// Writes what changed since the last save. Nothing is written if nothing changed. + pub async fn save( + &mut self, + storage: &S, + snapshot: Snapshot, + ) -> Result<(), StorageError> { + let plan = self.plan(snapshot); + if plan.batch.is_empty() { + self.files = plan.snapshot.files; + return Ok(()); + } + storage.commit(&plan.batch).await?; + + self.stored_files = plan.snapshot.files.keys().copied().collect(); + self.files = plan.snapshot.files; + self.trackpoint_chunks = plan.trackpoint_chunks; + self.waypoint_chunks = plan.waypoint_chunks; + self.order = Some(plan.snapshot.order); + self.categories = Some(plan.snapshot.categories); + Ok(()) + } + + fn plan(&self, snapshot: Snapshot) -> Plan { + let mut batch = Batch::default(); + // the chunks that the files use, which the storage has afterwards + let mut trackpoint_chunks = HashSet::new(); + let mut waypoint_chunks = HashSet::new(); + + for (id, file) in &snapshot.files { + let changed = self + .files + .get(id) + .is_none_or(|saved| !Rc::ptr_eq(saved, file)); + let segments = file.trk.iter().flat_map(|track| &track.trkseg); + for chunk in segments.flat_map(|segment| segment.chunks()) { + let new = trackpoint_chunks.insert(chunk.id.0); + if changed && new && !self.trackpoint_chunks.contains(&chunk.id.0) { + batch + .put_trackpoint_chunks + .push((chunk.id.0, encode_trackpoints(chunk))); + } + } + for chunk in file.wpt.chunks() { + let new = waypoint_chunks.insert(chunk.id.0); + if changed && new && !self.waypoint_chunks.contains(&chunk.id.0) { + batch + .put_waypoint_chunks + .push((chunk.id.0, encode_waypoints(chunk))); + } + } + if changed || !self.stored_files.contains(id) { + batch.put_files.push((*id, encode(&FileRecord::new(file)))); + } + } + + batch.delete_files = self + .stored_files + .iter() + .filter(|id| !snapshot.files.contains_key(id)) + .copied() + .collect(); + batch.delete_trackpoint_chunks = self + .trackpoint_chunks + .difference(&trackpoint_chunks) + .copied() + .collect(); + batch.delete_waypoint_chunks = self + .waypoint_chunks + .difference(&waypoint_chunks) + .copied() + .collect(); + if self.order.as_ref() != Some(&snapshot.order) { + batch.order = Some(encode(&snapshot.order)); + } + if self.categories.as_ref() != Some(&snapshot.categories) { + batch.categories = Some(encode(&snapshot.categories)); + } + + Plan { + batch, + snapshot, + trackpoint_chunks, + waypoint_chunks, + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::future::Future; + use std::task::{Context, Poll, Waker}; + + use crate::{ + Action, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse, write, + }; + + use super::*; + + fn block_on(future: F) -> F::Output { + let mut future = std::pin::pin!(future); + let mut context = Context::from_waker(Waker::noop()); + loop { + if let Poll::Ready(value) = future.as_mut().poll(&mut context) { + return value; + } + } + } + + /// A memory storage that remembers the batches it was given. + #[derive(Default)] + struct Recording { + inner: MemoryStorage, + batches: RefCell>, + } + + impl Storage for Recording { + async fn load(&self) -> Result { + self.inner.load().await + } + + async fn commit(&self, batch: &Batch) -> Result<(), StorageError> { + self.batches.borrow_mut().push(batch.clone()); + self.inner.commit(batch).await + } + + async fn load_settings(&self) -> Result, StorageError> { + self.inner.load_settings().await + } + + async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> { + self.inner.put_setting(key, value).await + } + + async fn delete_setting(&self, key: &str) -> Result<(), StorageError> { + self.inner.delete_setting(key).await + } + } + + fn snapshot_of(paths: &[&str]) -> Snapshot { + let mut snapshot = Snapshot::default(); + for path in paths { + let data = std::fs::read(path).unwrap(); + let file = parse(&data, &mut snapshot.categories).unwrap(); + snapshot.order.push(file.id); + snapshot.files.insert(file.id, Rc::new(file)); + } + snapshot + } + + fn gpx(file: &File, categories: &TrackpointCategories) -> Vec { + write(file, categories, ExportOptions::ALL) + } + + const FILES: [&str; 3] = [ + "data/with_tracks_and_segments.gpx", + "data/with_waypoint.gpx", + "data/with_surface.gpx", + ]; + + #[test] + fn test_saved_files_are_restored() { + let storage = Recording::default(); + let snapshot = snapshot_of(&FILES); + block_on(Persister::default().save(&storage, snapshot.clone())).unwrap(); + + let mut persister = Persister::default(); + let restored = block_on(persister.restore(&storage)).unwrap(); + assert_eq!(restored.unreadable, 0); + assert_eq!(restored.order, snapshot.order); + assert_eq!(restored.categories, snapshot.categories); + for file in &restored.files { + let original = &snapshot.files[&file.id]; + assert_eq!( + gpx(file, &restored.categories), + gpx(original, &snapshot.categories) + ); + // identities that the rest of the engine refers to are kept + assert_eq!(file.trk[0].id, original.trk[0].id); + assert_eq!(file.trk[0].trkseg[0].id, original.trk[0].trkseg[0].id); + assert_eq!( + file.trk[0].trkseg[0].chunks()[0].id, + original.trk[0].trkseg[0].chunks()[0].id + ); + } + // what was just read is saved + let again = Snapshot { + files: restored.files.iter().map(|f| (f.id, f.clone())).collect(), + order: restored.order, + categories: restored.categories, + }; + block_on(persister.save(&storage, again)).unwrap(); + assert_eq!(storage.batches.borrow().len(), 1); + } + + #[test] + fn test_nothing_is_written_when_nothing_changed() { + let storage = Recording::default(); + let snapshot = snapshot_of(&FILES); + let mut persister = Persister::default(); + block_on(persister.save(&storage, snapshot.clone())).unwrap(); + block_on(persister.save(&storage, snapshot)).unwrap(); + assert_eq!(storage.batches.borrow().len(), 1); + } + + #[test] + fn test_an_edit_writes_what_it_changed_only() { + let storage = Recording::default(); + let mut snapshot = snapshot_of(&FILES); + let mut persister = Persister::default(); + block_on(persister.save(&storage, snapshot.clone())).unwrap(); + let counts = storage.inner.counts(); + + // renaming a file writes its record, not its chunks + let id = snapshot.order[0]; + let mut file = (*snapshot.files[&id]).clone(); + file.info.name = "renamed".into(); + snapshot.files.insert(id, Rc::new(file)); + block_on(persister.save(&storage, snapshot.clone())).unwrap(); + { + let batches = storage.batches.borrow(); + let batch = batches.last().unwrap(); + assert_eq!(batch.put_files.len(), 1); + assert!(batch.put_trackpoint_chunks.is_empty()); + assert!(batch.put_waypoint_chunks.is_empty()); + assert!(batch.delete_trackpoint_chunks.is_empty()); + assert!(batch.order.is_none() && batch.categories.is_none()); + } + assert_eq!(storage.inner.counts(), counts); + + // changing a trackpoint replaces the chunk that holds it only + let mut file = (*snapshot.files[&id]).clone(); + let chunks_before = file + .trk + .iter() + .flat_map(|t| &t.trkseg) + .map(|s| s.chunks().len()) + .sum::(); + file.trk[0].trkseg[0].update(0, |point| point.ele += 1.0); + snapshot.files.insert(id, Rc::new(file)); + block_on(persister.save(&storage, snapshot.clone())).unwrap(); + { + let batches = storage.batches.borrow(); + let batch = batches.last().unwrap(); + assert_eq!(batch.put_files.len(), 1); + assert_eq!(batch.put_trackpoint_chunks.len(), 1); + assert_eq!(batch.delete_trackpoint_chunks.len(), 1); + } + assert_eq!(storage.inner.counts(), counts); + assert!(chunks_before >= 1); + + // a deleted file takes its chunks and its record away + snapshot.files.remove(&id); + snapshot.order.retain(|other| *other != id); + block_on(persister.save(&storage, snapshot)).unwrap(); + let (files, trackpoint_chunks, _) = storage.inner.counts(); + assert_eq!(files, counts.0 - 1); + assert!(trackpoint_chunks < counts.1); + } + + #[test] + fn test_chunks_shared_by_files_are_stored_once_and_stay_shared() { + let storage = Recording::default(); + let mut snapshot = snapshot_of(&["data/with_time.gpx"]); + let original = snapshot.files[&snapshot.order[0]].clone(); + let mut copy = (*original).clone(); + copy.id = FileId::default(); + snapshot.order.push(copy.id); + snapshot.files.insert(copy.id, Rc::new(copy)); + + let mut persister = Persister::default(); + block_on(persister.save(&storage, snapshot)).unwrap(); + let chunks = original.trk[0].trkseg[0].chunks().len(); + assert_eq!(storage.inner.counts().1, chunks); + + let restored = block_on(Persister::default().restore(&storage)).unwrap(); + assert_eq!(restored.files.len(), 2); + assert!(Rc::ptr_eq( + &restored.files[0].trk[0].trkseg[0].chunks()[0], + &restored.files[1].trk[0].trkseg[0].chunks()[0] + )); + } + + #[test] + fn test_unreadable_files_are_skipped_and_dropped_at_the_next_save() { + let storage = Recording::default(); + let snapshot = snapshot_of(&FILES); + let mut persister = Persister::default(); + block_on(persister.save(&storage, snapshot.clone())).unwrap(); + + // damage a record, and lose a chunk of another file + let broken = snapshot.order[0]; + block_on(storage.inner.commit(&Batch { + put_files: vec![(broken, vec![99, 1, 2])], + ..Default::default() + })) + .unwrap(); + let mut persister = Persister::default(); + let restored = block_on(persister.restore(&storage)).unwrap(); + assert_eq!(restored.unreadable, 1); + assert_eq!(restored.files.len(), FILES.len() - 1); + assert!(restored.files.iter().all(|file| file.id != broken)); + + let kept = Snapshot { + files: restored.files.iter().map(|f| (f.id, f.clone())).collect(), + order: restored.order, + categories: restored.categories, + }; + block_on(persister.save(&storage, kept)).unwrap(); + assert_eq!(storage.inner.counts().0, FILES.len() - 1); + } + + #[test] + fn test_an_engine_starts_from_what_is_restored() { + let storage = Recording::default(); + let mut engine = Engine::default(); + for path in FILES { + let data = std::fs::read(path).unwrap(); + assert!(engine.execute(Action::Edit(Command::Load(Load { + data: &data, + name: "file", + })))); + } + let snapshot = engine.snapshot(); + assert_eq!(snapshot.files.len(), FILES.len()); + block_on(Persister::default().save(&storage, snapshot.clone())).unwrap(); + + let restored = block_on(Persister::default().restore(&storage)).unwrap(); + let mut engine = Engine::default(); + engine.restore(restored); + assert_eq!(engine.snapshot().order, snapshot.order); + let diff = engine.last_diff().unwrap(); + assert_eq!(diff.added.len(), FILES.len()); + assert!(engine.order_changed()); + for id in &snapshot.order { + assert!(engine.file_structure(id).is_some()); + } + + // the files that were restored are not undone + assert!(!engine.can_undo()); + assert!(!engine.execute(Action::Undo)); + let data = std::fs::read(FILES[0]).unwrap(); + assert!(engine.execute(Action::Edit(Command::Load(Load { + data: &data, + name: "file", + })))); + assert!(engine.can_undo()); + assert!(engine.execute(Action::Undo)); + assert_eq!(engine.snapshot().files.len(), FILES.len()); + assert!(!engine.can_undo()); + } +} diff --git a/gpx-rs/wasm/Cargo.toml b/gpx-rs/wasm/Cargo.toml index a233d97f7..337301908 100644 --- a/gpx-rs/wasm/Cargo.toml +++ b/gpx-rs/wasm/Cargo.toml @@ -9,6 +9,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] console_error_panic_hook = "0.1.7" gpx-engine = { path = "../engine" } +idb = "0.6.5" js-sys = "0.3.106" uuid = { version = "1.26.1", features = ["js"] } wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4.79" diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 3501cb1f3..4ebeedc4b 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -9,11 +9,17 @@ //! - Ids read from the file tree are hyphenated UUID strings; the functions reading buffers take //! them as such. //! - Every function returns `false` when its arguments are invalid or the command did nothing. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; use wasm_bindgen::prelude::*; -use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds}; +mod storage; +use storage::IdbStorage; + +use gpx_engine::{ + self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds, Persister, Storage, +}; use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array, Uint32Array}; #[wasm_bindgen] @@ -92,12 +98,18 @@ thread_local! { } fn execute(action: Action) -> bool { - ENGINE.with(|engine| { - engine - .borrow_mut() - .as_mut() - .is_some_and(|engine| engine.execute(action)) - }) + let (changed, persist) = ENGINE.with(|engine| match engine.borrow_mut().as_mut() { + Some(engine) => { + let changed = engine.execute(action); + let persist = changed && (engine.last_diff().is_some() || engine.order_changed()); + (changed, persist) + } + None => (false, false), + }); + if persist { + schedule_save(); + } + changed } fn edit(command: Command) -> bool { @@ -131,6 +143,133 @@ pub fn start() { }); } +// Storage +// +// The files are saved in IndexedDB after each change that touches them, without making the change +// wait: `schedule_save` starts a loop that writes what changed since the last write, until +// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them. + +thread_local! { + static STORAGE: RefCell>> = const { RefCell::new(None) }; + static PERSISTER: RefCell> = const { RefCell::new(None) }; + static SAVING: Cell = const { Cell::new(false) }; + static DIRTY: Cell = const { Cell::new(false) }; + /// Resolve functions of the promises of `flush_storage`. + static WAITERS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = error)] + fn console_error(message: &str); +} + +fn storage() -> Option> { + STORAGE.with(|storage| storage.borrow().clone()) +} + +fn schedule_save() { + if storage().is_none() { + return; + } + DIRTY.set(true); + if SAVING.replace(true) { + // the running loop will go around again + return; + } + wasm_bindgen_futures::spawn_local(async { + while DIRTY.replace(false) { + let Some(storage) = storage() else { break }; + let Some(snapshot) = with_engine(Engine::snapshot) else { + break; + }; + let mut persister = PERSISTER + .with(|persister| persister.borrow_mut().take()) + .unwrap_or_default(); + let result = persister.save(&*storage, snapshot).await; + PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); + if let Err(error) = result { + // what failed is written with the next change + console_error(&error.to_string()); + } + } + SAVING.set(false); + for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) { + let _ = resolve.call0(&JsValue::UNDEFINED); + } + }); +} + +/// Opens the database `name` (it is created if it does not exist), puts the files that it holds +/// in the engine, which has to be empty, and returns the settings: an object of JSON strings by +/// key. The changes of the files that follow are saved. Call `last_update` afterwards to learn +/// about the files. +#[wasm_bindgen] +pub async fn open_storage(name: &str) -> Result { + let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false); + if !empty { + return Err(JsValue::from_str("the engine already has files")); + } + let storage = IdbStorage::open(name).await.map_err(to_js_error)?; + let mut persister = Persister::default(); + let restored = persister.restore(&storage).await.map_err(to_js_error)?; + let settings = storage.load_settings().await.map_err(to_js_error)?; + + ENGINE.with(|engine| { + if let Some(engine) = engine.borrow_mut().as_mut() { + engine.restore(restored); + } + }); + PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); + STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage))); + + let object = Object::new(); + for (key, value) in settings { + set(&object, &key, value); + } + Ok(object) +} + +fn to_js_error(error: engine::StorageError) -> JsValue { + JsValue::from_str(&error.to_string()) +} + +/// Resolves when what was changed has been written to the storage. +#[wasm_bindgen] +pub async fn flush_storage() -> Result<(), JsValue> { + if !SAVING.get() { + return Ok(()); + } + let promise = js_sys::Promise::new(&mut |resolve, _| { + WAITERS.with(|waiters| waiters.borrow_mut().push(resolve)); + }); + wasm_bindgen_futures::JsFuture::from(promise).await?; + Ok(()) +} + +/// Saves a setting, `value` being its JSON. +#[wasm_bindgen] +pub fn set_setting(key: String, value: String) { + if let Some(storage) = storage() { + wasm_bindgen_futures::spawn_local(async move { + if let Err(error) = storage.put_setting(&key, &value).await { + console_error(&error.to_string()); + } + }); + } +} + +#[wasm_bindgen] +pub fn delete_setting(key: String) { + if let Some(storage) = storage() { + wasm_bindgen_futures::spawn_local(async move { + if let Err(error) = storage.delete_setting(&key).await { + console_error(&error.to_string()); + } + }); + } +} + // Statistics buffers // // Each getter returns a copy of the buffer, as a new JS typed array. @@ -1309,12 +1448,7 @@ pub fn exportable_data(file_ids: &[u8]) -> u8 { }; let data = with_engine(|e| e.exportable_data(&ids)).unwrap_or(engine::ExportOptions::NONE); [ - data.time, - data.hr, - data.cad, - data.atemp, - data.power, - data.osm, + data.time, data.hr, data.cad, data.atemp, data.power, data.osm, ] .iter() .enumerate() diff --git a/gpx-rs/wasm/src/storage.rs b/gpx-rs/wasm/src/storage.rs new file mode 100644 index 000000000..0845b5442 --- /dev/null +++ b/gpx-rs/wasm/src/storage.rs @@ -0,0 +1,234 @@ +//! The storage of the engine in the browser: IndexedDB. +//! +//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object +//! store per kind of data. A batch is a single transaction, so that the stored files always are +//! ones that the engine had. + +use gpx_engine::{Batch, FileId, Storage, StorageError, StoredData}; +use idb::TransactionMode; +use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction}; +use js_sys::Uint8Array; +use uuid::Uuid; +use wasm_bindgen::JsValue; + +const FILES: &str = "files"; +const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks"; +const WAYPOINT_CHUNKS: &str = "waypoint_chunks"; +/// The order of the files and the categories of the trackpoints, under their own names. +const META: &str = "meta"; +const SETTINGS: &str = "settings"; +const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS]; + +const ORDER_KEY: &str = "order"; +const CATEGORIES_KEY: &str = "categories"; + +pub struct IdbStorage { + database: Database, +} + +fn error(error: impl std::fmt::Display) -> StorageError { + StorageError(error.to_string()) +} + +fn key(id: Uuid) -> JsValue { + JsValue::from_str(&id.to_string()) +} + +fn bytes(value: &[u8]) -> JsValue { + Uint8Array::from(value).into() +} + +fn to_vec(value: &JsValue) -> Vec { + Uint8Array::new(value).to_vec() +} + +fn to_uuid(key: &JsValue) -> Result { + key.as_string() + .and_then(|key| Uuid::parse_str(&key).ok()) + .ok_or_else(|| error("a key is not an id")) +} + +impl IdbStorage { + /// Opens the database, which is created if it does not exist. + pub async fn open(name: &str) -> Result { + let factory = Factory::new().map_err(error)?; + let mut request = factory.open(name, Some(1)).map_err(error)?; + request.on_upgrade_needed(|event| { + if let Ok(database) = event.database() { + for store in STORES { + let _ = database.create_object_store(store, ObjectStoreParams::new()); + } + } + }); + let database = request.await.map_err(error)?; + Ok(Self { database }) + } + + fn transaction( + &self, + stores: &[&str], + mode: TransactionMode, + ) -> Result { + self.database.transaction(stores, mode).map_err(error) + } + + /// Everything stored in the object store, as `(key, bytes)`. + async fn read_all(store: &ObjectStore) -> Result)>, StorageError> { + let keys = store.get_all_keys(None, None).map_err(error)?; + let values = store.get_all(None, None).map_err(error)?; + let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?); + Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect()) + } + + async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> { + let files = transaction.object_store(FILES).map_err(error)?; + let trackpoints = transaction.object_store(TRACKPOINT_CHUNKS).map_err(error)?; + let waypoints = transaction.object_store(WAYPOINT_CHUNKS).map_err(error)?; + let meta = transaction.object_store(META).map_err(error)?; + + // the requests are all made before waiting for them + let mut puts = vec![]; + let mut deletes = vec![]; + for (store, chunks) in [ + (&trackpoints, &batch.put_trackpoint_chunks), + (&waypoints, &batch.put_waypoint_chunks), + ] { + for (id, data) in chunks { + puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?); + } + } + for (FileId(id), data) in &batch.put_files { + puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?); + } + if let Some(order) = &batch.order { + let value = bytes(order); + puts.push( + meta.put(&value, Some(&JsValue::from_str(ORDER_KEY))) + .map_err(error)?, + ); + } + if let Some(categories) = &batch.categories { + let value = bytes(categories); + puts.push( + meta.put(&value, Some(&JsValue::from_str(CATEGORIES_KEY))) + .map_err(error)?, + ); + } + for FileId(id) in &batch.delete_files { + deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?); + } + for (store, ids) in [ + (&trackpoints, &batch.delete_trackpoint_chunks), + (&waypoints, &batch.delete_waypoint_chunks), + ] { + for id in ids { + deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?); + } + } + for put in puts { + put.await.map_err(error)?; + } + for delete in deletes { + delete.await.map_err(error)?; + } + Ok(()) + } +} + +impl Storage for IdbStorage { + async fn load(&self) -> Result { + let transaction = self.transaction( + &[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META], + TransactionMode::ReadOnly, + )?; + let store = |name| transaction.object_store(name).map_err(error); + let files = Self::read_all(&store(FILES)?).await?; + let trackpoint_chunks = Self::read_all(&store(TRACKPOINT_CHUNKS)?).await?; + let waypoint_chunks = Self::read_all(&store(WAYPOINT_CHUNKS)?).await?; + let meta = store(META)?; + let order = meta + .get(JsValue::from_str(ORDER_KEY)) + .map_err(error)? + .await + .map_err(error)?; + let categories = meta + .get(JsValue::from_str(CATEGORIES_KEY)) + .map_err(error)? + .await + .map_err(error)?; + transaction.await.map_err(error)?; + + let ids = |records: Vec<(JsValue, Vec)>| -> Result)>, StorageError> { + records + .into_iter() + .map(|(k, v)| Ok((to_uuid(&k)?, v))) + .collect() + }; + Ok(StoredData { + files: ids(files)? + .into_iter() + .map(|(id, data)| (FileId(id), data)) + .collect(), + trackpoint_chunks: ids(trackpoint_chunks)?, + waypoint_chunks: ids(waypoint_chunks)?, + order: order.as_ref().map(to_vec), + categories: categories.as_ref().map(to_vec), + }) + } + + async fn commit(&self, batch: &Batch) -> Result<(), StorageError> { + let transaction = self.transaction( + &[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META], + TransactionMode::ReadWrite, + )?; + match self.write(&transaction, batch).await { + Ok(()) => { + transaction.commit().map_err(error)?.await.map_err(error)?; + Ok(()) + } + Err(err) => { + // nothing of the batch is kept + let _ = transaction.abort(); + Err(err) + } + } + } + + async fn load_settings(&self) -> Result, StorageError> { + let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadOnly)?; + let store = transaction.object_store(SETTINGS).map_err(error)?; + let keys = store.get_all_keys(None, None).map_err(error)?; + let values = store.get_all(None, None).map_err(error)?; + let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?); + transaction.await.map_err(error)?; + Ok(keys + .into_iter() + .zip(values) + .filter_map(|(key, value)| Some((key.as_string()?, value.as_string()?))) + .collect()) + } + + async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> { + let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?; + let store = transaction.object_store(SETTINGS).map_err(error)?; + store + .put(&JsValue::from_str(value), Some(&JsValue::from_str(key))) + .map_err(error)? + .await + .map_err(error)?; + transaction.commit().map_err(error)?.await.map_err(error)?; + Ok(()) + } + + async fn delete_setting(&self, key: &str) -> Result<(), StorageError> { + let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?; + let store = transaction.object_store(SETTINGS).map_err(error)?; + store + .delete(Query::Key(JsValue::from_str(key))) + .map_err(error)? + .await + .map_err(error)?; + transaction.commit().map_err(error)?.await.map_err(error)?; + Ok(()) + } +} diff --git a/website/package-lock.json b/website/package-lock.json index 3f3d3651c..26c96efd6 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -14,11 +14,8 @@ "@maplibre/maplibre-gl-geocoder": "^1.9.4", "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", - "dexie": "^4.0.11", "file-saver": "^2.0.5", - "gpx": "file:../gpx", "gpx-rs": "file:../gpx-rs/pkg", - "immer": "^10.1.1", "jszip": "^3.10.1", "mapillary-js": "^4.1.2", "maplibre-gl": "^5.21.1", @@ -77,6 +74,7 @@ }, "../gpx": { "version": "1.0.0", + "extraneous": true, "hasInstallScript": true, "dependencies": { "fast-xml-parser": "^4.5.0", @@ -95,1557 +93,6 @@ "name": "gpx-rs", "version": "0.1.0" }, - "../gpx/node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "../gpx/node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "../gpx/node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "../gpx/node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "../gpx/node_modules/@eslint/config-array": { - "version": "0.19.2", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "../gpx/node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "../gpx/node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "../gpx/node_modules/@eslint/core": { - "version": "0.10.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "../gpx/node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "../gpx/node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "../gpx/node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "../gpx/node_modules/@eslint/js": { - "version": "9.19.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "../gpx/node_modules/@eslint/object-schema": { - "version": "2.1.6", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "../gpx/node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/core": "^0.10.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "../gpx/node_modules/@humanfs/core": { - "version": "0.19.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18.0" - } - }, - "../gpx/node_modules/@humanfs/node": { - "version": "0.16.6", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "../gpx/node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "../gpx/node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "../gpx/node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "../gpx/node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "../gpx/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "../gpx/node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "../gpx/node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "../gpx/node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "../gpx/node_modules/@tsconfig/node10": { - "version": "1.0.11", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/@tsconfig/node12": { - "version": "1.0.11", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/@tsconfig/node14": { - "version": "1.0.3", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/@tsconfig/node16": { - "version": "1.0.4", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/@types/estree": { - "version": "1.0.6", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/@types/geojson": { - "version": "7946.0.14", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/@types/json-schema": { - "version": "7.0.15", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/@types/node": { - "version": "20.16.10", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "../gpx/node_modules/@typescript-eslint/parser": { - "version": "8.22.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.22.0", - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/typescript-estree": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "../gpx/node_modules/@typescript-eslint/scope-manager": { - "version": "8.22.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "../gpx/node_modules/@typescript-eslint/types": { - "version": "8.22.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "../gpx/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.22.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" - } - }, - "../gpx/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.22.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.22.0", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "../gpx/node_modules/acorn": { - "version": "8.14.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "../gpx/node_modules/acorn-jsx": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "../gpx/node_modules/acorn-walk": { - "version": "8.3.4", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "../gpx/node_modules/ajv": { - "version": "6.12.6", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "../gpx/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "../gpx/node_modules/arg": { - "version": "4.1.3", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/argparse": { - "version": "2.0.1", - "dev": true, - "license": "Python-2.0", - "peer": true - }, - "../gpx/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "../gpx/node_modules/braces": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/callsites": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "../gpx/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "../gpx/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "../gpx/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/concat-map": { - "version": "0.0.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/create-require": { - "version": "1.1.1", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "../gpx/node_modules/debug": { - "version": "4.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "../gpx/node_modules/deep-is": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/diff": { - "version": "4.0.2", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "../gpx/node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/eslint": { - "version": "9.19.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.19.0", - "@eslint/plugin-kit": "^0.2.5", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "../gpx/node_modules/eslint-scope": { - "version": "8.2.0", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "../gpx/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "../gpx/node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "../gpx/node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "../gpx/node_modules/espree": { - "version": "10.3.0", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "../gpx/node_modules/esquery": { - "version": "1.6.0", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "../gpx/node_modules/esrecurse": { - "version": "4.3.0", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "../gpx/node_modules/estraverse": { - "version": "5.3.0", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "../gpx/node_modules/esutils": { - "version": "2.0.3", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "../gpx/node_modules/fast-deep-equal": { - "version": "3.1.3", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/fast-glob": { - "version": "3.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "../gpx/node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "../gpx/node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/fast-levenshtein": { - "version": "2.0.6", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/fast-xml-parser": { - "version": "4.5.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - }, - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^1.0.5" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "../gpx/node_modules/fastq": { - "version": "1.19.0", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "../gpx/node_modules/file-entry-cache": { - "version": "8.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "../gpx/node_modules/fill-range": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/flat-cache": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "../gpx/node_modules/flatted": { - "version": "3.3.2", - "dev": true, - "license": "ISC", - "peer": true - }, - "../gpx/node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "../gpx/node_modules/globals": { - "version": "14.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4" - } - }, - "../gpx/node_modules/immer": { - "version": "10.1.1", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "../gpx/node_modules/import-fresh": { - "version": "3.3.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.19" - } - }, - "../gpx/node_modules/is-extglob": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "../gpx/node_modules/is-glob": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "../gpx/node_modules/is-number": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "../gpx/node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "license": "ISC", - "peer": true - }, - "../gpx/node_modules/js-yaml": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "../gpx/node_modules/json-buffer": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/keyv": { - "version": "4.5.4", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "../gpx/node_modules/levn": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "../gpx/node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/lodash.merge": { - "version": "4.6.2", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/make-error": { - "version": "1.3.6", - "dev": true, - "license": "ISC" - }, - "../gpx/node_modules/merge2": { - "version": "1.4.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "../gpx/node_modules/micromatch": { - "version": "4.0.8", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "../gpx/node_modules/minimatch": { - "version": "9.0.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "../gpx/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/natural-compare": { - "version": "1.4.0", - "dev": true, - "license": "MIT", - "peer": true - }, - "../gpx/node_modules/optionator": { - "version": "0.9.4", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "../gpx/node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/parent-module": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "../gpx/node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "../gpx/node_modules/prelude-ls": { - "version": "1.2.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "../gpx/node_modules/prettier": { - "version": "3.4.2", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "../gpx/node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "../gpx/node_modules/queue-microtask": { - "version": "1.2.3", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "../gpx/node_modules/resolve-from": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "../gpx/node_modules/reusify": { - "version": "1.0.4", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "../gpx/node_modules/run-parallel": { - "version": "1.2.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "../gpx/node_modules/semver": { - "version": "7.7.0", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "../gpx/node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/strip-json-comments": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "../gpx/node_modules/strnum": { - "version": "1.0.5", - "license": "MIT" - }, - "../gpx/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "../gpx/node_modules/to-regex-range": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "../gpx/node_modules/ts-api-utils": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "../gpx/node_modules/ts-node": { - "version": "10.9.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "../gpx/node_modules/type-check": { - "version": "0.4.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "../gpx/node_modules/typescript": { - "version": "5.6.2", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "../gpx/node_modules/undici-types": { - "version": "6.19.8", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/uri-js": { - "version": "4.4.1", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "../gpx/node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "dev": true, - "license": "MIT" - }, - "../gpx/node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "../gpx/node_modules/word-wrap": { - "version": "1.2.5", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "../gpx/node_modules/yn": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "../gpx/node_modules/yocto-queue": { - "version": "0.1.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@algolia/abtesting": { "version": "1.16.1", "license": "MIT", @@ -4874,10 +3321,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dexie": { - "version": "4.4.2", - "license": "Apache-2.0" - }, "node_modules/dom-serializer": { "version": "2.0.0", "license": "MIT", @@ -5494,10 +3937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gpx": { - "resolved": "../gpx", - "link": true - }, "node_modules/gpx-rs": { "resolved": "../gpx-rs/pkg", "link": true @@ -5581,14 +4020,6 @@ "version": "3.0.6", "license": "MIT" }, - "node_modules/immer": { - "version": "10.2.0", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", diff --git a/website/package.json b/website/package.json index 9d68561a0..aed3ecb59 100644 --- a/website/package.json +++ b/website/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "node --import tsx --test \"src/**/*.test.ts\"", + "generate:example": "node --import tsx src/lib/scripts/example-statistics.ts", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .", "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" @@ -71,11 +72,8 @@ "@maplibre/maplibre-gl-geocoder": "^1.9.4", "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", - "dexie": "^4.0.11", "file-saver": "^2.0.5", - "gpx": "file:../gpx", "gpx-rs": "file:../gpx-rs/pkg", - "immer": "^10.1.1", "jszip": "^3.10.1", "mapillary-js": "^4.1.2", "maplibre-gl": "^5.21.1", diff --git a/website/src/lib/assets/example-data.ts b/website/src/lib/assets/example-data.ts new file mode 100644 index 000000000..11d90ede8 --- /dev/null +++ b/website/src/lib/assets/example-data.ts @@ -0,0 +1,330 @@ +// Generated by src/lib/scripts/example-statistics.ts from example.gpx: do not edit. +export const exampleData = { + global: { + totalDistance: 35.97253714756412, + movingDistance: 35.97253714756412, + totalTime: 11033, + movingTime: 11033, + elevationGain: 860.2000000000002, + elevationLoss: 537.8000000000003, + startTime: 1672527600024, + endTime: 1672538633024, + totalSpeed: 11.737617486742575, + movingSpeed: 11.737617486742575, + atemp: { avg: 17.676470588235293, count: 102 }, + bounds: { west: -66.660451, south: 48.745764, east: -66.510044, north: 48.92168 }, + }, + totalDistance: [ + 0, 0.04328068848542093, 0.11650720502525283, 0.3351587959345754, 1.0682436029275066, + 1.5026156340883394, 1.7821196261503274, 2.106207216285154, 2.2410399811984894, + 2.9453552435682093, 3.6025169832366335, 3.9844079748662025, 4.853369564407712, + 5.245189634628891, 5.701904241715643, 5.8426474906816495, 6.569912055579026, + 6.653689376713522, 6.7837648541758915, 6.977468888973151, 7.12513222708653, + 7.250970625751078, 7.327345186465471, 7.523057936506123, 7.870270298566722, + 8.331907238547238, 8.49966250663381, 8.712146310893873, 9.145642024727252, + 9.591804416173956, 9.867268606811916, 10.354226519938479, 10.4995033343533, + 10.768320772932757, 10.945182629048222, 11.179142591075422, 11.44408361293813, + 11.958196546084086, 12.320595371530443, 12.674140395065313, 12.985892133977105, + 13.620373921093831, 13.907784560535925, 14.347390028106815, 14.710337166798299, + 15.20320527974895, 15.87459256056528, 18.06487313488486, 19.45780363453859, + 19.576216563691453, 20.04629122790926, 20.11508563990313, 20.57906851024942, + 20.967882401756427, 21.174446555588826, 21.44870285726096, 21.70815037486119, + 21.853596645545945, 22.18038572464859, 22.673623863657042, 23.255471531923366, + 23.804171560172605, 24.12646319419839, 24.583516514019728, 24.96563101163832, + 25.28996993026779, 25.6464275900608, 25.83865311967249, 25.924206335752725, + 26.46043835304079, 26.66269792060754, 26.868668538885316, 26.951359258371486, + 27.47113438147256, 27.75981601401171, 27.946424773943168, 28.10186807045419, + 28.737895463790068, 28.87567424451052, 29.35605997508892, 29.60491379421725, + 29.786435932558835, 30.26638431743931, 30.60806722475413, 30.78584552536327, + 30.913622065558272, 31.059459055705133, 31.303159892950628, 31.570611362268927, + 31.794495167895366, 32.24998958790923, 32.37413113102769, 32.67948450835431, + 32.985096351049044, 33.56292688668583, 33.93674728522087, 34.064219803714735, + 34.54278128265628, 34.723139141379185, 35.335211178826825, 35.78464553975441, + 35.97253714756412, + ], + movingDistance: [ + 0, 0.04328068848542093, 0.11650720502525283, 0.3351587959345754, 1.0682436029275066, + 1.5026156340883394, 1.7821196261503274, 2.106207216285154, 2.2410399811984894, + 2.9453552435682093, 3.6025169832366335, 3.9844079748662025, 4.853369564407712, + 5.245189634628891, 5.701904241715643, 5.8426474906816495, 6.569912055579026, + 6.653689376713522, 6.7837648541758915, 6.977468888973151, 7.12513222708653, + 7.250970625751078, 7.327345186465471, 7.523057936506123, 7.870270298566722, + 8.331907238547238, 8.49966250663381, 8.712146310893873, 9.145642024727252, + 9.591804416173956, 9.867268606811916, 10.354226519938479, 10.4995033343533, + 10.768320772932757, 10.945182629048222, 11.179142591075422, 11.44408361293813, + 11.958196546084086, 12.320595371530443, 12.674140395065313, 12.985892133977105, + 13.620373921093831, 13.907784560535925, 14.347390028106815, 14.710337166798299, + 15.20320527974895, 15.87459256056528, 18.06487313488486, 19.45780363453859, + 19.576216563691453, 20.04629122790926, 20.11508563990313, 20.57906851024942, + 20.967882401756427, 21.174446555588826, 21.44870285726096, 21.70815037486119, + 21.853596645545945, 22.18038572464859, 22.673623863657042, 23.255471531923366, + 23.804171560172605, 24.12646319419839, 24.583516514019728, 24.96563101163832, + 25.28996993026779, 25.6464275900608, 25.83865311967249, 25.924206335752725, + 26.46043835304079, 26.66269792060754, 26.868668538885316, 26.951359258371486, + 27.47113438147256, 27.75981601401171, 27.946424773943168, 28.10186807045419, + 28.737895463790068, 28.87567424451052, 29.35605997508892, 29.60491379421725, + 29.786435932558835, 30.26638431743931, 30.60806722475413, 30.78584552536327, + 30.913622065558272, 31.059459055705133, 31.303159892950628, 31.570611362268927, + 31.794495167895366, 32.24998958790923, 32.37413113102769, 32.67948450835431, + 32.985096351049044, 33.56292688668583, 33.93674728522087, 34.064219803714735, + 34.54278128265628, 34.723139141379185, 35.335211178826825, 35.78464553975441, + 35.97253714756412, + ], + totalTime: [ + 0, 14000, 46000, 151000, 346000, 424000, 481000, 541000, 567000, 683000, 878000, 942000, + 1181000, 1285000, 1384000, 1412000, 1565000, 1588000, 1618000, 1660000, 1693000, 1720000, + 1734000, 1774000, 1841000, 1921000, 1952000, 1990000, 2063000, 2137000, 2183000, 2332000, + 2375000, 2476000, 2537000, 2628000, 2733000, 2955000, 3078000, 3210000, 3330000, 3757000, + 3916000, 4078000, 4146000, 4324000, 4571000, 5400000, 5824000, 6419000, 6578000, 6590000, + 6726000, 6847000, 7063000, 7278000, 7578000, 7785000, 7865000, 7950000, 8263000, 8417000, + 8482000, 8617000, 8682000, 8723000, 8788000, 8830000, 8853000, 8978000, 9012000, 9050000, + 9063000, 9154000, 9211000, 9248000, 9278000, 9403000, 9435000, 9528000, 9571000, 9603000, + 9685000, 9736000, 9780000, 9811000, 9843000, 9895000, 9940000, 9995000, 10126000, 10153000, + 10216000, 10270000, 10389000, 10475000, 10511000, 10618000, 10653000, 10908000, 11003000, + 11033000, + ], + movingTime: [ + 0, 14000, 46000, 151000, 346000, 424000, 481000, 541000, 567000, 683000, 878000, 942000, + 1181000, 1285000, 1384000, 1412000, 1565000, 1588000, 1618000, 1660000, 1693000, 1720000, + 1734000, 1774000, 1841000, 1921000, 1952000, 1990000, 2063000, 2137000, 2183000, 2332000, + 2375000, 2476000, 2537000, 2628000, 2733000, 2955000, 3078000, 3210000, 3330000, 3757000, + 3916000, 4078000, 4146000, 4324000, 4571000, 5400000, 5824000, 6419000, 6578000, 6590000, + 6726000, 6847000, 7063000, 7278000, 7578000, 7785000, 7865000, 7950000, 8263000, 8417000, + 8482000, 8617000, 8682000, 8723000, 8788000, 8830000, 8853000, 8978000, 9012000, 9050000, + 9063000, 9154000, 9211000, 9248000, 9278000, 9403000, 9435000, 9528000, 9571000, 9603000, + 9685000, 9736000, 9780000, 9811000, 9843000, 9895000, 9940000, 9995000, 10126000, 10153000, + 10216000, 10270000, 10389000, 10475000, 10511000, 10618000, 10653000, 10908000, 11003000, + 11033000, + ], + elevationGain: [ + 0, 5.2000000000000455, 19.000000000000057, 43.400000000000034, 75.60000000000002, + 80.66666666666669, 84.2000000000001, 86.33333333333343, 86.60000000000008, + 86.60000000000008, 120.40000000000009, 120.40000000000009, 143.40000000000015, + 160.0000000000001, 160.4666666666668, 161.80000000000013, 163.6000000000002, + 165.0666666666669, 165.26666666666682, 165.26666666666682, 165.26666666666682, + 165.26666666666682, 165.26666666666682, 165.26666666666682, 165.26666666666682, + 165.26666666666682, 165.26666666666682, 165.26666666666682, 165.26666666666682, + 165.26666666666682, 165.26666666666682, 179.66666666666686, 194.4000000000002, + 206.4000000000002, 221.9333333333335, 237.40000000000015, 263.73333333333346, + 289.6000000000001, 318.0666666666668, 340.46666666666675, 372.6000000000001, + 405.1333333333334, 438.86666666666684, 446.86666666666684, 448.86666666666684, + 489.7333333333335, 552.4000000000001, 636.8666666666668, 667.7333333333335, + 680.4000000000001, 680.4000000000001, 682.7333333333335, 692.2666666666668, + 706.9333333333335, 725.2000000000002, 754.2000000000002, 782.6000000000003, + 803.1333333333334, 803.1333333333334, 803.1333333333334, 843.5333333333334, + 843.5333333333334, 843.5333333333334, 843.5333333333334, 843.5333333333334, + 843.5333333333334, 843.5333333333334, 843.5333333333334, 845.2, 845.2, 845.2, 845.2, 845.2, + 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, + 846.9333333333333, 848.2666666666667, 848.4666666666666, 848.4666666666666, + 848.4666666666666, 850.4, 854.1333333333332, 856.2666666666668, 856.2666666666668, + 856.2666666666668, 856.2666666666668, 856.2666666666668, 858.4000000000001, + 860.2000000000002, 860.2000000000002, 860.2000000000002, 860.2000000000002, + 860.2000000000002, + ], + elevationLoss: [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.20000000000004547, 0.20000000000004547, 20.40000000000009, + 20.40000000000009, 20.40000000000009, 20.40000000000009, 20.40000000000009, + 20.40000000000009, 20.40000000000009, 20.40000000000009, 20.866666666666788, + 21.333333333333485, 21.800000000000182, 22.26666666666688, 23.133333333333496, + 25.26666666666688, 27.933333333333508, 31.06666666666689, 34.46666666666681, + 39.46666666666681, 45.400000000000205, 51.26666666666682, 51.26666666666682, + 51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682, + 51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682, + 51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682, + 51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682, + 51.26666666666682, 51.26666666666682, 54.73333333333352, 54.73333333333352, + 54.73333333333352, 54.73333333333352, 54.73333333333352, 54.73333333333352, + 54.73333333333352, 54.73333333333352, 71.33333333333354, 90.93333333333345, + 90.93333333333345, 103.26666666666671, 118.93333333333345, 137.93333333333345, + 182.53333333333347, 231.86666666666684, 275.6666666666669, 313.5333333333335, + 313.5333333333335, 314.5333333333335, 317.6666666666668, 323.80000000000024, + 332.40000000000026, 340.1333333333335, 346.5333333333335, 349.00000000000006, + 355.8666666666666, 361.7999999999999, 367.59999999999997, 371.13333333333327, + 378.59999999999997, 389.06666666666666, 400.3999999999999, 412.2000000000001, + 412.2000000000001, 412.2000000000001, 412.2000000000001, 414.0666666666668, + 415.2666666666668, 415.2666666666668, 415.2666666666668, 415.2666666666668, + 440.0666666666668, 462.2666666666668, 469.6000000000001, 472.2666666666667, + 472.2666666666667, 472.2666666666667, 473.40000000000015, 497.93333333333356, + 519.4000000000003, 537.8000000000003, + ], + slope: [ + 5.54519829509753, 11.329771405243958, 13.361742112430905, 6.556437280063828, + 3.3748571006965706, 1.4008034553191204, 0.8946446834706256, 0.34864437421436856, + 0.023833697214010865, 2.4238382250011337, 1.3088844858730833, 0.7354983423695479, + 3.1409086334150653, 1.2256423076302692, 0.20085098686760294, 0.437784077436669, + 0.5425120546520237, 0.7481781910257762, -0.3088521546718895, -0.4101153511136872, + -0.1462513564675528, -0.4945281465002597, -0.5145407171378057, -0.2946999437560697, + -0.7417945450232967, -1.0804074014330973, -1.2097652068240252, -1.269390092150544, + -1.4096385772574478, -1.6906250826786617, 1.6526278471972953, 3.985861405936538, + 6.327061969720709, 6.462045767621801, 6.718241042985547, 7.336125039207492, + 7.855681831346254, 6.708409718819214, 6.0060576064991755, 7.635690246436876, + 7.376614554438903, 7.961883391751326, 5.7495287376423185, 1.246024238407464, + 4.2065153584168264, 7.094663287987479, 5.1718091508901205, 4.191770925552759, + 3.162749054180518, -1.155504394077654, -2.486689363250133, 3.1532876038197415, + 3.7758117104036595, 3.829499622714933, 9.10942941353019, 14.240107950438869, + 12.941665571593896, -0.9317388039630531, -4.414487617055606, 1.6556817957741452, + 2.4589851525664814, -2.8014045434450368, -4.413963268792533, -7.960267137482532, + -12.683072647006348, -15.24684513552079, -15.673890080960359, -9.647966381748613, + 0.6754743880371754, -0.5145623969089836, -3.135485920405431, -3.879979247223856, + -3.352887180002204, -2.176987189136962, -0.9678293676606319, -1.2863539093324792, + -2.400594266311405, -1.938469929926933, -0.19412308181177693, -1.6181239765102629, + -4.647099739859, -3.3259229591723516, -2.84799279461206, -1.7710658389233644, + 0.785456382783462, 0.4385748023866403, 0.25671447794793106, -1.4085821207384774, + -0.6105810335595012, 1.8251983257651685, 1.3111677817452918, -6.658984457921569, + -7.692745586787598, -3.282613638567991, -1.324014882722225, 0.2792778338655941, + 1.5840695473562374, 0.1214109154598013, -4.214883885805801, -4.182735032776085, + -5.523076373263165, -10.644434966065772, + ], + slopeSegmentSlope: [ + 7.077037465314024, 7.077037465314024, 7.077037465314024, 7.077037465314024, + 0.5753520337401837, 0.5753520337401837, 0.5753520337401837, 0.5753520337401837, + 0.5753520337401837, 5.143330470981779, -5.289467529413177, 3.1409086334150653, + 3.1409086334150653, -0.553863318953831, -0.553863318953831, -0.553863318953831, + -0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831, + -0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831, + -0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831, + -0.553863318953831, -0.553863318953831, 6.771412441716311, 6.771412441716311, + 6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311, + 6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311, + 6.771412441716311, 6.771412441716311, 1.246024238407464, 1.246024238407464, + 5.604351892140715, 5.604351892140715, 5.604351892140715, 2.2941711127147597, + 2.2941711127147597, 2.2941711127147597, 2.2941711127147597, 2.2941711127147597, + 2.2941711127147597, 10.861290836693483, 10.861290836693483, 10.861290836693483, + 10.861290836693483, -4.414487617055606, -4.414487617055606, 6.94339810974512, + -3.539036752039, -3.539036752039, -3.539036752039, -13.990509017835178, -13.990509017835178, + -13.990509017835178, -13.990509017835178, -2.033792785924166, -2.033792785924166, + -2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166, + -2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166, + -2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166, + -2.033792785924166, -2.033792785924166, 0.45298474033593794, 0.45298474033593794, + 0.45298474033593794, 0.45298474033593794, 0.45298474033593794, 0.45298474033593794, + 0.45298474033593794, 0.45298474033593794, -7.692745586787598, -7.692745586787598, + -0.4142590757867528, -0.4142590757867528, -0.4142590757867528, -0.4142590757867528, + -0.4142590757867528, -5.154482373206842, -5.154482373206842, -5.154482373206842, + -5.154482373206842, + ], + slopeSegmentDistance: [ + 1.0682436029275066, 1.0682436029275066, 1.0682436029275066, 1.0682436029275066, + 1.8771116406407027, 1.8771116406407027, 1.8771116406407027, 1.8771116406407027, + 1.8771116406407027, 0.6571617396684242, 0.38189099162956897, 1.2607816597626882, + 1.2607816597626882, 4.622078972183025, 4.622078972183025, 4.622078972183025, + 4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025, + 4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025, + 4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025, + 4.622078972183025, 4.622078972183025, 4.040515953724009, 4.040515953724009, + 4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009, + 4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009, + 4.040515953724009, 4.040515953724009, 0.802552606262374, 0.802552606262374, + 3.3545359680865605, 3.3545359680865605, 3.3545359680865605, 2.9030092668715675, + 2.9030092668715675, 2.9030092668715675, 2.9030092668715675, 2.9030092668715675, + 2.9030092668715675, 0.8857142437895185, 0.8857142437895185, 0.8857142437895185, + 0.8857142437895185, 0.8200272181110968, 0.8200272181110968, 0.5818476682663238, + 1.3280449820963618, 1.3280449820963618, 1.3280449820963618, 1.255136605652762, + 1.255136605652762, 1.255136605652762, 1.255136605652762, 4.7694141050816405, + 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, + 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, + 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, + 4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 1.7660639062735584, + 1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 1.7660639062735584, + 1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 0.610965220021356, + 0.610965220021356, 1.738042790330141, 1.738042790330141, 1.738042790330141, + 1.738042790330141, 1.738042790330141, 1.2493980061849328, 1.2493980061849328, + 1.2493980061849328, 1.2493980061849328, + ], + lng: [ + -66.658624, -66.658127, -66.658472, -66.660451, -66.650629, -66.646183, -66.646388, + -66.642677, -66.640854, -66.633175, -66.635593, -66.630508, -66.633652, -66.633982, + -66.636621, -66.636833, -66.632505, -66.632642, -66.63365, -66.636288, -66.637602, + -66.639281, -66.640209, -66.641257, -66.645941, -66.648858, -66.647392, -66.647989, + -66.643139, -66.637565, -66.63382, -66.627346, -66.627095, -66.623563, -66.622373, + -66.619597, -66.617774, -66.61598, -66.612289, -66.612662, -66.611191, -66.610542, + -66.611253, -66.614945, -66.615775, -66.613768, -66.608474, -66.608964, -66.610557, + -66.609899, -66.604874, -66.605225, -66.610658, -66.609756, -66.610351, -66.609261, + -66.612153, -66.61214, -66.607951, -66.604558, -66.598406, -66.591258, -66.586899, + -66.580708, -66.575533, -66.571339, -66.567595, -66.564982, -66.56396, -66.558605, + -66.557755, -66.555736, -66.555848, -66.551324, -66.549922, -66.547886, -66.54855, + -66.545275, -66.543694, -66.53717, -66.533767, -66.531437, -66.524936, -66.520542, + -66.518785, -66.517736, -66.517472, -66.518888, -66.516545, -66.5175, -66.514672, + -66.515141, -66.517794, -66.51814, -66.515673, -66.512786, -66.513151, -66.5121, -66.513039, + -66.513625, -66.512188, -66.510044, + ], + lat: [ + 48.745764, 48.745974, 48.746592, 48.748063, 48.749297, 48.751879, 48.754389, 48.755973, + 48.756134, 48.759941, 48.765632, 48.764883, 48.772418, 48.775935, 48.779656, 48.780914, + 48.7868, 48.787548, 48.788511, 48.788629, 48.789636, 48.789875, 48.789562, 48.787943, + 48.787467, 48.791147, 48.792306, 48.794176, 48.79641, 48.798028, 48.798255, 48.797259, + 48.798555, 48.799212, 48.800596, 48.801637, 48.803695, 48.808165, 48.810336, 48.813506, + 48.816137, 48.821827, 48.824369, 48.827487, 48.830705, 48.834936, 48.839867, 48.859562, + 48.872045, 48.873018, 48.875654, 48.876228, 48.878383, 48.881829, 48.883645, 48.886005, + 48.887357, 48.888665, 48.88969, 48.893524, 48.896844, 48.89835, 48.897915, 48.89849, + 48.898005, 48.898957, 48.901011, 48.900816, 48.901191, 48.897895, 48.896164, 48.894872, + 48.894132, 48.890526, 48.888099, 48.887087, 48.885759, 48.88046, 48.879786, 48.87928, + 48.879289, 48.879852, 48.879259, 48.880304, 48.879199, 48.880118, 48.881418, 48.883402, + 48.885249, 48.887162, 48.890812, 48.891885, 48.894006, 48.896745, 48.901682, 48.904457, + 48.905578, 48.909826, 48.911326, 48.916817, 48.920747, 48.92168, + ], + ele: [ + 290.4, 292.8, 303.6, 331.8, 366, 371.2, 376, 376.6, 377.6, 376.8, 410.6, 390.4, 419.8, 430, + 430.2, 431.2, 434, 435.6, 435.6, 434.6, 434.2, 434.2, 433.2, 432.8, 431.6, 426.8, 424.8, + 422.2, 416.6, 409.8, 404.4, 422.4, 429.6, 448.6, 458.4, 476.2, 495, 537.4, 553.8, 580.4, + 604.6, 650.2, 678, 692, 688, 728, 770.6, 876, 920.8, 923.8, 914, 910.4, 930.8, 942.6, 953.6, + 986.4, 1029.6, 1038.8, 1025.2, 1002.6, 1043, 1030.4, 1018.6, 996, 951.8, 906.4, 848, 820.4, + 821.2, 824.6, 817.4, 811.8, 806.2, 791.6, 788.6, 787, 784.2, 768, 769.2, 766.8, 757.4, + 746.8, 735.4, 723.4, 726.2, 725.8, 727.4, 726.8, 720.2, 723.8, 732.6, 731.4, 704, 684.4, + 675, 671.8, 676.4, 681.4, 677.2, 648, 632.8, 612.8, + ], + timestamps: [ + 1672527600024, 1672527614024, 1672527646024, 1672527751024, 1672527946024, 1672528024024, + 1672528081024, 1672528141024, 1672528167024, 1672528283024, 1672528478024, 1672528542024, + 1672528781024, 1672528885024, 1672528984024, 1672529012024, 1672529165024, 1672529188024, + 1672529218024, 1672529260024, 1672529293024, 1672529320024, 1672529334024, 1672529374024, + 1672529441024, 1672529521024, 1672529552024, 1672529590024, 1672529663024, 1672529737024, + 1672529783024, 1672529932024, 1672529975024, 1672530076024, 1672530137024, 1672530228024, + 1672530333024, 1672530555024, 1672530678024, 1672530810024, 1672530930024, 1672531357024, + 1672531516024, 1672531678024, 1672531746024, 1672531924024, 1672532171024, 1672533000024, + 1672533424024, 1672534019024, 1672534178024, 1672534190024, 1672534326024, 1672534447024, + 1672534663024, 1672534878024, 1672535178024, 1672535385024, 1672535465024, 1672535550024, + 1672535863024, 1672536017024, 1672536082024, 1672536217024, 1672536282024, 1672536323024, + 1672536388024, 1672536430024, 1672536453024, 1672536578024, 1672536612024, 1672536650024, + 1672536663024, 1672536754024, 1672536811024, 1672536848024, 1672536878024, 1672537003024, + 1672537035024, 1672537128024, 1672537171024, 1672537203024, 1672537285024, 1672537336024, + 1672537380024, 1672537411024, 1672537443024, 1672537495024, 1672537540024, 1672537595024, + 1672537726024, 1672537753024, 1672537816024, 1672537870024, 1672537989024, 1672538075024, + 1672538111024, 1672538218024, 1672538253024, 1672538508024, 1672538603024, 1672538633024, + ], + speed: [ + 11.129319896251097, 9.117955175889351, 7.6697896847953, 11.420836774827047, + 15.395035228401284, 19.036693952608555, 18.572048682978913, 19.210619513643987, + 21.274175339570412, 15.759862403013887, 14.442431786381372, 14.861614825794996, + 13.232693805089438, 15.04790560743128, 16.935813242440407, 17.264243811647404, + 16.589493123379203, 14.52585047073047, 16.18897561298147, 16.38563389971064, + 16.410104206675626, 17.755284238150917, 18.13915405033632, 18.266639285649575, + 19.80855433570078, 20.41272026163526, 19.83856029634617, 20.950687073300834, + 21.54264747624692, 21.64879746253991, 14.075484992575811, 11.854401141400949, + 10.35235632485696, 9.903984326553841, 9.729990429694698, 9.163487459202377, + 8.576740789086204, 9.146209654876312, 10.107442573852628, 9.50423946352374, + 6.2274966978110875, 5.663502961794799, 8.153451667435336, 12.561692967584985, + 12.524125633787333, 9.861928041320306, 9.574353418670329, 10.294940036954442, + 5.339387972231342, 2.8097550877114332, 11.34461213077211, 12.95944740827415, + 11.945791216622078, 6.360121552587119, 4.016133735072683, 3.7307451444281803, + 2.874985478946628, 5.923509614057983, 17.89150294060575, 9.724394236656252, + 8.715142842518263, 14.317671160685329, 14.0282091692482, 15.10502071391872, + 23.992757532953096, 23.121393229442734, 18.46036898931696, 15.384668992168189, + 15.124505676526212, 16.720564185392, 20.41150929222635, 20.376094430396314, + 20.854586858789187, 19.665164326383863, 18.202610775470102, 18.378916465566082, + 18.382545054508643, 17.743326284094163, 17.803137925406997, 19.303399845178138, + 20.658045958555817, 20.88854283859139, 22.239643999271156, 19.684845773960607, + 14.666632358598804, 15.635058876677832, 16.694478316815264, 18.97060106834699, + 17.688069898010582, 13.14925598013496, 13.206895362508604, 17.179796817802924, + 18.798929846810953, 18.383772034644412, 16.711918843992997, 14.792250010689015, + 15.256799935436915, 16.704997292901556, 9.837060780048157, 10.918351526145143, + 18.354987899634057, 22.54699293716527, + ], + atemp: [ + 18, 18, 18, 19, 22, 22, 22, 21, 20, 20, 23, 23, 19, 19, 18, 18, 17, 17, 18, 18, 18, 19, 19, + 19, 19, 19, 18, 18, 17, 17, 16, 16, 16, 17, 18, 17, 17, 18, 18, 18, 19, 22, 23, 23, 23, 22, + 19, 18, 20, 20, 18, 18, 17, 19, 20, 21, 22, 22, 21, 19, 18, 16, 16, 16, 16, 16, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 15, 15, + 15, 15, 15, 15, 16, 16, 16, 18, 17, 17, + ], +}; diff --git a/website/src/lib/assets/example.gpx b/website/src/lib/assets/example.gpx new file mode 100644 index 000000000..f7cbb9eb6 --- /dev/null +++ b/website/src/lib/assets/example.gpx @@ -0,0 +1 @@ +Examplegpx.studioExamplecycling290.418292.818303.618331.81936622371.22237622376.621377.620376.820410.623390.423419.81943019430.218431.21843417435.617435.618434.618434.218434.219433.219432.819431.619426.819424.818422.218416.617409.817404.416422.416429.616448.617458.418476.21749517537.418553.818580.418604.619650.22267823692236882372822770.61987618920.820923.82091418910.418930.817942.619953.620986.4211029.6221038.8221025.2211002.6191043181030.4161018.61699616951.816906.41684815820.415821.215824.615817.415811.815806.215791.615788.61578715784.21576815769.215766.815757.415746.815735.415723.415726.215725.816727.416726.816720.216723.816732.615731.41570415684.41567515671.815676.416681.416677.21664818632.817612.817 \ No newline at end of file diff --git a/website/src/lib/assets/example.test.ts b/website/src/lib/assets/example.test.ts new file mode 100644 index 000000000..165cec782 --- /dev/null +++ b/website/src/lib/assets/example.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { computeExampleData, engineIsBuilt } from '../scripts/example-statistics'; +import { exampleData } from './example-data'; +import { exampleStatistics } from './example'; + +describe('exampleStatistics', () => { + it('has the statistics of the whole example for the whole range', () => { + const whole = exampleStatistics.slice(0, exampleStatistics.length - 1); + const global = exampleStatistics.global; + assert.ok(whole); + for (const key of [ + 'totalDistance', + 'movingDistance', + 'totalTime', + 'movingTime', + 'elevationGain', + 'elevationLoss', + 'startTime', + 'endTime', + 'totalSpeed', + 'movingSpeed', + ] as const) { + assert.ok(Math.abs((whole[key] as number) - (global[key] as number)) < 1e-6, key); + } + }); + + it('has no speed for a range that does not last', () => { + const point = exampleStatistics.slice(20, 20); + assert.equal(point?.totalTime, 0); + assert.equal(point?.totalSpeed, undefined); + assert.equal(point?.movingSpeed, undefined); + }); + + it('has nothing for ranges that are not in the example', () => { + assert.equal(exampleStatistics.slice(5, 500), undefined); + assert.equal(exampleStatistics.slice(-1, 3), undefined); + assert.equal(exampleStatistics.slice(10, 5), undefined); + }); + + it( + 'is what the engine computes (npm run generate:example if not)', + { skip: !engineIsBuilt() && 'the engine is not built' }, + async () => { + assert.deepEqual(JSON.parse(JSON.stringify(await computeExampleData())), exampleData); + } + ); +}); diff --git a/website/src/lib/assets/example.ts b/website/src/lib/assets/example.ts index 24cfaa753..643dc1012 100644 --- a/website/src/lib/assets/example.ts +++ b/website/src/lib/assets/example.ts @@ -1,4 +1,63 @@ -import { parseGPX } from 'gpx'; -export const exampleGPXFile = parseGPX( - `Examplegpx.studioExamplecycling290.418292.818303.618331.81936622371.22237622376.621377.620376.820410.623390.423419.81943019430.218431.21843417435.617435.618434.618434.218434.219433.219432.819431.619426.819424.818422.218416.617409.817404.416422.416429.616448.617458.418476.21749517537.418553.818580.418604.619650.22267823692236882372822770.61987618920.820923.82091418910.418930.817942.619953.620986.4211029.6221038.8221025.2211002.6191043181030.4161018.61699616951.816906.41684815820.415821.215824.615817.415811.815806.215791.615788.61578715784.21576815769.215766.815757.415746.815735.415723.415726.215725.816727.416726.816720.216723.816732.615731.41570415684.41567515671.815676.416681.416677.21664818632.817612.817` -); +/** + * The statistics of an example file (a ride of 102 trackpoints with timestamps and temperatures), + * for the pages that show the elevation profile and the statistics without a file: the home page + * and the documentation. They are the ones that the engine computes for `example.gpx`, kept as + * they are (see `example-data.ts`) so that the example does not need the engine. + */ +import type { GlobalStatistics, SelectionStatistics } from '$lib/engine'; +import { exampleData as data } from './example-data'; + +const length = data.lng.length; + +// the values that are cumulative over the selection: those of a range are the difference of its ends +const cumulative = { + totalDistance: data.totalDistance, + movingDistance: data.movingDistance, + totalTime: data.totalTime, + movingTime: data.movingTime, + elevationGain: data.elevationGain, + elevationLoss: data.elevationLoss, +}; + +/** The statistics of the trackpoints from `start` to `end` (both included), as the engine does. */ +function slice(start: number, end: number): GlobalStatistics | undefined { + if (!(start >= 0 && start <= end && end < length)) { + return undefined; + } + const delta = (values: number[]) => values[end] - values[start]; + // the times are in ms, in seconds in the statistics; a range cannot last a negative time + const totalTime = Math.max(0, delta(cumulative.totalTime)); + const movingTime = Math.max(0, delta(cumulative.movingTime)); + const totalDistance = delta(cumulative.totalDistance); + const movingDistance = delta(cumulative.movingDistance); + const hours = (ms: number) => ms / 3_600_000; + return { + ...data.global, + totalDistance, + movingDistance, + totalTime: totalTime / 1000, + movingTime: movingTime / 1000, + elevationGain: delta(cumulative.elevationGain), + elevationLoss: delta(cumulative.elevationLoss), + startTime: data.timestamps[start], + endTime: data.timestamps[end], + totalSpeed: totalTime > 0 ? totalDistance / hours(totalTime) : undefined, + movingSpeed: movingTime > 0 ? movingDistance / hours(movingTime) : undefined, + }; +} + +export const exampleStatistics: SelectionStatistics = { + global: data.global, + length, + totalDistance: Float64Array.from(data.totalDistance), + slope: Float64Array.from(data.slope), + lng: Float64Array.from(data.lng), + lat: Float64Array.from(data.lat), + ele: Float64Array.from(data.ele), + timestamps: BigInt64Array.from(data.timestamps, (time) => BigInt(time)), + speed: Float64Array.from(data.speed), + atemp: Float64Array.from(data.atemp), + slopeSegmentSlope: Float64Array.from(data.slopeSegmentSlope), + slopeSegmentDistance: Float64Array.from(data.slopeSegmentDistance), + slice, +}; diff --git a/website/src/lib/cached-fetch.test.ts b/website/src/lib/cached-fetch.test.ts new file mode 100644 index 000000000..d1f41df17 --- /dev/null +++ b/website/src/lib/cached-fetch.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import { cachedFetch, pruneCache } from './cached-fetch'; + +const keyOf = (request: RequestInfo | URL) => + request instanceof Request ? request.url : String(request); + +/** A Cache API in memory: only what `cachedFetch` uses. */ +class FakeCache { + entries = new Map(); + failToPut = false; + + async match(request: RequestInfo | URL) { + return this.entries.get(keyOf(request))?.clone(); + } + + async put(request: RequestInfo | URL, response: Response) { + if (this.failToPut) { + throw new Error('quota'); + } + this.entries.set(keyOf(request), response); + } + + async delete(request: RequestInfo | URL) { + return this.entries.delete(keyOf(request)); + } + + async keys() { + return [...this.entries.keys()].map((url) => new Request(url)); + } +} + +const global = globalThis as unknown as { caches?: unknown; fetch: unknown }; +const original = { caches: global.caches, fetch: global.fetch }; + +describe('cachedFetch', () => { + let cache: FakeCache; + let fetched: string[]; + let names: string[]; + let options: { name: string; maxAge: number }; + let counter = 0; + + beforeEach(() => { + cache = new FakeCache(); + fetched = []; + names = []; + // the caches are cleaned once per session and name: a new name for each test + options = { name: `test-${counter++}`, maxAge: 60_000 }; + global.caches = { + open: async (name: string) => { + names.push(name); + return cache; + }, + }; + global.fetch = async (url: string) => { + fetched.push(url); + return new Response(JSON.stringify({ url }), { + status: url.includes('missing') ? 404 : 200, + }); + }; + }); + + afterEach(() => { + global.caches = original.caches; + global.fetch = original.fetch; + mock.timers.reset(); + }); + + it('asks the network once, then uses what it kept', async () => { + const first = await cachedFetch('https://example.com/a', options); + assert.deepEqual(await first.json(), { url: 'https://example.com/a' }); + const second = await cachedFetch('https://example.com/a', options); + assert.deepEqual(await second.json(), { url: 'https://example.com/a' }); + assert.deepEqual(fetched, ['https://example.com/a']); + assert.ok(names.every((name) => name === options.name)); + + await cachedFetch('https://example.com/b', options); + assert.equal(fetched.length, 2); + }); + + it('asks the network again for what is too old, and keeps the new response', async () => { + mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + await cachedFetch('https://example.com/a', options); + mock.timers.setTime(1_000_000 + options.maxAge - 1); + await cachedFetch('https://example.com/a', options); + assert.equal(fetched.length, 1); + + mock.timers.setTime(1_000_000 + options.maxAge); + await cachedFetch('https://example.com/a', options); + assert.equal(fetched.length, 2); + // the new one is the one that is kept: it is not too old a minute later + mock.timers.setTime(1_000_000 + options.maxAge + 30_000); + await cachedFetch('https://example.com/a', options); + assert.equal(fetched.length, 2); + }); + + it('does not keep what failed', async () => { + const response = await cachedFetch('https://example.com/missing', options); + assert.equal(response.status, 404); + assert.equal(cache.entries.size, 0); + await cachedFetch('https://example.com/missing', options); + assert.equal(fetched.length, 2); + }); + + it('answers even if the response cannot be kept', async () => { + cache.failToPut = true; + const response = await cachedFetch('https://example.com/a', options); + assert.deepEqual(await response.json(), { url: 'https://example.com/a' }); + // let the failed put settle: it must not throw + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it('works without the Cache API', async () => { + global.caches = undefined; + const response = await cachedFetch('https://example.com/a', options); + assert.deepEqual(await response.json(), { url: 'https://example.com/a' }); + await cachedFetch('https://example.com/a', options); + assert.equal(fetched.length, 2); + }); + + it('works when the Cache API cannot be opened', async () => { + global.caches = { + open: async () => { + throw new Error('blocked'); + }, + }; + const response = await cachedFetch('https://example.com/a', options); + assert.equal(response.status, 200); + }); + + it('cleans what is too old', async () => { + mock.timers.enable({ apis: ['Date'], now: 5_000_000 }); + await cachedFetch('https://example.com/old', options); + mock.timers.setTime(5_000_000 + options.maxAge / 2); + await cachedFetch('https://example.com/recent', options); + // a response that was kept by something else is not trusted + await cache.put('https://example.com/foreign', new Response('{}')); + + mock.timers.setTime(5_000_000 + options.maxAge + 1); + await pruneCache(options); + assert.deepEqual([...cache.entries.keys()], ['https://example.com/recent']); + }); +}); diff --git a/website/src/lib/cached-fetch.ts b/website/src/lib/cached-fetch.ts new file mode 100644 index 000000000..f52e90576 --- /dev/null +++ b/website/src/lib/cached-fetch.ts @@ -0,0 +1,89 @@ +/** + * `fetch` that keeps the responses in the Cache API, which the browser does whatever the headers + * of the server are: the HTTP cache needs them to keep a response (and `cache: 'force-cache'` + * can only use what it kept). A kept response is used until it is older than `maxAge`. + * + * It works without the Cache API (insecure pages, private windows, blocked storage): the + * responses are then just not kept. + */ + +export type CachedFetchOptions = { + /** Name of the cache the responses are kept in. */ + name: string; + /** How long a kept response is used, in milliseconds. */ + maxAge: number; +}; + +/** When a response was kept (ms since the epoch): the server's date can be wrong or missing. */ +const KEPT_AT = 'x-kept-at'; + +/** The caches that were cleaned of their old responses in this session. */ +const pruned = new Set(); + +async function openCache(name: string): Promise { + try { + return typeof caches === 'undefined' ? undefined : await caches.open(name); + } catch { + return undefined; + } +} + +function age(response: Response): number { + const keptAt = Number(response.headers.get(KEPT_AT)); + return keptAt > 0 ? Date.now() - keptAt : Infinity; +} + +/** Deletes the responses that are too old to be used. */ +export async function pruneCache({ name, maxAge }: CachedFetchOptions) { + const cache = await openCache(name); + if (!cache) { + return; + } + try { + for (const request of await cache.keys()) { + const response = await cache.match(request); + if (!response || !(age(response) < maxAge)) { + await cache.delete(request); + } + } + } catch { + // what is left is cleaned another time + } +} + +/** + * The response for `url`: the kept one if there is one that is not too old, otherwise the one of + * the network, which is kept if it is successful. + */ +export async function cachedFetch(url: string, options: CachedFetchOptions): Promise { + const cache = await openCache(options.name); + if (cache && !pruned.has(options.name)) { + pruned.add(options.name); + void pruneCache(options); + } + + try { + const kept = await cache?.match(url); + if (kept && age(kept) < options.maxAge) { + return kept; + } + } catch { + // as if there was nothing + } + + const response = await fetch(url); + if (!response.ok || !cache) { + return response; + } + + const headers = new Headers(response.headers); + headers.set(KEPT_AT, String(Date.now())); + const stamped = new Response(await response.arrayBuffer(), { + status: response.status, + statusText: response.statusText, + headers, + }); + // best effort: the storage can be full + cache.put(url, stamped.clone()).catch(() => undefined); + return stamped; +} diff --git a/website/src/lib/components/elevation-profile/ElevationProfile.svelte b/website/src/lib/components/elevation-profile/ElevationProfile.svelte index 650710db8..674844e7b 100644 --- a/website/src/lib/components/elevation-profile/ElevationProfile.svelte +++ b/website/src/lib/components/elevation-profile/ElevationProfile.svelte @@ -18,7 +18,7 @@ Construction, } from '@lucide/svelte'; import type { Readable, Writable } from 'svelte/store'; - import type { Coordinates } from 'gpx'; + import type { Coordinates } from '$lib/geo'; import type { SelectionStatistics } from '$lib/engine'; import type { SlicedStatistics } from '$lib/logic/selection-statistics'; import { settings } from '$lib/logic/settings'; diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index 2dffa0d91..bf1a63eb3 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -21,7 +21,7 @@ import Chart, { type TooltipItem, } from 'chart.js/auto'; import { get, type Readable, type Writable } from 'svelte/store'; -import type { Coordinates } from 'gpx'; +import type { Coordinates } from '$lib/geo'; import { engine, NO_TIME, @@ -456,7 +456,7 @@ export class ElevationProfile { highway: highways[index], sacScale: sacScales[index], mtbScale: mtbScales[index], - coordinates: { lat: data.lat[index], lon: data.lng[index] }, + coordinates: { lat: data.lat[index], lng: data.lng[index] }, index, }); if (data.speed && (global.totalTime ?? 0) > 0) { diff --git a/website/src/lib/components/embedding/Embedding.svelte b/website/src/lib/components/embedding/Embedding.svelte index e3cd1f72b..b91000579 100644 --- a/website/src/lib/components/embedding/Embedding.svelte +++ b/website/src/lib/components/embedding/Embedding.svelte @@ -18,8 +18,7 @@ import { untrack } from 'svelte'; import { isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { engine } from '$lib/engine'; - import { hoveredPoint } from '$lib/logic/statistics'; - import { slicedStatistics } from '$lib/logic/selection-statistics'; + import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics'; let { useHash = true, @@ -39,7 +38,6 @@ distanceUnits, velocityUnits, temperatureUnits, - fileOrder, distanceMarkers, directionMarkers, } = settings; diff --git a/website/src/lib/components/embedding/EmbeddingPlayground.svelte b/website/src/lib/components/embedding/EmbeddingPlayground.svelte index f0278c7e6..b4088e225 100644 --- a/website/src/lib/components/embedding/EmbeddingPlayground.svelte +++ b/website/src/lib/components/embedding/EmbeddingPlayground.svelte @@ -63,10 +63,10 @@ let manualCamera = $state(false); let zoom = $state('0'); let lat = $state('0'); - let lon = $state('0'); + let lng = $state('0'); let bearing = $state('0'); let pitch = $state('0'); - let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : ''); + let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lng}/${bearing}/${pitch}` : ''); $effect(() => { if (options.elevation.show || options.elevation.height) { @@ -78,7 +78,7 @@ if ($map) { let center = $map.getCenter(); lat = center.lat.toFixed(4); - lon = center.lng.toFixed(4); + lng = center.lng.toFixed(4); zoom = $map.getZoom().toFixed(2); bearing = $map.getBearing().toFixed(1); pitch = $map.getPitch().toFixed(0); @@ -293,7 +293,7 @@