progress
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
/target/
|
||||||
|
/gen/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "gpx-studio-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Desktop shell for gpx.studio"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "gpx_studio_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tauri = { version = "2", features = ["test"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["serde"] }
|
||||||
|
gpx-engine = { path = "../gpx-rs/engine" }
|
||||||
|
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Permissions for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 820 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "tauri",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cargo tauri dev",
|
||||||
|
"build": "cargo tauri build"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
|
||||||
|
//! reversing, cropping, reducing, timestamps, merging, cleaning...
|
||||||
|
|
||||||
|
use gpx_engine::{self as engine, Command, LngLat, LngLatBounds};
|
||||||
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::state::{act, edit, AppState};
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn metadata(state: State<AppState>, name: String, desc: String) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::Metadata(engine::Metadata {
|
||||||
|
name: &name,
|
||||||
|
desc: &desc,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn style(
|
||||||
|
state: State<AppState>,
|
||||||
|
color: Option<String>,
|
||||||
|
opacity: Option<f64>,
|
||||||
|
width: Option<f64>,
|
||||||
|
) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::Style(engine::Style {
|
||||||
|
color: color.as_deref(),
|
||||||
|
opacity,
|
||||||
|
width,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn new_track(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::NewTrack(engine::NewTrack))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn new_track_segment(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::NewTrackSegment(engine::NewTrackSegment))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waypoints
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new_waypoint(
|
||||||
|
state: State<AppState>,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
name: String,
|
||||||
|
desc: String,
|
||||||
|
icon: String,
|
||||||
|
link: String,
|
||||||
|
) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::NewWaypoint(engine::NewWaypoint {
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
name: &name,
|
||||||
|
desc: &desc,
|
||||||
|
icon: &icon,
|
||||||
|
link: &link,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn move_waypoint(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_id: Uuid,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::MoveWaypoint(engine::MoveWaypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
waypoint_id: engine::WaypointId(waypoint_id),
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn delete_waypoint(state: State<AppState>, file_id: Uuid, waypoint_id: Uuid) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::DeleteWaypoint(engine::DeleteWaypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
waypoint_id: engine::WaypointId(waypoint_id),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn update_waypoint(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_id: Uuid,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
name: String,
|
||||||
|
desc: String,
|
||||||
|
icon: String,
|
||||||
|
link: String,
|
||||||
|
) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::EditWaypoint(engine::EditWaypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
waypoint_id: engine::WaypointId(waypoint_id),
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
name: &name,
|
||||||
|
desc: &desc,
|
||||||
|
icon: &icon,
|
||||||
|
link: &link,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn reverse(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::Reverse(engine::Reverse))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn round_trip(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::RoundTrip(engine::RoundTrip))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn crop(state: State<AppState>, start: u32, end: u32) -> Outcome {
|
||||||
|
if start > end {
|
||||||
|
return Outcome::failed(format!("the range {start}..{end} is reversed"));
|
||||||
|
}
|
||||||
|
edit!(state, Command::Crop(engine::Crop { start, end }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn reduce(state: State<AppState>, tolerance: f64) -> Outcome {
|
||||||
|
edit!(state, Command::Reduce(engine::Reduce { tolerance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn change_timestamps(
|
||||||
|
state: State<AppState>,
|
||||||
|
start_time: f64,
|
||||||
|
speed: f64,
|
||||||
|
ratio: f64,
|
||||||
|
) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::Time(engine::Time {
|
||||||
|
start_time: start_time as i64,
|
||||||
|
kind: engine::TimeKind::Change { speed, ratio },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn create_artificial_timestamps(
|
||||||
|
state: State<AppState>,
|
||||||
|
start_time: f64,
|
||||||
|
total_time: f64,
|
||||||
|
) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::Time(engine::Time {
|
||||||
|
start_time: start_time as i64,
|
||||||
|
kind: engine::TimeKind::Artificial { total_time },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::Merge(engine::Merge {
|
||||||
|
type_: merge_type.into(),
|
||||||
|
remove_gaps,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn extract(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::Extract(engine::Extract))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn elevation(state: State<AppState>, ele: Vec<f64>) -> Outcome {
|
||||||
|
edit!(state, Command::Elevation(engine::Elevation { ele: &ele }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn clean(
|
||||||
|
state: State<AppState>,
|
||||||
|
west: f64,
|
||||||
|
south: f64,
|
||||||
|
east: f64,
|
||||||
|
north: f64,
|
||||||
|
clean_type: CleanType,
|
||||||
|
trkpt: bool,
|
||||||
|
wpt: bool,
|
||||||
|
) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::Clean(engine::Clean {
|
||||||
|
bounds: LngLatBounds {
|
||||||
|
sw: LngLat {
|
||||||
|
lng: west,
|
||||||
|
lat: south,
|
||||||
|
},
|
||||||
|
ne: LngLat {
|
||||||
|
lng: east,
|
||||||
|
lat: north,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type_: clean_type.into(),
|
||||||
|
trkpt,
|
||||||
|
wpt,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
|
||||||
|
|
||||||
|
use gpx_engine::{self as engine, Action, Command};
|
||||||
|
use tauri::ipc::{InvokeBody, Request};
|
||||||
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::state::{act, edit, AppState};
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn new_file(
|
||||||
|
state: State<AppState>,
|
||||||
|
name: String,
|
||||||
|
lng: Option<f64>,
|
||||||
|
lat: Option<f64>,
|
||||||
|
ele: Option<f64>,
|
||||||
|
) -> Outcome {
|
||||||
|
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele: ele.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::New(engine::New {
|
||||||
|
name: &name,
|
||||||
|
trackpoint,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON) telling
|
||||||
|
/// the number of bytes and the name of each one.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn load_files(state: State<AppState>, request: Request<'_>) -> Outcome {
|
||||||
|
let InvokeBody::Raw(data) = request.body() else {
|
||||||
|
return Outcome::failed("the files are missing");
|
||||||
|
};
|
||||||
|
let header = |key: &str| {
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.get(key)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(urlencoding_decode)
|
||||||
|
};
|
||||||
|
let (Some(lengths), Some(names)) = (
|
||||||
|
header("lengths").and_then(|json| serde_json::from_str::<Vec<usize>>(&json).ok()),
|
||||||
|
header("names").and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok()),
|
||||||
|
) else {
|
||||||
|
return Outcome::failed("the lengths or the names of the files are not valid");
|
||||||
|
};
|
||||||
|
if lengths.len() != names.len() {
|
||||||
|
return Outcome::failed(format!(
|
||||||
|
"{} lengths for {} names",
|
||||||
|
lengths.len(),
|
||||||
|
names.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (data, mut offset) = (data.clone(), 0);
|
||||||
|
let mut ranges = Vec::with_capacity(names.len());
|
||||||
|
for (length, name) in lengths.iter().zip(&names) {
|
||||||
|
let end = offset + length;
|
||||||
|
if end > data.len() {
|
||||||
|
return Outcome::failed(format!("the data of {name} is shorter than its length"));
|
||||||
|
}
|
||||||
|
ranges.push(offset..end);
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
edit!(state, {
|
||||||
|
let files = ranges
|
||||||
|
.into_iter()
|
||||||
|
.zip(&names)
|
||||||
|
.map(|(range, name)| engine::Load {
|
||||||
|
data: &data[range],
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Command::LoadFiles(engine::LoadFiles { files })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headers are ASCII: the frontend percent-encodes the JSON.
|
||||||
|
fn urlencoding_decode(value: &str) -> Option<String> {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut decoded = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' {
|
||||||
|
let hex = value.get(i + 1..i + 3)?;
|
||||||
|
decoded.push(u8::from_str_radix(hex, 16).ok()?);
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
decoded.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(decoded).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn duplicate(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::Duplicate(engine::Duplicate))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn delete(state: State<AppState>, whole_files: bool) -> Outcome {
|
||||||
|
edit!(state, Command::Delete(engine::Delete { whole_files }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn delete_all(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::DeleteAll(engine::DeleteAll))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn reorder(state: State<AppState>, file_ids: Vec<Uuid>, index: usize) -> Outcome {
|
||||||
|
act!(
|
||||||
|
state,
|
||||||
|
Action::Reorder {
|
||||||
|
file_ids: file_ids.into_iter().map(engine::FileId).collect(),
|
||||||
|
index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn copy(state: State<AppState>) -> Outcome {
|
||||||
|
act!(state, Action::Copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn cut(state: State<AppState>) -> Outcome {
|
||||||
|
act!(state, Action::Cut)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn paste(state: State<AppState>) -> Outcome {
|
||||||
|
edit!(state, Command::Paste(engine::Paste))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn move_elements(state: State<AppState>, what: Selection, to: MoveTarget) -> Outcome {
|
||||||
|
edit!(
|
||||||
|
state,
|
||||||
|
Command::Move(engine::Move {
|
||||||
|
what: what.into(),
|
||||||
|
to: to.into(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn undo(state: State<AppState>) -> Outcome {
|
||||||
|
act!(state, Action::Undo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn redo(state: State<AppState>) -> Outcome {
|
||||||
|
act!(state, Action::Redo)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! The functions of the frontend's interface to the engine, as Tauri commands. They are the
|
||||||
|
//! counterparts of the functions that `gpx-rs/wasm` exports, and cross the same data: see
|
||||||
|
//! `website/src/lib/engine/backend`.
|
||||||
|
//!
|
||||||
|
//! The commands that edit the files or change the selection return an `Outcome`.
|
||||||
|
//! They are `async` (so they run off the main thread), the frontend sends them one after the other.
|
||||||
|
|
||||||
|
pub mod edit;
|
||||||
|
pub mod files;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod routing;
|
||||||
|
pub mod selection;
|
||||||
|
pub mod statistics;
|
||||||
|
pub mod storage;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! The files, their structure and statistics, the selection, the clipboard, and the coordinates of
|
||||||
|
//! the trackpoints and waypoints (flat `[lng, lat, ...]` little-endian `f64` arrays).
|
||||||
|
|
||||||
|
use gpx_engine::{self as engine, FileId};
|
||||||
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn file_order(state: State<AppState>) -> Vec<String> {
|
||||||
|
state.read(|e| ids(e.order()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn file_structure(state: State<AppState>, file_id: Uuid) -> Option<FileStructure> {
|
||||||
|
state.read(move |e| e.file_structure(&FileId(file_id)).map(FileStructure::new))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn file_statistics(state: State<AppState>, file_id: Uuid) -> Option<GlobalStatistics> {
|
||||||
|
state.read(move |e| {
|
||||||
|
e.file_statistics(&FileId(file_id))
|
||||||
|
.map(|s| GlobalStatistics::new(&s))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn selection(state: State<AppState>) -> Selection {
|
||||||
|
state.read(|e| e.selection().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn clipboard(state: State<AppState>) -> Option<Clipboard> {
|
||||||
|
state.read(|e| e.clipboard().map(Clipboard::new))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn can_undo(state: State<AppState>) -> bool {
|
||||||
|
state.read(|e| e.can_undo())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn can_redo(state: State<AppState>) -> bool {
|
||||||
|
state.read(|e| e.can_redo())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn can_paste(state: State<AppState>) -> bool {
|
||||||
|
state.read(|e| e.can_paste())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn waypoint(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_id: Uuid,
|
||||||
|
) -> Option<WaypointDetails> {
|
||||||
|
state.read(move |e| {
|
||||||
|
e.waypoint(&FileId(file_id), &engine::WaypointId(waypoint_id))
|
||||||
|
.map(WaypointDetails::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn trackpoint(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
segment_id: Uuid,
|
||||||
|
index: usize,
|
||||||
|
) -> Option<TrackpointDetails> {
|
||||||
|
state.read(move |e| {
|
||||||
|
e.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
|
||||||
|
.map(TrackpointDetails::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn export_file(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
time: bool,
|
||||||
|
hr: bool,
|
||||||
|
cad: bool,
|
||||||
|
atemp: bool,
|
||||||
|
power: bool,
|
||||||
|
osm: bool,
|
||||||
|
as_route: bool,
|
||||||
|
) -> Bytes {
|
||||||
|
let options = engine::ExportOptions {
|
||||||
|
time,
|
||||||
|
hr,
|
||||||
|
cad,
|
||||||
|
atemp,
|
||||||
|
power,
|
||||||
|
osm,
|
||||||
|
as_route,
|
||||||
|
};
|
||||||
|
optional(state.read(move |e| e.export(&FileId(file_id), options)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The data that some files have: a bit for the time (1), heart rate (2), cadence (4), temperature
|
||||||
|
/// (8), power (16) and the OpenStreetMap data (32).
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn exportable_data(state: State<AppState>, file_ids: Vec<Uuid>) -> u8 {
|
||||||
|
state.read(move |e| {
|
||||||
|
let ids: Vec<FileId> = file_ids.into_iter().map(FileId).collect();
|
||||||
|
let data = e.exportable_data(&ids);
|
||||||
|
[
|
||||||
|
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn reduction_distances(state: State<AppState>) -> Bytes {
|
||||||
|
Bytes::new(state.read(|e| f64s(&e.reduction_distances())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn segment_coordinates(state: State<AppState>, segment_id: Uuid) -> Bytes {
|
||||||
|
Bytes::new(
|
||||||
|
state.read(move |e| f64s(e.segment_coordinates(&engine::TrackSegmentId(segment_id)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn waypoint_coordinates(state: State<AppState>, file_id: Uuid) -> Bytes {
|
||||||
|
Bytes::new(state.read(move |e| f64s(e.waypoint_coordinates(&FileId(file_id)))))
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! The commands that refer to trackpoints by their index in the routing buffers, which only mean
|
||||||
|
//! something for the `revision` they were read with: the commands do nothing if the selection is
|
||||||
|
//! not the one of that revision anymore.
|
||||||
|
|
||||||
|
use gpx_engine::{self as engine, Command};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::state::{edit_at, AppState};
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn route(
|
||||||
|
state: State<AppState>,
|
||||||
|
revision: u32,
|
||||||
|
start: u32,
|
||||||
|
end: u32,
|
||||||
|
lng: Vec<f64>,
|
||||||
|
lat: Vec<f64>,
|
||||||
|
ele: Vec<f64>,
|
||||||
|
attributes: RouteAttributes,
|
||||||
|
anchors: Vec<u32>,
|
||||||
|
) -> Outcome {
|
||||||
|
if lng.len() != lat.len() || lat.len() != ele.len() {
|
||||||
|
return Outcome::failed("lng, lat and ele differ in length");
|
||||||
|
}
|
||||||
|
edit_at!(
|
||||||
|
state,
|
||||||
|
revision,
|
||||||
|
Command::Route(engine::Route {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
lng: &lng,
|
||||||
|
lat: &lat,
|
||||||
|
ele: &ele,
|
||||||
|
surface: attributes.surface.view(),
|
||||||
|
highway: attributes.highway.view(),
|
||||||
|
sac_scale: attributes.sac_scale.view(),
|
||||||
|
mtb_scale: attributes.mtb_scale.view(),
|
||||||
|
anchors: &anchors,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn insert_anchor(state: State<AppState>, revision: u32, lng: f64, lat: f64) -> Outcome {
|
||||||
|
edit_at!(
|
||||||
|
state,
|
||||||
|
revision,
|
||||||
|
Command::InsertAnchor(engine::InsertAnchor { lng, lat })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn change_loop_start(state: State<AppState>, revision: u32, index: u32) -> Outcome {
|
||||||
|
edit_at!(
|
||||||
|
state,
|
||||||
|
revision,
|
||||||
|
Command::ChangeLoopStart(engine::ChangeLoopStart { index })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn split(state: State<AppState>, revision: u32, at: u32, split_type: SplitType) -> Outcome {
|
||||||
|
edit_at!(
|
||||||
|
state,
|
||||||
|
revision,
|
||||||
|
Command::Split(engine::Split {
|
||||||
|
at,
|
||||||
|
split_type: split_type.into(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! Changing what is selected.
|
||||||
|
|
||||||
|
use gpx_engine::{self as engine, Action};
|
||||||
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::state::{act, AppState};
|
||||||
|
|
||||||
|
fn select_elements(
|
||||||
|
state: &AppState,
|
||||||
|
selection: Option<engine::Selection>,
|
||||||
|
mode: SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
match selection {
|
||||||
|
Some(selection) => act!(
|
||||||
|
state,
|
||||||
|
Action::Select {
|
||||||
|
selection,
|
||||||
|
mode: mode.into(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
None => Outcome::failed("the ids are not UUIDs"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_file_ids(ids: Vec<Uuid>) -> Vec<engine::FileId> {
|
||||||
|
ids.into_iter().map(engine::FileId).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn select(state: State<AppState>, file_ids: Vec<Uuid>, mode: SelectMode) -> Outcome {
|
||||||
|
let selection = engine::Selection::File {
|
||||||
|
file_ids: to_file_ids(file_ids).into_iter().collect(),
|
||||||
|
};
|
||||||
|
select_elements(&state, Some(selection), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn select_all(state: State<AppState>) -> Outcome {
|
||||||
|
act!(state, Action::SelectAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> Outcome {
|
||||||
|
act!(state, Action::ArrowSelect { down, add })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn select_tracks(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
track_ids: Vec<Uuid>,
|
||||||
|
mode: SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
let selection = engine::Selection::Track {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
|
||||||
|
};
|
||||||
|
select_elements(&state, Some(selection), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn select_segments(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
track_id: Uuid,
|
||||||
|
segment_ids: Vec<Uuid>,
|
||||||
|
mode: SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
let selection = engine::Selection::TrackSegment {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
trk_id: engine::TrackId(track_id),
|
||||||
|
trkseg_ids: segment_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(engine::TrackSegmentId)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
select_elements(&state, Some(selection), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn select_waypoint_group(state: State<AppState>, file_id: Uuid, mode: SelectMode) -> Outcome {
|
||||||
|
let selection = engine::Selection::Waypoints {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
};
|
||||||
|
select_elements(&state, Some(selection), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn select_waypoints(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_ids: Vec<Uuid>,
|
||||||
|
mode: SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
let selection = engine::Selection::Waypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
|
||||||
|
};
|
||||||
|
select_elements(&state, Some(selection), mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! The statistics of the selection and the anchors of the routing tool, as little-endian arrays
|
||||||
|
//! (one per call, copies of the buffers of the engine). Optional ones start with a byte telling
|
||||||
|
//! whether they are there.
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
macro_rules! f64_getters {
|
||||||
|
($($name:ident),*) => {$(
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn $name(state: State<AppState>) -> Bytes {
|
||||||
|
Bytes::new(state.read(|e| f64s(&e.statistics().$name)))
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
f64_getters!(
|
||||||
|
total_distance,
|
||||||
|
slope,
|
||||||
|
slope_segment_slope,
|
||||||
|
slope_segment_distance,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele
|
||||||
|
);
|
||||||
|
|
||||||
|
macro_rules! optional_f64_getters {
|
||||||
|
($($name:ident),*) => {$(
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn $name(state: State<AppState>) -> Bytes {
|
||||||
|
optional(state.read(|e| e.statistics().$name.as_deref().map(f64s)))
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
optional_f64_getters!(speed, hr, cad, atemp, power);
|
||||||
|
|
||||||
|
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), as `i64`.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn timestamps(state: State<AppState>) -> Bytes {
|
||||||
|
optional(state.read(|e| e.statistics().time.as_deref().map(i64s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn selection_statistics(state: State<AppState>) -> GlobalStatistics {
|
||||||
|
state.read(|e| GlobalStatistics::new(&e.statistics().global))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn slice_statistics(
|
||||||
|
state: State<AppState>,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
) -> Option<GlobalStatistics> {
|
||||||
|
state.read(move |e| {
|
||||||
|
e.statistics()
|
||||||
|
.slice(start, end)
|
||||||
|
.map(|s| GlobalStatistics::new(&s))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! intervals_getters {
|
||||||
|
($starts:ident, $values:ident, $field:ident) => {
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn $starts(state: State<AppState>) -> Bytes {
|
||||||
|
Bytes::new(state.read(|e| u32s(&e.statistics().$field.starts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn $values(state: State<AppState>) -> Bytes {
|
||||||
|
Bytes::new(state.read(|e| e.statistics().$field.values.clone()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals_getters!(surface_starts, surface_values, surface);
|
||||||
|
intervals_getters!(highway_starts, highway_values, highway);
|
||||||
|
intervals_getters!(sac_scale_starts, sac_scale_values, sac_scale);
|
||||||
|
intervals_getters!(mtb_scale_starts, mtb_scale_values, mtb_scale);
|
||||||
|
|
||||||
|
/// The names of the categories, in the order of their codes. The lists only grow.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn surfaces(state: State<AppState>) -> Vec<String> {
|
||||||
|
state.read(|e| e.categories().surface.names().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn highways(state: State<AppState>) -> Vec<String> {
|
||||||
|
state.read(|e| e.categories().highway.names().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn sac_scales(state: State<AppState>) -> Vec<String> {
|
||||||
|
state.read(|e| e.categories().sac_scale.names().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn mtb_scales(state: State<AppState>) -> Vec<String> {
|
||||||
|
state.read(|e| e.categories().mtb_scale.names().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing buffers
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn anchor_indices(state: State<AppState>) -> Bytes {
|
||||||
|
Bytes::new(state.read(|e| u32s(&e.routing().anchor_indices)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn anchor_zooms(state: State<AppState>) -> Bytes {
|
||||||
|
Bytes::new(state.read(|e| e.routing().anchor_zooms.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn segment_starts(state: State<AppState>) -> Bytes {
|
||||||
|
Bytes::new(state.read(|e| u32s(&e.routing().segment_starts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn segment_ids(state: State<AppState>) -> Vec<String> {
|
||||||
|
state.read(|e| {
|
||||||
|
e.routing()
|
||||||
|
.segment_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.0.to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn routing_revision(state: State<AppState>) -> u32 {
|
||||||
|
state.read(|e| e.routing().revision)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//! Keeping the files and the settings.
|
||||||
|
//!
|
||||||
|
//! For now nothing is written: the files only live as long as the app, and so do the settings.
|
||||||
|
//! The file system storage will go here.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::dto::Outcome;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// What opening the storage found.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StorageOpened {
|
||||||
|
settings: std::collections::BTreeMap<String, String>,
|
||||||
|
outcome: Outcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn open_storage(state: State<AppState>) -> StorageOpened {
|
||||||
|
StorageOpened {
|
||||||
|
settings: state.settings().clone(),
|
||||||
|
outcome: Outcome::failed_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves when what was changed has been written to the storage.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn flush_storage() {}
|
||||||
|
|
||||||
|
/// Reads the files again if they were written by someone else.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn sync_storage() -> Outcome {
|
||||||
|
Outcome::failed_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves a setting, `value` being its JSON.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn set_setting(state: State<AppState>, key: String, value: String) {
|
||||||
|
state.settings().insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn delete_setting(state: State<AppState>, key: String) {
|
||||||
|
state.settings().remove(&key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
//! What crosses to the frontend, as JSON: the same shapes as the objects of the WASM interface
|
||||||
|
//! (`gpx-rs/wasm/src/ts.rs` holds their TypeScript types).
|
||||||
|
|
||||||
|
use gpx_engine::{self as engine, FileId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Bytes that cross as they are: typed arrays (little-endian), GPX files.
|
||||||
|
pub type Bytes = tauri::ipc::Response;
|
||||||
|
|
||||||
|
pub fn f64s(values: &[f64]) -> Vec<u8> {
|
||||||
|
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn i64s(values: &[i64]) -> Vec<u8> {
|
||||||
|
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn u32s(values: &[u32]) -> Vec<u8> {
|
||||||
|
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An array that can be absent: the first byte tells whether it is there, the array follows.
|
||||||
|
pub fn optional(array: Option<Vec<u8>>) -> Bytes {
|
||||||
|
let mut bytes = vec![u8::from(array.is_some())];
|
||||||
|
bytes.extend(array.unwrap_or_default());
|
||||||
|
Bytes::new(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ids(items: &[FileId]) -> Vec<String> {
|
||||||
|
items.iter().map(|id| id.0.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome
|
||||||
|
|
||||||
|
/// What a call that edits the files or changes the selection did.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Outcome {
|
||||||
|
changed: bool,
|
||||||
|
order_changed: bool,
|
||||||
|
selection_changed: bool,
|
||||||
|
clipboard_changed: bool,
|
||||||
|
added: Vec<String>,
|
||||||
|
modified: Vec<String>,
|
||||||
|
removed: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn new(outcome: &engine::Outcome, error: Option<String>) -> Self {
|
||||||
|
let diff = outcome.diff.clone().unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
changed: outcome.changed,
|
||||||
|
order_changed: outcome.order_changed,
|
||||||
|
selection_changed: outcome.selection_changed,
|
||||||
|
clipboard_changed: outcome.clipboard_changed,
|
||||||
|
added: ids(&diff.added),
|
||||||
|
modified: ids(&diff.modified),
|
||||||
|
removed: ids(&diff.removed),
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a call that did nothing and has nothing to say.
|
||||||
|
pub fn failed_none() -> Self {
|
||||||
|
Self::new(&engine::Outcome::default(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a call that does nothing returns.
|
||||||
|
pub fn failed(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(&engine::Outcome::default(), Some(message.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection and clipboard
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Selection {
|
||||||
|
Empty,
|
||||||
|
File {
|
||||||
|
file_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
file_id: Uuid,
|
||||||
|
track_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
Segment {
|
||||||
|
file_id: Uuid,
|
||||||
|
track_id: Uuid,
|
||||||
|
segment_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
Waypoints {
|
||||||
|
file_id: Uuid,
|
||||||
|
},
|
||||||
|
Waypoint {
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&engine::Selection> for Selection {
|
||||||
|
fn from(selection: &engine::Selection) -> Self {
|
||||||
|
use engine::Selection as S;
|
||||||
|
match selection {
|
||||||
|
S::Empty => Self::Empty,
|
||||||
|
S::File { file_ids } => Self::File {
|
||||||
|
file_ids: file_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
S::Track { file_id, trk_ids } => Self::Track {
|
||||||
|
file_id: file_id.0,
|
||||||
|
track_ids: trk_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
S::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => Self::Segment {
|
||||||
|
file_id: file_id.0,
|
||||||
|
track_id: trk_id.0,
|
||||||
|
segment_ids: trkseg_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
S::Waypoints { file_id } => Self::Waypoints { file_id: file_id.0 },
|
||||||
|
S::Waypoint { file_id, wpt_ids } => Self::Waypoint {
|
||||||
|
file_id: file_id.0,
|
||||||
|
waypoint_ids: wpt_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Selection> for engine::Selection {
|
||||||
|
fn from(selection: Selection) -> Self {
|
||||||
|
use engine::Selection as S;
|
||||||
|
match selection {
|
||||||
|
Selection::Empty => S::Empty,
|
||||||
|
Selection::File { file_ids } => S::File {
|
||||||
|
file_ids: file_ids.into_iter().map(FileId).collect(),
|
||||||
|
},
|
||||||
|
Selection::Track { file_id, track_ids } => S::Track {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
|
||||||
|
},
|
||||||
|
Selection::Segment {
|
||||||
|
file_id,
|
||||||
|
track_id,
|
||||||
|
segment_ids,
|
||||||
|
} => S::TrackSegment {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
trk_id: engine::TrackId(track_id),
|
||||||
|
trkseg_ids: segment_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(engine::TrackSegmentId)
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Selection::Waypoints { file_id } => S::Waypoints {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
},
|
||||||
|
Selection::Waypoint {
|
||||||
|
file_id,
|
||||||
|
waypoint_ids,
|
||||||
|
} => S::Waypoint {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where moved elements go. A negative index is the start, an infinite one the end (which JSON
|
||||||
|
/// writes as `null`, so the frontend sends a very large number instead).
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum MoveTarget {
|
||||||
|
Files {
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
Tracks {
|
||||||
|
file_id: Uuid,
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
Segments {
|
||||||
|
file_id: Uuid,
|
||||||
|
track_id: Uuid,
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
Waypoints {
|
||||||
|
file_id: Uuid,
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MoveTarget> for engine::MoveTarget {
|
||||||
|
fn from(target: MoveTarget) -> Self {
|
||||||
|
use engine::MoveTarget as T;
|
||||||
|
let index = |index: f64| index.max(0.0) as usize;
|
||||||
|
match target {
|
||||||
|
MoveTarget::Files { index: i } => T::Files { index: index(i) },
|
||||||
|
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
index: index(i),
|
||||||
|
},
|
||||||
|
MoveTarget::Segments {
|
||||||
|
file_id,
|
||||||
|
track_id,
|
||||||
|
index: i,
|
||||||
|
} => T::Segments {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
trk_id: engine::TrackId(track_id),
|
||||||
|
index: index(i),
|
||||||
|
},
|
||||||
|
MoveTarget::Waypoints { file_id, index: i } => T::Waypoints {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
index: index(i),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Clipboard {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: &'static str,
|
||||||
|
ids: Vec<String>,
|
||||||
|
cut: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clipboard {
|
||||||
|
pub fn new(clipboard: &engine::Clipboard) -> Self {
|
||||||
|
let strings = |ids: Vec<Uuid>| ids.iter().map(Uuid::to_string).collect();
|
||||||
|
let (kind, ids) = match clipboard.content.ids() {
|
||||||
|
engine::ClipboardIds::Files(ids) => {
|
||||||
|
("files", strings(ids.into_iter().map(|id| id.0).collect()))
|
||||||
|
}
|
||||||
|
engine::ClipboardIds::Tracks(ids) => {
|
||||||
|
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
|
||||||
|
}
|
||||||
|
engine::ClipboardIds::Segments(ids) => (
|
||||||
|
"segments",
|
||||||
|
strings(ids.into_iter().map(|id| id.0).collect()),
|
||||||
|
),
|
||||||
|
engine::ClipboardIds::Waypoints(ids) => (
|
||||||
|
"waypoints",
|
||||||
|
strings(ids.into_iter().map(|id| id.0).collect()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
ids,
|
||||||
|
cut: clipboard.cut,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileStructure {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
desc: Option<String>,
|
||||||
|
tracks: Vec<TrackNode>,
|
||||||
|
waypoints: Vec<WaypointNode>,
|
||||||
|
waypoints_rev: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct TrackNode {
|
||||||
|
id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
desc: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
color: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
opacity: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
width: Option<f64>,
|
||||||
|
segments: Vec<SegmentNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SegmentNode {
|
||||||
|
id: String,
|
||||||
|
rev: String,
|
||||||
|
length: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct WaypointNode {
|
||||||
|
id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
sym: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileStructure {
|
||||||
|
pub fn new(file: &engine::FileStructure) -> Self {
|
||||||
|
Self {
|
||||||
|
id: file.id.0.to_string(),
|
||||||
|
name: file.name.clone(),
|
||||||
|
desc: file.desc.clone(),
|
||||||
|
tracks: file
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.map(|trk| TrackNode {
|
||||||
|
id: trk.id.0.to_string(),
|
||||||
|
name: trk.name.clone(),
|
||||||
|
desc: trk.desc.clone(),
|
||||||
|
color: trk.color.clone(),
|
||||||
|
opacity: trk.opacity,
|
||||||
|
width: trk.width,
|
||||||
|
segments: trk
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.map(|seg| SegmentNode {
|
||||||
|
id: seg.id.0.to_string(),
|
||||||
|
rev: seg.rev_id.0.to_string(),
|
||||||
|
length: seg.len as f64,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
waypoints: file
|
||||||
|
.waypoints
|
||||||
|
.iter()
|
||||||
|
.map(|wpt| WaypointNode {
|
||||||
|
id: wpt.id.0.to_string(),
|
||||||
|
name: wpt.name.clone(),
|
||||||
|
sym: wpt.sym.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
waypoints_rev: file.wpt_rev_id.0.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GlobalStatistics {
|
||||||
|
total_distance: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
moving_distance: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
total_time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
moving_time: Option<f64>,
|
||||||
|
elevation_gain: f64,
|
||||||
|
elevation_loss: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
start_time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
end_time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
total_speed: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
moving_speed: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
bounds: Option<Bounds>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Bounds {
|
||||||
|
west: f64,
|
||||||
|
south: f64,
|
||||||
|
east: f64,
|
||||||
|
north: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalStatistics {
|
||||||
|
pub fn new(stats: &engine::GlobalStatistics) -> Self {
|
||||||
|
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
|
||||||
|
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
|
||||||
|
Self {
|
||||||
|
total_distance: stats.total_distance,
|
||||||
|
moving_distance: stats.moving_distance,
|
||||||
|
total_time: seconds(stats.total_time),
|
||||||
|
moving_time: seconds(stats.moving_time),
|
||||||
|
elevation_gain: stats.elevation_gain,
|
||||||
|
elevation_loss: stats.elevation_loss,
|
||||||
|
start_time: stats.start_time.map(|t| t as f64),
|
||||||
|
end_time: stats.end_time.map(|t| t as f64),
|
||||||
|
total_speed: stats.total_speed(),
|
||||||
|
moving_speed: stats.moving_speed(),
|
||||||
|
bounds: (sw.lng <= ne.lng && sw.lat <= ne.lat).then_some(Bounds {
|
||||||
|
west: sw.lng,
|
||||||
|
south: sw.lat,
|
||||||
|
east: ne.lng,
|
||||||
|
north: ne.lat,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WaypointDetails {
|
||||||
|
id: String,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
desc: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
cmt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
sym: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
links: Option<Vec<Link>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Link {
|
||||||
|
href: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WaypointDetails {
|
||||||
|
pub fn new(wpt: &engine::Waypoint) -> Self {
|
||||||
|
Self {
|
||||||
|
id: wpt.id.0.to_string(),
|
||||||
|
lng: wpt.coordinates.lng,
|
||||||
|
lat: wpt.coordinates.lat,
|
||||||
|
ele: wpt.ele,
|
||||||
|
time: wpt.time.map(|t| t as f64),
|
||||||
|
name: wpt.name.clone(),
|
||||||
|
desc: wpt.desc.clone(),
|
||||||
|
cmt: wpt.cmt.clone(),
|
||||||
|
sym: wpt.sym.clone(),
|
||||||
|
links: (!wpt.links.is_empty()).then(|| {
|
||||||
|
wpt.links
|
||||||
|
.iter()
|
||||||
|
.map(|link| Link {
|
||||||
|
href: link.href.clone(),
|
||||||
|
text: link.text.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TrackpointDetails {
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
time: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackpointDetails {
|
||||||
|
pub fn new(pt: &engine::Trackpoint) -> Self {
|
||||||
|
Self {
|
||||||
|
lng: pt.coordinates.lng,
|
||||||
|
lat: pt.coordinates.lat,
|
||||||
|
ele: pt.ele,
|
||||||
|
time: pt.time.map(|t| t as f64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerations
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SelectMode {
|
||||||
|
Replace,
|
||||||
|
Add,
|
||||||
|
Toggle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SelectMode> for engine::SelectMode {
|
||||||
|
fn from(m: SelectMode) -> Self {
|
||||||
|
match m {
|
||||||
|
SelectMode::Replace => Self::Replace,
|
||||||
|
SelectMode::Add => Self::Add,
|
||||||
|
SelectMode::Toggle => Self::Toggle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum MergeType {
|
||||||
|
Connect,
|
||||||
|
Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MergeType> for engine::MergeType {
|
||||||
|
fn from(t: MergeType) -> Self {
|
||||||
|
match t {
|
||||||
|
MergeType::Connect => Self::Connect,
|
||||||
|
MergeType::Group => Self::Group,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SplitType {
|
||||||
|
Files,
|
||||||
|
Tracks,
|
||||||
|
Segments,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SplitType> for engine::SplitType {
|
||||||
|
fn from(t: SplitType) -> Self {
|
||||||
|
match t {
|
||||||
|
SplitType::Files => Self::Files,
|
||||||
|
SplitType::Tracks => Self::Tracks,
|
||||||
|
SplitType::Segments => Self::Segments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum CleanType {
|
||||||
|
Inside,
|
||||||
|
Outside,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CleanType> for engine::CleanType {
|
||||||
|
fn from(t: CleanType) -> Self {
|
||||||
|
match t {
|
||||||
|
CleanType::Inside => Self::Inside,
|
||||||
|
CleanType::Outside => Self::Outside,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing
|
||||||
|
|
||||||
|
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
pub struct RouteCategory {
|
||||||
|
pub starts: Vec<u32>,
|
||||||
|
pub values: Vec<u8>,
|
||||||
|
pub names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouteCategory {
|
||||||
|
pub fn view(&self) -> engine::RouteCategory<'_> {
|
||||||
|
engine::RouteCategory {
|
||||||
|
starts: &self.starts,
|
||||||
|
values: &self.values,
|
||||||
|
names: &self.names,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct RouteAttributes {
|
||||||
|
pub surface: RouteCategory,
|
||||||
|
pub highway: RouteCategory,
|
||||||
|
pub sac_scale: RouteCategory,
|
||||||
|
pub mtb_scale: RouteCategory,
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! The desktop shell of gpx.studio. The engine of the files (`gpx-rs/engine`) runs in this
|
||||||
|
//! process, and the frontend talks to it through the commands of `commands`.
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod dto;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use commands::*;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
with_engine(tauri::Builder::default())
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running gpx.studio");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gives the builder the engine and the commands that reach it.
|
||||||
|
fn with_engine<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||||
|
builder
|
||||||
|
.manage(state::AppState::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
// selection
|
||||||
|
selection::select,
|
||||||
|
selection::select_all,
|
||||||
|
selection::arrow_select,
|
||||||
|
selection::select_tracks,
|
||||||
|
selection::select_segments,
|
||||||
|
selection::select_waypoint_group,
|
||||||
|
selection::select_waypoints,
|
||||||
|
// files
|
||||||
|
files::new_file,
|
||||||
|
files::load_files,
|
||||||
|
files::duplicate,
|
||||||
|
files::delete,
|
||||||
|
files::delete_all,
|
||||||
|
files::reorder,
|
||||||
|
files::copy,
|
||||||
|
files::cut,
|
||||||
|
files::paste,
|
||||||
|
files::move_elements,
|
||||||
|
files::undo,
|
||||||
|
files::redo,
|
||||||
|
// edits and tools
|
||||||
|
edit::metadata,
|
||||||
|
edit::style,
|
||||||
|
edit::new_track,
|
||||||
|
edit::new_track_segment,
|
||||||
|
edit::new_waypoint,
|
||||||
|
edit::move_waypoint,
|
||||||
|
edit::delete_waypoint,
|
||||||
|
edit::update_waypoint,
|
||||||
|
edit::reverse,
|
||||||
|
edit::round_trip,
|
||||||
|
edit::crop,
|
||||||
|
edit::reduce,
|
||||||
|
edit::change_timestamps,
|
||||||
|
edit::create_artificial_timestamps,
|
||||||
|
edit::merge,
|
||||||
|
edit::extract,
|
||||||
|
edit::elevation,
|
||||||
|
edit::clean,
|
||||||
|
routing::route,
|
||||||
|
routing::insert_anchor,
|
||||||
|
routing::change_loop_start,
|
||||||
|
routing::split,
|
||||||
|
// queries
|
||||||
|
queries::file_order,
|
||||||
|
queries::file_structure,
|
||||||
|
queries::file_statistics,
|
||||||
|
queries::selection,
|
||||||
|
queries::clipboard,
|
||||||
|
queries::can_undo,
|
||||||
|
queries::can_redo,
|
||||||
|
queries::can_paste,
|
||||||
|
queries::waypoint,
|
||||||
|
queries::trackpoint,
|
||||||
|
queries::export_file,
|
||||||
|
queries::exportable_data,
|
||||||
|
queries::reduction_distances,
|
||||||
|
queries::segment_coordinates,
|
||||||
|
queries::waypoint_coordinates,
|
||||||
|
// statistics
|
||||||
|
statistics::total_distance,
|
||||||
|
statistics::slope,
|
||||||
|
statistics::slope_segment_slope,
|
||||||
|
statistics::slope_segment_distance,
|
||||||
|
statistics::lng,
|
||||||
|
statistics::lat,
|
||||||
|
statistics::ele,
|
||||||
|
statistics::speed,
|
||||||
|
statistics::hr,
|
||||||
|
statistics::cad,
|
||||||
|
statistics::atemp,
|
||||||
|
statistics::power,
|
||||||
|
statistics::timestamps,
|
||||||
|
statistics::selection_statistics,
|
||||||
|
statistics::slice_statistics,
|
||||||
|
statistics::surface_starts,
|
||||||
|
statistics::surface_values,
|
||||||
|
statistics::highway_starts,
|
||||||
|
statistics::highway_values,
|
||||||
|
statistics::sac_scale_starts,
|
||||||
|
statistics::sac_scale_values,
|
||||||
|
statistics::mtb_scale_starts,
|
||||||
|
statistics::mtb_scale_values,
|
||||||
|
statistics::surfaces,
|
||||||
|
statistics::highways,
|
||||||
|
statistics::sac_scales,
|
||||||
|
statistics::mtb_scales,
|
||||||
|
statistics::anchor_indices,
|
||||||
|
statistics::anchor_zooms,
|
||||||
|
statistics::segment_starts,
|
||||||
|
statistics::segment_ids,
|
||||||
|
statistics::routing_revision,
|
||||||
|
// storage
|
||||||
|
storage::open_storage,
|
||||||
|
storage::flush_storage,
|
||||||
|
storage::sync_storage,
|
||||||
|
storage::set_setting,
|
||||||
|
storage::delete_setting,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents an additional console window on Windows in release.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
gpx_studio_desktop_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! The state the commands share: the engine, and the settings of the frontend.
|
||||||
|
//!
|
||||||
|
//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own and
|
||||||
|
//! the commands send it jobs: they are done one after the other, in the order they were sent.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::mpsc::{self, Sender};
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use gpx_engine::{Action, Engine};
|
||||||
|
|
||||||
|
use crate::dto::Outcome;
|
||||||
|
|
||||||
|
type Job = Box<dyn FnOnce(&mut Engine) + Send>;
|
||||||
|
|
||||||
|
/// What the commands talk to. The engine is the same as in the browser (`gpx-rs`); here it lives in
|
||||||
|
/// the process of the app, and the frontend reaches it through the commands only.
|
||||||
|
pub struct AppState {
|
||||||
|
jobs: Mutex<Sender<Job>>,
|
||||||
|
/// The settings of the frontend, as JSON strings by key.
|
||||||
|
settings: Mutex<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppState {
|
||||||
|
fn default() -> Self {
|
||||||
|
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("engine".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let mut engine = Engine::default();
|
||||||
|
while let Ok(job) = receiver.recv() {
|
||||||
|
job(&mut engine);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("the engine thread could not be started");
|
||||||
|
Self {
|
||||||
|
jobs: Mutex::new(jobs),
|
||||||
|
settings: Mutex::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Runs `f` on the engine and waits for its result.
|
||||||
|
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
|
||||||
|
let (reply, result) = mpsc::channel();
|
||||||
|
let job: Job = Box::new(move |engine| {
|
||||||
|
let _ = reply.send(f(engine));
|
||||||
|
});
|
||||||
|
self.jobs
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.send(job)
|
||||||
|
.expect("the engine thread stopped");
|
||||||
|
result.recv().expect("the engine thread stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads from the engine.
|
||||||
|
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
|
||||||
|
self.run(move |engine| f(engine))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings(&self) -> MutexGuard<'_, BTreeMap<String, String>> {
|
||||||
|
self.settings.lock().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes an action and tells what it did, see `Outcome`.
|
||||||
|
pub fn execute(engine: &mut Engine, action: Action) -> Outcome {
|
||||||
|
match engine.execute(action) {
|
||||||
|
Ok(outcome) => Outcome::new(&outcome, None),
|
||||||
|
Err(error) => Outcome::failed(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes an action on the engine. The values the action uses are moved to the engine thread.
|
||||||
|
macro_rules! act {
|
||||||
|
($state:expr, $action:expr) => {
|
||||||
|
$state.run(move |engine| $crate::state::execute(engine, $action))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes a command (the command can borrow what it uses).
|
||||||
|
macro_rules! edit {
|
||||||
|
($state:expr, $command:expr) => {
|
||||||
|
act!($state, ::gpx_engine::Action::Edit($command))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `edit!`, but does nothing if the selection is not the one of `revision` anymore (the
|
||||||
|
/// indices of the routing buffers only mean something for their revision).
|
||||||
|
macro_rules! edit_at {
|
||||||
|
($state:expr, $revision:expr, $command:expr) => {{
|
||||||
|
let revision = $revision;
|
||||||
|
$state.run(move |engine| {
|
||||||
|
if engine.routing().revision != revision {
|
||||||
|
return $crate::dto::Outcome::failed(
|
||||||
|
"the selection changed since the routing buffers were read",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$crate::state::execute(engine, ::gpx_engine::Action::Edit($command))
|
||||||
|
})
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use {act, edit, edit_at};
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
//! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample
|
||||||
|
//! files of the engine.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
|
||||||
|
use tauri::webview::InvokeRequest;
|
||||||
|
use tauri::{ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody, WebviewWindow};
|
||||||
|
|
||||||
|
struct App(WebviewWindow<tauri::test::MockRuntime>);
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new() -> Self {
|
||||||
|
let app = crate::with_engine(mock_builder())
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.unwrap();
|
||||||
|
let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
// the app has to outlive its window
|
||||||
|
std::mem::forget(app);
|
||||||
|
Self(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
cmd: &str,
|
||||||
|
body: InvokeBody,
|
||||||
|
headers: tauri::http::HeaderMap,
|
||||||
|
) -> InvokeResponseBody {
|
||||||
|
get_ipc_response(
|
||||||
|
&self.0,
|
||||||
|
InvokeRequest {
|
||||||
|
cmd: cmd.into(),
|
||||||
|
callback: CallbackFn(0),
|
||||||
|
error: CallbackFn(1),
|
||||||
|
url: "tauri://localhost".parse().unwrap(),
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
invoke_key: INVOKE_KEY.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|error| panic!("{cmd} failed: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&self, cmd: &str, args: Value) -> Value {
|
||||||
|
match self.request(cmd, InvokeBody::Json(args), Default::default()) {
|
||||||
|
InvokeResponseBody::Json(json) => serde_json::from_str(&json).unwrap(),
|
||||||
|
InvokeResponseBody::Raw(_) => panic!("{cmd} returned bytes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&self, cmd: &str, args: Value) -> Vec<u8> {
|
||||||
|
match self.request(cmd, InvokeBody::Json(args), Default::default()) {
|
||||||
|
InvokeResponseBody::Raw(bytes) => bytes,
|
||||||
|
InvokeResponseBody::Json(_) => panic!("{cmd} returned JSON"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(&self, names: &[&str]) -> Value {
|
||||||
|
let mut data = vec![];
|
||||||
|
let mut lengths = vec![];
|
||||||
|
for name in names {
|
||||||
|
let file = std::fs::read(format!("../gpx-rs/engine/data/{name}.gpx")).unwrap();
|
||||||
|
lengths.push(file.len());
|
||||||
|
data.extend(file);
|
||||||
|
}
|
||||||
|
let header = |value: Value| value.to_string().replace('"', "%22").parse().unwrap();
|
||||||
|
let mut headers = tauri::http::HeaderMap::new();
|
||||||
|
headers.insert("lengths", header(json!(lengths)));
|
||||||
|
headers.insert("names", header(json!(names)));
|
||||||
|
match self.request("load_files", InvokeBody::Raw(data), headers) {
|
||||||
|
InvokeResponseBody::Json(json) => serde_json::from_str(&json).unwrap(),
|
||||||
|
InvokeResponseBody::Raw(_) => panic!("load_files returned bytes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f64s(bytes: &[u8]) -> Vec<f64> {
|
||||||
|
bytes
|
||||||
|
.chunks_exact(8)
|
||||||
|
.map(|chunk| f64::from_le_bytes(chunk.try_into().unwrap()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_files_and_describes_them() {
|
||||||
|
let app = App::new();
|
||||||
|
let outcome = app.load(&["simple", "with_tracks"]);
|
||||||
|
assert_eq!(outcome["changed"], true);
|
||||||
|
assert_eq!(outcome["added"].as_array().unwrap().len(), 2);
|
||||||
|
assert!(outcome.get("error").is_none());
|
||||||
|
|
||||||
|
let order = app.call("file_order", json!({}));
|
||||||
|
let first = order[0].as_str().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
app.call("selection", json!({})),
|
||||||
|
json!({ "type": "file", "fileIds": [first] })
|
||||||
|
);
|
||||||
|
|
||||||
|
let structure = app.call("file_structure", json!({ "fileId": first }));
|
||||||
|
assert_eq!(structure["name"], "simple");
|
||||||
|
let segment = &structure["tracks"][0]["segments"][0];
|
||||||
|
let length = segment["length"].as_f64().unwrap() as usize;
|
||||||
|
assert!(length > 0);
|
||||||
|
|
||||||
|
// coordinates are little-endian doubles, lng and lat for each trackpoint
|
||||||
|
let coordinates =
|
||||||
|
f64s(&app.bytes("segment_coordinates", json!({ "segmentId": segment["id"] })));
|
||||||
|
assert_eq!(coordinates.len(), 2 * length);
|
||||||
|
|
||||||
|
let statistics = app.call("file_statistics", json!({ "fileId": first }));
|
||||||
|
assert!(statistics["totalDistance"].as_f64().unwrap() > 0.0);
|
||||||
|
assert!(
|
||||||
|
statistics["bounds"]["west"].as_f64().unwrap()
|
||||||
|
<= statistics["bounds"]["east"].as_f64().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.call(
|
||||||
|
"file_structure",
|
||||||
|
json!({ "fileId": "00000000-0000-0000-0000-000000000000" })
|
||||||
|
),
|
||||||
|
Value::Null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_the_statistics_of_the_selection() {
|
||||||
|
let app = App::new();
|
||||||
|
app.load(&["simple"]);
|
||||||
|
let distance = f64s(&app.bytes("total_distance", json!({})));
|
||||||
|
assert!(distance.len() > 1);
|
||||||
|
assert_eq!(f64s(&app.bytes("lng", json!({}))).len(), distance.len());
|
||||||
|
assert!(
|
||||||
|
app.call("selection_statistics", json!({}))["totalDistance"]
|
||||||
|
.as_f64()
|
||||||
|
.unwrap()
|
||||||
|
> 0.0
|
||||||
|
);
|
||||||
|
// optional arrays start with a byte telling whether they are there
|
||||||
|
let hr = app.bytes("hr", json!({}));
|
||||||
|
assert_eq!(hr[0], 0);
|
||||||
|
assert!(
|
||||||
|
app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.call("slice_statistics", json!({ "start": 0, "end": 100000 })),
|
||||||
|
Value::Null
|
||||||
|
);
|
||||||
|
|
||||||
|
app.load(&["with_hr"]);
|
||||||
|
app.call("select_all", json!({}));
|
||||||
|
let hr = app.bytes("hr", json!({}));
|
||||||
|
assert_eq!(hr[0], 1);
|
||||||
|
assert_eq!((hr.len() - 1) % 8, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edits_undoes_and_selects() {
|
||||||
|
let app = App::new();
|
||||||
|
let outcome = app.call(
|
||||||
|
"new_file",
|
||||||
|
json!({ "name": "route", "lng": 4.4, "lat": 50.8 }),
|
||||||
|
);
|
||||||
|
assert_eq!(outcome["changed"], true);
|
||||||
|
assert_eq!(app.call("can_undo", json!({})), true);
|
||||||
|
|
||||||
|
let id = app.call("file_order", json!({}))[0].clone();
|
||||||
|
let outcome = app.call("metadata", json!({ "name": "renamed", "desc": "" }));
|
||||||
|
assert_eq!(outcome["modified"], json!([id]));
|
||||||
|
assert_eq!(
|
||||||
|
app.call("file_structure", json!({ "fileId": id }))["name"],
|
||||||
|
"renamed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let outcome = app.call("select", json!({ "fileIds": [], "mode": "replace" }));
|
||||||
|
assert_eq!(outcome["selectionChanged"], true);
|
||||||
|
assert_eq!(app.call("selection", json!({})), json!({ "type": "empty" }));
|
||||||
|
app.call("select", json!({ "fileIds": [id], "mode": "toggle" }));
|
||||||
|
|
||||||
|
app.call("undo", json!({}));
|
||||||
|
assert_eq!(
|
||||||
|
app.call("file_structure", json!({ "fileId": id }))["name"],
|
||||||
|
"route"
|
||||||
|
);
|
||||||
|
assert_eq!(app.call("can_redo", json!({})), true);
|
||||||
|
app.call("undo", json!({}));
|
||||||
|
assert_eq!(app.call("file_order", json!({})), json!([]));
|
||||||
|
|
||||||
|
// what cannot be done says why
|
||||||
|
let outcome = app.call("reverse", json!({}));
|
||||||
|
assert_eq!(outcome["changed"], false);
|
||||||
|
assert!(outcome["error"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_and_clipboard() {
|
||||||
|
let app = App::new();
|
||||||
|
app.load(&["simple", "with_tracks"]);
|
||||||
|
let ids = app.call("file_order", json!({}));
|
||||||
|
app.call("select", json!({ "fileIds": [ids[0]], "mode": "replace" }));
|
||||||
|
app.call("copy", json!({}));
|
||||||
|
assert_eq!(
|
||||||
|
app.call("clipboard", json!({})),
|
||||||
|
json!({ "type": "files", "ids": [ids[0]], "cut": false })
|
||||||
|
);
|
||||||
|
|
||||||
|
let outcome = app.call(
|
||||||
|
"move_elements",
|
||||||
|
json!({
|
||||||
|
"what": { "type": "file", "fileIds": [ids[0]] },
|
||||||
|
"to": { "type": "files", "index": 9007199254740991u64 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(outcome["orderChanged"], true);
|
||||||
|
assert_eq!(app.call("file_order", json!({})), json!([ids[1], ids[0]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waypoints_exports_and_routing_revisions() {
|
||||||
|
let app = App::new();
|
||||||
|
app.call("new_file", json!({ "name": "file" }));
|
||||||
|
let outcome = app.call(
|
||||||
|
"new_waypoint",
|
||||||
|
json!({ "lng": 4.4, "lat": 50.8, "ele": 100, "name": "summit", "desc": "", "icon": "Summit", "link": "https://example.com" }),
|
||||||
|
);
|
||||||
|
assert_eq!(outcome["changed"], true);
|
||||||
|
let file = app.call("file_order", json!({}))[0].clone();
|
||||||
|
let waypoint =
|
||||||
|
app.call("file_structure", json!({ "fileId": file }))["waypoints"][0]["id"].clone();
|
||||||
|
let details = app.call(
|
||||||
|
"waypoint",
|
||||||
|
json!({ "fileId": file, "waypointId": waypoint }),
|
||||||
|
);
|
||||||
|
assert_eq!(details["name"], "summit");
|
||||||
|
assert_eq!(details["sym"], "Summit");
|
||||||
|
assert_eq!(details["links"][0]["href"], "https://example.com");
|
||||||
|
|
||||||
|
let exported = app.bytes(
|
||||||
|
"export_file",
|
||||||
|
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }),
|
||||||
|
);
|
||||||
|
assert_eq!(exported[0], 1);
|
||||||
|
assert!(String::from_utf8(exported[1..].to_vec())
|
||||||
|
.unwrap()
|
||||||
|
.contains("<wpt"));
|
||||||
|
assert_eq!(app.call("exportable_data", json!({ "fileIds": [file] })), 0);
|
||||||
|
|
||||||
|
let stale = app.call(
|
||||||
|
"route",
|
||||||
|
json!({
|
||||||
|
"revision": 12345, "start": 0, "end": 0,
|
||||||
|
"lng": [4.4], "lat": [50.8], "ele": [0],
|
||||||
|
"attributes": { "surface": { "starts": [0], "values": [0], "names": [] } },
|
||||||
|
"anchors": [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(stale["changed"], false);
|
||||||
|
assert!(stale["error"].as_str().unwrap().contains("routing buffers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_settings_for_the_session() {
|
||||||
|
let app = App::new();
|
||||||
|
app.call(
|
||||||
|
"set_setting",
|
||||||
|
json!({ "key": "units", "value": "\"metric\"" }),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.call("open_storage", json!({}))["settings"]["units"],
|
||||||
|
"\"metric\""
|
||||||
|
);
|
||||||
|
app.call("delete_setting", json!({ "key": "units" }));
|
||||||
|
assert_eq!(app.call("open_storage", json!({}))["settings"], json!({}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "gpx.studio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "gpx.studio",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283",
|
||||||
|
"devUrl": "http://localhost:4283/app",
|
||||||
|
"beforeBuildCommand": "BASE_PATH='' npm --prefix ../website run build",
|
||||||
|
"frontendDist": "../website/build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "gpx.studio",
|
||||||
|
"url": "app.html",
|
||||||
|
"width": 1400,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": { "csp": null }
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"@sveltejs/kit": "^2.21.2",
|
"@sveltejs/kit": "^2.21.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"@tauri-apps/api": "^2.12.3",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
@@ -2635,6 +2636,17 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.3.tgz",
|
||||||
|
"integrity": "sha512-9s0PKw0p4r3kFq92jhpivYSp+cSht15o0gXpZxuQd0+dV9KCexEVkNaLELVoLdJWyoH4KlfQo5h3W2LIM8siJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@sveltejs/kit": "^2.21.2",
|
"@sveltejs/kit": "^2.21.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"@tauri-apps/api": "^2.12.3",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
|
|||||||
@@ -400,10 +400,13 @@ export class ElevationProfile {
|
|||||||
} else if (startIndex !== endIndex) {
|
} else if (startIndex !== endIndex) {
|
||||||
const start = Math.min(startIndex, endIndex);
|
const start = Math.min(startIndex, endIndex);
|
||||||
const end = Math.max(startIndex, endIndex);
|
const end = Math.max(startIndex, endIndex);
|
||||||
const global = get(this._statistics).slice(start, end);
|
get(this._statistics)
|
||||||
|
.slice(start, end)
|
||||||
|
.then((global) => {
|
||||||
if (global) {
|
if (global) {
|
||||||
this._slicedStatistics.set({ global, start, end });
|
this._slicedStatistics.set({ global, start, end });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,21 @@
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
// the data that none of them has is not offered
|
// the data that none of them has is not offered
|
||||||
let hide: Record<keyof ExportOptions, boolean> = $derived.by(() => {
|
let hide: Record<keyof ExportOptions, boolean> = $state({
|
||||||
const available = engine.exportableData(fileIds);
|
time: false,
|
||||||
return {
|
hr: false,
|
||||||
|
cad: false,
|
||||||
|
atemp: false,
|
||||||
|
power: false,
|
||||||
|
osm: false,
|
||||||
|
asRoute: false,
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
const ids = fileIds;
|
||||||
|
engine.exportableData(ids).then((available) => {
|
||||||
|
// the files may have changed since
|
||||||
|
if (ids === fileIds) {
|
||||||
|
hide = {
|
||||||
time: !available.time,
|
time: !available.time,
|
||||||
hr: !available.hr,
|
hr: !available.hr,
|
||||||
cad: !available.cad,
|
cad: !available.cad,
|
||||||
@@ -66,6 +78,8 @@
|
|||||||
// always possible
|
// always possible
|
||||||
asRoute: false,
|
asRoute: false,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -31,11 +31,15 @@ export function allFileIds(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportFiles(fileIds: string[], options: ExportOptions) {
|
async function exportFiles(fileIds: string[], options: ExportOptions) {
|
||||||
const files = fileIds.flatMap((fileId) => {
|
const files = (
|
||||||
const gpx = engine.exportFile(fileId, options);
|
await Promise.all(
|
||||||
|
fileIds.map(async (fileId) => {
|
||||||
|
const gpx = await engine.exportFile(fileId, options);
|
||||||
const name = get(engine.files).get(fileId);
|
const name = get(engine.files).get(fileId);
|
||||||
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
|
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
|
||||||
});
|
})
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
|
||||||
if (files.length === 1) {
|
if (files.length === 1) {
|
||||||
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
|
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
|
||||||
|
|||||||
@@ -152,9 +152,9 @@
|
|||||||
engine.selectNode(node, 'toggle');
|
engine.selectNode(node, 'toggle');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onmouseenter={() => {
|
onmouseenter={async () => {
|
||||||
if (node.type === 'waypoint' && !isHidden(fileState, node.waypointId)) {
|
if (node.type === 'waypoint' && !isHidden(fileState, node.waypointId)) {
|
||||||
const waypoint = engine.waypoint(node.fileId, node.waypointId);
|
const waypoint = await engine.waypoint(node.fileId, node.waypointId);
|
||||||
if (waypoint) {
|
if (waypoint) {
|
||||||
waypointPopup?.setItem({
|
waypointPopup?.setItem({
|
||||||
item: waypoint,
|
item: waypoint,
|
||||||
|
|||||||
@@ -393,13 +393,15 @@ export class GPXLayer {
|
|||||||
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
layerOnMouseMove(e: any) {
|
async layerOnMouseMove(e: any) {
|
||||||
if (e.originalEvent.shiftKey) {
|
if (e.originalEvent.shiftKey) {
|
||||||
const { segmentId } = e.features[0].properties;
|
const { segmentId } = e.features[0].properties;
|
||||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
|
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
|
||||||
const index = closestPointIndex(engine.segmentCoordinates(segmentId), lngLat);
|
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat);
|
||||||
const trackpoint =
|
const trackpoint =
|
||||||
index === undefined ? undefined : engine.trackpoint(this.fileId, segmentId, index);
|
index === undefined
|
||||||
|
? undefined
|
||||||
|
: await engine.trackpoint(this.fileId, segmentId, index);
|
||||||
if (trackpoint) {
|
if (trackpoint) {
|
||||||
trackpointPopup?.setItem({
|
trackpointPopup?.setItem({
|
||||||
item: trackpoint,
|
item: trackpoint,
|
||||||
@@ -469,8 +471,8 @@ export class GPXLayer {
|
|||||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
|
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
showWaypointPopup(waypointId: string) {
|
async showWaypointPopup(waypointId: string) {
|
||||||
const waypoint = engine.waypoint(this.fileId, waypointId);
|
const waypoint = await engine.waypoint(this.fileId, waypointId);
|
||||||
if (waypoint) {
|
if (waypoint) {
|
||||||
waypointPopup?.setItem({ item: waypoint, kind: 'waypoint', fileId: this.fileId });
|
waypointPopup?.setItem({ item: waypoint, kind: 'waypoint', fileId: this.fileId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,14 @@
|
|||||||
let reducedLayer = new ReducedLayer();
|
let reducedLayer = new ReducedLayer();
|
||||||
|
|
||||||
// read again only when the selection or the files change, not with the tolerance
|
// read again only when the selection or the files change, not with the tolerance
|
||||||
let distances = $derived.by(() => {
|
let distances = $state<Float64Array>(new Float64Array());
|
||||||
void $statistics;
|
$effect(() => {
|
||||||
return engine.reductionDistances();
|
const current = $statistics;
|
||||||
|
engine.reductionDistances().then((values) => {
|
||||||
|
if (current === $statistics) {
|
||||||
|
distances = values;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -61,8 +61,12 @@
|
|||||||
function updateSlicedStatistics([startStep, endStep]: number[]) {
|
function updateSlicedStatistics([startStep, endStep]: number[]) {
|
||||||
if (validSelection && (startStep !== 0 || endStep !== steps)) {
|
if (validSelection && (startStep !== 0 || endStep !== steps)) {
|
||||||
const [start, end] = [toIndex(startStep), toIndex(endStep)];
|
const [start, end] = [toIndex(startStep), toIndex(endStep)];
|
||||||
const global = $statistics.slice(start, end);
|
$statistics.slice(start, end).then((global) => {
|
||||||
|
// the slider may have moved since
|
||||||
|
if (validSelection) {
|
||||||
slicedStatistics.set(global && { global, start, end });
|
slicedStatistics.set(global && { global, start, end });
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
slicedStatistics.set(undefined);
|
slicedStatistics.set(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ export class WaypointSelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// read again each time the file changes
|
// read again each time the file changes
|
||||||
const read = () => {
|
const read = async () => {
|
||||||
const waypoint = engine.waypoint(target.fileId, target.id);
|
const waypoint = await engine.waypoint(target.fileId, target.id);
|
||||||
this._selection.set(waypoint ? { ...target, waypoint } : undefined);
|
this._selection.set(waypoint ? { ...target, waypoint } : undefined);
|
||||||
};
|
};
|
||||||
const file = get(engine.files).get(target.fileId);
|
const file = get(engine.files).get(target.fileId);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { exampleData } from '$lib/data/example/example-data';
|
|||||||
import { exampleStatistics } from '$lib/data/example';
|
import { exampleStatistics } from '$lib/data/example';
|
||||||
|
|
||||||
describe('exampleStatistics', () => {
|
describe('exampleStatistics', () => {
|
||||||
it('has the statistics of the whole example for the whole range', () => {
|
it('has the statistics of the whole example for the whole range', async () => {
|
||||||
const whole = exampleStatistics.slice(0, exampleStatistics.length - 1);
|
const whole = await exampleStatistics.slice(0, exampleStatistics.length - 1);
|
||||||
const global = exampleStatistics.global;
|
const global = exampleStatistics.global;
|
||||||
assert.ok(whole);
|
assert.ok(whole);
|
||||||
for (const key of [
|
for (const key of [
|
||||||
@@ -25,17 +25,17 @@ describe('exampleStatistics', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has no speed for a range that does not last', () => {
|
it('has no speed for a range that does not last', async () => {
|
||||||
const point = exampleStatistics.slice(20, 20);
|
const point = await exampleStatistics.slice(20, 20);
|
||||||
assert.equal(point?.totalTime, 0);
|
assert.equal(point?.totalTime, 0);
|
||||||
assert.equal(point?.totalSpeed, undefined);
|
assert.equal(point?.totalSpeed, undefined);
|
||||||
assert.equal(point?.movingSpeed, undefined);
|
assert.equal(point?.movingSpeed, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has nothing for ranges that are not in the example', () => {
|
it('has nothing for ranges that are not in the example', async () => {
|
||||||
assert.equal(exampleStatistics.slice(5, 500), undefined);
|
assert.equal(await exampleStatistics.slice(5, 500), undefined);
|
||||||
assert.equal(exampleStatistics.slice(-1, 3), undefined);
|
assert.equal(await exampleStatistics.slice(-1, 3), undefined);
|
||||||
assert.equal(exampleStatistics.slice(10, 5), undefined);
|
assert.equal(await exampleStatistics.slice(10, 5), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(
|
it(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const cumulative = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** The statistics of the trackpoints from `start` to `end` (both included), as the engine does. */
|
/** The statistics of the trackpoints from `start` to `end` (both included), as the engine does. */
|
||||||
function slice(start: number, end: number): GlobalStatistics | undefined {
|
async function slice(start: number, end: number): Promise<GlobalStatistics | undefined> {
|
||||||
if (!(start >= 0 && start <= end && end < length)) {
|
if (!(start >= 0 && start <= end && end < length)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Backend } from './types';
|
||||||
|
|
||||||
|
export type { Backend } from './types';
|
||||||
|
|
||||||
|
/** Whether the page runs in the desktop app, which has the engine in its own process. */
|
||||||
|
function inDesktopApp() {
|
||||||
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the engine of the files: the one of the desktop app when the page runs in it, the
|
||||||
|
* WebAssembly module otherwise.
|
||||||
|
*/
|
||||||
|
export async function loadBackend(): Promise<Backend> {
|
||||||
|
return inDesktopApp()
|
||||||
|
? (await import('./tauri')).createTauriBackend()
|
||||||
|
: (await import('./wasm')).createWasmBackend();
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { RouteAttributes, StorageOpened } from 'gpx-rs';
|
||||||
|
import type { Backend } from './types';
|
||||||
|
|
||||||
|
/** An array that a command returns as its bytes. */
|
||||||
|
const array = async <T>(
|
||||||
|
command: string,
|
||||||
|
type: { new (buffer: ArrayBuffer): T },
|
||||||
|
args?: Record<string, unknown>
|
||||||
|
) => new type(await invoke<ArrayBuffer>(command, args));
|
||||||
|
|
||||||
|
/** Like `array`, for the arrays that can be absent: the first byte tells whether they are there. */
|
||||||
|
const optionalArray = async <T>(
|
||||||
|
command: string,
|
||||||
|
type: { new (buffer: ArrayBuffer): T },
|
||||||
|
args?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
const bytes = await invoke<ArrayBuffer>(command, args);
|
||||||
|
return new Uint8Array(bytes)[0] === 1 ? new type(bytes.slice(1)) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const category = (c?: RouteAttributes['surface']) =>
|
||||||
|
c && { starts: Array.from(c.starts), values: Array.from(c.values), names: c.names };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine in the Rust process of the desktop app, reached through its commands (see
|
||||||
|
* `tauri/src/commands`). Typed arrays are returned as bytes, little-endian.
|
||||||
|
*/
|
||||||
|
export async function createTauriBackend(): Promise<Backend> {
|
||||||
|
const f64 = (command: string, args?: Record<string, unknown>) =>
|
||||||
|
array(command, Float64Array, args);
|
||||||
|
const u32 = (command: string) => array(command, Uint32Array);
|
||||||
|
const u8 = (command: string) => array(command, Uint8Array);
|
||||||
|
const f64Optional = (command: string) => optionalArray(command, Float64Array);
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: (fileIds, mode) => invoke('select', { fileIds, mode }),
|
||||||
|
select_all: () => invoke('select_all'),
|
||||||
|
arrow_select: (down, add) => invoke('arrow_select', { down, add }),
|
||||||
|
select_tracks: (fileId, trackIds, mode) =>
|
||||||
|
invoke('select_tracks', { fileId, trackIds, mode }),
|
||||||
|
select_segments: (fileId, trackId, segmentIds, mode) =>
|
||||||
|
invoke('select_segments', { fileId, trackId, segmentIds, mode }),
|
||||||
|
select_waypoint_group: (fileId, mode) => invoke('select_waypoint_group', { fileId, mode }),
|
||||||
|
select_waypoints: (fileId, waypointIds, mode) =>
|
||||||
|
invoke('select_waypoints', { fileId, waypointIds, mode }),
|
||||||
|
|
||||||
|
undo: () => invoke('undo'),
|
||||||
|
redo: () => invoke('redo'),
|
||||||
|
copy: () => invoke('copy'),
|
||||||
|
cut: () => invoke('cut'),
|
||||||
|
paste: () => invoke('paste'),
|
||||||
|
|
||||||
|
new_file: (name, lng, lat, ele) => invoke('new_file', { name, lng, lat, ele }),
|
||||||
|
load_files: (files) => {
|
||||||
|
const data = new Uint8Array(files.reduce((sum, file) => sum + file.data.length, 0));
|
||||||
|
let offset = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
data.set(file.data, offset);
|
||||||
|
offset += file.data.length;
|
||||||
|
}
|
||||||
|
// headers are ASCII
|
||||||
|
const header = (value: unknown) => encodeURIComponent(JSON.stringify(value));
|
||||||
|
return invoke('load_files', data, {
|
||||||
|
headers: {
|
||||||
|
lengths: header(files.map((file) => file.data.length)),
|
||||||
|
names: header(files.map((file) => file.name)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
duplicate: () => invoke('duplicate'),
|
||||||
|
delete: (wholeFiles) => invoke('delete', { wholeFiles }),
|
||||||
|
delete_all: () => invoke('delete_all'),
|
||||||
|
reorder: (fileIds, index) => invoke('reorder', { fileIds, index }),
|
||||||
|
// an infinite index is the end of the list, which JSON cannot tell
|
||||||
|
move_elements: (what, to) =>
|
||||||
|
invoke('move_elements', {
|
||||||
|
what,
|
||||||
|
to: { ...to, index: Math.min(to.index, Number.MAX_SAFE_INTEGER) },
|
||||||
|
}),
|
||||||
|
metadata: (name, desc) => invoke('metadata', { name, desc }),
|
||||||
|
style: (color, opacity, width) => invoke('style', { color, opacity, width }),
|
||||||
|
new_track: () => invoke('new_track'),
|
||||||
|
new_track_segment: () => invoke('new_track_segment'),
|
||||||
|
|
||||||
|
new_waypoint: (lng, lat, ele, name, desc, icon, link) =>
|
||||||
|
invoke('new_waypoint', { lng, lat, ele, name, desc, icon, link }),
|
||||||
|
update_waypoint: (fileId, waypointId, lng, lat, ele, name, desc, icon, link) =>
|
||||||
|
invoke('update_waypoint', {
|
||||||
|
fileId,
|
||||||
|
waypointId,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
icon,
|
||||||
|
link,
|
||||||
|
}),
|
||||||
|
move_waypoint: (fileId, waypointId, lng, lat, ele) =>
|
||||||
|
invoke('move_waypoint', { fileId, waypointId, lng, lat, ele }),
|
||||||
|
delete_waypoint: (fileId, waypointId) => invoke('delete_waypoint', { fileId, waypointId }),
|
||||||
|
|
||||||
|
reverse: () => invoke('reverse'),
|
||||||
|
extract: () => invoke('extract'),
|
||||||
|
round_trip: () => invoke('round_trip'),
|
||||||
|
elevation: (ele) => invoke('elevation', { ele: Array.from(ele) }),
|
||||||
|
change_timestamps: (startTime, speed, ratio) =>
|
||||||
|
invoke('change_timestamps', { startTime, speed, ratio }),
|
||||||
|
create_artificial_timestamps: (startTime, totalTime) =>
|
||||||
|
invoke('create_artificial_timestamps', { startTime, totalTime }),
|
||||||
|
clean: (west, south, east, north, inside, trkpt, wpt) =>
|
||||||
|
invoke('clean', {
|
||||||
|
west,
|
||||||
|
south,
|
||||||
|
east,
|
||||||
|
north,
|
||||||
|
cleanType: inside ? 'inside' : 'outside',
|
||||||
|
trkpt,
|
||||||
|
wpt,
|
||||||
|
}),
|
||||||
|
merge: (mergeType, removeGaps) => invoke('merge', { mergeType, removeGaps }),
|
||||||
|
reduce: (tolerance) => invoke('reduce', { tolerance }),
|
||||||
|
crop: (start, end) => invoke('crop', { start, end }),
|
||||||
|
split: (revision, at, splitType) => invoke('split', { revision, at, splitType }),
|
||||||
|
route: (revision, start, end, lng, lat, ele, attributes, anchors) =>
|
||||||
|
invoke('route', {
|
||||||
|
revision,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
lng: Array.from(lng),
|
||||||
|
lat: Array.from(lat),
|
||||||
|
ele: Array.from(ele),
|
||||||
|
attributes: {
|
||||||
|
surface: category(attributes.surface),
|
||||||
|
highway: category(attributes.highway),
|
||||||
|
sacScale: category(attributes.sacScale),
|
||||||
|
mtbScale: category(attributes.mtbScale),
|
||||||
|
},
|
||||||
|
anchors: Array.from(anchors),
|
||||||
|
}),
|
||||||
|
insert_anchor: (revision, lng, lat) => invoke('insert_anchor', { revision, lng, lat }),
|
||||||
|
change_loop_start: (revision, index) => invoke('change_loop_start', { revision, index }),
|
||||||
|
|
||||||
|
can_undo: () => invoke('can_undo'),
|
||||||
|
can_redo: () => invoke('can_redo'),
|
||||||
|
can_paste: () => invoke('can_paste'),
|
||||||
|
clipboard: async () => (await invoke('clipboard')) ?? undefined,
|
||||||
|
selection: () => invoke('selection'),
|
||||||
|
file_order: () => invoke('file_order'),
|
||||||
|
file_structure: async (fileId) => (await invoke('file_structure', { fileId })) ?? undefined,
|
||||||
|
file_statistics: async (fileId) =>
|
||||||
|
(await invoke('file_statistics', { fileId })) ?? undefined,
|
||||||
|
waypoint: async (fileId, waypointId) =>
|
||||||
|
(await invoke('waypoint', { fileId, waypointId })) ?? undefined,
|
||||||
|
trackpoint: async (fileId, segmentId, index) =>
|
||||||
|
(await invoke('trackpoint', { fileId, segmentId, index })) ?? undefined,
|
||||||
|
export_file: async (fileId, time, hr, cad, atemp, power, osm, asRoute) => {
|
||||||
|
const file = await optionalArray('export_file', Uint8Array, {
|
||||||
|
fileId,
|
||||||
|
time,
|
||||||
|
hr,
|
||||||
|
cad,
|
||||||
|
atemp,
|
||||||
|
power,
|
||||||
|
osm,
|
||||||
|
asRoute,
|
||||||
|
});
|
||||||
|
return file as Uint8Array<ArrayBuffer> | undefined;
|
||||||
|
},
|
||||||
|
exportable_data: (fileIds) => invoke('exportable_data', { fileIds }),
|
||||||
|
reduction_distances: () => f64('reduction_distances'),
|
||||||
|
segment_coordinates: (segmentId) => f64('segment_coordinates', { segmentId }),
|
||||||
|
waypoint_coordinates: (fileId) => f64('waypoint_coordinates', { fileId }),
|
||||||
|
|
||||||
|
selection_statistics: () => invoke('selection_statistics'),
|
||||||
|
slice_statistics: async (start, end) =>
|
||||||
|
(await invoke('slice_statistics', { start, end })) ?? undefined,
|
||||||
|
total_distance: () => f64('total_distance'),
|
||||||
|
slope: () => f64('slope'),
|
||||||
|
lng: () => f64('lng'),
|
||||||
|
lat: () => f64('lat'),
|
||||||
|
ele: () => f64('ele'),
|
||||||
|
timestamps: () => optionalArray('timestamps', BigInt64Array),
|
||||||
|
speed: () => f64Optional('speed'),
|
||||||
|
hr: () => f64Optional('hr'),
|
||||||
|
cad: () => f64Optional('cad'),
|
||||||
|
atemp: () => f64Optional('atemp'),
|
||||||
|
power: () => f64Optional('power'),
|
||||||
|
slope_segment_slope: () => f64('slope_segment_slope'),
|
||||||
|
slope_segment_distance: () => f64('slope_segment_distance'),
|
||||||
|
surface_starts: () => u32('surface_starts'),
|
||||||
|
surface_values: () => u8('surface_values'),
|
||||||
|
surfaces: () => invoke('surfaces'),
|
||||||
|
highway_starts: () => u32('highway_starts'),
|
||||||
|
highway_values: () => u8('highway_values'),
|
||||||
|
highways: () => invoke('highways'),
|
||||||
|
sac_scale_starts: () => u32('sac_scale_starts'),
|
||||||
|
sac_scale_values: () => u8('sac_scale_values'),
|
||||||
|
sac_scales: () => invoke('sac_scales'),
|
||||||
|
mtb_scale_starts: () => u32('mtb_scale_starts'),
|
||||||
|
mtb_scale_values: () => u8('mtb_scale_values'),
|
||||||
|
mtb_scales: () => invoke('mtb_scales'),
|
||||||
|
anchor_indices: () => u32('anchor_indices'),
|
||||||
|
anchor_zooms: () => u8('anchor_zooms'),
|
||||||
|
segment_starts: () => u32('segment_starts'),
|
||||||
|
segment_ids: () => invoke('segment_ids'),
|
||||||
|
routing_revision: () => invoke('routing_revision'),
|
||||||
|
|
||||||
|
importsLegacyData: false,
|
||||||
|
open_storage: () => invoke<StorageOpened>('open_storage'),
|
||||||
|
flush_storage: () => invoke('flush_storage'),
|
||||||
|
sync_storage: () => invoke('sync_storage'),
|
||||||
|
set_setting: (key, value) => invoke('set_setting', { key, value }),
|
||||||
|
delete_setting: (key) => invoke('delete_setting', { key }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type {
|
||||||
|
Clipboard,
|
||||||
|
FileStructure,
|
||||||
|
GlobalStatistics,
|
||||||
|
MoveTarget,
|
||||||
|
Outcome,
|
||||||
|
RouteAttributes,
|
||||||
|
Selection,
|
||||||
|
StorageOpened,
|
||||||
|
TrackpointDetails,
|
||||||
|
WaypointDetails,
|
||||||
|
} from 'gpx-rs';
|
||||||
|
import type { MergeType, SelectMode, SplitType } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the facade needs of the engine of the files, wherever it runs: the WASM module in the
|
||||||
|
* browser (`wasm.ts`), or the Rust process of the desktop app, through its commands (`tauri.ts`).
|
||||||
|
* Both give the same results: see `gpx-rs/wasm` for what each function does.
|
||||||
|
*
|
||||||
|
* Everything is asynchronous, because the desktop app is reached through messages. Ids are
|
||||||
|
* hyphenated UUID strings, the arrays of numbers are typed arrays, and what the functions that
|
||||||
|
* edit the files or change the selection return is an `Outcome`.
|
||||||
|
*/
|
||||||
|
export interface Backend {
|
||||||
|
// Selection
|
||||||
|
select(fileIds: string[], mode: SelectMode): Promise<Outcome>;
|
||||||
|
select_all(): Promise<Outcome>;
|
||||||
|
arrow_select(down: boolean, add: boolean): Promise<Outcome>;
|
||||||
|
select_tracks(fileId: string, trackIds: string[], mode: SelectMode): Promise<Outcome>;
|
||||||
|
select_segments(
|
||||||
|
fileId: string,
|
||||||
|
trackId: string,
|
||||||
|
segmentIds: string[],
|
||||||
|
mode: SelectMode
|
||||||
|
): Promise<Outcome>;
|
||||||
|
select_waypoint_group(fileId: string, mode: SelectMode): Promise<Outcome>;
|
||||||
|
select_waypoints(fileId: string, waypointIds: string[], mode: SelectMode): Promise<Outcome>;
|
||||||
|
|
||||||
|
// History and clipboard
|
||||||
|
undo(): Promise<Outcome>;
|
||||||
|
redo(): Promise<Outcome>;
|
||||||
|
copy(): Promise<Outcome>;
|
||||||
|
cut(): Promise<Outcome>;
|
||||||
|
paste(): Promise<Outcome>;
|
||||||
|
|
||||||
|
// Files
|
||||||
|
new_file(name: string, lng?: number, lat?: number, ele?: number): Promise<Outcome>;
|
||||||
|
load_files(files: { data: Uint8Array; name: string }[]): Promise<Outcome>;
|
||||||
|
duplicate(): Promise<Outcome>;
|
||||||
|
delete(wholeFiles: boolean): Promise<Outcome>;
|
||||||
|
delete_all(): Promise<Outcome>;
|
||||||
|
reorder(fileIds: string[], index: number): Promise<Outcome>;
|
||||||
|
move_elements(what: Selection, to: MoveTarget): Promise<Outcome>;
|
||||||
|
metadata(name: string, desc: string): Promise<Outcome>;
|
||||||
|
style(color?: string, opacity?: number, width?: number): Promise<Outcome>;
|
||||||
|
new_track(): Promise<Outcome>;
|
||||||
|
new_track_segment(): Promise<Outcome>;
|
||||||
|
|
||||||
|
// Waypoints
|
||||||
|
new_waypoint(
|
||||||
|
lng: number,
|
||||||
|
lat: number,
|
||||||
|
ele: number,
|
||||||
|
name: string,
|
||||||
|
desc: string,
|
||||||
|
icon: string,
|
||||||
|
link: string
|
||||||
|
): Promise<Outcome>;
|
||||||
|
update_waypoint(
|
||||||
|
fileId: string,
|
||||||
|
waypointId: string,
|
||||||
|
lng: number,
|
||||||
|
lat: number,
|
||||||
|
ele: number,
|
||||||
|
name: string,
|
||||||
|
desc: string,
|
||||||
|
icon: string,
|
||||||
|
link: string
|
||||||
|
): Promise<Outcome>;
|
||||||
|
move_waypoint(
|
||||||
|
fileId: string,
|
||||||
|
waypointId: string,
|
||||||
|
lng: number,
|
||||||
|
lat: number,
|
||||||
|
ele: number
|
||||||
|
): Promise<Outcome>;
|
||||||
|
delete_waypoint(fileId: string, waypointId: string): Promise<Outcome>;
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
reverse(): Promise<Outcome>;
|
||||||
|
extract(): Promise<Outcome>;
|
||||||
|
round_trip(): Promise<Outcome>;
|
||||||
|
elevation(ele: Float64Array): Promise<Outcome>;
|
||||||
|
change_timestamps(startTime: number, speed: number, ratio: number): Promise<Outcome>;
|
||||||
|
create_artificial_timestamps(startTime: number, totalTime: number): Promise<Outcome>;
|
||||||
|
clean(
|
||||||
|
west: number,
|
||||||
|
south: number,
|
||||||
|
east: number,
|
||||||
|
north: number,
|
||||||
|
inside: boolean,
|
||||||
|
trackpoints: boolean,
|
||||||
|
waypoints: boolean
|
||||||
|
): Promise<Outcome>;
|
||||||
|
merge(type: MergeType, removeGaps: boolean): Promise<Outcome>;
|
||||||
|
reduce(tolerance: number): Promise<Outcome>;
|
||||||
|
crop(start: number, end: number): Promise<Outcome>;
|
||||||
|
split(revision: number, at: number, type: SplitType): Promise<Outcome>;
|
||||||
|
route(
|
||||||
|
revision: number,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
lng: Float64Array,
|
||||||
|
lat: Float64Array,
|
||||||
|
ele: Float64Array,
|
||||||
|
attributes: RouteAttributes,
|
||||||
|
anchors: Uint32Array
|
||||||
|
): Promise<Outcome>;
|
||||||
|
insert_anchor(revision: number, lng: number, lat: number): Promise<Outcome>;
|
||||||
|
change_loop_start(revision: number, index: number): Promise<Outcome>;
|
||||||
|
|
||||||
|
// The files and the selection
|
||||||
|
can_undo(): Promise<boolean>;
|
||||||
|
can_redo(): Promise<boolean>;
|
||||||
|
can_paste(): Promise<boolean>;
|
||||||
|
clipboard(): Promise<Clipboard | undefined>;
|
||||||
|
selection(): Promise<Selection>;
|
||||||
|
file_order(): Promise<string[]>;
|
||||||
|
file_structure(fileId: string): Promise<FileStructure | undefined>;
|
||||||
|
file_statistics(fileId: string): Promise<GlobalStatistics | undefined>;
|
||||||
|
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined>;
|
||||||
|
trackpoint(
|
||||||
|
fileId: string,
|
||||||
|
segmentId: string,
|
||||||
|
index: number
|
||||||
|
): Promise<TrackpointDetails | undefined>;
|
||||||
|
/** A file as GPX (UTF-8 bytes), `undefined` if it does not exist. */
|
||||||
|
export_file(
|
||||||
|
fileId: string,
|
||||||
|
time: boolean,
|
||||||
|
hr: boolean,
|
||||||
|
cad: boolean,
|
||||||
|
atemp: boolean,
|
||||||
|
power: boolean,
|
||||||
|
osm: boolean,
|
||||||
|
asRoute: boolean
|
||||||
|
): Promise<Uint8Array<ArrayBuffer> | undefined>;
|
||||||
|
exportable_data(fileIds: string[]): Promise<number>;
|
||||||
|
reduction_distances(): Promise<Float64Array>;
|
||||||
|
segment_coordinates(segmentId: string): Promise<Float64Array>;
|
||||||
|
waypoint_coordinates(fileId: string): Promise<Float64Array>;
|
||||||
|
|
||||||
|
// The statistics of the selection
|
||||||
|
selection_statistics(): Promise<GlobalStatistics | undefined>;
|
||||||
|
slice_statistics(start: number, end: number): Promise<GlobalStatistics | undefined>;
|
||||||
|
total_distance(): Promise<Float64Array>;
|
||||||
|
slope(): Promise<Float64Array>;
|
||||||
|
lng(): Promise<Float64Array>;
|
||||||
|
lat(): Promise<Float64Array>;
|
||||||
|
ele(): Promise<Float64Array>;
|
||||||
|
timestamps(): Promise<BigInt64Array | undefined>;
|
||||||
|
speed(): Promise<Float64Array | undefined>;
|
||||||
|
hr(): Promise<Float64Array | undefined>;
|
||||||
|
cad(): Promise<Float64Array | undefined>;
|
||||||
|
atemp(): Promise<Float64Array | undefined>;
|
||||||
|
power(): Promise<Float64Array | undefined>;
|
||||||
|
slope_segment_slope(): Promise<Float64Array>;
|
||||||
|
slope_segment_distance(): Promise<Float64Array>;
|
||||||
|
surface_starts(): Promise<Uint32Array>;
|
||||||
|
surface_values(): Promise<Uint8Array>;
|
||||||
|
surfaces(): Promise<string[]>;
|
||||||
|
highway_starts(): Promise<Uint32Array>;
|
||||||
|
highway_values(): Promise<Uint8Array>;
|
||||||
|
highways(): Promise<string[]>;
|
||||||
|
sac_scale_starts(): Promise<Uint32Array>;
|
||||||
|
sac_scale_values(): Promise<Uint8Array>;
|
||||||
|
sac_scales(): Promise<string[]>;
|
||||||
|
mtb_scale_starts(): Promise<Uint32Array>;
|
||||||
|
mtb_scale_values(): Promise<Uint8Array>;
|
||||||
|
mtb_scales(): Promise<string[]>;
|
||||||
|
anchor_indices(): Promise<Uint32Array>;
|
||||||
|
anchor_zooms(): Promise<Uint8Array>;
|
||||||
|
segment_starts(): Promise<Uint32Array>;
|
||||||
|
segment_ids(): Promise<string[]>;
|
||||||
|
routing_revision(): Promise<number>;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
/**
|
||||||
|
* Whether the files that the previous versions of the app kept in the browser can be imported
|
||||||
|
* (they are in the IndexedDB of the browser).
|
||||||
|
*/
|
||||||
|
readonly importsLegacyData: boolean;
|
||||||
|
/**
|
||||||
|
* Opens the storage: the files that it holds are put in the engine, and the changes of the
|
||||||
|
* files are kept from then on. `onSaved` is called after the files were written, `onBehind`
|
||||||
|
* when a write was refused because another window wrote since (call `sync_storage` then), and
|
||||||
|
* `onNewer` when what is stored comes from a newer version of the app.
|
||||||
|
*/
|
||||||
|
open_storage(
|
||||||
|
name: string,
|
||||||
|
onSaved: () => void,
|
||||||
|
onBehind: () => void,
|
||||||
|
onNewer: () => void
|
||||||
|
): Promise<StorageOpened>;
|
||||||
|
/** Resolves when what was changed has been written to the storage. */
|
||||||
|
flush_storage(): Promise<void>;
|
||||||
|
/** Reads the files again if another window wrote them since, and tells what changed. */
|
||||||
|
sync_storage(): Promise<Outcome>;
|
||||||
|
set_setting(key: string, json: string): Promise<void>;
|
||||||
|
delete_setting(key: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { Backend } from './types';
|
||||||
|
import type { MergeType, SelectMode, SplitType } from '../types';
|
||||||
|
|
||||||
|
type Wasm = typeof import('gpx-rs');
|
||||||
|
|
||||||
|
/** A call of the module, which is synchronous, as an asynchronous one. */
|
||||||
|
const lift =
|
||||||
|
<A extends unknown[], R>(f: (...args: A) => R) =>
|
||||||
|
async (...args: A): Promise<R> =>
|
||||||
|
f(...args);
|
||||||
|
|
||||||
|
/** Hyphenated UUID strings to the concatenated 16-byte form the module takes. */
|
||||||
|
function idsToBytes(ids: string[]): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(ids.length * 16);
|
||||||
|
ids.forEach((id, i) => {
|
||||||
|
const hex = id.replaceAll('-', '');
|
||||||
|
for (let j = 0; j < 16; j++) {
|
||||||
|
bytes[i * 16 + j] = parseInt(hex.slice(2 * j, 2 * j + 2), 16);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The engine in the page, as the WebAssembly module of `gpx-rs`. */
|
||||||
|
export async function createWasmBackend(): Promise<Backend> {
|
||||||
|
const w: Wasm = await import('gpx-rs');
|
||||||
|
w.start();
|
||||||
|
|
||||||
|
const mode = (m: SelectMode) =>
|
||||||
|
({ replace: w.SelectMode.Replace, add: w.SelectMode.Add, toggle: w.SelectMode.Toggle })[m];
|
||||||
|
const splitType = (t: SplitType) =>
|
||||||
|
({ files: w.SplitType.Files, tracks: w.SplitType.Tracks, segments: w.SplitType.Segments })[
|
||||||
|
t
|
||||||
|
];
|
||||||
|
const mergeType = (t: MergeType) => (t === 'connect' ? w.MergeType.Connect : w.MergeType.Group);
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: lift((ids, m) => w.select(idsToBytes(ids), mode(m))),
|
||||||
|
select_all: lift(w.select_all),
|
||||||
|
arrow_select: lift(w.arrow_select),
|
||||||
|
select_tracks: lift((file, ids, m) => w.select_tracks(file, idsToBytes(ids), mode(m))),
|
||||||
|
select_segments: lift((file, track, ids, m) =>
|
||||||
|
w.select_segments(file, track, idsToBytes(ids), mode(m))
|
||||||
|
),
|
||||||
|
select_waypoint_group: lift((file, m) => w.select_waypoint_group(file, mode(m))),
|
||||||
|
select_waypoints: lift((file, ids, m) =>
|
||||||
|
w.select_waypoints(file, idsToBytes(ids), mode(m))
|
||||||
|
),
|
||||||
|
|
||||||
|
undo: lift(w.undo),
|
||||||
|
redo: lift(w.redo),
|
||||||
|
copy: lift(w.copy),
|
||||||
|
cut: lift(w.cut),
|
||||||
|
paste: lift(w.paste),
|
||||||
|
|
||||||
|
new_file: lift(w.new_file),
|
||||||
|
load_files: lift((files) => {
|
||||||
|
const lengths = Uint32Array.from(files.map((file) => file.data.length));
|
||||||
|
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
|
||||||
|
let offset = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
data.set(file.data, offset);
|
||||||
|
offset += file.data.length;
|
||||||
|
}
|
||||||
|
return w.load_files(
|
||||||
|
data,
|
||||||
|
lengths,
|
||||||
|
files.map((file) => file.name)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
duplicate: lift(w.duplicate),
|
||||||
|
delete: lift(w._delete),
|
||||||
|
delete_all: lift(w.delete_all),
|
||||||
|
reorder: lift((ids, index) => w.reorder(idsToBytes(ids), index)),
|
||||||
|
move_elements: lift(w.move_elements),
|
||||||
|
metadata: lift(w.metadata),
|
||||||
|
style: lift(w.style),
|
||||||
|
new_track: lift(w.new_track),
|
||||||
|
new_track_segment: lift(w.new_track_segment),
|
||||||
|
|
||||||
|
new_waypoint: lift(w.new_waypoint),
|
||||||
|
update_waypoint: lift(w.update_waypoint),
|
||||||
|
move_waypoint: lift(w.move_waypoint),
|
||||||
|
delete_waypoint: lift(w.delete_waypoint),
|
||||||
|
|
||||||
|
reverse: lift(w.reverse),
|
||||||
|
extract: lift(w.extract),
|
||||||
|
round_trip: lift(w.round_trip),
|
||||||
|
elevation: lift(w.elevation),
|
||||||
|
change_timestamps: lift(w.changeTimestamps),
|
||||||
|
create_artificial_timestamps: lift(w.createArtificialTimestamps),
|
||||||
|
clean: lift((west, south, east, north, inside, trackpoints, waypoints) =>
|
||||||
|
w.clean(
|
||||||
|
west,
|
||||||
|
south,
|
||||||
|
east,
|
||||||
|
north,
|
||||||
|
inside ? w.CleanType.Inside : w.CleanType.Outside,
|
||||||
|
trackpoints,
|
||||||
|
waypoints
|
||||||
|
)
|
||||||
|
),
|
||||||
|
merge: lift((type, removeGaps) => w.merge(mergeType(type), removeGaps)),
|
||||||
|
reduce: lift(w.reduce),
|
||||||
|
crop: lift(w.crop),
|
||||||
|
split: lift((revision, at, type) => w.split(revision, at, splitType(type))),
|
||||||
|
route: lift(w.route),
|
||||||
|
insert_anchor: lift(w.insert_anchor),
|
||||||
|
change_loop_start: lift(w.change_loop_start),
|
||||||
|
|
||||||
|
can_undo: lift(w.can_undo),
|
||||||
|
can_redo: lift(w.can_redo),
|
||||||
|
can_paste: lift(w.can_paste),
|
||||||
|
clipboard: lift(w.clipboard),
|
||||||
|
selection: lift(w.selection),
|
||||||
|
file_order: lift(w.file_order),
|
||||||
|
file_structure: lift(w.file_structure),
|
||||||
|
file_statistics: lift(w.file_statistics),
|
||||||
|
waypoint: lift(w.waypoint),
|
||||||
|
trackpoint: lift(w.trackpoint),
|
||||||
|
// a copy out of the memory of the engine, so a plain ArrayBuffer
|
||||||
|
export_file: lift(
|
||||||
|
(...args: Parameters<Backend['export_file']>) =>
|
||||||
|
w.export_file(...args) as Uint8Array<ArrayBuffer> | undefined
|
||||||
|
),
|
||||||
|
exportable_data: lift((ids) => w.exportable_data(idsToBytes(ids))),
|
||||||
|
reduction_distances: lift(w.reduction_distances),
|
||||||
|
segment_coordinates: lift(w.segment_coordinates),
|
||||||
|
waypoint_coordinates: lift(w.waypoint_coordinates),
|
||||||
|
|
||||||
|
selection_statistics: lift(w.selection_statistics),
|
||||||
|
slice_statistics: lift(w.slice_statistics),
|
||||||
|
total_distance: lift(w.total_distance),
|
||||||
|
slope: lift(w.slope),
|
||||||
|
lng: lift(w.lng),
|
||||||
|
lat: lift(w.lat),
|
||||||
|
ele: lift(w.ele),
|
||||||
|
timestamps: lift(w.timestamps),
|
||||||
|
speed: lift(w.speed),
|
||||||
|
hr: lift(w.hr),
|
||||||
|
cad: lift(w.cad),
|
||||||
|
atemp: lift(w.atemp),
|
||||||
|
power: lift(w.power),
|
||||||
|
slope_segment_slope: lift(w.slope_segment_slope),
|
||||||
|
slope_segment_distance: lift(w.slope_segment_distance),
|
||||||
|
surface_starts: lift(w.surface_starts),
|
||||||
|
surface_values: lift(w.surface_values),
|
||||||
|
surfaces: lift(w.surfaces),
|
||||||
|
highway_starts: lift(w.highway_starts),
|
||||||
|
highway_values: lift(w.highway_values),
|
||||||
|
highways: lift(w.highways),
|
||||||
|
sac_scale_starts: lift(w.sac_scale_starts),
|
||||||
|
sac_scale_values: lift(w.sac_scale_values),
|
||||||
|
sac_scales: lift(w.sac_scales),
|
||||||
|
mtb_scale_starts: lift(w.mtb_scale_starts),
|
||||||
|
mtb_scale_values: lift(w.mtb_scale_values),
|
||||||
|
mtb_scales: lift(w.mtb_scales),
|
||||||
|
anchor_indices: lift(w.anchor_indices),
|
||||||
|
anchor_zooms: lift(w.anchor_zooms),
|
||||||
|
segment_starts: lift(w.segment_starts),
|
||||||
|
segment_ids: lift(w.segment_ids),
|
||||||
|
routing_revision: lift(w.routing_revision),
|
||||||
|
|
||||||
|
importsLegacyData: true,
|
||||||
|
open_storage: (name, onSaved, onBehind, onNewer) =>
|
||||||
|
w.open_storage(name, onSaved, onBehind, onNewer),
|
||||||
|
flush_storage: () => w.flush_storage(),
|
||||||
|
sync_storage: () => w.sync_storage(),
|
||||||
|
set_setting: lift(w.set_setting),
|
||||||
|
delete_setting: lift(w.delete_setting),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,3 @@
|
|||||||
import type { SelectMode } from './types';
|
|
||||||
|
|
||||||
export type Wasm = typeof import('gpx-rs');
|
|
||||||
|
|
||||||
/** The enumeration of the engine for a way to combine a selection. */
|
|
||||||
export function selectMode(wasm: Wasm, mode: SelectMode) {
|
|
||||||
return {
|
|
||||||
replace: wasm.SelectMode.Replace,
|
|
||||||
add: wasm.SelectMode.Add,
|
|
||||||
toggle: wasm.SelectMode.Toggle,
|
|
||||||
}[mode];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A flat `[lng, lat, ...]` array as positions. */
|
/** A flat `[lng, lat, ...]` array as positions. */
|
||||||
export function toPositions(flat: Float64Array): [number, number][] {
|
export function toPositions(flat: Float64Array): [number, number][] {
|
||||||
const positions: [number, number][] = new Array(flat.length / 2);
|
const positions: [number, number][] = new Array(flat.length / 2);
|
||||||
@@ -19,15 +6,3 @@ export function toPositions(flat: Float64Array): [number, number][] {
|
|||||||
}
|
}
|
||||||
return positions;
|
return positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export /** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */
|
|
||||||
function idsToBytes(ids: string[]): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(ids.length * 16);
|
|
||||||
ids.forEach((id, i) => {
|
|
||||||
const hex = id.replaceAll('-', '');
|
|
||||||
for (let j = 0; j < 16; j++) {
|
|
||||||
bytes[i * 16 + j] = parseInt(hex.slice(2 * j, 2 * j + 2), 16);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
@@ -218,7 +218,7 @@ describe('statistics', () => {
|
|||||||
assert.equal(get(engine.statistics).hr, undefined);
|
assert.equal(get(engine.statistics).hr, undefined);
|
||||||
|
|
||||||
const request = engine.requestStatistics();
|
const request = engine.requestStatistics();
|
||||||
request.set(['hr', 'anchors']);
|
await request.set(['hr', 'anchors']);
|
||||||
const statistics = get(engine.statistics);
|
const statistics = get(engine.statistics);
|
||||||
assert.equal(statistics.hr!.length, statistics.length);
|
assert.equal(statistics.hr!.length, statistics.length);
|
||||||
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
|
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
|
||||||
@@ -239,14 +239,14 @@ describe('statistics', () => {
|
|||||||
it('gives the statistics of a part of the selection', async () => {
|
it('gives the statistics of a part of the selection', async () => {
|
||||||
await load('simple');
|
await load('simple');
|
||||||
const statistics = get(engine.statistics);
|
const statistics = get(engine.statistics);
|
||||||
const part = statistics.slice(2, 5)!;
|
const part = (await statistics.slice(2, 5))!;
|
||||||
assert.ok(part.totalDistance > 0);
|
assert.ok(part.totalDistance > 0);
|
||||||
assert.ok(part.totalDistance < statistics.global.totalDistance);
|
assert.ok(part.totalDistance < statistics.global.totalDistance);
|
||||||
assert.equal(statistics.slice(0, statistics.length + 10), undefined);
|
assert.equal(await statistics.slice(0, statistics.length + 10), undefined);
|
||||||
|
|
||||||
// a slice is only valid for the selection it was made on
|
// a slice is only valid for the selection it was made on
|
||||||
await engine.select([]);
|
await engine.select([]);
|
||||||
assert.equal(statistics.slice(2, 5), undefined);
|
assert.equal(await statistics.slice(2, 5), undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ describe('file state', () => {
|
|||||||
const file = fileStates()[0];
|
const file = fileStates()[0];
|
||||||
assert.ok(file.waypoints.features.length > 0);
|
assert.ok(file.waypoints.features.length > 0);
|
||||||
const feature = file.waypoints.features[0];
|
const feature = file.waypoints.features[0];
|
||||||
const details = engine.waypoint(file.structure.id, feature.properties.waypointId)!;
|
const details = (await engine.waypoint(file.structure.id, feature.properties.waypointId))!;
|
||||||
assert.deepEqual(feature.geometry.coordinates, [details.lng, details.lat]);
|
assert.deepEqual(feature.geometry.coordinates, [details.lng, details.lat]);
|
||||||
assert.equal(feature.properties.name, details.name);
|
assert.equal(feature.properties.name, details.name);
|
||||||
assert.equal(details.links?.[0].href, 'https://gpx.studio');
|
assert.equal(details.links?.[0].href, 'https://gpx.studio');
|
||||||
@@ -330,23 +330,23 @@ describe('waypoints', () => {
|
|||||||
const id = file.waypoints.features[0].properties.waypointId;
|
const id = file.waypoints.features[0].properties.waypointId;
|
||||||
const fileId = file.structure.id;
|
const fileId = file.structure.id;
|
||||||
|
|
||||||
let details = engine.waypoint(fileId, id)!;
|
let details = (await engine.waypoint(fileId, id))!;
|
||||||
assert.equal(details.name, 'summit');
|
assert.equal(details.name, 'summit');
|
||||||
assert.equal(details.sym, 'Summit');
|
assert.equal(details.sym, 'Summit');
|
||||||
assert.equal(details.links![0].href, 'https://example.com');
|
assert.equal(details.links![0].href, 'https://example.com');
|
||||||
|
|
||||||
await engine.updateWaypoint(fileId, id, { ...waypoint, name: 'peak', link: '' });
|
await engine.updateWaypoint(fileId, id, { ...waypoint, name: 'peak', link: '' });
|
||||||
details = engine.waypoint(fileId, id)!;
|
details = (await engine.waypoint(fileId, id))!;
|
||||||
assert.equal(details.name, 'peak');
|
assert.equal(details.name, 'peak');
|
||||||
assert.equal(details.links, undefined);
|
assert.equal(details.links, undefined);
|
||||||
|
|
||||||
await engine.moveWaypoint(fileId, id, 5, 51, 200);
|
await engine.moveWaypoint(fileId, id, 5, 51, 200);
|
||||||
assert.deepEqual(fileStates()[0].waypoints.features[0].geometry.coordinates, [5, 51]);
|
assert.deepEqual(fileStates()[0].waypoints.features[0].geometry.coordinates, [5, 51]);
|
||||||
assert.equal(engine.waypoint(fileId, id)!.ele, 200);
|
assert.equal((await engine.waypoint(fileId, id))!.ele, 200);
|
||||||
|
|
||||||
await engine.deleteWaypoint(fileId, id);
|
await engine.deleteWaypoint(fileId, id);
|
||||||
assert.equal(fileStates()[0].waypoints.features.length, 0);
|
assert.equal(fileStates()[0].waypoints.features.length, 0);
|
||||||
assert.equal(engine.waypoint(fileId, id), undefined);
|
assert.equal(await engine.waypoint(fileId, id), undefined);
|
||||||
assert.equal(await engine.deleteWaypoint(fileId, id), false);
|
assert.equal(await engine.deleteWaypoint(fileId, id), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -395,8 +395,10 @@ describe('observer of the files', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('export', () => {
|
describe('export', () => {
|
||||||
const text = (id: string, options: Partial<ExportOptions> = {}) =>
|
const text = async (id: string, options: Partial<ExportOptions> = {}) =>
|
||||||
new TextDecoder().decode(engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options }));
|
new TextDecoder().decode(
|
||||||
|
await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options })
|
||||||
|
);
|
||||||
|
|
||||||
it('writes tracks, or routes when asked to', async () => {
|
it('writes tracks, or routes when asked to', async () => {
|
||||||
await load('with_tracks_and_segments');
|
await load('with_tracks_and_segments');
|
||||||
@@ -407,11 +409,11 @@ describe('export', () => {
|
|||||||
);
|
);
|
||||||
const id = file.structure.id;
|
const id = file.structure.id;
|
||||||
|
|
||||||
const tracks = text(id);
|
const tracks = await text(id);
|
||||||
assert.ok(tracks.includes('<trk>') && !tracks.includes('<rte>'));
|
assert.ok(tracks.includes('<trk>') && !tracks.includes('<rte>'));
|
||||||
assert.equal(tracks.match(/<trkseg>/g)?.length, segments);
|
assert.equal(tracks.match(/<trkseg>/g)?.length, segments);
|
||||||
|
|
||||||
const routes = text(id, { asRoute: true });
|
const routes = await text(id, { asRoute: true });
|
||||||
assert.ok(routes.includes('<rte>') && !routes.includes('<trk>'));
|
assert.ok(routes.includes('<rte>') && !routes.includes('<trk>'));
|
||||||
assert.equal(routes.match(/<rte>/g)?.length, segments);
|
assert.equal(routes.match(/<rte>/g)?.length, segments);
|
||||||
assert.ok(routes.includes('<rtept'));
|
assert.ok(routes.includes('<rtept'));
|
||||||
@@ -419,7 +421,7 @@ describe('export', () => {
|
|||||||
|
|
||||||
it('does not count the way a file is written among its data', async () => {
|
it('does not count the way a file is written among its data', async () => {
|
||||||
await load('simple');
|
await load('simple');
|
||||||
const data = engine.exportableData(get(engine.order));
|
const data = await engine.exportableData(get(engine.order));
|
||||||
assert.equal(data.asRoute, false);
|
assert.equal(data.asRoute, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,7 +429,7 @@ describe('export', () => {
|
|||||||
await load('with_tracks_and_segments');
|
await load('with_tracks_and_segments');
|
||||||
const id = get(engine.order)[0];
|
const id = get(engine.order)[0];
|
||||||
const before = fileStates()[0].segments.features.length;
|
const before = fileStates()[0].segments.features.length;
|
||||||
const routes = engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true })!;
|
const routes = (await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true }))!;
|
||||||
await engine.loadFiles([{ data: routes, name: 'routes' }]);
|
await engine.loadFiles([{ data: routes, name: 'routes' }]);
|
||||||
const [, loaded] = get(engine.order);
|
const [, loaded] = get(engine.order);
|
||||||
const state = get(get(engine.files).get(loaded)!);
|
const state = get(get(engine.files).get(loaded)!);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { categoryIntervals } from '$lib/engine/trackpoint-categories';
|
import { categoryIntervals } from '$lib/engine/trackpoint-categories';
|
||||||
import { selectedElementIds, type FileTreeNode } from '$lib/engine/selection-helpers';
|
import { selectedElementIds, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
import type { RouteAttributes } from 'gpx-rs';
|
import type { RouteAttributes } from 'gpx-rs';
|
||||||
import { idsToBytes, selectMode, type Wasm } from './convert';
|
import { loadBackend, type Backend } from './backend';
|
||||||
import { readFileState } from './file-state';
|
import { readFileState } from './file-state';
|
||||||
import { StatisticsReader } from './statistics';
|
import { StatisticsReader } from './statistics';
|
||||||
import { openStoredFiles } from './storage';
|
import { openStoredFiles } from './storage';
|
||||||
@@ -34,7 +34,8 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrapper around the WASM engine.
|
* Thin wrapper around the engine of the files (the WASM module in the browser, the Rust process
|
||||||
|
* in the desktop app: see `backend`).
|
||||||
*
|
*
|
||||||
* The engine owns the files; this class mirrors what the UI needs in stores. After each action,
|
* The engine owns the files; this class mirrors what the UI needs in stores. After each action,
|
||||||
* only what the engine reports as changed is read again: the order of the files, and the state
|
* only what the engine reports as changed is read again: the order of the files, and the state
|
||||||
@@ -42,7 +43,9 @@ import {
|
|||||||
* segments and waypoints that did not change are reused.
|
* segments and waypoints that did not change are reused.
|
||||||
*/
|
*/
|
||||||
class Engine {
|
class Engine {
|
||||||
private wasm: Wasm | null = null;
|
private backend: Backend | null = null;
|
||||||
|
/** The calls to the engine, one after the other: each one is applied before the next starts. */
|
||||||
|
private _queue: Promise<unknown> = Promise.resolve();
|
||||||
/** Resolves once the engine can be used (never on the server). */
|
/** Resolves once the engine can be used (never on the server). */
|
||||||
readonly ready: Promise<void>;
|
readonly ready: Promise<void>;
|
||||||
|
|
||||||
@@ -61,7 +64,7 @@ class Engine {
|
|||||||
private _canRedo = writable(false);
|
private _canRedo = writable(false);
|
||||||
private _canPaste = writable(false);
|
private _canPaste = writable(false);
|
||||||
private _clipboard = writable<Clipboard | undefined>(undefined);
|
private _clipboard = writable<Clipboard | undefined>(undefined);
|
||||||
private _statistics = new StatisticsReader(() => this.wasm);
|
private _statistics = new StatisticsReader((task) => this.enqueue(task));
|
||||||
|
|
||||||
/** Ids of the files in display order. */
|
/** Ids of the files in display order. */
|
||||||
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
|
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
|
||||||
@@ -89,9 +92,8 @@ class Engine {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.ready = browser
|
this.ready = browser
|
||||||
? import('gpx-rs').then((wasm) => {
|
? loadBackend().then((backend) => {
|
||||||
wasm.start();
|
this.backend = backend;
|
||||||
this.wasm = wasm;
|
|
||||||
})
|
})
|
||||||
: new Promise(() => {});
|
: new Promise(() => {});
|
||||||
}
|
}
|
||||||
@@ -110,9 +112,9 @@ class Engine {
|
|||||||
openStorage(): Promise<Record<string, string>> {
|
openStorage(): Promise<Record<string, string>> {
|
||||||
this._storage ??= this.ready.then(() =>
|
this._storage ??= this.ready.then(() =>
|
||||||
openStoredFiles({
|
openStoredFiles({
|
||||||
wasm: this.wasm!,
|
backend: this.backend!,
|
||||||
visibility: this._visibility,
|
visibility: this._visibility,
|
||||||
sync: (update) => this.sync(this.wasm!, update),
|
sync: (update) => this.enqueue(() => this.sync(this.backend!, update)),
|
||||||
outdated: () => this._outdated.set(true),
|
outdated: () => this._outdated.set(true),
|
||||||
hasFiles: () => get(this._order).length > 0,
|
hasFiles: () => get(this._order).length > 0,
|
||||||
loadFiles: (files) => this.loadFiles(files),
|
loadFiles: (files) => this.loadFiles(files),
|
||||||
@@ -124,11 +126,11 @@ class Engine {
|
|||||||
|
|
||||||
/** Keeps a setting, `json` being its JSON. Nothing is kept before `openStorage` is done. */
|
/** Keeps a setting, `json` being its JSON. Nothing is kept before `openStorage` is done. */
|
||||||
setSetting(key: string, json: string) {
|
setSetting(key: string, json: string) {
|
||||||
this.wasm?.set_setting(key, json);
|
void this.backend?.set_setting(key, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSetting(key: string) {
|
deleteSetting(key: string) {
|
||||||
this.wasm?.delete_setting(key);
|
void this.backend?.delete_setting(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selection. Like the edits, each action resolves to whether the engine changed something.
|
// Selection. Like the edits, each action resolves to whether the engine changed something.
|
||||||
@@ -138,7 +140,7 @@ class Engine {
|
|||||||
* `SelectMode` for how the files combine with the selection.
|
* `SelectMode` for how the files combine with the selection.
|
||||||
*/
|
*/
|
||||||
select(fileIds: string[], mode: SelectMode = 'replace') {
|
select(fileIds: string[], mode: SelectMode = 'replace') {
|
||||||
return this.run((w) => w.select(idsToBytes(fileIds), selectMode(w, mode)));
|
return this.run((b) => b.select(fileIds, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,7 +149,7 @@ class Engine {
|
|||||||
* the files when nothing is selected.
|
* the files when nothing is selected.
|
||||||
*/
|
*/
|
||||||
selectAll() {
|
selectAll() {
|
||||||
return this.run((w) => w.select_all());
|
return this.run((b) => b.select_all());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,13 +158,13 @@ class Engine {
|
|||||||
* it replaces it.
|
* it replaces it.
|
||||||
*/
|
*/
|
||||||
arrowSelect(down: boolean, add: boolean) {
|
arrowSelect(down: boolean, add: boolean) {
|
||||||
return this.run((w) => w.arrow_select(down, add));
|
return this.run((b) => b.arrow_select(down, add));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selecting elements inside a file. Ids that do not exist are ignored.
|
// Selecting elements inside a file. Ids that do not exist are ignored.
|
||||||
|
|
||||||
selectTracks(fileId: string, trackIds: string[], mode: SelectMode = 'replace') {
|
selectTracks(fileId: string, trackIds: string[], mode: SelectMode = 'replace') {
|
||||||
return this.run((w) => w.select_tracks(fileId, idsToBytes(trackIds), selectMode(w, mode)));
|
return this.run((b) => b.select_tracks(fileId, trackIds, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
selectSegments(
|
selectSegments(
|
||||||
@@ -171,20 +173,16 @@ class Engine {
|
|||||||
segmentIds: string[],
|
segmentIds: string[],
|
||||||
mode: SelectMode = 'replace'
|
mode: SelectMode = 'replace'
|
||||||
) {
|
) {
|
||||||
return this.run((w) =>
|
return this.run((b) => b.select_segments(fileId, trackId, segmentIds, mode));
|
||||||
w.select_segments(fileId, trackId, idsToBytes(segmentIds), selectMode(w, mode))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Selects the node standing for all the waypoints of the file. */
|
/** Selects the node standing for all the waypoints of the file. */
|
||||||
selectWaypointGroup(fileId: string, mode: SelectMode = 'replace') {
|
selectWaypointGroup(fileId: string, mode: SelectMode = 'replace') {
|
||||||
return this.run((w) => w.select_waypoint_group(fileId, selectMode(w, mode)));
|
return this.run((b) => b.select_waypoint_group(fileId, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
selectWaypoints(fileId: string, waypointIds: string[], mode: SelectMode = 'replace') {
|
selectWaypoints(fileId: string, waypointIds: string[], mode: SelectMode = 'replace') {
|
||||||
return this.run((w) =>
|
return this.run((b) => b.select_waypoints(fileId, waypointIds, mode));
|
||||||
w.select_waypoints(fileId, idsToBytes(waypointIds), selectMode(w, mode))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Selects a node of the file tree. */
|
/** Selects a node of the file tree. */
|
||||||
@@ -206,23 +204,23 @@ class Engine {
|
|||||||
// History
|
// History
|
||||||
|
|
||||||
undo() {
|
undo() {
|
||||||
return this.run((w) => w.undo());
|
return this.run((b) => b.undo());
|
||||||
}
|
}
|
||||||
|
|
||||||
redo() {
|
redo() {
|
||||||
return this.run((w) => w.redo());
|
return this.run((b) => b.redo());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
|
|
||||||
/** Puts the selected elements in the clipboard. */
|
/** Puts the selected elements in the clipboard. */
|
||||||
copy() {
|
copy() {
|
||||||
return this.run((w) => w.copy());
|
return this.run((b) => b.copy());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Like `copy`, but the elements are moved when they are pasted. */
|
/** Like `copy`, but the elements are moved when they are pasted. */
|
||||||
cut() {
|
cut() {
|
||||||
return this.run((w) => w.cut());
|
return this.run((b) => b.cut());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edits of the files
|
// Edits of the files
|
||||||
@@ -232,7 +230,7 @@ class Engine {
|
|||||||
* segment that hold it, in the same undo step.
|
* segment that hold it, in the same undo step.
|
||||||
*/
|
*/
|
||||||
newFile(name: string, trackpoint?: { lng: number; lat: number; ele?: number }) {
|
newFile(name: string, trackpoint?: { lng: number; lat: number; ele?: number }) {
|
||||||
return this.run((w) => w.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele));
|
return this.run((b) => b.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,24 +239,11 @@ class Engine {
|
|||||||
* the first one that was read is selected.
|
* the first one that was read is selected.
|
||||||
*/
|
*/
|
||||||
loadFiles(files: { data: Uint8Array; name: string }[]) {
|
loadFiles(files: { data: Uint8Array; name: string }[]) {
|
||||||
const lengths = Uint32Array.from(files.map((file) => file.data.length));
|
return this.run((b) => b.load_files(files));
|
||||||
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
|
|
||||||
let offset = 0;
|
|
||||||
for (const file of files) {
|
|
||||||
data.set(file.data, offset);
|
|
||||||
offset += file.data.length;
|
|
||||||
}
|
|
||||||
return this.run((w) =>
|
|
||||||
w.load_files(
|
|
||||||
data,
|
|
||||||
lengths,
|
|
||||||
files.map((file) => file.name)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicate() {
|
duplicate() {
|
||||||
return this.run((w) => w.duplicate());
|
return this.run((b) => b.duplicate());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,16 +251,16 @@ class Engine {
|
|||||||
* are deleted instead, even if only a track or a waypoint is selected.
|
* are deleted instead, even if only a track or a waypoint is selected.
|
||||||
*/
|
*/
|
||||||
delete(wholeFiles = false) {
|
delete(wholeFiles = false) {
|
||||||
return this.run((w) => w._delete(wholeFiles));
|
return this.run((b) => b.delete(wholeFiles));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAll() {
|
deleteAll() {
|
||||||
return this.run((w) => w.delete_all());
|
return this.run((b) => b.delete_all());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves the files, in the given order, to `index` among the other files. */
|
/** Moves the files, in the given order, to `index` among the other files. */
|
||||||
reorder(fileIds: string[], index: number) {
|
reorder(fileIds: string[], index: number) {
|
||||||
return this.run((w) => w.reorder(idsToBytes(fileIds), index));
|
return this.run((b) => b.reorder(fileIds, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -289,7 +274,7 @@ class Engine {
|
|||||||
* copied again, what was cut is moved the first time and copied after that.
|
* copied again, what was cut is moved the first time and copied after that.
|
||||||
*/
|
*/
|
||||||
paste() {
|
paste() {
|
||||||
return this.run((w) => w.paste());
|
return this.run((b) => b.paste());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,7 +288,7 @@ class Engine {
|
|||||||
* node), among the waypoints of a file.
|
* node), among the waypoints of a file.
|
||||||
*/
|
*/
|
||||||
move(what: Selection, to: MoveTarget) {
|
move(what: Selection, to: MoveTarget) {
|
||||||
return this.run((w) => w.move_elements(what, to));
|
return this.run((b) => b.move_elements(what, to));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edits of the waypoints
|
// Edits of the waypoints
|
||||||
@@ -313,8 +298,8 @@ class Engine {
|
|||||||
* strings are fields that the waypoint does not have.
|
* strings are fields that the waypoint does not have.
|
||||||
*/
|
*/
|
||||||
newWaypoint(waypoint: NewWaypoint) {
|
newWaypoint(waypoint: NewWaypoint) {
|
||||||
return this.run((w) =>
|
return this.run((b) =>
|
||||||
w.new_waypoint(
|
b.new_waypoint(
|
||||||
waypoint.lng,
|
waypoint.lng,
|
||||||
waypoint.lat,
|
waypoint.lat,
|
||||||
waypoint.ele,
|
waypoint.ele,
|
||||||
@@ -328,8 +313,8 @@ class Engine {
|
|||||||
|
|
||||||
/** Changes a waypoint, selected or not. Empty strings remove the field. */
|
/** Changes a waypoint, selected or not. Empty strings remove the field. */
|
||||||
updateWaypoint(fileId: string, waypointId: string, waypoint: NewWaypoint) {
|
updateWaypoint(fileId: string, waypointId: string, waypoint: NewWaypoint) {
|
||||||
return this.run((w) =>
|
return this.run((b) =>
|
||||||
w.update_waypoint(
|
b.update_waypoint(
|
||||||
fileId,
|
fileId,
|
||||||
waypointId,
|
waypointId,
|
||||||
waypoint.lng,
|
waypoint.lng,
|
||||||
@@ -345,22 +330,22 @@ class Engine {
|
|||||||
|
|
||||||
/** Moves a waypoint, selected or not: only its position and elevation change. */
|
/** Moves a waypoint, selected or not: only its position and elevation change. */
|
||||||
moveWaypoint(fileId: string, waypointId: string, lng: number, lat: number, ele: number) {
|
moveWaypoint(fileId: string, waypointId: string, lng: number, lat: number, ele: number) {
|
||||||
return this.run((w) => w.move_waypoint(fileId, waypointId, lng, lat, ele));
|
return this.run((b) => b.move_waypoint(fileId, waypointId, lng, lat, ele));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes a waypoint, selected or not. */
|
/** Deletes a waypoint, selected or not. */
|
||||||
deleteWaypoint(fileId: string, waypointId: string) {
|
deleteWaypoint(fileId: string, waypointId: string) {
|
||||||
return this.run((w) => w.delete_waypoint(fileId, waypointId));
|
return this.run((b) => b.delete_waypoint(fileId, waypointId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edits of the selection
|
// Edits of the selection
|
||||||
|
|
||||||
newTrack() {
|
newTrack() {
|
||||||
return this.run((w) => w.new_track());
|
return this.run((b) => b.new_track());
|
||||||
}
|
}
|
||||||
|
|
||||||
newTrackSegment() {
|
newTrackSegment() {
|
||||||
return this.run((w) => w.new_track_segment());
|
return this.run((b) => b.new_track_segment());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,7 +354,7 @@ class Engine {
|
|||||||
* nothing to reverse (fewer than two trackpoints).
|
* nothing to reverse (fewer than two trackpoints).
|
||||||
*/
|
*/
|
||||||
reverse() {
|
reverse() {
|
||||||
return this.run((w) => w.reverse());
|
return this.run((b) => b.reverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -378,7 +363,7 @@ class Engine {
|
|||||||
* Resolves to `false` when there is nothing to extract.
|
* Resolves to `false` when there is nothing to extract.
|
||||||
*/
|
*/
|
||||||
extract() {
|
extract() {
|
||||||
return this.run((w) => w.extract());
|
return this.run((b) => b.extract());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -387,7 +372,7 @@ class Engine {
|
|||||||
* number of elevations is not the number of trackpoints.
|
* number of elevations is not the number of trackpoints.
|
||||||
*/
|
*/
|
||||||
setElevation(ele: Float64Array) {
|
setElevation(ele: Float64Array) {
|
||||||
return this.run((w) => w.elevation(ele));
|
return this.run((b) => b.elevation(ele));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -396,7 +381,7 @@ class Engine {
|
|||||||
* `speed` (km/h). Resolves to `false` when there is nothing to set, or the values are invalid.
|
* `speed` (km/h). Resolves to `false` when there is nothing to set, or the values are invalid.
|
||||||
*/
|
*/
|
||||||
changeTimestamps(startTime: Date, speed: number, ratio: number) {
|
changeTimestamps(startTime: Date, speed: number, ratio: number) {
|
||||||
return this.run((w) => w.changeTimestamps(startTime.getTime(), speed, ratio));
|
return this.run((b) => b.change_timestamps(startTime.getTime(), speed, ratio));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -405,7 +390,7 @@ class Engine {
|
|||||||
* nothing to set.
|
* nothing to set.
|
||||||
*/
|
*/
|
||||||
createArtificialTimestamps(startTime: Date, totalTime: number) {
|
createArtificialTimestamps(startTime: Date, totalTime: number) {
|
||||||
return this.run((w) => w.createArtificialTimestamps(startTime.getTime(), totalTime));
|
return this.run((b) => b.create_artificial_timestamps(startTime.getTime(), totalTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -418,13 +403,13 @@ class Engine {
|
|||||||
trackpoints: boolean,
|
trackpoints: boolean,
|
||||||
waypoints: boolean
|
waypoints: boolean
|
||||||
) {
|
) {
|
||||||
return this.run((w) =>
|
return this.run((b) =>
|
||||||
w.clean(
|
b.clean(
|
||||||
bounds.west,
|
bounds.west,
|
||||||
bounds.south,
|
bounds.south,
|
||||||
bounds.east,
|
bounds.east,
|
||||||
bounds.north,
|
bounds.north,
|
||||||
inside ? w.CleanType.Inside : w.CleanType.Outside,
|
inside,
|
||||||
trackpoints,
|
trackpoints,
|
||||||
waypoints
|
waypoints
|
||||||
)
|
)
|
||||||
@@ -437,9 +422,7 @@ class Engine {
|
|||||||
* Resolves to `false` when there is nothing to merge.
|
* Resolves to `false` when there is nothing to merge.
|
||||||
*/
|
*/
|
||||||
merge(type: MergeType, removeGaps = false) {
|
merge(type: MergeType, removeGaps = false) {
|
||||||
return this.run((w) =>
|
return this.run((b) => b.merge(type, removeGaps));
|
||||||
w.merge(type === 'connect' ? w.MergeType.Connect : w.MergeType.Group, removeGaps)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -448,7 +431,7 @@ class Engine {
|
|||||||
* is nothing to remove.
|
* is nothing to remove.
|
||||||
*/
|
*/
|
||||||
reduce(tolerance: number) {
|
reduce(tolerance: number) {
|
||||||
return this.run((w) => w.reduce(tolerance));
|
return this.run((b) => b.reduce(tolerance));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,17 +441,7 @@ class Engine {
|
|||||||
* one of `SelectionAnchors`, which `at` is an index of.
|
* one of `SelectionAnchors`, which `at` is an index of.
|
||||||
*/
|
*/
|
||||||
split(revision: number, at: number, splitType: SplitType) {
|
split(revision: number, at: number, splitType: SplitType) {
|
||||||
return this.run((w) =>
|
return this.run((b) => b.split(revision, at, splitType));
|
||||||
w.split(
|
|
||||||
revision,
|
|
||||||
at,
|
|
||||||
{
|
|
||||||
files: w.SplitType.Files,
|
|
||||||
tracks: w.SplitType.Tracks,
|
|
||||||
segments: w.SplitType.Segments,
|
|
||||||
}[splitType]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -478,7 +451,7 @@ class Engine {
|
|||||||
* the range covers the whole selection.
|
* the range covers the whole selection.
|
||||||
*/
|
*/
|
||||||
crop(start: number, end: number) {
|
crop(start: number, end: number) {
|
||||||
return this.run((w) => w.crop(Math.max(start, 0), Math.max(end, 0)));
|
return this.run((b) => b.crop(Math.max(start, 0), Math.max(end, 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -487,16 +460,16 @@ class Engine {
|
|||||||
* repeated), and goes on in time. Resolves to `false` when no segment has the two trackpoints that a way back needs.
|
* repeated), and goes on in time. Resolves to `false` when no segment has the two trackpoints that a way back needs.
|
||||||
*/
|
*/
|
||||||
roundTrip() {
|
roundTrip() {
|
||||||
return this.run((w) => w.round_trip());
|
return this.run((b) => b.round_trip());
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata(name: string, desc: string) {
|
metadata(name: string, desc: string) {
|
||||||
return this.run((w) => w.metadata(name, desc));
|
return this.run((b) => b.metadata(name, desc));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only the given fields are changed. */
|
/** Only the given fields are changed. */
|
||||||
style(style: { color?: string; opacity?: number; width?: number }) {
|
style(style: { color?: string; opacity?: number; width?: number }) {
|
||||||
return this.run((w) => w.style(style.color, style.opacity, style.width));
|
return this.run((b) => b.style(style.color, style.opacity, style.width));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routing
|
// Routing
|
||||||
@@ -529,8 +502,8 @@ class Engine {
|
|||||||
routeAttributes[key] = intervals;
|
routeAttributes[key] = intervals;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.run((w) =>
|
return this.run((b) =>
|
||||||
w.route(
|
b.route(
|
||||||
revision,
|
revision,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@@ -549,7 +522,7 @@ class Engine {
|
|||||||
* one of `SelectionAnchors`.
|
* one of `SelectionAnchors`.
|
||||||
*/
|
*/
|
||||||
insertAnchor(revision: number, lng: number, lat: number) {
|
insertAnchor(revision: number, lng: number, lat: number) {
|
||||||
return this.run((w) => w.insert_anchor(revision, lng, lat));
|
return this.run((b) => b.insert_anchor(revision, lng, lat));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -557,7 +530,7 @@ class Engine {
|
|||||||
* the trackpoints before it go to the end, and the loop closes on it. `revision` is the one of `SelectionAnchors`.
|
* the trackpoints before it go to the end, and the loop closes on it. `revision` is the one of `SelectionAnchors`.
|
||||||
*/
|
*/
|
||||||
changeLoopStart(revision: number, index: number) {
|
changeLoopStart(revision: number, index: number) {
|
||||||
return this.run((w) => w.change_loop_start(revision, index));
|
return this.run((b) => b.change_loop_start(revision, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visibility, which is kept by the frontend and not by the engine
|
// Visibility, which is kept by the frontend and not by the engine
|
||||||
@@ -601,16 +574,20 @@ class Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Requests, which do not change anything
|
// Requests, which do not change anything. They wait for the calls made before them.
|
||||||
|
|
||||||
/** All the data of a waypoint, `undefined` if it does not exist (or before the engine is loaded). */
|
/** All the data of a waypoint, `undefined` if it does not exist (or before the engine is loaded). */
|
||||||
waypoint(fileId: string, waypointId: string): WaypointDetails | undefined {
|
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined> {
|
||||||
return this.wasm?.waypoint(fileId, waypointId);
|
return this.read((b) => b.waypoint(fileId, waypointId), undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A trackpoint of a segment, `undefined` if it does not exist. */
|
/** A trackpoint of a segment, `undefined` if it does not exist. */
|
||||||
trackpoint(fileId: string, segmentId: string, index: number): TrackpointDetails | undefined {
|
trackpoint(
|
||||||
return this.wasm?.trackpoint(fileId, segmentId, index);
|
fileId: string,
|
||||||
|
segmentId: string,
|
||||||
|
index: number
|
||||||
|
): Promise<TrackpointDetails | undefined> {
|
||||||
|
return this.read((b) => b.trackpoint(fileId, segmentId, index), undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -620,9 +597,10 @@ class Engine {
|
|||||||
exportFile(
|
exportFile(
|
||||||
fileId: string,
|
fileId: string,
|
||||||
options: ExportOptions = ALL_EXPORT_OPTIONS
|
options: ExportOptions = ALL_EXPORT_OPTIONS
|
||||||
): Uint8Array<ArrayBuffer> | undefined {
|
): Promise<Uint8Array<ArrayBuffer> | undefined> {
|
||||||
// a copy out of the memory of the engine, so a plain ArrayBuffer
|
return this.read(
|
||||||
return this.wasm?.export_file(
|
(b) =>
|
||||||
|
b.export_file(
|
||||||
fileId,
|
fileId,
|
||||||
options.time,
|
options.time,
|
||||||
options.hr,
|
options.hr,
|
||||||
@@ -631,12 +609,14 @@ class Engine {
|
|||||||
options.power,
|
options.power,
|
||||||
options.osm,
|
options.osm,
|
||||||
options.asRoute
|
options.asRoute
|
||||||
) as Uint8Array<ArrayBuffer> | undefined;
|
),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The data that some files have, which the options of `exportFile` can leave out. */
|
/** The data that some files have, which the options of `exportFile` can leave out. */
|
||||||
exportableData(fileIds: string[]): ExportOptions {
|
async exportableData(fileIds: string[]): Promise<ExportOptions> {
|
||||||
const bits = this.wasm?.exportable_data(idsToBytes(fileIds)) ?? 0;
|
const bits = await this.read((b) => b.exportable_data(fileIds), 0);
|
||||||
return {
|
return {
|
||||||
time: (bits & 1) !== 0,
|
time: (bits & 1) !== 0,
|
||||||
hr: (bits & 2) !== 0,
|
hr: (bits & 2) !== 0,
|
||||||
@@ -654,19 +634,19 @@ class Engine {
|
|||||||
* (m) of `reduce` that keeps it: infinite for the ends of the segments. Not requested with
|
* (m) of `reduce` that keeps it: infinite for the ends of the segments. Not requested with
|
||||||
* the statistics: ask when they changed.
|
* the statistics: ask when they changed.
|
||||||
*/
|
*/
|
||||||
reductionDistances(): Float64Array {
|
reductionDistances(): Promise<Float64Array> {
|
||||||
return this.wasm?.reduction_distances() ?? new Float64Array();
|
return this.read((b) => b.reduction_distances(), new Float64Array());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coordinates, as flat [lng, lat, ...] arrays. The getters copy them out of the WASM memory.
|
// Coordinates, as flat [lng, lat, ...] arrays: copies.
|
||||||
// Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed.
|
// Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed.
|
||||||
|
|
||||||
segmentCoordinates(segmentId: string): Float64Array {
|
segmentCoordinates(segmentId: string): Promise<Float64Array> {
|
||||||
return this.wasm?.segment_coordinates(segmentId) ?? new Float64Array();
|
return this.read((b) => b.segment_coordinates(segmentId), new Float64Array());
|
||||||
}
|
}
|
||||||
|
|
||||||
waypointCoordinates(fileId: string): Float64Array {
|
waypointCoordinates(fileId: string): Promise<Float64Array> {
|
||||||
return this.wasm?.waypoint_coordinates(fileId) ?? new Float64Array();
|
return this.read((b) => b.waypoint_coordinates(fileId), new Float64Array());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -682,17 +662,34 @@ class Engine {
|
|||||||
|
|
||||||
// Plumbing
|
// Plumbing
|
||||||
|
|
||||||
|
/** Does `task` after the ones that were queued before it, whatever they do. */
|
||||||
|
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this._queue.then(task, task);
|
||||||
|
this._queue = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a request to the engine, once the calls made before it are done. Resolves to
|
||||||
|
* `fallback` when the engine is not loaded.
|
||||||
|
*/
|
||||||
|
private read<T>(request: (backend: Backend) => Promise<T>, fallback: T): Promise<T> {
|
||||||
|
return this.enqueue(async () => (this.backend ? request(this.backend) : fallback));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a call to the engine and applies its outcome to what is kept of the files. Resolves to
|
* Makes a call to the engine and applies its outcome to what is kept of the files. Resolves to
|
||||||
* whether anything changed. When nothing did, `lastError` tells why.
|
* whether anything changed. When nothing did, `lastError` tells why.
|
||||||
*/
|
*/
|
||||||
private async run(action: (wasm: Wasm) => Outcome): Promise<boolean> {
|
private async run(action: (backend: Backend) => Promise<Outcome>): Promise<boolean> {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
const wasm = this.wasm!;
|
return this.enqueue(async () => {
|
||||||
const outcome = action(wasm);
|
const backend = this.backend!;
|
||||||
this.sync(wasm, outcome);
|
const outcome = await action(backend);
|
||||||
|
await this.sync(backend, outcome);
|
||||||
this.report(outcome);
|
this.report(outcome);
|
||||||
return outcome.changed;
|
return outcome.changed;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Logs why a call did nothing, and keeps it in `lastError`. */
|
/** Logs why a call did nothing, and keeps it in `lastError`. */
|
||||||
@@ -706,26 +703,34 @@ class Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Updates what is kept of the engine with what a call changed. */
|
/** Updates what is kept of the engine with what a call changed. */
|
||||||
private sync(wasm: Wasm, update: Outcome) {
|
private async sync(backend: Backend, update: Outcome) {
|
||||||
this._canUndo.set(wasm.can_undo());
|
const [canUndo, canRedo, canPaste] = await Promise.all([
|
||||||
this._canRedo.set(wasm.can_redo());
|
backend.can_undo(),
|
||||||
this._canPaste.set(wasm.can_paste());
|
backend.can_redo(),
|
||||||
|
backend.can_paste(),
|
||||||
|
]);
|
||||||
|
this._canUndo.set(canUndo);
|
||||||
|
this._canRedo.set(canRedo);
|
||||||
|
this._canPaste.set(canPaste);
|
||||||
if (update.clipboardChanged) {
|
if (update.clipboardChanged) {
|
||||||
this._clipboard.set(wasm.clipboard());
|
this._clipboard.set(await backend.clipboard());
|
||||||
}
|
}
|
||||||
if (update.selectionChanged) {
|
if (update.selectionChanged) {
|
||||||
this._selection.set(wasm.selection());
|
this._selection.set(await backend.selection());
|
||||||
}
|
}
|
||||||
if (update.orderChanged) {
|
if (update.orderChanged) {
|
||||||
this._order.set(wasm.file_order());
|
this._order.set(await backend.file_order());
|
||||||
}
|
}
|
||||||
const added: [string, FileState][] = [];
|
const added: [string, FileState][] = [];
|
||||||
for (const id of update.added) {
|
const addedStates = await Promise.all(
|
||||||
const state = readFileState(wasm, id, this._colors, this._visibility);
|
update.added.map((id) => readFileState(backend, id, this._colors, this._visibility))
|
||||||
|
);
|
||||||
|
update.added.forEach((id, i) => {
|
||||||
|
const state = addedStates[i];
|
||||||
if (state) {
|
if (state) {
|
||||||
added.push([id, state]);
|
added.push([id, state]);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
if (added.length > 0 || update.removed.length > 0) {
|
if (added.length > 0 || update.removed.length > 0) {
|
||||||
this._files.update((files) => {
|
this._files.update((files) => {
|
||||||
const next = new Map(files);
|
const next = new Map(files);
|
||||||
@@ -739,14 +744,22 @@ class Engine {
|
|||||||
}
|
}
|
||||||
if (update.modified.length > 0) {
|
if (update.modified.length > 0) {
|
||||||
const files = get(this._files);
|
const files = get(this._files);
|
||||||
for (const id of update.modified) {
|
const states = await Promise.all(
|
||||||
|
update.modified.map((id) => {
|
||||||
const store = files.get(id);
|
const store = files.get(id);
|
||||||
const state =
|
return (
|
||||||
store && readFileState(wasm, id, this._colors, this._visibility, get(store));
|
store &&
|
||||||
|
readFileState(backend, id, this._colors, this._visibility, get(store))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
update.modified.forEach((id, i) => {
|
||||||
|
const store = files.get(id);
|
||||||
|
const state = states[i];
|
||||||
if (store && state) {
|
if (store && state) {
|
||||||
store.set(state);
|
store.set(state);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// the statistics depend on the selection, on its order, and on the files
|
// the statistics depend on the selection, on its order, and on the files
|
||||||
@@ -755,7 +768,7 @@ class Engine {
|
|||||||
update.orderChanged ||
|
update.orderChanged ||
|
||||||
update.added.length + update.modified.length + update.removed.length > 0
|
update.added.length + update.modified.length + update.removed.length > 0
|
||||||
) {
|
) {
|
||||||
this._statistics.refresh(wasm);
|
await this._statistics.refresh(backend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import type { Feature, LineString } from 'geojson';
|
import type { Feature, LineString } from 'geojson';
|
||||||
import { normalizeColor, type FileColorAllocator } from '$lib/engine/file-colors';
|
import { normalizeColor, type FileColorAllocator } from '$lib/engine/file-colors';
|
||||||
import type { Visibility } from '$lib/engine/file-visibility';
|
import type { Visibility } from '$lib/engine/file-visibility';
|
||||||
import { toPositions, type Wasm } from './convert';
|
import type { Backend } from './backend';
|
||||||
|
import { toPositions } from './convert';
|
||||||
import { EMPTY_STATISTICS, type FileState, type SegmentProperties } from './types';
|
import { EMPTY_STATISTICS, type FileState, type SegmentProperties } from './types';
|
||||||
|
|
||||||
/** Reads the state of a file, reusing from `previous` what did not change. */
|
/** Reads the state of a file, reusing from `previous` what did not change. */
|
||||||
export function readFileState(
|
export async function readFileState(
|
||||||
wasm: Wasm,
|
backend: Backend,
|
||||||
id: string,
|
id: string,
|
||||||
colors: FileColorAllocator,
|
colors: FileColorAllocator,
|
||||||
visibility: ReadonlyMap<string, Visibility>,
|
visibility: ReadonlyMap<string, Visibility>,
|
||||||
previous?: FileState
|
previous?: FileState
|
||||||
): FileState | null {
|
): Promise<FileState | null> {
|
||||||
const structure = wasm.file_structure(id);
|
const structure = await backend.file_structure(id);
|
||||||
if (!structure) {
|
if (!structure) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,11 @@ export function readFileState(
|
|||||||
const previousSegments = new Map(
|
const previousSegments = new Map(
|
||||||
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
|
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
|
||||||
);
|
);
|
||||||
const segments: Feature<LineString, SegmentProperties>[] = [];
|
// the coordinates of the segments that changed, read together
|
||||||
|
const segments: (
|
||||||
|
| Feature<LineString, SegmentProperties>
|
||||||
|
| Promise<Feature<LineString, SegmentProperties>>
|
||||||
|
)[] = [];
|
||||||
structure.tracks.forEach((track, trackIndex) => {
|
structure.tracks.forEach((track, trackIndex) => {
|
||||||
track.segments.forEach((segment, segmentIndex) => {
|
track.segments.forEach((segment, segmentIndex) => {
|
||||||
const properties: SegmentProperties = {
|
const properties: SegmentProperties = {
|
||||||
@@ -48,17 +53,18 @@ export function readFileState(
|
|||||||
segments.push(
|
segments.push(
|
||||||
unchanged
|
unchanged
|
||||||
? old
|
? old
|
||||||
: {
|
: backend.segment_coordinates(segment.id).then((coordinates) => ({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: {
|
geometry: {
|
||||||
type: 'LineString',
|
type: 'LineString',
|
||||||
coordinates: toPositions(wasm.segment_coordinates(segment.id)),
|
coordinates: toPositions(coordinates),
|
||||||
},
|
},
|
||||||
properties,
|
properties,
|
||||||
}
|
}))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const segmentFeatures = await Promise.all(segments);
|
||||||
|
|
||||||
const previousStructure = previous?.structure;
|
const previousStructure = previous?.structure;
|
||||||
const waypointsUnchanged =
|
const waypointsUnchanged =
|
||||||
@@ -73,7 +79,7 @@ export function readFileState(
|
|||||||
);
|
);
|
||||||
let waypoints = previous?.waypoints;
|
let waypoints = previous?.waypoints;
|
||||||
if (!waypoints || !waypointsUnchanged) {
|
if (!waypoints || !waypointsUnchanged) {
|
||||||
const coordinates = toPositions(wasm.waypoint_coordinates(id));
|
const coordinates = toPositions(await backend.waypoint_coordinates(id));
|
||||||
waypoints = {
|
waypoints = {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: structure.waypoints.map((waypoint, index) => ({
|
features: structure.waypoints.map((waypoint, index) => ({
|
||||||
@@ -93,8 +99,8 @@ export function readFileState(
|
|||||||
return {
|
return {
|
||||||
structure,
|
structure,
|
||||||
color,
|
color,
|
||||||
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
|
statistics: (await backend.file_statistics(id)) ?? EMPTY_STATISTICS,
|
||||||
segments: { type: 'FeatureCollection', features: segments },
|
segments: { type: 'FeatureCollection', features: segmentFeatures },
|
||||||
waypoints,
|
waypoints,
|
||||||
visibility: visibility.get(id) ?? new Map(),
|
visibility: visibility.get(id) ?? new Map(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { get, writable, type Readable } from 'svelte/store';
|
import { get, writable, type Readable } from 'svelte/store';
|
||||||
import { categoryIntervals, type CategoryIntervals } from '$lib/engine/trackpoint-categories';
|
import { categoryIntervals, type CategoryIntervals } from '$lib/engine/trackpoint-categories';
|
||||||
import type { Wasm } from './convert';
|
import type { Backend } from './backend';
|
||||||
import {
|
import {
|
||||||
EMPTY_SELECTION_STATISTICS,
|
EMPTY_SELECTION_STATISTICS,
|
||||||
EMPTY_STATISTICS,
|
EMPTY_STATISTICS,
|
||||||
@@ -23,35 +23,49 @@ export class StatisticsReader {
|
|||||||
/** The metrics that were read for the current statistics. */
|
/** The metrics that were read for the current statistics. */
|
||||||
private _loaded = new Set<StatisticsMetric>();
|
private _loaded = new Set<StatisticsMetric>();
|
||||||
|
|
||||||
/** @param wasm The engine, `null` before it is loaded. */
|
/** The engine, as of the last time the statistics were read. */
|
||||||
constructor(private wasm: () => Wasm | null) {}
|
private _backend: Backend | null = null;
|
||||||
|
|
||||||
|
/** @param enqueue Does a task after the calls to the engine that were made before it. */
|
||||||
|
constructor(private enqueue: <T>(task: () => Promise<T>) => Promise<T>) {}
|
||||||
|
|
||||||
/** Reads the statistics of the selection again, after it or the files changed. */
|
/** Reads the statistics of the selection again, after it or the files changed. */
|
||||||
refresh(wasm: Wasm) {
|
async refresh(backend: Backend) {
|
||||||
this._store.set(this.read(wasm));
|
this._backend = backend;
|
||||||
|
this._store.set(await this.read(backend));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the statistics buffers of the engine (the getters copy them), but only the metrics
|
* Reads the statistics buffers of the engine (the getters copy them), but only the metrics
|
||||||
* that were requested.
|
* that were requested.
|
||||||
*/
|
*/
|
||||||
private read(wasm: Wasm): SelectionStatistics {
|
private async read(backend: Backend): Promise<SelectionStatistics> {
|
||||||
const version = ++this._version;
|
const version = ++this._version;
|
||||||
this._loaded.clear();
|
this._loaded.clear();
|
||||||
const totalDistance = wasm.total_distance();
|
const [global, totalDistance, slope, lng, lat, ele, timestamps] = await Promise.all([
|
||||||
|
backend.selection_statistics(),
|
||||||
|
backend.total_distance(),
|
||||||
|
backend.slope(),
|
||||||
|
backend.lng(),
|
||||||
|
backend.lat(),
|
||||||
|
backend.ele(),
|
||||||
|
backend.timestamps(),
|
||||||
|
]);
|
||||||
const statistics: SelectionStatistics = {
|
const statistics: SelectionStatistics = {
|
||||||
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
|
global: global ?? EMPTY_STATISTICS,
|
||||||
length: totalDistance.length,
|
length: totalDistance.length,
|
||||||
totalDistance,
|
totalDistance,
|
||||||
slope: wasm.slope(),
|
slope,
|
||||||
lng: wasm.lng(),
|
lng,
|
||||||
lat: wasm.lat(),
|
lat,
|
||||||
ele: wasm.ele(),
|
ele,
|
||||||
timestamps: wasm.timestamps(),
|
timestamps,
|
||||||
slice: (start, end) =>
|
slice: (start, end) =>
|
||||||
version === this._version ? wasm.slice_statistics(start, end) : undefined,
|
this.enqueue(async () =>
|
||||||
|
version === this._version ? backend.slice_statistics(start, end) : undefined
|
||||||
|
),
|
||||||
};
|
};
|
||||||
this.readMetrics(wasm, statistics, this.requestedMetrics());
|
await this.readMetrics(backend, statistics, this.requestedMetrics());
|
||||||
return statistics;
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,81 +76,95 @@ export class StatisticsReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the metrics that were not read yet into `statistics`, which is the current one. */
|
/** Reads the metrics that were not read yet into `statistics`, which is the current one. */
|
||||||
private readMetrics(
|
private async readMetrics(
|
||||||
wasm: Wasm,
|
backend: Backend,
|
||||||
statistics: SelectionStatistics,
|
statistics: SelectionStatistics,
|
||||||
metrics: Set<StatisticsMetric>
|
metrics: Set<StatisticsMetric>
|
||||||
) {
|
) {
|
||||||
const categories = (
|
const categories = async (
|
||||||
starts: Uint32Array,
|
starts: Promise<Uint32Array>,
|
||||||
values: Uint8Array,
|
values: Promise<Uint8Array>,
|
||||||
names: () => string[]
|
names: Promise<string[]>
|
||||||
): CategoryIntervals => ({
|
): Promise<CategoryIntervals> => {
|
||||||
starts,
|
const [s, v, n] = await Promise.all([starts, values, names]);
|
||||||
values,
|
return { starts: s, values: v, names: n };
|
||||||
names: names(),
|
};
|
||||||
});
|
|
||||||
|
|
||||||
for (const metric of metrics) {
|
const missing = [...metrics].filter((metric) => !this._loaded.has(metric));
|
||||||
if (this._loaded.has(metric)) {
|
missing.forEach((metric) => this._loaded.add(metric));
|
||||||
continue;
|
await Promise.all(
|
||||||
}
|
missing.map(async (metric) => {
|
||||||
this._loaded.add(metric);
|
|
||||||
switch (metric) {
|
switch (metric) {
|
||||||
case 'speed':
|
case 'speed':
|
||||||
statistics.speed = wasm.speed();
|
statistics.speed = await backend.speed();
|
||||||
break;
|
break;
|
||||||
case 'hr':
|
case 'hr':
|
||||||
statistics.hr = wasm.hr();
|
statistics.hr = await backend.hr();
|
||||||
break;
|
break;
|
||||||
case 'cad':
|
case 'cad':
|
||||||
statistics.cad = wasm.cad();
|
statistics.cad = await backend.cad();
|
||||||
break;
|
break;
|
||||||
case 'atemp':
|
case 'atemp':
|
||||||
statistics.atemp = wasm.atemp();
|
statistics.atemp = await backend.atemp();
|
||||||
break;
|
break;
|
||||||
case 'power':
|
case 'power':
|
||||||
statistics.power = wasm.power();
|
statistics.power = await backend.power();
|
||||||
break;
|
break;
|
||||||
case 'slopeSegment':
|
case 'slopeSegment':
|
||||||
statistics.slopeSegmentSlope = wasm.slope_segment_slope();
|
[statistics.slopeSegmentSlope, statistics.slopeSegmentDistance] =
|
||||||
statistics.slopeSegmentDistance = wasm.slope_segment_distance();
|
await Promise.all([
|
||||||
|
backend.slope_segment_slope(),
|
||||||
|
backend.slope_segment_distance(),
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
case 'surface':
|
case 'surface':
|
||||||
statistics.surface = categories(
|
statistics.surface = await categories(
|
||||||
wasm.surface_starts(),
|
backend.surface_starts(),
|
||||||
wasm.surface_values(),
|
backend.surface_values(),
|
||||||
wasm.surfaces
|
backend.surfaces()
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'anchors':
|
case 'anchors': {
|
||||||
|
const [indices, zooms, segmentStarts, segmentIds, revision] =
|
||||||
|
await Promise.all([
|
||||||
|
backend.anchor_indices(),
|
||||||
|
backend.anchor_zooms(),
|
||||||
|
backend.segment_starts(),
|
||||||
|
backend.segment_ids(),
|
||||||
|
backend.routing_revision(),
|
||||||
|
]);
|
||||||
statistics.anchors = {
|
statistics.anchors = {
|
||||||
indices: wasm.anchor_indices(),
|
indices,
|
||||||
zooms: wasm.anchor_zooms(),
|
zooms,
|
||||||
segmentStarts: wasm.segment_starts(),
|
segmentStarts,
|
||||||
segmentIds: wasm.segment_ids(),
|
segmentIds,
|
||||||
revision: wasm.routing_revision(),
|
revision,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'highway':
|
case 'highway':
|
||||||
statistics.highway = categories(
|
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
|
||||||
wasm.highway_starts(),
|
await Promise.all([
|
||||||
wasm.highway_values(),
|
categories(
|
||||||
wasm.highways
|
backend.highway_starts(),
|
||||||
);
|
backend.highway_values(),
|
||||||
statistics.sacScale = categories(
|
backend.highways()
|
||||||
wasm.sac_scale_starts(),
|
),
|
||||||
wasm.sac_scale_values(),
|
categories(
|
||||||
wasm.sac_scales
|
backend.sac_scale_starts(),
|
||||||
);
|
backend.sac_scale_values(),
|
||||||
statistics.mtbScale = categories(
|
backend.sac_scales()
|
||||||
wasm.mtb_scale_starts(),
|
),
|
||||||
wasm.mtb_scale_values(),
|
categories(
|
||||||
wasm.mtb_scales
|
backend.mtb_scale_starts(),
|
||||||
);
|
backend.mtb_scale_values(),
|
||||||
|
backend.mtb_scales()
|
||||||
|
),
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,7 +183,7 @@ export class StatisticsReader {
|
|||||||
for (const metric of metrics) {
|
for (const metric of metrics) {
|
||||||
wanted.add(metric);
|
wanted.add(metric);
|
||||||
}
|
}
|
||||||
this.loadMissing();
|
return this.loadMissing();
|
||||||
},
|
},
|
||||||
release: () => {
|
release: () => {
|
||||||
this._requests.delete(wanted);
|
this._requests.delete(wanted);
|
||||||
@@ -164,16 +192,22 @@ export class StatisticsReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the requested metrics that are not in the current statistics yet. */
|
/** Reads the requested metrics that are not in the current statistics yet. */
|
||||||
private loadMissing() {
|
private async loadMissing() {
|
||||||
const wasm = this.wasm();
|
|
||||||
const metrics = this.requestedMetrics();
|
const metrics = this.requestedMetrics();
|
||||||
if (!wasm || [...metrics].every((metric) => this._loaded.has(metric))) {
|
if ([...metrics].every((metric) => this._loaded.has(metric))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// queued, so that the buffers of the engine still are the ones of the current statistics:
|
||||||
|
// they are read after every action
|
||||||
|
await this.enqueue(async () => {
|
||||||
|
const backend = this._backend;
|
||||||
|
const requested = this.requestedMetrics();
|
||||||
|
if (!backend || [...requested].every((metric) => this._loaded.has(metric))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// the buffers of the engine still are the ones of the current statistics, as they are
|
|
||||||
// read after every action
|
|
||||||
const statistics = { ...get(this._store) };
|
const statistics = { ...get(this._store) };
|
||||||
this.readMetrics(wasm, statistics, metrics);
|
await this.readMetrics(backend, statistics, requested);
|
||||||
this._store.set(statistics);
|
this._store.set(statistics);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/storage/legacy-import';
|
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/storage/legacy-import';
|
||||||
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/engine/file-visibility';
|
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/engine/file-visibility';
|
||||||
import type { Outcome, StorageOpened } from 'gpx-rs';
|
import type { Outcome, StorageOpened } from 'gpx-rs';
|
||||||
import type { Wasm } from './convert';
|
import type { Backend } from './backend';
|
||||||
|
|
||||||
/** Name of the IndexedDB database that keeps the files and the settings. */
|
/** Name of the IndexedDB database that keeps the files and the settings. */
|
||||||
const STORAGE_NAME = 'gpx-studio';
|
const STORAGE_NAME = 'gpx-studio';
|
||||||
@@ -11,7 +11,7 @@ const CHANNEL_NAME = 'gpx-studio-storage';
|
|||||||
|
|
||||||
/** What opening the storage needs of the engine facade. */
|
/** What opening the storage needs of the engine facade. */
|
||||||
export type StorageHost = {
|
export type StorageHost = {
|
||||||
wasm: Wasm;
|
backend: Backend;
|
||||||
/** The explicit visibility per file, to fill with the one that was saved. */
|
/** The explicit visibility per file, to fill with the one that was saved. */
|
||||||
visibility: Map<string, Visibility>;
|
visibility: Map<string, Visibility>;
|
||||||
/** Applies what a call changed to what the facade keeps of the files. */
|
/** Applies what a call changed to what the facade keeps of the files. */
|
||||||
@@ -34,11 +34,11 @@ function followOtherTabs(host: StorageHost): {
|
|||||||
behind: () => void;
|
behind: () => void;
|
||||||
newer: () => void;
|
newer: () => void;
|
||||||
} {
|
} {
|
||||||
const { wasm } = host;
|
const { backend } = host;
|
||||||
let reading = Promise.resolve();
|
let reading = Promise.resolve();
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
reading = reading
|
reading = reading
|
||||||
.then(async () => host.sync(await wasm.sync_storage()))
|
.then(async () => host.sync(await backend.sync_storage()))
|
||||||
.catch((error) => console.error('The files could not be read again', error));
|
.catch((error) => console.error('The files could not be read again', error));
|
||||||
};
|
};
|
||||||
const channel =
|
const channel =
|
||||||
@@ -61,17 +61,17 @@ function followOtherTabs(host: StorageHost): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the storage of the browser: the files that it holds are put in the engine, and the
|
* Opens the storage: 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.
|
* changes of the files are kept from then on. Resolves to the settings, as JSON strings by key.
|
||||||
*
|
*
|
||||||
* The first time, what the previous versions of the app kept in the browser is imported.
|
* The first time, what the previous versions of the app kept in the browser is imported.
|
||||||
*/
|
*/
|
||||||
export async function openStoredFiles(host: StorageHost): Promise<Record<string, string>> {
|
export async function openStoredFiles(host: StorageHost): Promise<Record<string, string>> {
|
||||||
const { wasm } = host;
|
const { backend } = host;
|
||||||
let opened: StorageOpened;
|
let opened: StorageOpened;
|
||||||
const tabs = followOtherTabs(host);
|
const tabs = followOtherTabs(host);
|
||||||
try {
|
try {
|
||||||
opened = await wasm.open_storage(STORAGE_NAME, tabs.saved, tabs.behind, tabs.newer);
|
opened = await backend.open_storage(STORAGE_NAME, tabs.saved, tabs.behind, tabs.newer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('The files cannot be kept in this browser', error);
|
console.error('The files cannot be kept in this browser', error);
|
||||||
return {};
|
return {};
|
||||||
@@ -83,16 +83,20 @@ export async function openStoredFiles(host: StorageHost): Promise<Record<string,
|
|||||||
);
|
);
|
||||||
host.sync(opened.outcome);
|
host.sync(opened.outcome);
|
||||||
|
|
||||||
if (settings[LEGACY_IMPORTED_KEY] === undefined && !host.hasFiles()) {
|
if (
|
||||||
|
backend.importsLegacyData &&
|
||||||
|
settings[LEGACY_IMPORTED_KEY] === undefined &&
|
||||||
|
!host.hasFiles()
|
||||||
|
) {
|
||||||
settings = { ...settings, ...(await importLegacyData(host)) };
|
settings = { ...settings, ...(await importLegacyData(host)) };
|
||||||
await wasm.flush_storage();
|
await backend.flush_storage();
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts the files and the settings of the former database in the engine. */
|
/** Puts the files and the settings of the former database in the engine. */
|
||||||
async function importLegacyData(host: StorageHost): Promise<Record<string, string>> {
|
async function importLegacyData(host: StorageHost): Promise<Record<string, string>> {
|
||||||
const { wasm } = host;
|
const { backend } = host;
|
||||||
const imported: Record<string, string> = {};
|
const imported: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
const legacy = await readLegacyData();
|
const legacy = await readLegacyData();
|
||||||
@@ -108,7 +112,7 @@ async function importLegacyData(host: StorageHost): Promise<Record<string, strin
|
|||||||
await host.deselect();
|
await host.deselect();
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(legacy.settings)) {
|
for (const [key, value] of Object.entries(legacy.settings)) {
|
||||||
wasm.set_setting(key, value);
|
await backend.set_setting(key, value);
|
||||||
imported[key] = value;
|
imported[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +121,7 @@ async function importLegacyData(host: StorageHost): Promise<Record<string, strin
|
|||||||
console.error('The files of the previous version could not be imported', error);
|
console.error('The files of the previous version could not be imported', error);
|
||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
|
await backend.set_setting(LEGACY_IMPORTED_KEY, 'true');
|
||||||
imported[LEGACY_IMPORTED_KEY] = 'true';
|
imported[LEGACY_IMPORTED_KEY] = 'true';
|
||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ export type SelectionStatistics = {
|
|||||||
* the part of the elevation profile that was dragged over. `undefined` if the range is not
|
* the part of the elevation profile that was dragged over. `undefined` if the range is not
|
||||||
* in the selection, or if the selection is not the current one anymore.
|
* in the selection, or if the selection is not the current one anymore.
|
||||||
*/
|
*/
|
||||||
slice(start: number, end: number): GlobalStatistics | undefined;
|
slice(start: number, end: number): Promise<GlobalStatistics | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
|
export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
|
||||||
@@ -240,12 +240,12 @@ export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
|
|||||||
lng: new Float64Array(),
|
lng: new Float64Array(),
|
||||||
lat: new Float64Array(),
|
lat: new Float64Array(),
|
||||||
ele: new Float64Array(),
|
ele: new Float64Array(),
|
||||||
slice: () => undefined,
|
slice: async () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatisticsRequest = {
|
export type StatisticsRequest = {
|
||||||
/** Replaces the metrics needed by this consumer. */
|
/** Replaces the metrics needed by this consumer. Resolves when the missing ones are read. */
|
||||||
set(metrics: Iterable<StatisticsMetric>): void;
|
set(metrics: Iterable<StatisticsMetric>): Promise<void>;
|
||||||
/** The consumer does not need any metric anymore. */
|
/** The consumer does not need any metric anymore. */
|
||||||
release(): void;
|
release(): void;
|
||||||
};
|
};
|
||||||
|
|||||||