mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
progress
This commit is contained in:
1 parent
606a0e974a
commit
67a4c090bd
107 files changed
+3474
-3948
No files matched your search
Generated
+245
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
|
||||
<rte>
|
||||
<name>with extensions</name>
|
||||
<rtept lat="1" lon="1">
|
||||
<ele>10</ele>
|
||||
<extensions>
|
||||
<gpxx:RoutePointExtension>
|
||||
<gpxx:rpt lat="1.1" lon="1.1"/>
|
||||
<gpxx:rpt lat="1.2" lon="1.2"></gpxx:rpt>
|
||||
</gpxx:RoutePointExtension>
|
||||
</extensions>
|
||||
</rtept>
|
||||
<rtept lat="2" lon="2">
|
||||
<ele>20</ele>
|
||||
<time>2024-01-01T00:00:00Z</time>
|
||||
</rtept>
|
||||
<rtept lat="3" lon="3"/>
|
||||
</rte>
|
||||
<rte>
|
||||
<name>empty</name>
|
||||
</rte>
|
||||
</gpx>
|
||||
@@ -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<String>);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<Trackpoint>,
|
||||
}
|
||||
|
||||
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<Waypoint>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<C: Chunk> {
|
||||
chunks: Vec<Rc<C>>,
|
||||
cumul_length: Vec<usize>,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LngLat {
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
|
||||
@@ -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<Track>,
|
||||
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<String>,
|
||||
@@ -29,7 +28,7 @@ pub struct FileInfo {
|
||||
pub time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Author {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TrackSegment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TrackInfo {
|
||||
pub name: Option<String>,
|
||||
pub cmt: Option<String>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WaypointChunk>,
|
||||
/// 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<WaypointChunk>) {
|
||||
self.chunks.push_shared(chunk);
|
||||
self.rev_id = Default::default();
|
||||
}
|
||||
|
||||
/// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`].
|
||||
pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec<Waypoint>) {
|
||||
self.chunks.splice(start, end, waypoints);
|
||||
|
||||
@@ -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<File,
|
||||
let mut stack: Vec<GPXElement> = 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<Trackpoint> = 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<File,
|
||||
stack.push(GPXElement::Link(link));
|
||||
}
|
||||
"text" => 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<File,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
name if is_route_point_extension(name) => {
|
||||
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<File,
|
||||
}
|
||||
}
|
||||
}
|
||||
"rtept" => {
|
||||
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<File,
|
||||
gpx.trk.push(trk);
|
||||
}
|
||||
}
|
||||
"rte" => {
|
||||
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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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<TrackSegmentId> = after
|
||||
.trk
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<Vec<u8>> = (0..12)
|
||||
.map(|i| {
|
||||
format!(r#"<gpx version="1.1"><metadata><name>{i}</name></metadata></gpx>"#)
|
||||
.into_bytes()
|
||||
})
|
||||
.collect();
|
||||
let loads = datas
|
||||
.iter()
|
||||
.map(|data| Load { data, name: "x" })
|
||||
.collect::<Vec<_>>();
|
||||
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;
|
||||
|
||||
@@ -9,9 +9,21 @@ use crate::{File, FileId};
|
||||
pub struct Stack {
|
||||
entries: Vec<StackEntry>,
|
||||
index: Option<usize>,
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod core;
|
||||
mod engine;
|
||||
mod storage;
|
||||
|
||||
pub use core::*;
|
||||
pub use engine::*;
|
||||
pub use storage::*;
|
||||
@@ -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<T: Serialize + ?Sized>(value: &T) -> Vec<u8> {
|
||||
postcard::to_extend(value, vec![VERSION]).expect("serializing to memory")
|
||||
}
|
||||
|
||||
pub fn decode<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Option<T> {
|
||||
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<TrackRecord>,
|
||||
waypoints: Vec<WaypointChunkId>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TrackRecord {
|
||||
id: TrackId,
|
||||
info: TrackInfo,
|
||||
segments: Vec<SegmentRecord>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SegmentRecord {
|
||||
id: TrackSegmentId,
|
||||
chunks: Vec<TrackpointChunkId>,
|
||||
}
|
||||
|
||||
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<Rc<TrackpointChunk>>,
|
||||
waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option<Rc<WaypointChunk>>,
|
||||
) -> Option<File> {
|
||||
let mut file = File {
|
||||
id,
|
||||
info: self.info,
|
||||
..Default::default()
|
||||
};
|
||||
for track in self.tracks {
|
||||
let mut trkseg = Vec::with_capacity(track.segments.len());
|
||||
for segment in track.segments {
|
||||
let mut built = TrackSegment::default();
|
||||
built.id = segment.id;
|
||||
for chunk in &segment.chunks {
|
||||
built.push_shared(trackpoint_chunks(chunk)?);
|
||||
}
|
||||
trkseg.push(built);
|
||||
}
|
||||
file.trk.push(Track {
|
||||
id: track.id,
|
||||
info: track.info,
|
||||
trkseg,
|
||||
});
|
||||
}
|
||||
let mut wpt = Waypoints::default();
|
||||
for chunk in &self.waypoints {
|
||||
wpt.push_shared(waypoint_chunks(chunk)?);
|
||||
}
|
||||
file.wpt = wpt;
|
||||
Some(file)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec<u8> {
|
||||
encode(&chunk.trkpt)
|
||||
}
|
||||
|
||||
pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Option<TrackpointChunk> {
|
||||
Some(TrackpointChunk {
|
||||
id: TrackpointChunkId(id),
|
||||
trkpt: decode::<Vec<Trackpoint>>(bytes)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec<u8> {
|
||||
encode(&chunk.wpt)
|
||||
}
|
||||
|
||||
pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Option<WaypointChunk> {
|
||||
Some(WaypointChunk {
|
||||
id: WaypointChunkId(id),
|
||||
wpt: decode::<Vec<Waypoint>>(bytes)?,
|
||||
})
|
||||
}
|
||||
@@ -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<State>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
files: HashMap<FileId, Vec<u8>>,
|
||||
trackpoint_chunks: HashMap<Uuid, Vec<u8>>,
|
||||
waypoint_chunks: HashMap<Uuid, Vec<u8>>,
|
||||
order: Option<Vec<u8>>,
|
||||
categories: Option<Vec<u8>>,
|
||||
settings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
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<StoredData, StorageError> {
|
||||
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<Vec<(String, String)>, StorageError> {
|
||||
let state = self.state.borrow();
|
||||
Ok(state
|
||||
.settings
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
self.state
|
||||
.borrow_mut()
|
||||
.settings
|
||||
.insert(key.to_owned(), value.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
self.state.borrow_mut().settings.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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<u8>)>,
|
||||
pub trackpoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
||||
pub waypoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
||||
/// The order of the files.
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// The categories of the trackpoints, which their chunks refer to.
|
||||
pub categories: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A change of the stored data, to be applied all at once: a stored state is always one that the
|
||||
/// engine had.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Batch {
|
||||
pub put_files: Vec<(FileId, Vec<u8>)>,
|
||||
pub delete_files: Vec<FileId>,
|
||||
pub put_trackpoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
||||
pub delete_trackpoint_chunks: Vec<Uuid>,
|
||||
pub put_waypoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
||||
pub delete_waypoint_chunks: Vec<Uuid>,
|
||||
pub order: Option<Vec<u8>>,
|
||||
pub categories: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<StoredData, StorageError>;
|
||||
|
||||
/// Applies a batch: either all of it is stored, or none.
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError>;
|
||||
|
||||
/// Reads the settings, as `(key, value)`. The engine does not look into them.
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError>;
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError>;
|
||||
}
|
||||
@@ -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<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
}
|
||||
|
||||
/// What was read from the storage, to start an engine with (see [`crate::Engine::restore`]).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Restored {
|
||||
pub files: Vec<Rc<File>>,
|
||||
pub order: Vec<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
/// The files that could not be read: they are dropped at the next save.
|
||||
pub unreadable: usize,
|
||||
}
|
||||
|
||||
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
|
||||
/// already holds.
|
||||
///
|
||||
/// A file whose `Rc` is the saved one has not changed, which is what makes a save cheap: only the
|
||||
/// files that changed are looked at to find the chunks to write, and the others are only counted
|
||||
/// to find the chunks that nothing uses anymore.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Persister {
|
||||
/// The files as they are saved.
|
||||
files: HashMap<FileId, Rc<File>>,
|
||||
/// The files that have a record, including the ones that could not be read.
|
||||
stored_files: HashSet<FileId>,
|
||||
trackpoint_chunks: HashSet<Uuid>,
|
||||
waypoint_chunks: HashSet<Uuid>,
|
||||
order: Option<Vec<FileId>>,
|
||||
categories: Option<TrackpointCategories>,
|
||||
}
|
||||
|
||||
/// What a save writes, and what is stored once it is written.
|
||||
struct Plan {
|
||||
batch: Batch,
|
||||
snapshot: Snapshot,
|
||||
trackpoint_chunks: HashSet<Uuid>,
|
||||
waypoint_chunks: HashSet<Uuid>,
|
||||
}
|
||||
|
||||
impl Persister {
|
||||
/// Reads the files of the storage. The persister then knows that they are saved.
|
||||
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
|
||||
let data = storage.load().await?;
|
||||
*self = Self::default();
|
||||
|
||||
let categories: Option<TrackpointCategories> = data.categories.as_deref().and_then(decode);
|
||||
let stored_order: Option<Vec<FileId>> = data.order.as_deref().and_then(decode);
|
||||
|
||||
let trackpoint_bytes: HashMap<Uuid, Vec<u8>> = data.trackpoint_chunks.into_iter().collect();
|
||||
let waypoint_bytes: HashMap<Uuid, Vec<u8>> = data.waypoint_chunks.into_iter().collect();
|
||||
// chunks are decoded once, whatever the number of files that use them
|
||||
let trackpoint_cache: RefCell<HashMap<Uuid, Option<Rc<TrackpointChunk>>>> =
|
||||
Default::default();
|
||||
let waypoint_cache: RefCell<HashMap<Uuid, Option<Rc<WaypointChunk>>>> = Default::default();
|
||||
let trackpoint_chunk = |id: &crate::TrackpointChunkId| {
|
||||
trackpoint_cache
|
||||
.borrow_mut()
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = 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<FileId, Rc<File>> = HashMap::new();
|
||||
for (id, bytes) in &data.files {
|
||||
self.stored_files.insert(*id);
|
||||
let file = decode::<FileRecord>(bytes)
|
||||
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
||||
match file {
|
||||
Some(file) => {
|
||||
files.insert(*id, Rc::new(file));
|
||||
}
|
||||
None => restored.unreadable += 1,
|
||||
}
|
||||
}
|
||||
|
||||
restored.order = stored_order
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|id| files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
let ordered: HashSet<FileId> = restored.order.iter().copied().collect();
|
||||
restored
|
||||
.order
|
||||
.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
|
||||
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
|
||||
|
||||
self.files = files;
|
||||
self.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<S: Storage>(
|
||||
&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<F: Future>(future: F) -> F::Output {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
loop {
|
||||
if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory storage that remembers the batches it was given.
|
||||
#[derive(Default)]
|
||||
struct Recording {
|
||||
inner: MemoryStorage,
|
||||
batches: RefCell<Vec<Batch>>,
|
||||
}
|
||||
|
||||
impl Storage for Recording {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
self.inner.load().await
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
||||
self.batches.borrow_mut().push(batch.clone());
|
||||
self.inner.commit(batch).await
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
self.inner.load_settings().await
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
self.inner.put_setting(key, value).await
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
self.inner.delete_setting(key).await
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_of(paths: &[&str]) -> Snapshot {
|
||||
let mut snapshot = Snapshot::default();
|
||||
for path in paths {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = parse(&data, &mut snapshot.categories).unwrap();
|
||||
snapshot.order.push(file.id);
|
||||
snapshot.files.insert(file.id, Rc::new(file));
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn gpx(file: &File, categories: &TrackpointCategories) -> Vec<u8> {
|
||||
write(file, categories, ExportOptions::ALL)
|
||||
}
|
||||
|
||||
const FILES: [&str; 3] = [
|
||||
"data/with_tracks_and_segments.gpx",
|
||||
"data/with_waypoint.gpx",
|
||||
"data/with_surface.gpx",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_saved_files_are_restored() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.unreadable, 0);
|
||||
assert_eq!(restored.order, snapshot.order);
|
||||
assert_eq!(restored.categories, snapshot.categories);
|
||||
for file in &restored.files {
|
||||
let original = &snapshot.files[&file.id];
|
||||
assert_eq!(
|
||||
gpx(file, &restored.categories),
|
||||
gpx(original, &snapshot.categories)
|
||||
);
|
||||
// identities that the rest of the engine refers to are kept
|
||||
assert_eq!(file.trk[0].id, original.trk[0].id);
|
||||
assert_eq!(file.trk[0].trkseg[0].id, original.trk[0].trkseg[0].id);
|
||||
assert_eq!(
|
||||
file.trk[0].trkseg[0].chunks()[0].id,
|
||||
original.trk[0].trkseg[0].chunks()[0].id
|
||||
);
|
||||
}
|
||||
// what was just read is saved
|
||||
let again = Snapshot {
|
||||
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||
order: restored.order,
|
||||
categories: restored.categories,
|
||||
};
|
||||
block_on(persister.save(&storage, again)).unwrap();
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_is_written_when_nothing_changed() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_edit_writes_what_it_changed_only() {
|
||||
let storage = Recording::default();
|
||||
let mut snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
let counts = storage.inner.counts();
|
||||
|
||||
// renaming a file writes its record, not its chunks
|
||||
let id = snapshot.order[0];
|
||||
let mut file = (*snapshot.files[&id]).clone();
|
||||
file.info.name = "renamed".into();
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
{
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert!(batch.put_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::<usize>();
|
||||
file.trk[0].trkseg[0].update(0, |point| point.ele += 1.0);
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
{
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert_eq!(batch.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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
+148
-14
@@ -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<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
||||
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
|
||||
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
||||
/// Resolve functions of the promises of `flush_storage`.
|
||||
static WAITERS: RefCell<Vec<js_sys::Function>> = 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<Rc<IdbStorage>> {
|
||||
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<Object, JsValue> {
|
||||
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()
|
||||
|
||||
@@ -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<u8> {
|
||||
Uint8Array::new(value).to_vec()
|
||||
}
|
||||
|
||||
fn to_uuid(key: &JsValue) -> Result<Uuid, StorageError> {
|
||||
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<Self, StorageError> {
|
||||
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<Transaction, StorageError> {
|
||||
self.database.transaction(stores, mode).map_err(error)
|
||||
}
|
||||
|
||||
/// Everything stored in the object store, as `(key, bytes)`.
|
||||
async fn read_all(store: &ObjectStore) -> Result<Vec<(JsValue, Vec<u8>)>, 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<StoredData, StorageError> {
|
||||
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<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, 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<Vec<(String, String)>, 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(())
|
||||
}
|
||||
}
|
||||
Generated
+1
-1570
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><gpx creator="StravaGPX" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2"><metadata><name>Example</name><author><name>gpx.studio</name><link href="https://gpx.studio"/></author><time>2023-08-20T12:42:07.000Z</time></metadata><trk><name>Example</name><type>cycling</type><trkseg><trkpt lat="48.745764" lon="-66.658624"><ele>290.4</ele><time>2022-12-31T23:00:00.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.745974" lon="-66.658127"><ele>292.8</ele><time>2022-12-31T23:00:14.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.746592" lon="-66.658472"><ele>303.6</ele><time>2022-12-31T23:00:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.748063" lon="-66.660451"><ele>331.8</ele><time>2022-12-31T23:02:31.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.749297" lon="-66.650629"><ele>366</ele><time>2022-12-31T23:05:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.751879" lon="-66.646183"><ele>371.2</ele><time>2022-12-31T23:07:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.754389" lon="-66.646388"><ele>376</ele><time>2022-12-31T23:08:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.755973" lon="-66.642677"><ele>376.6</ele><time>2022-12-31T23:09:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>21</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.756134" lon="-66.640854"><ele>377.6</ele><time>2022-12-31T23:09:27.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.759941" lon="-66.633175"><ele>376.8</ele><time>2022-12-31T23:11:23.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.765632" lon="-66.635593"><ele>410.6</ele><time>2022-12-31T23:14:38.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.764883" lon="-66.630508"><ele>390.4</ele><time>2022-12-31T23:15:42.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.772418" lon="-66.633652"><ele>419.8</ele><time>2022-12-31T23:19:41.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.775935" lon="-66.633982"><ele>430</ele><time>2022-12-31T23:21:25.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.779656" lon="-66.636621"><ele>430.2</ele><time>2022-12-31T23:23:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.780914" lon="-66.636833"><ele>431.2</ele><time>2022-12-31T23:23:32.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.7868" lon="-66.632505"><ele>434</ele><time>2022-12-31T23:26:05.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.787548" lon="-66.632642"><ele>435.6</ele><time>2022-12-31T23:26:28.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkLine truncated
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,63 @@
|
||||
import { parseGPX } from 'gpx';
|
||||
export const exampleGPXFile = parseGPX(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><gpx creator="StravaGPX" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2"><metadata><name>Example</name><author><name>gpx.studio</name><link href="https://gpx.studio"/></author><time>2023-08-20T12:42:07.000Z</time></metadata><trk><name>Example</name><type>cycling</type><trkseg><trkpt lat="48.745764" lon="-66.658624"><ele>290.4</ele><time>2022-12-31T23:00:00.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.745974" lon="-66.658127"><ele>292.8</ele><time>2022-12-31T23:00:14.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.746592" lon="-66.658472"><ele>303.6</ele><time>2022-12-31T23:00:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.748063" lon="-66.660451"><ele>331.8</ele><time>2022-12-31T23:02:31.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.749297" lon="-66.650629"><ele>366</ele><time>2022-12-31T23:05:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.751879" lon="-66.646183"><ele>371.2</ele><time>2022-12-31T23:07:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.754389" lon="-66.646388"><ele>376</ele><time>2022-12-31T23:08:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.755973" lon="-66.642677"><ele>376.6</ele><time>2022-12-31T23:09:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>21</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.756134" lon="-66.640854"><ele>377.6</ele><time>2022-12-31T23:09:27.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.759941" lon="-66.633175"><ele>376.8</ele><time>2022-12-31T23:11:23.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.765632" lon="-66.635593"><ele>410.6</ele><time>2022-12-31T23:14:38.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.764883" lon="-66.630508"><ele>390.4</ele><time>2022-12-31T23:15:42.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.772418" lon="-66.633652"><ele>419.8</ele><time>2022-12-31T23:19:41.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.775935" lon="-66.633982"><ele>430</ele><time>2022-12-31T23:21:25.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.779656" lon="-66.636621"><ele>430.2</ele><time>2022-12-31T23:23:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.780914" lon="-66.636833"><ele>431.2</ele><time>2022-12-31T23:23:32.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.7868" lon="-66.632505"><ele>434</ele><time>2022-12-31T23:26:05.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.787548" lon="-66.632642"><ele>435.6</ele><time>2022-12-31T23:26:28.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions>
Line truncated
|
||||
);
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -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<string, Response>();
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
|
||||
async function openCache(name: string): Promise<Cache | undefined> {
|
||||
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<Response> {
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{i18n._('embedding.longitude')}</span>
|
||||
<span>{lon}</span>
|
||||
<span>{lng}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{i18n._('embedding.zoom')}</span>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import FileListNodeStore from './FileListNodeStore.svelte';
|
||||
import FileListNode from './FileListNode.svelte';
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import { ListLevel } from './file-list';
|
||||
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
|
||||
import { allowedMoves, dragging, SortableFileList, type ListLevel } from './sortable-file-list';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import type { FileTreeNode } from '$lib/selection-helpers';
|
||||
|
||||
@@ -26,14 +25,14 @@
|
||||
let container: HTMLElement;
|
||||
let sortableLevel: ListLevel =
|
||||
node === null
|
||||
? ListLevel.FILE
|
||||
? 'file'
|
||||
: node.type === 'file'
|
||||
? waypointRoot
|
||||
? ListLevel.WAYPOINTS
|
||||
: ListLevel.TRACK
|
||||
? 'waypoints'
|
||||
: 'track'
|
||||
: node.type === 'waypoints'
|
||||
? ListLevel.WAYPOINT
|
||||
: ListLevel.SEGMENT;
|
||||
? 'waypoint'
|
||||
: 'segment';
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
|
||||
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
Scissors,
|
||||
FileStack,
|
||||
} from '@lucide/svelte';
|
||||
import { ListLevel } from './file-list';
|
||||
import { getContext } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||
@@ -56,14 +55,7 @@
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
let embedding = getContext<boolean>('embedding');
|
||||
|
||||
const levels = {
|
||||
file: ListLevel.FILE,
|
||||
track: ListLevel.TRACK,
|
||||
segment: ListLevel.SEGMENT,
|
||||
waypoints: ListLevel.WAYPOINTS,
|
||||
waypoint: ListLevel.WAYPOINT,
|
||||
};
|
||||
let level = $derived(levels[node.type]);
|
||||
let level = $derived(node.type);
|
||||
|
||||
let singleSelection = $derived(selectionSize($selection) === 1);
|
||||
|
||||
@@ -128,7 +120,7 @@
|
||||
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
|
||||
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
|
||||
{/if}
|
||||
{#if level === ListLevel.FILE || level === ListLevel.TRACK}
|
||||
{#if level === 'file' || level === 'track'}
|
||||
<div
|
||||
class="absolute {orientation === 'vertical'
|
||||
? 'top-0 bottom-0 right-0 w-1'
|
||||
@@ -178,9 +170,9 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if level === ListLevel.SEGMENT}
|
||||
{#if level === 'segment'}
|
||||
<Waypoints size="16" class="mx-1 shrink-0" />
|
||||
{:else if level === ListLevel.WAYPOINT}
|
||||
{:else if level === 'waypoint'}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
{@const SymbolIcon = symbols[symbolKey].icon}
|
||||
<SymbolIcon size="16" class="mx-1 shrink-0" />
|
||||
@@ -250,7 +242,7 @@
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if level !== ListLevel.WAYPOINTS}
|
||||
{#if level !== 'waypoints'}
|
||||
<ContextMenu.Item onclick={() => engine.selectAll()}>
|
||||
<FileStack size="16" />
|
||||
{i18n._('menu.select_all')}
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
export enum ListLevel {
|
||||
ROOT,
|
||||
FILE,
|
||||
TRACK,
|
||||
SEGMENT,
|
||||
WAYPOINTS,
|
||||
WAYPOINT,
|
||||
}
|
||||
|
||||
export abstract class ListItem {
|
||||
[x: string]: any;
|
||||
level: ListLevel;
|
||||
|
||||
constructor(level: ListLevel) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
abstract getId(): string | number;
|
||||
abstract getFullId(): string;
|
||||
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
|
||||
abstract getFileId(): string;
|
||||
abstract getParent(): ListItem;
|
||||
abstract extend(id: string | number): ListItem;
|
||||
}
|
||||
|
||||
export class ListRootItem extends ListItem {
|
||||
constructor() {
|
||||
super(ListLevel.ROOT);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return 'root';
|
||||
}
|
||||
|
||||
getFullId(): string {
|
||||
return 'root';
|
||||
}
|
||||
|
||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getFileId(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
getParent(): ListItem {
|
||||
return this;
|
||||
}
|
||||
|
||||
extend(id: string): ListFileItem {
|
||||
return new ListFileItem(id);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListFileItem extends ListItem {
|
||||
fileId: string;
|
||||
|
||||
constructor(fileId: string) {
|
||||
super(ListLevel.FILE);
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
getFullId(): string {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||
switch (level) {
|
||||
case ListLevel.ROOT:
|
||||
return this.fileId;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getFileId(): string {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
getParent(): ListItem {
|
||||
return new ListRootItem();
|
||||
}
|
||||
|
||||
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
|
||||
if (id === 'waypoints') {
|
||||
return new ListWaypointsItem(this.fileId);
|
||||
} else {
|
||||
return new ListTrackItem(this.fileId, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ListTrackItem extends ListItem {
|
||||
fileId: string;
|
||||
trackIndex: number;
|
||||
|
||||
constructor(fileId: string, trackIndex: number) {
|
||||
super(ListLevel.TRACK);
|
||||
this.fileId = fileId;
|
||||
this.trackIndex = trackIndex;
|
||||
}
|
||||
|
||||
getId(): number {
|
||||
return this.trackIndex;
|
||||
}
|
||||
|
||||
getFullId(): string {
|
||||
return `${this.fileId}-track-${this.trackIndex}`;
|
||||
}
|
||||
|
||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||
switch (level) {
|
||||
case ListLevel.ROOT:
|
||||
return this.fileId;
|
||||
case ListLevel.FILE:
|
||||
return this.trackIndex;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getFileId(): string {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
getTrackIndex(): number {
|
||||
return this.trackIndex;
|
||||
}
|
||||
|
||||
getParent(): ListItem {
|
||||
return new ListFileItem(this.fileId);
|
||||
}
|
||||
|
||||
extend(id: number): ListTrackSegmentItem {
|
||||
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListTrackSegmentItem extends ListItem {
|
||||
fileId: string;
|
||||
trackIndex: number;
|
||||
segmentIndex: number;
|
||||
|
||||
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
|
||||
super(ListLevel.SEGMENT);
|
||||
this.fileId = fileId;
|
||||
this.trackIndex = trackIndex;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
|
||||
getId(): number {
|
||||
return this.segmentIndex;
|
||||
}
|
||||
|
||||
getFullId(): string {
|
||||
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
|
||||
}
|
||||
|
||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||
switch (level) {
|
||||
case ListLevel.ROOT:
|
||||
return this.fileId;
|
||||
case ListLevel.FILE:
|
||||
return this.trackIndex;
|
||||
case ListLevel.TRACK:
|
||||
return this.segmentIndex;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getFileId(): string {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
getTrackIndex(): number {
|
||||
return this.trackIndex;
|
||||
}
|
||||
|
||||
getSegmentIndex(): number {
|
||||
return this.segmentIndex;
|
||||
}
|
||||
|
||||
getParent(): ListItem {
|
||||
return new ListTrackItem(this.fileId, this.trackIndex);
|
||||
}
|
||||
|
||||
extend(): ListTrackSegmentItem {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class ListWaypointsItem extends ListItem {
|
||||
fileId: string;
|
||||
|
||||
constructor(fileId: string) {
|
||||
super(ListLevel.WAYPOINTS);
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return 'waypoints';
|
||||
}
|
||||
|
||||
getFullId(): string {
|
||||
return `${this.fileId}-waypoints`;
|
||||
}
|
||||
|
||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||
switch (level) {
|
||||
case ListLevel.ROOT:
|
||||
return this.fileId;
|
||||
case ListLevel.FILE:
|
||||
return 'waypoints';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getFileId(): string {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
getParent(): ListItem {
|
||||
return new ListFileItem(this.fileId);
|
||||
}
|
||||
|
||||
extend(id: number): ListWaypointItem {
|
||||
return new ListWaypointItem(this.fileId, id);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListWaypointItem extends ListItem {
|
||||
fileId: string;
|
||||
waypointIndex: number;
|
||||
|
||||
constructor(fileId: string, waypointIndex: number) {
|
||||
super(ListLevel.WAYPOINT);
|
||||
this.fileId = fileId;
|
||||
this.waypointIndex = waypointIndex;
|
||||
}
|
||||
|
||||
getId(): number {
|
||||
return this.waypointIndex;
|
||||
}
|
||||
|
||||
getFullId(): string {
|
||||
return `${this.fileId}-waypoint-${this.waypointIndex}`;
|
||||
}
|
||||
|
||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||
switch (level) {
|
||||
case ListLevel.ROOT:
|
||||
return this.fileId;
|
||||
case ListLevel.FILE:
|
||||
return 'waypoints';
|
||||
case ListLevel.WAYPOINTS:
|
||||
return this.waypointIndex;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getFileId(): string {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
getWaypointIndex(): number {
|
||||
return this.waypointIndex;
|
||||
}
|
||||
|
||||
getParent(): ListItem {
|
||||
return new ListWaypointsItem(this.fileId);
|
||||
}
|
||||
|
||||
extend(): ListWaypointItem {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export function sortItems(items: ListItem[], reverse: boolean = false) {
|
||||
items.sort((a, b) => {
|
||||
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
|
||||
return a.getTrackIndex() - b.getTrackIndex();
|
||||
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
|
||||
return a.getSegmentIndex() - b.getSegmentIndex();
|
||||
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
|
||||
return a.getWaypointIndex() - b.getWaypointIndex();
|
||||
}
|
||||
return a.level - b.level;
|
||||
});
|
||||
if (reverse) {
|
||||
items.reverse();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { isMac } from '$lib/utils';
|
||||
import Sortable, { type Direction } from 'sortablejs/Sortable';
|
||||
import { ListLevel } from './file-list';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { tick } from 'svelte';
|
||||
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
||||
import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
|
||||
|
||||
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
|
||||
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.ROOT]: [],
|
||||
[ListLevel.FILE]: [ListLevel.FILE],
|
||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
file: ['file'],
|
||||
track: ['file', 'track'],
|
||||
segment: ['file', 'track', 'segment'],
|
||||
waypoints: ['waypoints'],
|
||||
waypoint: ['waypoints', 'waypoint'],
|
||||
};
|
||||
|
||||
export const dragging = writable<ListLevel | null>(null);
|
||||
@@ -84,11 +85,11 @@ export class SortableFileList {
|
||||
childNode(id: string): FileTreeNode | undefined {
|
||||
const parent = this._parent;
|
||||
switch (this._sortableLevel) {
|
||||
case ListLevel.FILE:
|
||||
case 'file':
|
||||
return { type: 'file', fileId: id };
|
||||
case ListLevel.TRACK:
|
||||
case 'track':
|
||||
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
|
||||
case ListLevel.SEGMENT:
|
||||
case 'segment':
|
||||
return parent?.type === 'track'
|
||||
? {
|
||||
type: 'segment',
|
||||
@@ -97,9 +98,9 @@ export class SortableFileList {
|
||||
segmentId: id,
|
||||
}
|
||||
: undefined;
|
||||
case ListLevel.WAYPOINTS:
|
||||
case 'waypoints':
|
||||
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
|
||||
case ListLevel.WAYPOINT:
|
||||
case 'waypoint':
|
||||
return parent
|
||||
? { type: 'waypoint', fileId: parent.fileId, waypointId: id }
|
||||
: undefined;
|
||||
@@ -110,11 +111,11 @@ export class SortableFileList {
|
||||
private selectionOf(ids: string[]): Selection | undefined {
|
||||
const parent = this._parent;
|
||||
switch (this._sortableLevel) {
|
||||
case ListLevel.FILE:
|
||||
case 'file':
|
||||
return { type: 'file', fileIds: ids };
|
||||
case ListLevel.TRACK:
|
||||
case 'track':
|
||||
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
|
||||
case ListLevel.SEGMENT:
|
||||
case 'segment':
|
||||
return parent?.type === 'track'
|
||||
? {
|
||||
type: 'segment',
|
||||
@@ -123,9 +124,9 @@ export class SortableFileList {
|
||||
segmentIds: ids,
|
||||
}
|
||||
: undefined;
|
||||
case ListLevel.WAYPOINTS:
|
||||
case 'waypoints':
|
||||
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
|
||||
case ListLevel.WAYPOINT:
|
||||
case 'waypoint':
|
||||
return parent
|
||||
? { type: 'waypoint', fileId: parent.fileId, waypointIds: ids }
|
||||
: undefined;
|
||||
@@ -136,20 +137,20 @@ export class SortableFileList {
|
||||
private targetAt(index: number): MoveTarget | undefined {
|
||||
const parent = this._parent;
|
||||
switch (this._sortableLevel) {
|
||||
case ListLevel.FILE:
|
||||
case 'file':
|
||||
return { type: 'files', index };
|
||||
case ListLevel.TRACK:
|
||||
case 'track':
|
||||
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
|
||||
case ListLevel.SEGMENT:
|
||||
case 'segment':
|
||||
return parent?.type === 'track'
|
||||
? { type: 'segments', fileId: parent.fileId, trackId: parent.trackId, index }
|
||||
: undefined;
|
||||
case ListLevel.WAYPOINTS:
|
||||
case 'waypoints':
|
||||
// the list holds the node standing for the waypoints: they go after the others
|
||||
return parent
|
||||
? { type: 'waypoints', fileId: parent.fileId, index: Infinity }
|
||||
: undefined;
|
||||
case ListLevel.WAYPOINT:
|
||||
case 'waypoint':
|
||||
return parent ? { type: 'waypoints', fileId: parent.fileId, index } : undefined;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +162,7 @@ export class SortableFileList {
|
||||
return;
|
||||
}
|
||||
|
||||
if (from === to && this._sortableLevel === ListLevel.FILE) {
|
||||
if (from === to && this._sortableLevel === 'file') {
|
||||
// the order of the files is the one of the list
|
||||
this.updateToFileOrder();
|
||||
return;
|
||||
@@ -169,7 +170,7 @@ export class SortableFileList {
|
||||
|
||||
// The event is triggered on the source and on the destination list: handle it once.
|
||||
// Nothing to move in the list of the single node of the waypoints.
|
||||
if (to !== this || (from === to && this._sortableLevel === ListLevel.WAYPOINTS)) {
|
||||
if (to !== this || (from === to && this._sortableLevel === 'waypoints')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -245,19 +246,19 @@ export class SortableFileList {
|
||||
engine.select([]);
|
||||
} else {
|
||||
switch (this._sortableLevel) {
|
||||
case ListLevel.FILE:
|
||||
case 'file':
|
||||
engine.select(selected);
|
||||
break;
|
||||
case ListLevel.TRACK:
|
||||
case 'track':
|
||||
engine.selectTracks(parent!.fileId, selected);
|
||||
break;
|
||||
case ListLevel.SEGMENT:
|
||||
case 'segment':
|
||||
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected);
|
||||
break;
|
||||
case ListLevel.WAYPOINTS:
|
||||
case 'waypoints':
|
||||
engine.selectWaypointGroup(parent!.fileId);
|
||||
break;
|
||||
case ListLevel.WAYPOINT:
|
||||
case 'waypoint':
|
||||
engine.selectWaypoints(parent!.fileId, selected);
|
||||
break;
|
||||
}
|
||||
@@ -266,7 +267,7 @@ export class SortableFileList {
|
||||
}
|
||||
|
||||
updateFromFileOrder() {
|
||||
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
|
||||
if (!this._sortable || this._sortableLevel !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,7 +283,7 @@ export class SortableFileList {
|
||||
}
|
||||
|
||||
updateToFileOrder() {
|
||||
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
|
||||
if (!this._sortable || this._sortableLevel !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,7 +305,7 @@ export class SortableFileList {
|
||||
if (element instanceof HTMLElement) {
|
||||
let attr = element.getAttribute('data-id');
|
||||
if (attr) {
|
||||
if (this._sortableLevel === ListLevel.FILE && !files.has(attr)) {
|
||||
if (this._sortableLevel === 'file' && !files.has(attr)) {
|
||||
element.remove();
|
||||
} else {
|
||||
this._elements[attr] = element;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type FilterSpecification,
|
||||
@@ -13,7 +12,6 @@ import { getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { engine, type FileState, type Selection } from '$lib/engine';
|
||||
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection-helpers';
|
||||
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
|
||||
@@ -615,7 +613,7 @@ export class GPXLayer {
|
||||
getElevation([
|
||||
{
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
lng: e.lngLat.lng,
|
||||
},
|
||||
]).then(async (ele) => {
|
||||
if (this.draggedWaypointIndex === null) {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { hoveredPoint } from '$lib/logic/statistics';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { get } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/all-hidden';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { slicedStatistics } from '$lib/logic/selection-statistics';
|
||||
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -93,7 +92,7 @@ export class StartEndMarkers {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [hovered.lon, hovered.lat],
|
||||
coordinates: [hovered.lng, hovered.lat],
|
||||
},
|
||||
properties: {
|
||||
icon: 'hover-marker',
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
const desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
const [ele] = await getElevation([{ lat: poi.item.lat, lon: poi.item.lng }]);
|
||||
const [ele] = await getElevation([{ lat: poi.item.lat, lng: poi.item.lng }]);
|
||||
await engine.newWaypoint({
|
||||
lng: poi.item.lng,
|
||||
lat: poi.item.lat,
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
||||
import { getLayers } from './utils';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/map/map-popup';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { db } from '$lib/db';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { cachedFetch } from '$lib/cached-fetch';
|
||||
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
/** The responses of Overpass are kept in the browser for a week. */
|
||||
const CACHE = { name: 'overpass', maxAge: 7 * 24 * 3600 * 1000 };
|
||||
|
||||
const mercator = new SphericalMercator({
|
||||
size: 256,
|
||||
});
|
||||
|
||||
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
||||
data.set({ type: 'FeatureCollection', features: pois.map((poi) => poi.poi) });
|
||||
});
|
||||
|
||||
export class OverpassLayer {
|
||||
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
|
||||
minZoom = 12;
|
||||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
popup: MapPopup;
|
||||
|
||||
data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
// What was queried this session: the queries that were made for each tile. The responses are
|
||||
// kept longer, see `cachedFetch`.
|
||||
queriedTiles = new Map<string, Set<string>>();
|
||||
currentQueries: Set<string> = new Set();
|
||||
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
|
||||
|
||||
unsubscribes: (() => void)[] = [];
|
||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||
@@ -54,7 +53,7 @@ export class OverpassLayer {
|
||||
add() {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(this.data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
@@ -77,7 +76,7 @@ export class OverpassLayer {
|
||||
update() {
|
||||
this.loadIcons();
|
||||
|
||||
const fullData = get(data);
|
||||
const fullData = get(this.data);
|
||||
const queries = getCurrentQueries();
|
||||
const d: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
@@ -158,7 +157,6 @@ export class OverpassLayer {
|
||||
}
|
||||
|
||||
let tileLimits = mercator.xyz(bbox, this.queryZoom);
|
||||
let time = Date.now();
|
||||
|
||||
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
|
||||
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
|
||||
@@ -166,66 +164,35 @@ export class OverpassLayer {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.overpasstiles
|
||||
.where('[x+y]')
|
||||
.equals([x, y])
|
||||
.toArray()
|
||||
.then((querytiles) => {
|
||||
let missingQueries = queries.filter(
|
||||
(query) =>
|
||||
!querytiles.some(
|
||||
(querytile) =>
|
||||
querytile.query === query &&
|
||||
time - querytile.time < this.expirationTime
|
||||
)
|
||||
);
|
||||
if (missingQueries.length > 0) {
|
||||
this.queryTile(x, y, missingQueries);
|
||||
}
|
||||
});
|
||||
const queried = this.queriedTiles.get(`${x},${y}`);
|
||||
const missingQueries = queries.filter((query) => !queried?.has(query));
|
||||
if (missingQueries.length > 0) {
|
||||
this.queryTile(x, y, missingQueries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queryTile(x: number, y: number, queries: string[]) {
|
||||
if (this.currentQueries.size > 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentQueries.add(`${x},${y}`);
|
||||
|
||||
const bounds = mercator.bbox(x, y, this.queryZoom);
|
||||
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
|
||||
.then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
this.currentQueries.delete(`${x},${y}`);
|
||||
return Promise.reject();
|
||||
},
|
||||
() => this.currentQueries.delete(`${x},${y}`)
|
||||
)
|
||||
cachedFetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`, CACHE)
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject()))
|
||||
.then((data) => this.storeOverpassData(x, y, queries, data))
|
||||
.catch(() => this.currentQueries.delete(`${x},${y}`));
|
||||
}
|
||||
|
||||
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||
let time = Date.now();
|
||||
let queryTiles = queries.map((query) => ({ x, y, query, time }));
|
||||
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
|
||||
|
||||
if (data.elements === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let element of data.elements) {
|
||||
for (let query of queries) {
|
||||
if (belongsToQuery(element, query)) {
|
||||
pois.push({
|
||||
query,
|
||||
id: element.id,
|
||||
poi: {
|
||||
this.data.update((updatedData) => {
|
||||
for (let element of data.elements) {
|
||||
for (let query of queries) {
|
||||
if (belongsToQuery(element, query)) {
|
||||
updatedData.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
@@ -242,17 +209,17 @@ export class OverpassLayer {
|
||||
tags: element.tags,
|
||||
type: element.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
|
||||
await db.overpasstiles.bulkPut(queryTiles);
|
||||
await db.overpassdata.bulkPut(pois);
|
||||
return updatedData;
|
||||
});
|
||||
|
||||
const queried = this.queriedTiles.get(`${x},${y}`) ?? new Set<string>();
|
||||
queries.forEach((query) => queried.add(query));
|
||||
this.queriedTiles.set(`${x},${y}`, queried);
|
||||
|
||||
this.currentQueries.delete(`${x},${y}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { engine } from '$lib/engine';
|
||||
import { get } from 'svelte/store';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
|
||||
@@ -246,17 +247,23 @@ export class MapLayerEventManager {
|
||||
layerIds: string[],
|
||||
bounds: maplibregl.LngLatBounds
|
||||
): string[] {
|
||||
const files = get(engine.files);
|
||||
let result = layerIds.filter((layerId) => {
|
||||
if (!this._map.getLayer(layerId)) return false;
|
||||
const fileId = layerId.replace('-waypoints', '');
|
||||
if (fileId === layerId) {
|
||||
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
|
||||
} else {
|
||||
return (
|
||||
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
|
||||
true
|
||||
);
|
||||
// the tracks of a file are all within its bounds
|
||||
const file = files.get(fileId);
|
||||
const box = file && get(file).statistics.bounds;
|
||||
return box
|
||||
? bounds.getWest() <= box.east &&
|
||||
bounds.getEast() >= box.west &&
|
||||
bounds.getSouth() <= box.north &&
|
||||
bounds.getNorth() >= box.south
|
||||
: true;
|
||||
}
|
||||
// the waypoints have no bounds of their own
|
||||
return true;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
async function addElevation() {
|
||||
const stats = get(statistics);
|
||||
const points = Array.from(stats.lng, (lon, i) => ({ lon, lat: stats.lat[i] }));
|
||||
const points = Array.from(stats.lng, (lng, i) => ({ lng, lat: stats.lat[i] }));
|
||||
const ele = await getElevation(points);
|
||||
|
||||
// the selection or the files may have changed while the elevation was loading
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { distance, type Coordinates } from 'gpx';
|
||||
import { distance, type Coordinates } from '$lib/geo';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import maplibregl, {
|
||||
type MapMouseEvent,
|
||||
@@ -50,17 +50,11 @@ type Anchor = {
|
||||
type AnchorProperties = { anchorIndex: number; minZoom: number };
|
||||
type AnchorFeature = GeoJSON.Feature<GeoJSON.Point, AnchorProperties>;
|
||||
|
||||
type Position = { lng: number; lat: number };
|
||||
|
||||
/** Whether the tool has to show controls for a selection: it covers segments, or can get some. */
|
||||
function isRoutable(selection: Selection) {
|
||||
return selection.type === 'file' || selection.type === 'track' || selection.type === 'segment';
|
||||
}
|
||||
|
||||
function toCoordinates({ lng, lat }: Position): Coordinates {
|
||||
return { lat, lon: lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchors of the selection of the engine, shown on the map where the user can drag them to
|
||||
* reroute the segments, or click on the segments to add some. Every interaction is turned into a
|
||||
@@ -103,7 +97,7 @@ export class RoutingControls {
|
||||
* Where the pointer hovers a segment of the selection: an anchor that can be dragged or
|
||||
* clicked to be added.
|
||||
*/
|
||||
temporaryAnchor: (Position & { segment: number }) | null = null;
|
||||
temporaryAnchor: (Coordinates & { segment: number }) | null = null;
|
||||
showTemporaryAnchorBinded: (e: MapLayerMouseEvent) => void =
|
||||
this.showTemporaryAnchor.bind(this);
|
||||
updateTemporaryAnchorBinded: (e: MapMouseEvent) => void = this.updateTemporaryAnchor.bind(this);
|
||||
@@ -328,11 +322,11 @@ export class RoutingControls {
|
||||
this.popup.remove();
|
||||
}
|
||||
|
||||
position(index: number): Position {
|
||||
position(index: number): Coordinates {
|
||||
return { lng: this.statistics!.lng[index], lat: this.statistics!.lat[index] };
|
||||
}
|
||||
|
||||
async moveAnchor(anchor: Anchor, coordinates: Position) {
|
||||
async moveAnchor(anchor: Anchor, coordinates: Coordinates) {
|
||||
// Move the anchor and update the route from and to the neighbouring anchors
|
||||
const initialAnchor = anchor;
|
||||
const initialCoordinates = { lng: anchor.lng, lat: anchor.lat };
|
||||
@@ -349,7 +343,7 @@ export class RoutingControls {
|
||||
const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
|
||||
|
||||
const chain: Anchor[] = [];
|
||||
const targets: Position[] = [];
|
||||
const targets: Coordinates[] = [];
|
||||
if (previousAnchor) {
|
||||
chain.push(previousAnchor);
|
||||
targets.push(this.position(previousAnchor.index));
|
||||
@@ -457,7 +451,7 @@ export class RoutingControls {
|
||||
const first = this.position(start);
|
||||
const last = this.position(end - 1);
|
||||
// the end of the segment has to be close to its start, up to a kilometer
|
||||
return distance(toCoordinates(first), toCoordinates(last)) <= 1000;
|
||||
return distance(first, last) <= 1000;
|
||||
}
|
||||
|
||||
async appendAnchor(e: maplibregl.MapMouseEvent) {
|
||||
@@ -480,7 +474,7 @@ export class RoutingControls {
|
||||
this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat });
|
||||
}
|
||||
|
||||
async appendAnchorWithCoordinates(coordinates: Position) {
|
||||
async appendAnchorWithCoordinates(coordinates: Coordinates) {
|
||||
// Add a new anchor to the end of the last segment
|
||||
const statistics = this.statistics;
|
||||
const revision = this.anchorData?.revision;
|
||||
@@ -546,7 +540,7 @@ export class RoutingControls {
|
||||
* single segment) or where they were moved to, and replaces what is between the first and
|
||||
* the last anchor by the route.
|
||||
*/
|
||||
async routeBetweenAnchors(anchors: Anchor[], targets: Position[]): Promise<boolean> {
|
||||
async routeBetweenAnchors(anchors: Anchor[], targets: Coordinates[]): Promise<boolean> {
|
||||
const revision = this.anchorData?.revision;
|
||||
if (revision === undefined) {
|
||||
return false;
|
||||
@@ -565,7 +559,7 @@ export class RoutingControls {
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await route(targets.map(toCoordinates));
|
||||
response = await route(targets);
|
||||
} catch (e: any) {
|
||||
toast.error(i18n._(e.message, e.message));
|
||||
return false;
|
||||
@@ -581,9 +575,9 @@ export class RoutingControls {
|
||||
const keepFirst =
|
||||
first.index !== segmentStart &&
|
||||
(first.index !== lastIndex ||
|
||||
distance(toCoordinates(targets[0]), {
|
||||
distance(targets[0], {
|
||||
lat: response.lat[0],
|
||||
lon: response.lng[0],
|
||||
lng: response.lng[0],
|
||||
}) > 1);
|
||||
const keepLast = last.index !== lastIndex;
|
||||
|
||||
@@ -843,7 +837,7 @@ export class RoutingControls {
|
||||
return false;
|
||||
}
|
||||
|
||||
moveAnchorFeature(anchor: Anchor, coordinates: Position) {
|
||||
moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) {
|
||||
const source = get(map)?.getSource(
|
||||
this.layers.get(anchor.id === this.anchors.length ? 0 : anchor.zoom)?.id ?? ''
|
||||
) as GeoJSONSource | undefined;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { distance, type Coordinates } from 'gpx';
|
||||
import { distance, type Coordinates } from '$lib/geo';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { getElevation } from '$lib/utils';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -143,7 +143,7 @@ async function getGraphHopperRoute(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
points: points.map((point) => [point.lon, point.lat]),
|
||||
points: points.map((point) => [point.lng, point.lat]),
|
||||
profile: graphHopperProfile,
|
||||
elevation: true,
|
||||
points_encoded: false,
|
||||
@@ -209,7 +209,7 @@ async function getBRouterRoute(
|
||||
points: Coordinates[],
|
||||
brouterProfile: string
|
||||
): Promise<RoutedPoints> {
|
||||
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
|
||||
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lng.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
|
||||
|
||||
let response = await fetch(url);
|
||||
|
||||
@@ -283,15 +283,15 @@ function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
|
||||
const dist = distance(points[i], points[i + 1]) / 1000;
|
||||
for (let d = 0; d < dist; d += step) {
|
||||
const lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
|
||||
const lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
|
||||
addPoint(route, lon, lat, 0);
|
||||
const lng = points[i].lng + (d / dist) * (points[i + 1].lng - points[i].lng);
|
||||
addPoint(route, lng, lat, 0);
|
||||
}
|
||||
}
|
||||
|
||||
const last = points[points.length - 1];
|
||||
addPoint(route, last.lon, last.lat, 0);
|
||||
addPoint(route, last.lng, last.lat, 0);
|
||||
|
||||
return getElevation(route.lng.map((lng, i) => ({ lon: lng, lat: route.lat[i] }))).then(
|
||||
return getElevation(route.lng.map((lng, i) => ({ lng, lat: route.lat[i] }))).then(
|
||||
(elevations) => {
|
||||
route.ele = elevations;
|
||||
return route;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export const MIN_ANCHOR_ZOOM = 0;
|
||||
export const MAX_ANCHOR_ZOOM = 22;
|
||||
|
||||
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
|
||||
if (distance === undefined) {
|
||||
return MIN_ANCHOR_ZOOM;
|
||||
}
|
||||
|
||||
const rad = Math.PI / 180;
|
||||
const lat = latitude * rad;
|
||||
|
||||
return Math.min(
|
||||
MAX_ANCHOR_ZOOM,
|
||||
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAnchorPoints(file: GPXFile) {
|
||||
let segments = file.getSegments();
|
||||
|
||||
for (let segment of segments) {
|
||||
if (!segment._data.anchors) {
|
||||
// New segment, compute anchor points for it
|
||||
computeAnchorPoints(segment);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.trkpt.length > 0) {
|
||||
// Ensure first and last points are anchors and always visible
|
||||
segment.trkpt[0]._data.anchor = true;
|
||||
segment.trkpt[0]._data.zoom = 0;
|
||||
segment.trkpt[segment.trkpt.length - 1]._data.anchor = true;
|
||||
segment.trkpt[segment.trkpt.length - 1]._data.zoom = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function computeAnchorPoints(segment: TrackSegment) {
|
||||
let points = segment.trkpt;
|
||||
let anchors = ramerDouglasPeucker(points, 1);
|
||||
anchors.forEach((anchor) => {
|
||||
let point = anchor.point;
|
||||
point._data.anchor = true;
|
||||
point._data.zoom = getZoomLevelForDistance(point.getLatitude(), anchor.distance);
|
||||
});
|
||||
segment._data.anchors = true;
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
longitude = parseFloat(longitude.toFixed(6));
|
||||
|
||||
const target = $selectedWaypoint;
|
||||
const [ele] = await getElevation([{ lat: latitude, lon: longitude }]);
|
||||
const [ele] = await getElevation([{ lat: latitude, lng: longitude }]);
|
||||
const waypoint = {
|
||||
lng: longitude,
|
||||
lat: latitude,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import Dexie from 'dexie';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { enableMapSet, enablePatches, type Patch } from 'immer';
|
||||
|
||||
enableMapSet();
|
||||
enablePatches();
|
||||
|
||||
export class Database extends Dexie {
|
||||
fileids!: Dexie.Table<string, string>;
|
||||
files!: Dexie.Table<GPXFile, string>;
|
||||
patches!: Dexie.Table<{ patch: Patch[]; inversePatch: Patch[]; index: number }, number>;
|
||||
settings!: Dexie.Table<any, string>;
|
||||
overpasstiles!: Dexie.Table<
|
||||
{ query: string; x: number; y: number; time: number },
|
||||
[string, number, number]
|
||||
>;
|
||||
overpassdata!: Dexie.Table<
|
||||
{ query: string; id: number; poi: GeoJSON.Feature },
|
||||
[string, number]
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
super('Database', {
|
||||
cache: 'immutable',
|
||||
});
|
||||
this.version(1).stores({
|
||||
fileids: ',&fileid',
|
||||
files: '',
|
||||
patches: ',patch',
|
||||
settings: '',
|
||||
overpasstiles: '[query+x+y],[x+y]',
|
||||
overpassdata: '[query+id]',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new Database();
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Fitxers i estadístiques
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ També pots utilitzar la rodeta del ratolí per apropar o allunyar el perfil d'e
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ També pots utilitzar la rodeta del ratolí per apropar o allunyar el perfil d'e
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Soubory a statistiky
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Pomocí kolečka myši můžete také výškový profil přiblížit a oddálit
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Pomocí kolečka myši můžete také výškový profil přiblížit a oddálit
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Dateien und Statistiken
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Sie können auch das Mausrad verwenden, um auf dem Höhenprofil heranzuzoomen un
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Sie können auch das Mausrad verwenden, um auf dem Höhenprofil heranzuzoomen un
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Archivos y estadísticas
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Puede usar el ratón para acercar o alejar el perfil de elevación y moverse hac
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Puede usar el ratón para acercar o alejar el perfil de elevación y moverse hac
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Fitxategiak eta estatistikak
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -82,8 +82,8 @@ Saguaren gurpila ere erabil dezakezu altueren profila handitzeko eta mugitzeko e
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -91,8 +91,8 @@ Saguaren gurpila ere erabil dezakezu altueren profila handitzeko eta mugitzeko e
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Fichiers et statistiques
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Vous pouvez également utiliser la molette de la souris pour zoomer ou dézoomer
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Vous pouvez également utiliser la molette de la souris pour zoomer ou dézoomer
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Fájlok és statisztikák
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Az egérgörgővel is méretezheti a magassági profilt. Balra és jobbra mozogh
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Az egérgörgővel is méretezheti a magassági profilt. Balra és jobbra mozogh
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: File e statistiche
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Fare clic sul profilo per resettare la selezione.
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Fare clic sul profilo per resettare la selezione.
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Puslapis nerastas
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Bestanden en statistieken
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Je kunt het muiswiel ook gebruiken om in en uit te zoomen op het hoogte profiel,
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Je kunt het muiswiel ook gebruiken om in en uit te zoomen op het hoogte profiel,
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Pliki i statystyki
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Arquivos e estatísticas
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Você também pode usar a roda do mouse para aumentar e diminuir o zoom no perfi
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Você também pode usar a roda do mouse para aumentar e diminuir o zoom no perfi
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Файлы и статистика
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ title: Файлы и статистика
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ title: Файлы и статистика
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Datoteke i statistike
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Takođe možete da koristite točkić miša da zumirate i umanjite profil nadmo
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Takođe možete da koristite točkić miša da zumirate i umanjite profil nadmo
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ Du kan också använda mushjulet för att zooma in och ut på höjdprofilen och
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ Du kan också använda mushjulet för att zooma in och ut på höjdprofilen och
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Dosyalar ve istatistikler
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -82,8 +82,8 @@ Ayrıca, fare tekerleği ile yükselti profiline yakınlaşıp uzaklaşabilir ve
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -91,8 +91,8 @@ Ayrıca, fare tekerleği ile yükselti profiline yakınlaşıp uzaklaşabilir ve
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Файли та статистика
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: Files and statistics
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: 檔案和統計
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ title: 文件和统计
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { exampleStatistics } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let statistics = writable(exampleStatistics);
|
||||
let slicedStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -83,8 +83,8 @@ title: 文件和统计
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
@@ -92,8 +92,8 @@ title: 文件和统计
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+102
-1
@@ -1,7 +1,14 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import';
|
||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
||||
import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
|
||||
import { setHidden, type Visibility } from '$lib/file-visibility';
|
||||
import {
|
||||
parseVisibility,
|
||||
setHidden,
|
||||
stringifyVisibility,
|
||||
VISIBILITY_KEY,
|
||||
type Visibility,
|
||||
} from '$lib/file-visibility';
|
||||
import { categoryIntervals, type CategoryIntervals } from '$lib/trackpoint-categories';
|
||||
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
|
||||
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
|
||||
@@ -32,6 +39,9 @@ export type {
|
||||
|
||||
type Wasm = typeof import('gpx-rs');
|
||||
|
||||
/** Name of the IndexedDB database that keeps the files and the settings. */
|
||||
const STORAGE_NAME = 'gpx-studio';
|
||||
|
||||
/**
|
||||
* How a merge puts the selected elements together: `connect` makes a single segment of their
|
||||
* trackpoints, `group` keeps them as they are, in the first one.
|
||||
@@ -345,6 +355,85 @@ class Engine {
|
||||
: new Promise(() => {});
|
||||
}
|
||||
|
||||
// Storage
|
||||
|
||||
private _storage?: Promise<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Opens the storage of the browser: the files that it holds are put in the engine, and the
|
||||
* changes of the files are kept from then on. Resolves to the settings, as JSON strings by
|
||||
* key. Only the app does it (the embedded map has no files of its own), once.
|
||||
*
|
||||
* The first time, what the previous versions of the app kept in the browser is imported.
|
||||
*/
|
||||
openStorage(): Promise<Record<string, string>> {
|
||||
this._storage ??= this.openStorageOnce();
|
||||
return this._storage;
|
||||
}
|
||||
|
||||
private async openStorageOnce(): Promise<Record<string, string>> {
|
||||
await this.ready;
|
||||
const wasm = this.wasm!;
|
||||
let settings: Record<string, string>;
|
||||
try {
|
||||
settings = (await wasm.open_storage(STORAGE_NAME)) as Record<string, string>;
|
||||
} catch (error) {
|
||||
console.error('The files cannot be kept in this browser', error);
|
||||
return {};
|
||||
}
|
||||
// what was hidden, which the files that were restored need when they are read
|
||||
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
||||
this._visibility.set(fileId, visibility)
|
||||
);
|
||||
this.sync(wasm);
|
||||
|
||||
if (settings[LEGACY_IMPORTED_KEY] === undefined && get(this._order).length === 0) {
|
||||
settings = { ...settings, ...(await this.importLegacyData(wasm)) };
|
||||
await wasm.flush_storage();
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** Puts the files and the settings of the former database in the engine. */
|
||||
private async importLegacyData(wasm: Wasm): Promise<Record<string, string>> {
|
||||
const imported: Record<string, string> = {};
|
||||
try {
|
||||
const legacy = await readLegacyData();
|
||||
if (legacy) {
|
||||
const encoder = new TextEncoder();
|
||||
if (legacy.files.length > 0) {
|
||||
await this.loadFiles(
|
||||
legacy.files.map((file) => ({
|
||||
data: encoder.encode(file.gpx),
|
||||
name: file.name,
|
||||
}))
|
||||
);
|
||||
await this.select([]);
|
||||
}
|
||||
for (const [key, value] of Object.entries(legacy.settings)) {
|
||||
wasm.set_setting(key, value);
|
||||
imported[key] = value;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// it is tried again next time
|
||||
console.error('The files of the previous version could not be imported', error);
|
||||
return imported;
|
||||
}
|
||||
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
|
||||
imported[LEGACY_IMPORTED_KEY] = 'true';
|
||||
return imported;
|
||||
}
|
||||
|
||||
/** Keeps a setting, `json` being its JSON. Nothing is kept before `openStorage` is done. */
|
||||
setSetting(key: string, json: string) {
|
||||
this.wasm?.set_setting(key, json);
|
||||
}
|
||||
|
||||
deleteSetting(key: string) {
|
||||
this.wasm?.delete_setting(key);
|
||||
}
|
||||
|
||||
// Actions. Each one resolves to whether the engine changed something.
|
||||
|
||||
/**
|
||||
@@ -845,6 +934,18 @@ class Engine {
|
||||
);
|
||||
this._visibility.set(fileId, visibility);
|
||||
store.update((state) => ({ ...state, visibility }));
|
||||
this.saveVisibility();
|
||||
}
|
||||
|
||||
/** Keeps what is hidden, for the files that exist (the others are not coming back). */
|
||||
private saveVisibility() {
|
||||
const files = get(this._files);
|
||||
const json = stringifyVisibility(this._visibility, (fileId) => files.has(fileId));
|
||||
if (json === undefined) {
|
||||
this.deleteSetting(VISIBILITY_KEY);
|
||||
} else {
|
||||
this.setSetting(VISIBILITY_KEY, json);
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinates, as flat [lng, lat, ...] arrays. The getters copy them out of the WASM memory.
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
isHidden,
|
||||
isSegmentHidden,
|
||||
isWaypointHidden,
|
||||
parseVisibility,
|
||||
setHidden,
|
||||
stringifyVisibility,
|
||||
waypointsKey,
|
||||
type Visibility,
|
||||
type VisibilityState,
|
||||
@@ -142,3 +144,54 @@ describe('file visibility', () => {
|
||||
assert.deepEqual([...before], [['S1', false]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kept visibility', () => {
|
||||
const visibility = (entries: [string, boolean][]): Visibility => new Map(entries);
|
||||
|
||||
it('comes back as it was kept', () => {
|
||||
const files = new Map([
|
||||
[
|
||||
'F',
|
||||
visibility([
|
||||
['F', false],
|
||||
['S1', true],
|
||||
[WPTS, false],
|
||||
]),
|
||||
],
|
||||
['G', visibility([['G', false]])],
|
||||
]);
|
||||
const json = stringifyVisibility(files, () => true);
|
||||
assert.deepEqual(parseVisibility(json), files);
|
||||
});
|
||||
|
||||
it('leaves out the files that are gone and the ones with nothing set', () => {
|
||||
const files = new Map([
|
||||
['F', visibility([['F', false]])],
|
||||
['G', visibility([['G', false]])],
|
||||
['H', visibility([])],
|
||||
]);
|
||||
const json = stringifyVisibility(files, (fileId) => fileId !== 'G');
|
||||
assert.deepEqual([...parseVisibility(json).keys()], ['F']);
|
||||
});
|
||||
|
||||
it('is nothing when there is nothing to keep', () => {
|
||||
assert.equal(
|
||||
stringifyVisibility(new Map(), () => true),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
stringifyVisibility(new Map([['F', visibility([['F', false]])]]), () => false),
|
||||
undefined
|
||||
);
|
||||
assert.equal(parseVisibility(undefined).size, 0);
|
||||
});
|
||||
|
||||
it('ignores what is not visibility', () => {
|
||||
assert.equal(parseVisibility('not json').size, 0);
|
||||
assert.equal(parseVisibility('3').size, 0);
|
||||
assert.equal(parseVisibility('null').size, 0);
|
||||
const parsed = parseVisibility('{"F":{"F":false,"S1":"no"},"G":3,"H":{}}');
|
||||
assert.deepEqual([...parsed.keys()], ['F']);
|
||||
assert.deepEqual([...parsed.get('F')!], [['F', false]]);
|
||||
});
|
||||
});
|
||||
@@ -110,3 +110,55 @@ export function setHidden(
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Name under which the visibility of the files is kept in the storage. */
|
||||
export const VISIBILITY_KEY = 'visibility';
|
||||
|
||||
/**
|
||||
* The visibility of the files as JSON, to keep it: the explicit visibility of each file by id,
|
||||
* leaving out the files that `keep` refuses (the ones that are gone) and the ones that have none.
|
||||
* `undefined` when there is nothing to keep.
|
||||
*/
|
||||
export function stringifyVisibility(
|
||||
files: ReadonlyMap<string, Visibility>,
|
||||
keep: (fileId: string) => boolean
|
||||
): string | undefined {
|
||||
const data: Record<string, Record<string, boolean>> = {};
|
||||
files.forEach((visibility, fileId) => {
|
||||
if (visibility.size > 0 && keep(fileId)) {
|
||||
data[fileId] = Object.fromEntries(visibility);
|
||||
}
|
||||
});
|
||||
return Object.keys(data).length > 0 ? JSON.stringify(data) : undefined;
|
||||
}
|
||||
|
||||
/** What `stringifyVisibility` gave back. What does not make sense is left out. */
|
||||
export function parseVisibility(json: string | undefined): Map<string, Visibility> {
|
||||
const files = new Map<string, Visibility>();
|
||||
if (json === undefined) {
|
||||
return files;
|
||||
}
|
||||
try {
|
||||
const data: unknown = JSON.parse(json);
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return files;
|
||||
}
|
||||
for (const [fileId, entries] of Object.entries(data)) {
|
||||
if (typeof entries !== 'object' || entries === null) {
|
||||
continue;
|
||||
}
|
||||
const visibility = new Map<string, boolean>();
|
||||
for (const [id, shown] of Object.entries(entries)) {
|
||||
if (typeof shown === 'boolean') {
|
||||
visibility.set(id, shown);
|
||||
}
|
||||
}
|
||||
if (visibility.size > 0) {
|
||||
files.set(fileId, visibility);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not something that was kept
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type Coordinates = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
|
||||
/** The distance in meters between two coordinates (haversine formula). */
|
||||
export function distance(a: Coordinates, b: Coordinates): number {
|
||||
const rad = Math.PI / 180;
|
||||
const lat1 = a.lat * rad;
|
||||
const lat2 = b.lat * rad;
|
||||
const dLat = lat2 - lat1;
|
||||
const dLng = (b.lng - a.lng) * rad;
|
||||
const h =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
return EARTH_RADIUS * 2 * Math.asin(Math.sqrt(Math.min(h, 1)));
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* One-time import of what the previous versions of the app kept in the browser: the files and the
|
||||
* settings of an IndexedDB database named `Database` (managed with Dexie, which is not needed to
|
||||
* read it). The files were stored as the objects of the former `gpx` library; they are written
|
||||
* back as GPX, which the engine loads. The old database is left as it was.
|
||||
*/
|
||||
|
||||
const LEGACY_DATABASE = 'Database';
|
||||
|
||||
/** Setting that tells that the import is done (or that there was nothing to import). */
|
||||
export const LEGACY_IMPORTED_KEY = 'legacyImported';
|
||||
|
||||
/** Settings of the old database that mean nothing anymore. */
|
||||
const DROPPED_SETTINGS = new Set(['patchIndex', 'fileOrder']);
|
||||
|
||||
type Json = any;
|
||||
|
||||
function request<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the old database, `undefined` if there is none (and none is created). */
|
||||
async function openLegacyDatabase(): Promise<IDBDatabase | undefined> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof indexedDB.databases === 'function') {
|
||||
const databases = await indexedDB.databases();
|
||||
if (!databases.some((database) => database.name === LEGACY_DATABASE)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const open = indexedDB.open(LEGACY_DATABASE);
|
||||
let created = false;
|
||||
open.onupgradeneeded = () => {
|
||||
// it did not exist: do not leave an empty database behind
|
||||
created = true;
|
||||
open.transaction?.abort();
|
||||
};
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => {
|
||||
if (created) {
|
||||
indexedDB.deleteDatabase(LEGACY_DATABASE);
|
||||
}
|
||||
resolve(undefined);
|
||||
};
|
||||
open.onblocked = () => resolve(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async function readAll(
|
||||
database: IDBDatabase,
|
||||
store: string
|
||||
): Promise<Map<string, Json> | undefined> {
|
||||
if (!database.objectStoreNames.contains(store)) {
|
||||
return undefined;
|
||||
}
|
||||
const objectStore = database.transaction(store, 'readonly').objectStore(store);
|
||||
const [keys, values] = await Promise.all([
|
||||
request(objectStore.getAllKeys()),
|
||||
request(objectStore.getAll()),
|
||||
]);
|
||||
return new Map(keys.map((key, i) => [String(key), values[i]]));
|
||||
}
|
||||
|
||||
// GPX, from the stored objects
|
||||
|
||||
function escape(text: unknown): string {
|
||||
return String(text).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
function element(name: string, value: unknown): string {
|
||||
return value === undefined || value === null || value === ''
|
||||
? ''
|
||||
: `<${name}>${escape(value)}</${name}>`;
|
||||
}
|
||||
|
||||
function timeElement(value: unknown): string {
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? '' : element('time', date.toISOString());
|
||||
}
|
||||
|
||||
function link(value: Json): string {
|
||||
const href = value?.attributes?.href;
|
||||
return href
|
||||
? `<link href="${escape(href).replaceAll('"', '"')}">${element('text', value.text)}</link>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function coordinates(point: Json): string {
|
||||
const { lat, lon } = point.attributes ?? {};
|
||||
return `lat="${Number(lat)}" lon="${Number(lon)}"`;
|
||||
}
|
||||
|
||||
function trackpoint(point: Json): string {
|
||||
const extensions = point.extensions ?? {};
|
||||
const trackPointExtension = extensions['gpxtpx:TrackPointExtension'] ?? {};
|
||||
const osm = Object.entries(trackPointExtension['gpxtpx:Extensions'] ?? {})
|
||||
.map(([key, value]) => element(key, value))
|
||||
.join('');
|
||||
const inner = [
|
||||
element('gpxtpx:atemp', trackPointExtension['gpxtpx:atemp']),
|
||||
element('gpxtpx:hr', trackPointExtension['gpxtpx:hr']),
|
||||
element('gpxtpx:cad', trackPointExtension['gpxtpx:cad']),
|
||||
osm ? `<gpxtpx:Extensions>${osm}</gpxtpx:Extensions>` : '',
|
||||
].join('');
|
||||
const power = element(
|
||||
'gpxpx:PowerInWatts',
|
||||
extensions['gpxpx:PowerExtension']?.['gpxpx:PowerInWatts']
|
||||
);
|
||||
const extension =
|
||||
inner || power
|
||||
? `<extensions>${inner ? `<gpxtpx:TrackPointExtension>${inner}</gpxtpx:TrackPointExtension>` : ''}${power ? `<gpxpx:PowerExtension>${power}</gpxpx:PowerExtension>` : ''}</extensions>`
|
||||
: '';
|
||||
return `<trkpt ${coordinates(point)}>${element('ele', point.ele)}${point.time ? timeElement(point.time) : ''}${extension}</trkpt>`;
|
||||
}
|
||||
|
||||
function waypoint(point: Json): string {
|
||||
return `<wpt ${coordinates(point)}>${element('ele', point.ele)}${point.time ? timeElement(point.time) : ''}${element('name', point.name)}${element('cmt', point.cmt)}${element('desc', point.desc)}${link(point.link)}${element('sym', point.sym)}${element('type', point.type)}</wpt>`;
|
||||
}
|
||||
|
||||
function track(track: Json): string {
|
||||
const style = track.extensions?.['gpx_style:line'];
|
||||
const line = style
|
||||
? `<extensions><gpx_style:line>${element('gpx_style:color', style['gpx_style:color'])}${element('gpx_style:opacity', style['gpx_style:opacity'])}${element('gpx_style:width', style['gpx_style:width'])}</gpx_style:line></extensions>`
|
||||
: '';
|
||||
const segments = (track.trkseg ?? [])
|
||||
.map(
|
||||
(segment: Json) => `<trkseg>${(segment.trkpt ?? []).map(trackpoint).join('')}</trkseg>`
|
||||
)
|
||||
.join('');
|
||||
return `<trk>${element('name', track.name)}${element('cmt', track.cmt)}${element('desc', track.desc)}${element('src', track.src)}${link(track.link)}${element('type', track.type)}${line}${segments}</trk>`;
|
||||
}
|
||||
|
||||
/** A file as the old versions stored it, as GPX. */
|
||||
export function legacyFileToGPX(file: Json): string {
|
||||
const metadata = file.metadata ?? {};
|
||||
const author = metadata.author;
|
||||
const authorXml = author
|
||||
? `<author>${element('name', author.name)}${link(author.link)}</author>`
|
||||
: '';
|
||||
const metadataXml = `<metadata>${element('name', metadata.name)}${element('desc', metadata.desc)}${authorXml}${link(metadata.link)}${metadata.time ? timeElement(metadata.time) : ''}</metadata>`;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="https://gpx.studio" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2">${metadataXml}${(file.wpt ?? []).map(waypoint).join('')}${(file.trk ?? []).map(track).join('')}</gpx>`;
|
||||
}
|
||||
|
||||
/** What the old database holds. */
|
||||
export type LegacyData = {
|
||||
/** The files as GPX, in the order they had. */
|
||||
files: { gpx: string; name: string }[];
|
||||
/** The settings, as JSON strings by key. */
|
||||
settings: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Reads the old database, `undefined` if there is none. */
|
||||
export async function readLegacyData(): Promise<LegacyData | undefined> {
|
||||
const database = await openLegacyDatabase();
|
||||
if (!database) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const [files, settings] = await Promise.all([
|
||||
readAll(database, 'files'),
|
||||
readAll(database, 'settings'),
|
||||
]);
|
||||
if (!files && !settings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// the order of the files was a setting; the ids (`gpx-<n>`) tell the order they were added in
|
||||
const byAge = [...(files?.keys() ?? [])].sort(
|
||||
(a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1])
|
||||
);
|
||||
const order: string[] = Array.isArray(settings?.get('fileOrder'))
|
||||
? settings!.get('fileOrder')
|
||||
: [];
|
||||
const ids = [
|
||||
...order.filter((id) => files?.has(id)),
|
||||
...byAge.filter((id) => !order.includes(id)),
|
||||
];
|
||||
|
||||
const result: LegacyData = { files: [], settings: {} };
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const file = files!.get(id);
|
||||
result.files.push({
|
||||
gpx: legacyFileToGPX(file),
|
||||
name: file?.metadata?.name || 'file',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Could not import the file ${id}`, error);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of settings ?? []) {
|
||||
if (!DROPPED_SETTINGS.has(key) && value !== undefined) {
|
||||
try {
|
||||
result.settings[key] = JSON.stringify(value);
|
||||
} catch {
|
||||
// not something that can be kept
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import { db, type Database } from '$lib/db';
|
||||
import { liveQuery } from 'dexie';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { applyPatches, produceWithPatches, type Patch, type WritableDraft } from 'immer';
|
||||
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||
import {
|
||||
derived,
|
||||
get,
|
||||
writable,
|
||||
type Readable,
|
||||
type Unsubscriber,
|
||||
type Writable,
|
||||
} from 'svelte/store';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
|
||||
const MAX_PATCHES = 100;
|
||||
|
||||
export class FileActionManager {
|
||||
private _db: Database;
|
||||
private _files: Map<string, GPXFile>;
|
||||
private _fileSubscriptions: Map<string, Unsubscriber>;
|
||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
|
||||
private _patchIndex: Writable<number>;
|
||||
private _patchMinIndex: Writable<number>;
|
||||
private _patchMaxIndex: Writable<number>;
|
||||
private _canUndo: Readable<boolean>;
|
||||
private _canRedo: Readable<boolean>;
|
||||
|
||||
constructor(db: Database) {
|
||||
this._db = db;
|
||||
this._files = new Map();
|
||||
this._fileSubscriptions = new Map();
|
||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileState, fileId) => {
|
||||
this._fileSubscriptions.set(
|
||||
fileId,
|
||||
fileState.subscribe((fileWithStatistics) => {
|
||||
if (fileWithStatistics) {
|
||||
this._files.set(fileId, fileWithStatistics.file);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
(fileId) => {
|
||||
let unsubscribe = this._fileSubscriptions.get(fileId);
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
this._fileSubscriptions.delete(fileId);
|
||||
}
|
||||
this._files.delete(fileId);
|
||||
},
|
||||
() => {
|
||||
this._fileSubscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this._fileSubscriptions.clear();
|
||||
this._files.clear();
|
||||
}
|
||||
);
|
||||
|
||||
this._patchIndex = writable(-1);
|
||||
this._patchMinIndex = writable(0);
|
||||
this._patchMaxIndex = writable(0);
|
||||
|
||||
liveQuery(() => db.settings.get('patchIndex')).subscribe((value) => {
|
||||
if (value !== undefined) {
|
||||
this._patchIndex.set(value);
|
||||
}
|
||||
});
|
||||
liveQuery(() =>
|
||||
(db.patches.orderBy(':id').keys() as Promise<number[]>).then((keys) => {
|
||||
if (keys.length === 0) {
|
||||
return { min: 0, max: 0 };
|
||||
} else {
|
||||
return { min: keys[0], max: keys[keys.length - 1] + 1 };
|
||||
}
|
||||
})
|
||||
).subscribe((value) => {
|
||||
this._patchMinIndex.set(value.min);
|
||||
this._patchMaxIndex.set(value.max);
|
||||
});
|
||||
|
||||
this._canUndo = derived(
|
||||
[this._patchIndex, this._patchMinIndex],
|
||||
([$patchIndex, $patchMinIndex]) => {
|
||||
return $patchIndex >= $patchMinIndex;
|
||||
}
|
||||
);
|
||||
this._canRedo = derived(
|
||||
[this._patchIndex, this._patchMaxIndex],
|
||||
([$patchIndex, $patchMaxIndex]) => {
|
||||
return $patchIndex < $patchMaxIndex - 1;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
get canUndo(): Readable<boolean> {
|
||||
return this._canUndo;
|
||||
}
|
||||
|
||||
get canRedo(): Readable<boolean> {
|
||||
return this._canRedo;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (get(this.canUndo)) {
|
||||
const patchIndex = get(this._patchIndex);
|
||||
this._db.patches.get(patchIndex).then((patch) => {
|
||||
if (patch) {
|
||||
this.apply(patch.inversePatch);
|
||||
this._db.settings.put(patchIndex - 1, 'patchIndex');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (get(this.canRedo)) {
|
||||
const patchIndex = get(this._patchIndex) + 1;
|
||||
this._db.patches.get(patchIndex).then((patch) => {
|
||||
if (patch) {
|
||||
this.apply(patch.patch);
|
||||
this._db.settings.put(patchIndex, 'patchIndex');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
apply(patch: Patch[]) {
|
||||
let newFiles = applyPatches(this._files, patch);
|
||||
return this.commitFileStateChange(newFiles, patch);
|
||||
}
|
||||
|
||||
commitFileStateChange(newFiles: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
|
||||
let changedFileIds = getChangedFileIds(patch);
|
||||
let updatedFileIds: string[] = [],
|
||||
deletedFileIds: string[] = [];
|
||||
|
||||
changedFileIds.forEach((id) => {
|
||||
if (newFiles.has(id)) {
|
||||
updatedFileIds.push(id);
|
||||
} else {
|
||||
deletedFileIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
let updatedFiles = updatedFileIds
|
||||
.map((id) => newFiles.get(id))
|
||||
.filter((file) => file !== undefined) as GPXFile[];
|
||||
updatedFileIds = updatedFiles.map((file) => file._data.id);
|
||||
|
||||
selection.updateFiles(updatedFiles, deletedFileIds);
|
||||
|
||||
// @ts-ignore
|
||||
return this._db.transaction('rw', this._db.fileids, this._db.files, async () => {
|
||||
if (updatedFileIds.length > 0) {
|
||||
await this._db.fileids.bulkPut(updatedFileIds, updatedFileIds);
|
||||
await this._db.files.bulkPut(updatedFiles, updatedFileIds);
|
||||
}
|
||||
if (deletedFileIds.length > 0) {
|
||||
await this._db.fileids.bulkDelete(deletedFileIds);
|
||||
await this._db.files.bulkDelete(deletedFileIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
|
||||
const [newFileCollection, patch, inversePatch] = produceWithPatches(this._files, callback);
|
||||
|
||||
this.storePatches(patch, inversePatch);
|
||||
|
||||
return this.commitFileStateChange(newFileCollection, patch);
|
||||
}
|
||||
|
||||
applyToFiles(fileIds: string[], callback: (file: WritableDraft<GPXFile>) => void) {
|
||||
const [newFileCollection, patch, inversePatch] = produceWithPatches(
|
||||
this._files,
|
||||
(draft) => {
|
||||
fileIds.forEach((fileId) => {
|
||||
let file = draft.get(fileId);
|
||||
if (file) {
|
||||
callback(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.storePatches(patch, inversePatch);
|
||||
|
||||
return this.commitFileStateChange(newFileCollection, patch);
|
||||
}
|
||||
|
||||
applyToFile(fileId: string, callback: (file: WritableDraft<GPXFile>) => void) {
|
||||
return this.applyToFiles([fileId], callback);
|
||||
}
|
||||
|
||||
applyEachToFilesAndGlobal(
|
||||
fileIds: string[],
|
||||
callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[],
|
||||
globalCallback: (files: Map<string, GPXFile>, context?: any) => void,
|
||||
context?: any
|
||||
) {
|
||||
const [newFileCollection, patch, inversePatch] = produceWithPatches(
|
||||
this._files,
|
||||
(draft) => {
|
||||
fileIds.forEach((fileId, index) => {
|
||||
let file = draft.get(fileId);
|
||||
if (file) {
|
||||
callbacks[index](file, context);
|
||||
}
|
||||
});
|
||||
globalCallback(draft, context);
|
||||
}
|
||||
);
|
||||
|
||||
this.storePatches(patch, inversePatch);
|
||||
|
||||
return this.commitFileStateChange(newFileCollection, patch);
|
||||
}
|
||||
|
||||
async storePatches(patch: Patch[], inversePatch: Patch[]) {
|
||||
this._db.patches.where(':id').above(get(this._patchIndex)).delete(); // Delete all patches after the current patch to avoid redoing them
|
||||
if (get(this._patchMaxIndex) - get(this._patchMinIndex) + 1 > MAX_PATCHES) {
|
||||
this._db.patches
|
||||
.where(':id')
|
||||
.belowOrEqual(get(this._patchMaxIndex) - MAX_PATCHES)
|
||||
.delete();
|
||||
}
|
||||
this._db.transaction('rw', this._db.patches, this._db.settings, async () => {
|
||||
let index = get(this._patchIndex) + 1;
|
||||
await this._db.patches.put(
|
||||
{
|
||||
patch,
|
||||
inversePatch,
|
||||
index,
|
||||
},
|
||||
index
|
||||
);
|
||||
await this._db.settings.put(index, 'patchIndex');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get the file ids of the files that have changed in the patch
|
||||
function getChangedFileIds(patch: Patch[]): string[] {
|
||||
let changedFileIds = new Set<string>();
|
||||
for (let p of patch) {
|
||||
changedFileIds.add(p.path[0] as string);
|
||||
}
|
||||
return Array.from(changedFileIds);
|
||||
}
|
||||
|
||||
export const fileActionManager = new FileActionManager(db);
|
||||
@@ -1,59 +1,16 @@
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
import { applyToOrderedItemsFromFile, copied, cut, selection } from '$lib/logic/selection';
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListLevel,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
type ListItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { freeze } from 'immer';
|
||||
import {
|
||||
GPXFile,
|
||||
Track,
|
||||
TrackPoint,
|
||||
TrackSegment,
|
||||
Waypoint,
|
||||
type LineStyleExtension,
|
||||
type WaypointType,
|
||||
} from 'gpx';
|
||||
import { get } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { getClosestTrackSegments, getElevation } from '$lib/utils';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { boundsManager } from './bounds';
|
||||
import { engine } from '$lib/engine';
|
||||
import { defaultFileName } from '$lib/default-file-name';
|
||||
|
||||
// Generate unique file ids, different from the ones in the database
|
||||
export function getFileIds(n: number) {
|
||||
let ids = [];
|
||||
for (let index = 0; ids.length < n; index++) {
|
||||
let id = `gpx-${index}`;
|
||||
if (!fileStateCollection.getFile(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** The name of a new file: the translated default name, numbered if other files have it. */
|
||||
export function newFileName() {
|
||||
const names = [...get(engine.files).values()].map((file) => get(file).structure.name);
|
||||
return defaultFileName(i18n._('menu.new_file'), names);
|
||||
}
|
||||
|
||||
export function newGPXFile() {
|
||||
let file = new GPXFile();
|
||||
file.metadata.name = newFileName();
|
||||
return file;
|
||||
}
|
||||
|
||||
export function createFile() {
|
||||
// the engine selects the new file
|
||||
engine.newFile(newFileName());
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/simplify';
|
||||
import { type Database } from '$lib/db';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { GPXFile } from 'gpx';
|
||||
import { GPXStatisticsTree, type GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { get, writable, type Subscriber, type Writable } from 'svelte/store';
|
||||
|
||||
// Observe a single file from the database, and maintain its statistics
|
||||
export class GPXFileState {
|
||||
private _fileId: string;
|
||||
private _file: Writable<GPXFileWithStatistics | undefined>;
|
||||
private _subscription: { unsubscribe: () => void } | undefined;
|
||||
|
||||
constructor(fileId: string, file?: GPXFile) {
|
||||
this._fileId = fileId;
|
||||
this._file = writable(file ? { file, statistics: new GPXStatisticsTree(file) } : undefined);
|
||||
}
|
||||
|
||||
connectToDatabase(db: Database) {
|
||||
if (this._subscription) return;
|
||||
this._subscription = liveQuery(() => db.files.get(this._fileId)).subscribe((value) => {
|
||||
if (value !== undefined) {
|
||||
let file = new GPXFile(value);
|
||||
updateAnchorPoints(file);
|
||||
let statistics = new GPXStatisticsTree(file);
|
||||
this._file.set({ file, statistics });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(run: Subscriber<GPXFileWithStatistics | undefined>, invalidate?: () => void) {
|
||||
return this._file.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._subscription?.unsubscribe();
|
||||
this._subscription = undefined;
|
||||
}
|
||||
|
||||
get file(): GPXFile | undefined {
|
||||
return get(this._file)?.file;
|
||||
}
|
||||
|
||||
get statistics(): GPXStatisticsTree | undefined {
|
||||
return get(this._file)?.statistics;
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the file ids in the database, and maintain a map of file states for the corresponding files
|
||||
export class GPXFileStateCollection {
|
||||
private _files: Writable<Map<string, GPXFileState>>;
|
||||
private _subscription: { unsubscribe: () => void } | null = null;
|
||||
|
||||
constructor() {
|
||||
this._files = writable(new Map());
|
||||
}
|
||||
|
||||
connectToDatabase(db: Database): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._subscription) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this._subscription = liveQuery(() => db.fileids.toArray()).subscribe((dbFileIds) => {
|
||||
const currentFiles = get(this._files);
|
||||
// Find new files to observe
|
||||
let newFiles = dbFileIds
|
||||
.filter((id) => !currentFiles.has(id))
|
||||
.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
|
||||
// Find deleted files to stop observing
|
||||
let deletedFiles = Array.from(currentFiles.keys()).filter(
|
||||
(id) => !dbFileIds.find((fileId) => fileId === id)
|
||||
);
|
||||
|
||||
if (newFiles.length > 0 || deletedFiles.length > 0) {
|
||||
// Update the map of file states
|
||||
this._files.update(($files) => {
|
||||
newFiles.forEach((id) => {
|
||||
const fileState = new GPXFileState(id);
|
||||
fileState.connectToDatabase(db);
|
||||
$files.set(id, fileState);
|
||||
});
|
||||
deletedFiles.forEach((id) => {
|
||||
$files.get(id)?.destroy();
|
||||
$files.delete(id);
|
||||
});
|
||||
return $files;
|
||||
});
|
||||
|
||||
// Update the file order
|
||||
let fileOrder = get(settings.fileOrder).filter(
|
||||
(id) => !deletedFiles.includes(id)
|
||||
);
|
||||
newFiles.forEach((id) => {
|
||||
if (!fileOrder.includes(id)) {
|
||||
fileOrder.push(id);
|
||||
}
|
||||
});
|
||||
settings.fileOrder.set(fileOrder);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
disconnectFromDatabase() {
|
||||
this._subscription?.unsubscribe();
|
||||
this._subscription = null;
|
||||
this._files.update(($files) => {
|
||||
$files.forEach((fileState) => {
|
||||
fileState.destroy();
|
||||
});
|
||||
return new Map();
|
||||
});
|
||||
}
|
||||
|
||||
setEmbeddedFiles(files: GPXFile[]) {
|
||||
this._files.update(($files) => {
|
||||
$files.clear();
|
||||
files.forEach((file) => {
|
||||
const id = file._data.id;
|
||||
if (!$files.has(id)) {
|
||||
const fileState = new GPXFileState(id, file);
|
||||
$files.set(id, fileState);
|
||||
}
|
||||
});
|
||||
return $files;
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(run: Subscriber<Map<string, GPXFileState>>, invalidate?: () => void) {
|
||||
return this._files.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return get(this._files).size;
|
||||
}
|
||||
|
||||
getFileState(fileId: string): GPXFileState | undefined {
|
||||
return get(this._files).get(fileId);
|
||||
}
|
||||
|
||||
getFile(fileId: string): GPXFile | undefined {
|
||||
let fileState = get(this._files).get(fileId);
|
||||
return fileState?.file;
|
||||
}
|
||||
|
||||
getStatistics(fileId: string): GPXStatisticsTree | undefined {
|
||||
let fileState = get(this._files).get(fileId);
|
||||
return fileState?.statistics;
|
||||
}
|
||||
|
||||
forEach(callback: (fileId: string, file: GPXFile) => void) {
|
||||
get(this._files).forEach((fileState, fileId) => {
|
||||
if (fileState.file) {
|
||||
callback(fileId, fileState.file);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collection of all file states
|
||||
export const fileStateCollection = new GPXFileStateCollection();
|
||||
|
||||
export type GPXFileStateCallback = (files: Map<string, GPXFileState>) => void;
|
||||
export class GPXFileStateCollectionObserver {
|
||||
private _fileIds: Set<string>;
|
||||
private _onFilesAdded: GPXFileStateCallback;
|
||||
private _onFileRemoved: (fileId: string) => void;
|
||||
private _onDestroy: () => void;
|
||||
private _unsubscribe: () => void;
|
||||
|
||||
constructor(
|
||||
onFilesAdded: GPXFileStateCallback,
|
||||
onFileRemoved: (fileId: string) => void,
|
||||
onDestroy: () => void
|
||||
) {
|
||||
this._fileIds = new Set();
|
||||
this._onFilesAdded = onFilesAdded;
|
||||
this._onFileRemoved = onFileRemoved;
|
||||
this._onDestroy = onDestroy;
|
||||
|
||||
this._unsubscribe = fileStateCollection.subscribe((files) => {
|
||||
this._fileIds.forEach((fileId) => {
|
||||
if (!files.has(fileId)) {
|
||||
this._onFileRemoved(fileId);
|
||||
this._fileIds.delete(fileId);
|
||||
}
|
||||
});
|
||||
let newFiles = new Map<string, GPXFileState>();
|
||||
files.forEach((file: GPXFileState, fileId: string) => {
|
||||
if (!this._fileIds.has(fileId)) {
|
||||
newFiles.set(fileId, file);
|
||||
this._fileIds.add(fileId);
|
||||
}
|
||||
});
|
||||
if (newFiles.size > 0) {
|
||||
this._onFilesAdded(newFiles);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._onDestroy();
|
||||
this._unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import { SelectedGPXFilesObserver, selection } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
|
||||
export class AllHidden {
|
||||
private _value: Writable<boolean>;
|
||||
|
||||
constructor() {
|
||||
this._value = writable(false);
|
||||
new SelectedGPXFilesObserver(() => this.update());
|
||||
}
|
||||
|
||||
subscribe(run: (value: boolean) => void, invalidate?: () => void) {
|
||||
return this._value.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
update() {
|
||||
let hidden = true;
|
||||
selection.applyToSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
for (let item of items) {
|
||||
if (!hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item instanceof ListFileItem) {
|
||||
hidden = hidden && file._data.hidden === true;
|
||||
} else if (
|
||||
item instanceof ListTrackItem &&
|
||||
item.getTrackIndex() < file.trk.length
|
||||
) {
|
||||
hidden = hidden && file.trk[item.getTrackIndex()]._data.hidden === true;
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
item.getTrackIndex() < file.trk.length &&
|
||||
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
|
||||
) {
|
||||
hidden =
|
||||
hidden &&
|
||||
file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()]._data
|
||||
.hidden === true;
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
hidden = hidden && file._data.hiddenWpt === true;
|
||||
} else if (
|
||||
item instanceof ListWaypointItem &&
|
||||
item.getWaypointIndex() < file.wpt.length
|
||||
) {
|
||||
hidden = hidden && file.wpt[item.getWaypointIndex()]._data.hidden === true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (hidden != get(this._value)) {
|
||||
this._value.set(hidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const allHidden = new AllHidden();
|
||||
@@ -1,5 +1,6 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { engine, type GlobalStatistics } from '$lib/engine';
|
||||
import type { Coordinates } from '$lib/geo';
|
||||
|
||||
/** The part of the selection that is highlighted in the elevation profile. */
|
||||
export type SlicedStatistics = {
|
||||
@@ -14,3 +15,6 @@ export const slicedStatistics = writable<SlicedStatistics | undefined>(undefined
|
||||
|
||||
// the indices only make sense for the selection they were made on
|
||||
engine.statistics.subscribe(() => slicedStatistics.set(undefined));
|
||||
|
||||
/** The point of the elevation profile that the pointer is over, shown on the map. */
|
||||
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
|
||||
@@ -1,140 +0,0 @@
|
||||
import type { ListItem } from '$lib/components/file-list/file-list';
|
||||
|
||||
export class SelectionTreeType {
|
||||
item: ListItem;
|
||||
selected: boolean;
|
||||
children: {
|
||||
[key: string | number]: SelectionTreeType;
|
||||
};
|
||||
size: number = 0;
|
||||
|
||||
constructor(item: ListItem) {
|
||||
this.item = item;
|
||||
this.selected = false;
|
||||
this.children = {};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selected = false;
|
||||
for (let key in this.children) {
|
||||
this.children[key].clear();
|
||||
}
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
_setOrToggle(item: ListItem, value?: boolean) {
|
||||
if (item.level === this.item.level) {
|
||||
let newSelected = value === undefined ? !this.selected : value;
|
||||
if (this.selected !== newSelected) {
|
||||
this.selected = newSelected;
|
||||
this.size += this.selected ? 1 : -1;
|
||||
}
|
||||
} else {
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (!this.children.hasOwnProperty(id)) {
|
||||
this.children[id] = new SelectionTreeType(this.item.extend(id));
|
||||
}
|
||||
this.size -= this.children[id].size;
|
||||
this.children[id]._setOrToggle(item, value);
|
||||
this.size += this.children[id].size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(item: ListItem, value: boolean) {
|
||||
this._setOrToggle(item, value);
|
||||
}
|
||||
|
||||
toggle(item: ListItem) {
|
||||
this._setOrToggle(item);
|
||||
}
|
||||
|
||||
has(item: ListItem): boolean {
|
||||
if (item.level === this.item.level) {
|
||||
return this.selected;
|
||||
} else {
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].has(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level <= item.level &&
|
||||
(self || this.item.level < item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].hasAnyParent(item, self);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level >= item.level &&
|
||||
(self || this.item.level > item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].hasAnyChildren(item, self, ignoreIds);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let key in this.children) {
|
||||
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
|
||||
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getSelected(selection: ListItem[] = []): ListItem[] {
|
||||
if (this.selected) {
|
||||
selection.push(this.item);
|
||||
}
|
||||
for (let key in this.children) {
|
||||
this.children[key].getSelected(selection);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
forEach(callback: (item: ListItem) => void) {
|
||||
if (this.selected) {
|
||||
callback(this.item);
|
||||
}
|
||||
for (let key in this.children) {
|
||||
this.children[key].forEach(callback);
|
||||
}
|
||||
}
|
||||
|
||||
getChild(id: string | number): SelectionTreeType | undefined {
|
||||
return this.children[id];
|
||||
}
|
||||
|
||||
deleteChild(id: string | number) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
this.size -= this.children[id].size;
|
||||
delete this.children[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import {
|
||||
ListFileItem,
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListLevel,
|
||||
sortItems,
|
||||
ListWaypointsItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
||||
import { SelectionTreeType } from '$lib/logic/selection-tree';
|
||||
|
||||
export class Selection {
|
||||
private _selection: Writable<SelectionTreeType>;
|
||||
private _copied: Writable<ListItem[] | undefined>;
|
||||
private _cut: Writable<boolean>;
|
||||
|
||||
constructor() {
|
||||
this._selection = writable(new SelectionTreeType(new ListRootItem()));
|
||||
this._copied = writable(undefined);
|
||||
this._cut = writable(false);
|
||||
}
|
||||
|
||||
subscribe(
|
||||
run: (value: SelectionTreeType) => void,
|
||||
invalidate?: (value?: SelectionTreeType) => void
|
||||
) {
|
||||
return this._selection.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
update(updater: (value: SelectionTreeType) => SelectionTreeType) {
|
||||
this._selection.update(updater);
|
||||
}
|
||||
|
||||
selectItem(item: ListItem) {
|
||||
this._selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
$selection.set(item, true);
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
|
||||
selectFile(fileId: string) {
|
||||
this.selectItem(new ListFileItem(fileId));
|
||||
}
|
||||
|
||||
addSelectItem(item: ListItem) {
|
||||
this._selection.update(($selection) => {
|
||||
$selection.toggle(item);
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
|
||||
addSelectFile(fileId: string) {
|
||||
this.addSelectItem(new ListFileItem(fileId));
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
let item: ListItem = new ListRootItem();
|
||||
get(this._selection).forEach((i) => {
|
||||
item = i;
|
||||
});
|
||||
this._selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
if (item instanceof ListRootItem || item instanceof ListFileItem) {
|
||||
fileStateCollection.forEach((fileId, file) => {
|
||||
$selection.set(new ListFileItem(fileId), true);
|
||||
});
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk.forEach((_track, trackId) => {
|
||||
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
|
||||
$selection.set(
|
||||
new ListTrackSegmentItem(
|
||||
item.getFileId(),
|
||||
item.getTrackIndex(),
|
||||
segmentId
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.wpt.forEach((_waypoint, waypointId) => {
|
||||
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
|
||||
selectFileWhenLoaded(fileId: string) {
|
||||
const unsubscribe = fileStateCollection.subscribe((files) => {
|
||||
if (files.has(fileId)) {
|
||||
this.selectFile(fileId);
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set(items: ListItem[]) {
|
||||
this._selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
items.forEach((item) => {
|
||||
$selection.set(item, true);
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
|
||||
updateFiles(updatedFiles: GPXFile[], deletedFileIds: string[]) {
|
||||
let removedItems: ListItem[] = [];
|
||||
applyToOrderedItemsFromFile(get(this._selection).getSelected(), (fileId, level, items) => {
|
||||
let file = updatedFiles.find((file) => file._data.id === fileId);
|
||||
if (file) {
|
||||
items.forEach((item) => {
|
||||
if (item instanceof ListTrackItem) {
|
||||
let newTrackIndex = file.trk.findIndex(
|
||||
(track) => track._data.trackIndex === item.getTrackIndex()
|
||||
);
|
||||
if (newTrackIndex === -1) {
|
||||
removedItems.push(item);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
let newTrackIndex = file.trk.findIndex(
|
||||
(track) => track._data.trackIndex === item.getTrackIndex()
|
||||
);
|
||||
if (newTrackIndex === -1) {
|
||||
removedItems.push(item);
|
||||
} else {
|
||||
let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex(
|
||||
(segment) => segment._data.segmentIndex === item.getSegmentIndex()
|
||||
);
|
||||
if (newSegmentIndex === -1) {
|
||||
removedItems.push(item);
|
||||
}
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
let newWaypointIndex = file.wpt.findIndex(
|
||||
(wpt) => wpt._data.index === item.getWaypointIndex()
|
||||
);
|
||||
if (newWaypointIndex === -1) {
|
||||
removedItems.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (deletedFileIds.includes(fileId)) {
|
||||
items.forEach((item) => {
|
||||
removedItems.push(item);
|
||||
});
|
||||
}
|
||||
});
|
||||
if (removedItems.length > 0) {
|
||||
this._selection.update(($selection) => {
|
||||
removedItems.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
$selection.deleteChild(item.getFileId());
|
||||
} else {
|
||||
$selection.set(item, false);
|
||||
}
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateFromKey(down: boolean, shift: boolean) {
|
||||
let selected = get(this._selection).getSelected();
|
||||
if (selected.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let next: ListItem | undefined = undefined;
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
let order = get(settings.fileOrder);
|
||||
let limitIndex: number | undefined = undefined;
|
||||
selected.forEach((item) => {
|
||||
let index = order.indexOf(item.getFileId());
|
||||
if (
|
||||
limitIndex === undefined ||
|
||||
(down && index > limitIndex) ||
|
||||
(!down && index < limitIndex)
|
||||
) {
|
||||
limitIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (limitIndex !== undefined) {
|
||||
let nextIndex = down ? limitIndex + 1 : limitIndex - 1;
|
||||
|
||||
while (true) {
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = order.length - 1;
|
||||
} else if (nextIndex >= order.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
|
||||
if (nextIndex === limitIndex) {
|
||||
break;
|
||||
}
|
||||
|
||||
next = new ListFileItem(order[nextIndex]);
|
||||
if (!get(selection).has(next)) {
|
||||
break;
|
||||
}
|
||||
|
||||
nextIndex += down ? 1 : -1;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
selected[0] instanceof ListTrackItem &&
|
||||
selected[selected.length - 1] instanceof ListTrackItem
|
||||
) {
|
||||
let fileId = selected[0].getFileId();
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
let numberOfTracks = file.trk.length;
|
||||
let trackIndex = down
|
||||
? selected[selected.length - 1].getTrackIndex()
|
||||
: selected[0].getTrackIndex();
|
||||
if (down && trackIndex < numberOfTracks - 1) {
|
||||
next = new ListTrackItem(fileId, trackIndex + 1);
|
||||
} else if (!down && trackIndex > 0) {
|
||||
next = new ListTrackItem(fileId, trackIndex - 1);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
selected[0] instanceof ListTrackSegmentItem &&
|
||||
selected[selected.length - 1] instanceof ListTrackSegmentItem
|
||||
) {
|
||||
let fileId = selected[0].getFileId();
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
let trackIndex = selected[0].getTrackIndex();
|
||||
let numberOfSegments = file.trk[trackIndex].trkseg.length;
|
||||
let segmentIndex = down
|
||||
? selected[selected.length - 1].getSegmentIndex()
|
||||
: selected[0].getSegmentIndex();
|
||||
if (down && segmentIndex < numberOfSegments - 1) {
|
||||
next = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex + 1);
|
||||
} else if (!down && segmentIndex > 0) {
|
||||
next = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex - 1);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
selected[0] instanceof ListWaypointItem &&
|
||||
selected[selected.length - 1] instanceof ListWaypointItem
|
||||
) {
|
||||
let fileId = selected[0].getFileId();
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
let numberOfWaypoints = file.wpt.length;
|
||||
let waypointIndex = down
|
||||
? selected[selected.length - 1].getWaypointIndex()
|
||||
: selected[0].getWaypointIndex();
|
||||
if (down && waypointIndex < numberOfWaypoints - 1) {
|
||||
next = new ListWaypointItem(fileId, waypointIndex + 1);
|
||||
} else if (!down && waypointIndex > 0) {
|
||||
next = new ListWaypointItem(fileId, waypointIndex - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (next && (!get(this._selection).has(next) || !shift)) {
|
||||
if (shift) {
|
||||
this.addSelectItem(next);
|
||||
} else {
|
||||
this.selectItem(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||
let selected: ListItem[] = [];
|
||||
this.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
selected.push(...items);
|
||||
}, reverse);
|
||||
return selected;
|
||||
}
|
||||
|
||||
applyToSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void
|
||||
) {
|
||||
let selectedItems = get(this._selection).getSelected();
|
||||
get(fileStateCollection).forEach((_, fileId) => {
|
||||
let level: ListLevel | undefined = undefined;
|
||||
let items: ListItem[] = [];
|
||||
selectedItems.forEach((item) => {
|
||||
if (item.getFileId() === fileId) {
|
||||
level = item.level;
|
||||
if (
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem ||
|
||||
item instanceof ListTrackSegmentItem ||
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (items.length > 0) {
|
||||
callback(fileId, level, items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyToOrderedSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
applyToOrderedItemsFromFile(get(this._selection).getSelected(), callback, reverse);
|
||||
}
|
||||
|
||||
copySelection(): boolean {
|
||||
let selected = get(this._selection).getSelected();
|
||||
if (selected.length > 0) {
|
||||
this._copied.set(selected);
|
||||
this._cut.set(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cutSelection() {
|
||||
if (this.copySelection()) {
|
||||
this._cut.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
resetCopied() {
|
||||
this._copied.set(undefined);
|
||||
this._cut.set(false);
|
||||
}
|
||||
|
||||
get copied(): Readable<ListItem[] | undefined> {
|
||||
return this._copied;
|
||||
}
|
||||
|
||||
get cut(): Readable<boolean> {
|
||||
return this._cut;
|
||||
}
|
||||
}
|
||||
|
||||
export const selection = new Selection();
|
||||
export const copied = selection.copied;
|
||||
export const cut = selection.cut;
|
||||
|
||||
export function applyToOrderedItemsFromFile(
|
||||
selectedItems: ListItem[],
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
get(settings.fileOrder).forEach((fileId) => {
|
||||
let level: ListLevel | undefined = undefined;
|
||||
let items: ListItem[] = [];
|
||||
selectedItems.forEach((item) => {
|
||||
if (item.getFileId() === fileId) {
|
||||
level = item.level;
|
||||
if (
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem ||
|
||||
item instanceof ListTrackSegmentItem ||
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length > 0) {
|
||||
sortItems(items, reverse);
|
||||
callback(fileId, level, items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class SelectedGPXFilesObserver {
|
||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
|
||||
private _unsubscribes: Map<string, () => void> = new Map();
|
||||
|
||||
constructor(onSelectedFileChange: () => void) {
|
||||
this._unsubscribes = new Map();
|
||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileState, fileId) => {
|
||||
this._unsubscribes.set(
|
||||
fileId,
|
||||
fileState.subscribe(() => {
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(fileId))) {
|
||||
onSelectedFileChange();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
(fileId) => {
|
||||
this._unsubscribes.get(fileId)?.();
|
||||
this._unsubscribes.delete(fileId);
|
||||
},
|
||||
() => {
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
this._unsubscribes.clear();
|
||||
}
|
||||
);
|
||||
selection.subscribe(() => onSelectedFileChange());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { type Database } from '$lib/db';
|
||||
import { liveQuery } from 'dexie';
|
||||
import {
|
||||
basemaps,
|
||||
defaultBasemap,
|
||||
@@ -15,11 +13,35 @@ import {
|
||||
type LayerTreeType,
|
||||
} from '$lib/assets/layers';
|
||||
import { browser } from '$app/environment';
|
||||
import { engine } from '$lib/engine';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
|
||||
/** What the settings were saved as, by key: JSON strings (see `Engine.openStorage`). */
|
||||
export type StoredSettings = Record<string, string>;
|
||||
|
||||
/** Reads the saved value of a setting, `undefined` if there is none or if it cannot be read. */
|
||||
function storedValue(stored: StoredSettings, key: string): { value: any } | undefined {
|
||||
if (!Object.hasOwn(stored, key)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return { value: JSON.parse(stored[key]) };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps a value; `undefined` is not something JSON can hold, it means no value. */
|
||||
function save(key: string, value: unknown) {
|
||||
const json = JSON.stringify(value);
|
||||
if (json === undefined) {
|
||||
engine.deleteSetting(key);
|
||||
} else {
|
||||
engine.setSetting(key, json);
|
||||
}
|
||||
}
|
||||
|
||||
export class Setting<V> {
|
||||
private _db: Database | null = null;
|
||||
private _subscription: { unsubscribe: () => void } | null = null;
|
||||
private _key: string;
|
||||
private _value: Writable<V>;
|
||||
private _validator?: (value: V) => V;
|
||||
@@ -30,30 +52,12 @@ export class Setting<V> {
|
||||
this._validator = validator;
|
||||
}
|
||||
|
||||
connectToDatabase(db: Database) {
|
||||
if (this._db) return;
|
||||
this._db = db;
|
||||
|
||||
let first = true;
|
||||
this._subscription = liveQuery(() => db.settings.get(this._key)).subscribe((value) => {
|
||||
if (value === undefined) {
|
||||
if (!first) {
|
||||
this._value.set(value);
|
||||
}
|
||||
} else {
|
||||
if (this._validator) {
|
||||
value = this._validator(value);
|
||||
}
|
||||
this._value.set(value);
|
||||
}
|
||||
first = false;
|
||||
});
|
||||
}
|
||||
|
||||
disconnectFromDatabase() {
|
||||
this._subscription?.unsubscribe();
|
||||
this._subscription = null;
|
||||
this._db = null;
|
||||
/** Takes the saved value, if there is one. */
|
||||
connect(stored: StoredSettings) {
|
||||
const saved = storedValue(stored, this._key);
|
||||
if (saved) {
|
||||
this._value.set(this._validator ? this._validator(saved.value) : saved.value);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(run: (value: V) => void, invalidate?: (value?: V) => void) {
|
||||
@@ -62,11 +66,8 @@ export class Setting<V> {
|
||||
|
||||
set(value: V) {
|
||||
if (typeof value === 'object' || value !== get(this._value)) {
|
||||
if (this._db) {
|
||||
this._db.settings.put(value, this._key);
|
||||
} else {
|
||||
this._value.set(value);
|
||||
}
|
||||
this._value.set(value);
|
||||
save(this._key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +76,8 @@ export class Setting<V> {
|
||||
}
|
||||
}
|
||||
|
||||
/** A setting that has no value until it is connected: then the saved one, or the initial one. */
|
||||
export class SettingInitOnFirstRead<V> {
|
||||
private _db: Database | null = null;
|
||||
private _subscription: { unsubscribe: () => void } | null = null;
|
||||
private _key: string;
|
||||
private _value: Writable<V | undefined>;
|
||||
private _initial: V;
|
||||
@@ -90,49 +90,27 @@ export class SettingInitOnFirstRead<V> {
|
||||
this._validator = validator;
|
||||
}
|
||||
|
||||
connectToDatabase(db: Database) {
|
||||
if (this._db) return;
|
||||
this._db = db;
|
||||
|
||||
let first = true;
|
||||
this._subscription = liveQuery(() => db.settings.get(this._key)).subscribe((value) => {
|
||||
if (value === undefined) {
|
||||
if (first) {
|
||||
this._value.set(this._initial);
|
||||
} else {
|
||||
this._value.set(value);
|
||||
}
|
||||
} else {
|
||||
if (this._validator) {
|
||||
value = this._validator(value);
|
||||
}
|
||||
this._value.set(value);
|
||||
}
|
||||
first = false;
|
||||
});
|
||||
connect(stored: StoredSettings) {
|
||||
const saved = storedValue(stored, this._key);
|
||||
if (saved) {
|
||||
this._value.set(this._validator ? this._validator(saved.value) : saved.value);
|
||||
} else {
|
||||
this._value.set(this._initial);
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.set(this._initial);
|
||||
}
|
||||
|
||||
disconnectFromDatabase() {
|
||||
this._subscription?.unsubscribe();
|
||||
this._subscription = null;
|
||||
this._db = null;
|
||||
}
|
||||
|
||||
subscribe(run: (value: V | undefined) => void, invalidate?: (value?: V | undefined) => void) {
|
||||
return this._value.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
set(value: V) {
|
||||
if (typeof value === 'object' || value !== get(this._value)) {
|
||||
if (this._db) {
|
||||
this._db.settings.put(value, this._key);
|
||||
} else {
|
||||
this._value.set(value);
|
||||
}
|
||||
this._value.set(value);
|
||||
save(this._key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,19 +300,12 @@ export const settings = {
|
||||
defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
|
||||
bottomPanelSize: new Setting('bottomPanelSize', 170),
|
||||
rightPanelSize: new Setting('rightPanelSize', 240),
|
||||
connectToDatabase(db: Database) {
|
||||
/** Takes the saved values (see `Engine.openStorage`). */
|
||||
connect(stored: StoredSettings) {
|
||||
for (const key in settings) {
|
||||
const setting = (settings as any)[key];
|
||||
if (setting instanceof Setting || setting instanceof SettingInitOnFirstRead) {
|
||||
setting.connectToDatabase(db);
|
||||
}
|
||||
}
|
||||
},
|
||||
disconnectFromDatabase() {
|
||||
for (const key in settings) {
|
||||
const setting = (settings as any)[key];
|
||||
if (setting instanceof Setting || setting instanceof SettingInitOnFirstRead) {
|
||||
setting.disconnectFromDatabase();
|
||||
setting.connect(stored);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user