This commit is contained in:
vcoppe committed 2026-10-08 18:34:19 +02:00
1 parent 606a0e974a
commit 67a4c090bd
107 files changed
+3445 -3919

No files matched your search

+245
View File
@@ -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"
+3 -1
View File
@@ -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>
+2 -2
View File
@@ -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,
+8 -32
View File
@@ -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);
}
}
+1 -1
View File
@@ -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>,
+2 -2
View File
@@ -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,
+4 -5
View File
@@ -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>,
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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 -1
View File
@@ -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 -2
View File
@@ -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,
+7 -1
View File
@@ -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);
+116 -2
View File
@@ -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
+76 -3
View File
@@ -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;
+144 -2
View File
@@ -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 {
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());
}
}
+2
View File
@@ -1,5 +1,7 @@
mod core;
mod engine;
mod storage;
pub use core::*;
pub use engine::*;
pub use storage::*;
+130
View File
@@ -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)?,
})
}
+105
View File
@@ -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(())
}
}
+85
View File
@@ -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>;
}
+485
View File
@@ -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());
}
}
+2
View File
@@ -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
View File
@@ -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()
+234
View File
@@ -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(())
}
}
+1 -1570
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -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",
+330
View File
@@ -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,
],
};
+1
View File
@@ -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
+48
View File
@@ -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);
}
);
});
+63 -4
View File
@@ -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,
};
+143
View File
@@ -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']);
});
});
+89
View File
@@ -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
)
);
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;
}
this.data.update((updatedData) => {
for (let element of data.elements) {
for (let query of queries) {
if (belongsToQuery(element, query)) {
pois.push({
query,
id: element.id,
poi: {
updatedData.features.push({
type: 'Feature',
geometry: {
type: 'Point',
@@ -242,16 +209,16 @@ export class OverpassLayer {
tags: element.tags,
type: element.type,
},
},
});
}
}
}
return updatedData;
});
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
await db.overpasstiles.bulkPut(queryTiles);
await db.overpassdata.bulkPut(pois);
});
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,
-37
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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
View File
@@ -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.
+53
View File
@@ -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]]);
});
});
+52
View File
@@ -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;
}
+19
View File
@@ -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)));
}
+211
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
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('"', '&quot;')}">${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);
-43
View File
@@ -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());
-208
View File
@@ -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();
}
}
-67
View File
@@ -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);
-140
View File
@@ -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];
}
}
}
-424
View File
@@ -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());
}
}
+42 -71
View File
@@ -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);
/** 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);
}
} else {
if (this._validator) {
value = this._validator(value);
}
this._value.set(value);
}
first = false;
});
}
disconnectFromDatabase() {
this._subscription?.unsubscribe();
this._subscription = null;
this._db = null;
}
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);
}
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) {
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);
} else {
this._value.set(value);
}
} else {
if (this._validator) {
value = this._validator(value);
}
this._value.set(value);
}
first = false;
});
}
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);
}
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