This commit is contained in:
vcoppe committed 2026-10-14 08:51:38 +02:00
1 parent ce8398f749
commit 41b9116c6a
60 files changed
+7476 -291

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/target/
/gen/
+4404
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -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]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"name": "tauri",
"private": true,
"scripts": {
"dev": "cargo tauri dev",
"build": "cargo tauri build"
}
}
+246
View File
@@ -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,
})
)
}
+161
View File
@@ -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)
}
+14
View File
@@ -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;
+136
View File
@@ -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)))))
}
+73
View File
@@ -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(),
})
)
}
+102
View File
@@ -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)
}
+135
View File
@@ -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)
}
+46
View File
@@ -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);
}
+578
View File
@@ -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,
}
+125
View File
@@ -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;
+6
View File
@@ -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()
}
+107
View File
@@ -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};
+274
View File
@@ -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!({}));
}
+37
View File
@@ -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"
]
}
}
+12
View File
@@ -31,6 +31,7 @@
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8",
"@tauri-apps/api": "^2.12.3",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7",
@@ -2635,6 +2636,17 @@
"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": {
"version": "0.6.0",
"dev": true,
+1
View File
@@ -24,6 +24,7 @@
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8",
"@tauri-apps/api": "^2.12.3",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7",
@@ -400,10 +400,13 @@ export class ElevationProfile {
} else if (startIndex !== endIndex) {
const start = Math.min(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) {
this._slicedStatistics.set({ global, start, end });
}
});
}
}
}
@@ -54,9 +54,21 @@
return [];
});
// the data that none of them has is not offered
let hide: Record<keyof ExportOptions, boolean> = $derived.by(() => {
const available = engine.exportableData(fileIds);
return {
let hide: Record<keyof ExportOptions, boolean> = $state({
time: false,
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,
hr: !available.hr,
cad: !available.cad,
@@ -66,6 +78,8 @@
// always possible
asRoute: false,
};
}
});
});
$effect(() => {
@@ -31,11 +31,15 @@ export function allFileIds(): string[] {
}
async function exportFiles(fileIds: string[], options: ExportOptions) {
const files = fileIds.flatMap((fileId) => {
const gpx = engine.exportFile(fileId, options);
const files = (
await Promise.all(
fileIds.map(async (fileId) => {
const gpx = await engine.exportFile(fileId, options);
const name = get(engine.files).get(fileId);
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
});
})
)
).flat();
if (files.length === 1) {
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
@@ -152,9 +152,9 @@
engine.selectNode(node, 'toggle');
}
}}
onmouseenter={() => {
onmouseenter={async () => {
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) {
waypointPopup?.setItem({
item: waypoint,
@@ -393,13 +393,15 @@ export class GPXLayer {
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
}
layerOnMouseMove(e: any) {
async layerOnMouseMove(e: any) {
if (e.originalEvent.shiftKey) {
const { segmentId } = e.features[0].properties;
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 =
index === undefined ? undefined : engine.trackpoint(this.fileId, segmentId, index);
index === undefined
? undefined
: await engine.trackpoint(this.fileId, segmentId, index);
if (trackpoint) {
trackpointPopup?.setItem({
item: trackpoint,
@@ -469,8 +471,8 @@ export class GPXLayer {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
}
showWaypointPopup(waypointId: string) {
const waypoint = engine.waypoint(this.fileId, waypointId);
async showWaypointPopup(waypointId: string) {
const waypoint = await engine.waypoint(this.fileId, waypointId);
if (waypoint) {
waypointPopup?.setItem({ item: waypoint, kind: 'waypoint', fileId: this.fileId });
}
@@ -30,9 +30,14 @@
let reducedLayer = new ReducedLayer();
// read again only when the selection or the files change, not with the tolerance
let distances = $derived.by(() => {
void $statistics;
return engine.reductionDistances();
let distances = $state<Float64Array>(new Float64Array());
$effect(() => {
const current = $statistics;
engine.reductionDistances().then((values) => {
if (current === $statistics) {
distances = values;
}
});
});
$effect(() => {
@@ -61,8 +61,12 @@
function updateSlicedStatistics([startStep, endStep]: number[]) {
if (validSelection && (startStep !== 0 || endStep !== steps)) {
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 });
}
});
} else {
slicedStatistics.set(undefined);
}
@@ -92,8 +92,8 @@ export class WaypointSelection {
}
// read again each time the file changes
const read = () => {
const waypoint = engine.waypoint(target.fileId, target.id);
const read = async () => {
const waypoint = await engine.waypoint(target.fileId, target.id);
this._selection.set(waypoint ? { ...target, waypoint } : undefined);
};
const file = get(engine.files).get(target.fileId);
+8 -8
View File
@@ -5,8 +5,8 @@ import { exampleData } from '$lib/data/example/example-data';
import { exampleStatistics } from '$lib/data/example';
describe('exampleStatistics', () => {
it('has the statistics of the whole example for the whole range', () => {
const whole = exampleStatistics.slice(0, exampleStatistics.length - 1);
it('has the statistics of the whole example for the whole range', async () => {
const whole = await exampleStatistics.slice(0, exampleStatistics.length - 1);
const global = exampleStatistics.global;
assert.ok(whole);
for (const key of [
@@ -25,17 +25,17 @@ describe('exampleStatistics', () => {
}
});
it('has no speed for a range that does not last', () => {
const point = exampleStatistics.slice(20, 20);
it('has no speed for a range that does not last', async () => {
const point = await exampleStatistics.slice(20, 20);
assert.equal(point?.totalTime, 0);
assert.equal(point?.totalSpeed, undefined);
assert.equal(point?.movingSpeed, undefined);
});
it('has nothing for ranges that are not in the example', () => {
assert.equal(exampleStatistics.slice(5, 500), undefined);
assert.equal(exampleStatistics.slice(-1, 3), undefined);
assert.equal(exampleStatistics.slice(10, 5), undefined);
it('has nothing for ranges that are not in the example', async () => {
assert.equal(await exampleStatistics.slice(5, 500), undefined);
assert.equal(await exampleStatistics.slice(-1, 3), undefined);
assert.equal(await exampleStatistics.slice(10, 5), undefined);
});
it(
+1 -1
View File
@@ -20,7 +20,7 @@ const cumulative = {
};
/** 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)) {
return undefined;
}
+18
View File
@@ -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();
}
+217
View File
@@ -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 }),
};
}
+211
View File
@@ -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>;
}
+172
View File
@@ -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),
};
}
-25
View File
@@ -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. */
export function toPositions(flat: Float64Array): [number, number][] {
const positions: [number, number][] = new Array(flat.length / 2);
@@ -19,15 +6,3 @@ export function toPositions(flat: Float64Array): [number, number][] {
}
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;
}
+17 -15
View File
@@ -218,7 +218,7 @@ describe('statistics', () => {
assert.equal(get(engine.statistics).hr, undefined);
const request = engine.requestStatistics();
request.set(['hr', 'anchors']);
await request.set(['hr', 'anchors']);
const statistics = get(engine.statistics);
assert.equal(statistics.hr!.length, statistics.length);
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 () => {
await load('simple');
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 < 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
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];
assert.ok(file.waypoints.features.length > 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.equal(feature.properties.name, details.name);
assert.equal(details.links?.[0].href, 'https://gpx.studio');
@@ -330,23 +330,23 @@ describe('waypoints', () => {
const id = file.waypoints.features[0].properties.waypointId;
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.sym, 'Summit');
assert.equal(details.links![0].href, 'https://example.com');
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.links, undefined);
await engine.moveWaypoint(fileId, id, 5, 51, 200);
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);
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);
});
});
@@ -395,8 +395,10 @@ describe('observer of the files', () => {
});
describe('export', () => {
const text = (id: string, options: Partial<ExportOptions> = {}) =>
new TextDecoder().decode(engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options }));
const text = async (id: string, options: Partial<ExportOptions> = {}) =>
new TextDecoder().decode(
await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options })
);
it('writes tracks, or routes when asked to', async () => {
await load('with_tracks_and_segments');
@@ -407,11 +409,11 @@ describe('export', () => {
);
const id = file.structure.id;
const tracks = text(id);
const tracks = await text(id);
assert.ok(tracks.includes('<trk>') && !tracks.includes('<rte>'));
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.equal(routes.match(/<rte>/g)?.length, segments);
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 () => {
await load('simple');
const data = engine.exportableData(get(engine.order));
const data = await engine.exportableData(get(engine.order));
assert.equal(data.asRoute, false);
});
@@ -427,7 +429,7 @@ describe('export', () => {
await load('with_tracks_and_segments');
const id = get(engine.order)[0];
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' }]);
const [, loaded] = get(engine.order);
const state = get(get(engine.files).get(loaded)!);
+136 -123
View File
@@ -10,7 +10,7 @@ import {
import { categoryIntervals } from '$lib/engine/trackpoint-categories';
import { selectedElementIds, type FileTreeNode } from '$lib/engine/selection-helpers';
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 { StatisticsReader } from './statistics';
import { openStoredFiles } from './storage';
@@ -34,7 +34,8 @@ import {
} 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,
* 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.
*/
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). */
readonly ready: Promise<void>;
@@ -61,7 +64,7 @@ class Engine {
private _canRedo = writable(false);
private _canPaste = writable(false);
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. */
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
@@ -89,9 +92,8 @@ class Engine {
constructor() {
this.ready = browser
? import('gpx-rs').then((wasm) => {
wasm.start();
this.wasm = wasm;
? loadBackend().then((backend) => {
this.backend = backend;
})
: new Promise(() => {});
}
@@ -110,9 +112,9 @@ class Engine {
openStorage(): Promise<Record<string, string>> {
this._storage ??= this.ready.then(() =>
openStoredFiles({
wasm: this.wasm!,
backend: this.backend!,
visibility: this._visibility,
sync: (update) => this.sync(this.wasm!, update),
sync: (update) => this.enqueue(() => this.sync(this.backend!, update)),
outdated: () => this._outdated.set(true),
hasFiles: () => get(this._order).length > 0,
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. */
setSetting(key: string, json: string) {
this.wasm?.set_setting(key, json);
void this.backend?.set_setting(key, json);
}
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.
@@ -138,7 +140,7 @@ class Engine {
* `SelectMode` for how the files combine with the selection.
*/
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.
*/
selectAll() {
return this.run((w) => w.select_all());
return this.run((b) => b.select_all());
}
/**
@@ -156,13 +158,13 @@ class Engine {
* it replaces it.
*/
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.
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(
@@ -171,20 +173,16 @@ class Engine {
segmentIds: string[],
mode: SelectMode = 'replace'
) {
return this.run((w) =>
w.select_segments(fileId, trackId, idsToBytes(segmentIds), selectMode(w, mode))
);
return this.run((b) => b.select_segments(fileId, trackId, segmentIds, mode));
}
/** Selects the node standing for all the waypoints of the file. */
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') {
return this.run((w) =>
w.select_waypoints(fileId, idsToBytes(waypointIds), selectMode(w, mode))
);
return this.run((b) => b.select_waypoints(fileId, waypointIds, mode));
}
/** Selects a node of the file tree. */
@@ -206,23 +204,23 @@ class Engine {
// History
undo() {
return this.run((w) => w.undo());
return this.run((b) => b.undo());
}
redo() {
return this.run((w) => w.redo());
return this.run((b) => b.redo());
}
// Clipboard
/** Puts the selected elements in the clipboard. */
copy() {
return this.run((w) => w.copy());
return this.run((b) => b.copy());
}
/** Like `copy`, but the elements are moved when they are pasted. */
cut() {
return this.run((w) => w.cut());
return this.run((b) => b.cut());
}
// Edits of the files
@@ -232,7 +230,7 @@ class Engine {
* segment that hold it, in the same undo step.
*/
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.
*/
loadFiles(files: { data: Uint8Array; name: string }[]) {
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 this.run((w) =>
w.load_files(
data,
lengths,
files.map((file) => file.name)
)
);
return this.run((b) => b.load_files(files));
}
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.
*/
delete(wholeFiles = false) {
return this.run((w) => w._delete(wholeFiles));
return this.run((b) => b.delete(wholeFiles));
}
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. */
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.
*/
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.
*/
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
@@ -313,8 +298,8 @@ class Engine {
* strings are fields that the waypoint does not have.
*/
newWaypoint(waypoint: NewWaypoint) {
return this.run((w) =>
w.new_waypoint(
return this.run((b) =>
b.new_waypoint(
waypoint.lng,
waypoint.lat,
waypoint.ele,
@@ -328,8 +313,8 @@ class Engine {
/** Changes a waypoint, selected or not. Empty strings remove the field. */
updateWaypoint(fileId: string, waypointId: string, waypoint: NewWaypoint) {
return this.run((w) =>
w.update_waypoint(
return this.run((b) =>
b.update_waypoint(
fileId,
waypointId,
waypoint.lng,
@@ -345,22 +330,22 @@ class Engine {
/** Moves a waypoint, selected or not: only its position and elevation change. */
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. */
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
newTrack() {
return this.run((w) => w.new_track());
return this.run((b) => b.new_track());
}
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).
*/
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.
*/
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.
*/
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.
*/
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.
*/
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,
waypoints: boolean
) {
return this.run((w) =>
w.clean(
return this.run((b) =>
b.clean(
bounds.west,
bounds.south,
bounds.east,
bounds.north,
inside ? w.CleanType.Inside : w.CleanType.Outside,
inside,
trackpoints,
waypoints
)
@@ -437,9 +422,7 @@ class Engine {
* Resolves to `false` when there is nothing to merge.
*/
merge(type: MergeType, removeGaps = false) {
return this.run((w) =>
w.merge(type === 'connect' ? w.MergeType.Connect : w.MergeType.Group, removeGaps)
);
return this.run((b) => b.merge(type, removeGaps));
}
/**
@@ -448,7 +431,7 @@ class Engine {
* is nothing to remove.
*/
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.
*/
split(revision: number, at: number, splitType: SplitType) {
return this.run((w) =>
w.split(
revision,
at,
{
files: w.SplitType.Files,
tracks: w.SplitType.Tracks,
segments: w.SplitType.Segments,
}[splitType]
)
);
return this.run((b) => b.split(revision, at, splitType));
}
/**
@@ -478,7 +451,7 @@ class Engine {
* the range covers the whole selection.
*/
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.
*/
roundTrip() {
return this.run((w) => w.round_trip());
return this.run((b) => b.round_trip());
}
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. */
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
@@ -529,8 +502,8 @@ class Engine {
routeAttributes[key] = intervals;
}
}
return this.run((w) =>
w.route(
return this.run((b) =>
b.route(
revision,
start,
end,
@@ -549,7 +522,7 @@ class Engine {
* one of `SelectionAnchors`.
*/
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`.
*/
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
@@ -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). */
waypoint(fileId: string, waypointId: string): WaypointDetails | undefined {
return this.wasm?.waypoint(fileId, waypointId);
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined> {
return this.read((b) => b.waypoint(fileId, waypointId), undefined);
}
/** A trackpoint of a segment, `undefined` if it does not exist. */
trackpoint(fileId: string, segmentId: string, index: number): TrackpointDetails | undefined {
return this.wasm?.trackpoint(fileId, segmentId, index);
trackpoint(
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(
fileId: string,
options: ExportOptions = ALL_EXPORT_OPTIONS
): Uint8Array<ArrayBuffer> | undefined {
// a copy out of the memory of the engine, so a plain ArrayBuffer
return this.wasm?.export_file(
): Promise<Uint8Array<ArrayBuffer> | undefined> {
return this.read(
(b) =>
b.export_file(
fileId,
options.time,
options.hr,
@@ -631,12 +609,14 @@ class Engine {
options.power,
options.osm,
options.asRoute
) as Uint8Array<ArrayBuffer> | undefined;
),
undefined
);
}
/** The data that some files have, which the options of `exportFile` can leave out. */
exportableData(fileIds: string[]): ExportOptions {
const bits = this.wasm?.exportable_data(idsToBytes(fileIds)) ?? 0;
async exportableData(fileIds: string[]): Promise<ExportOptions> {
const bits = await this.read((b) => b.exportable_data(fileIds), 0);
return {
time: (bits & 1) !== 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
* the statistics: ask when they changed.
*/
reductionDistances(): Float64Array {
return this.wasm?.reduction_distances() ?? new Float64Array();
reductionDistances(): Promise<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.
segmentCoordinates(segmentId: string): Float64Array {
return this.wasm?.segment_coordinates(segmentId) ?? new Float64Array();
segmentCoordinates(segmentId: string): Promise<Float64Array> {
return this.read((b) => b.segment_coordinates(segmentId), new Float64Array());
}
waypointCoordinates(fileId: string): Float64Array {
return this.wasm?.waypoint_coordinates(fileId) ?? new Float64Array();
waypointCoordinates(fileId: string): Promise<Float64Array> {
return this.read((b) => b.waypoint_coordinates(fileId), new Float64Array());
}
/**
@@ -682,17 +662,34 @@ class Engine {
// 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
* 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;
const wasm = this.wasm!;
const outcome = action(wasm);
this.sync(wasm, outcome);
return this.enqueue(async () => {
const backend = this.backend!;
const outcome = await action(backend);
await this.sync(backend, outcome);
this.report(outcome);
return outcome.changed;
});
}
/** 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. */
private sync(wasm: Wasm, update: Outcome) {
this._canUndo.set(wasm.can_undo());
this._canRedo.set(wasm.can_redo());
this._canPaste.set(wasm.can_paste());
private async sync(backend: Backend, update: Outcome) {
const [canUndo, canRedo, canPaste] = await Promise.all([
backend.can_undo(),
backend.can_redo(),
backend.can_paste(),
]);
this._canUndo.set(canUndo);
this._canRedo.set(canRedo);
this._canPaste.set(canPaste);
if (update.clipboardChanged) {
this._clipboard.set(wasm.clipboard());
this._clipboard.set(await backend.clipboard());
}
if (update.selectionChanged) {
this._selection.set(wasm.selection());
this._selection.set(await backend.selection());
}
if (update.orderChanged) {
this._order.set(wasm.file_order());
this._order.set(await backend.file_order());
}
const added: [string, FileState][] = [];
for (const id of update.added) {
const state = readFileState(wasm, id, this._colors, this._visibility);
const addedStates = await Promise.all(
update.added.map((id) => readFileState(backend, id, this._colors, this._visibility))
);
update.added.forEach((id, i) => {
const state = addedStates[i];
if (state) {
added.push([id, state]);
}
}
});
if (added.length > 0 || update.removed.length > 0) {
this._files.update((files) => {
const next = new Map(files);
@@ -739,14 +744,22 @@ class Engine {
}
if (update.modified.length > 0) {
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 state =
store && readFileState(wasm, id, this._colors, this._visibility, get(store));
return (
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) {
store.set(state);
}
}
});
}
// the statistics depend on the selection, on its order, and on the files
@@ -755,7 +768,7 @@ class Engine {
update.orderChanged ||
update.added.length + update.modified.length + update.removed.length > 0
) {
this._statistics.refresh(wasm);
await this._statistics.refresh(backend);
}
}
}
+18 -12
View File
@@ -1,18 +1,19 @@
import type { Feature, LineString } from 'geojson';
import { normalizeColor, type FileColorAllocator } from '$lib/engine/file-colors';
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';
/** Reads the state of a file, reusing from `previous` what did not change. */
export function readFileState(
wasm: Wasm,
export async function readFileState(
backend: Backend,
id: string,
colors: FileColorAllocator,
visibility: ReadonlyMap<string, Visibility>,
previous?: FileState
): FileState | null {
const structure = wasm.file_structure(id);
): Promise<FileState | null> {
const structure = await backend.file_structure(id);
if (!structure) {
return null;
}
@@ -25,7 +26,11 @@ export function readFileState(
const previousSegments = new Map(
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) => {
track.segments.forEach((segment, segmentIndex) => {
const properties: SegmentProperties = {
@@ -48,17 +53,18 @@ export function readFileState(
segments.push(
unchanged
? old
: {
: backend.segment_coordinates(segment.id).then((coordinates) => ({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: toPositions(wasm.segment_coordinates(segment.id)),
coordinates: toPositions(coordinates),
},
properties,
}
}))
);
});
});
const segmentFeatures = await Promise.all(segments);
const previousStructure = previous?.structure;
const waypointsUnchanged =
@@ -73,7 +79,7 @@ export function readFileState(
);
let waypoints = previous?.waypoints;
if (!waypoints || !waypointsUnchanged) {
const coordinates = toPositions(wasm.waypoint_coordinates(id));
const coordinates = toPositions(await backend.waypoint_coordinates(id));
waypoints = {
type: 'FeatureCollection',
features: structure.waypoints.map((waypoint, index) => ({
@@ -93,8 +99,8 @@ export function readFileState(
return {
structure,
color,
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments },
statistics: (await backend.file_statistics(id)) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segmentFeatures },
waypoints,
visibility: visibility.get(id) ?? new Map(),
};
+105 -71
View File
@@ -1,6 +1,6 @@
import { get, writable, type Readable } from 'svelte/store';
import { categoryIntervals, type CategoryIntervals } from '$lib/engine/trackpoint-categories';
import type { Wasm } from './convert';
import type { Backend } from './backend';
import {
EMPTY_SELECTION_STATISTICS,
EMPTY_STATISTICS,
@@ -23,35 +23,49 @@ export class StatisticsReader {
/** The metrics that were read for the current statistics. */
private _loaded = new Set<StatisticsMetric>();
/** @param wasm The engine, `null` before it is loaded. */
constructor(private wasm: () => Wasm | null) {}
/** The engine, as of the last time the statistics were read. */
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. */
refresh(wasm: Wasm) {
this._store.set(this.read(wasm));
async refresh(backend: Backend) {
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
* that were requested.
*/
private read(wasm: Wasm): SelectionStatistics {
private async read(backend: Backend): Promise<SelectionStatistics> {
const version = ++this._version;
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 = {
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
global: global ?? EMPTY_STATISTICS,
length: totalDistance.length,
totalDistance,
slope: wasm.slope(),
lng: wasm.lng(),
lat: wasm.lat(),
ele: wasm.ele(),
timestamps: wasm.timestamps(),
slope,
lng,
lat,
ele,
timestamps,
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;
}
@@ -62,81 +76,95 @@ export class StatisticsReader {
}
/** Reads the metrics that were not read yet into `statistics`, which is the current one. */
private readMetrics(
wasm: Wasm,
private async readMetrics(
backend: Backend,
statistics: SelectionStatistics,
metrics: Set<StatisticsMetric>
) {
const categories = (
starts: Uint32Array,
values: Uint8Array,
names: () => string[]
): CategoryIntervals => ({
starts,
values,
names: names(),
});
const categories = async (
starts: Promise<Uint32Array>,
values: Promise<Uint8Array>,
names: Promise<string[]>
): Promise<CategoryIntervals> => {
const [s, v, n] = await Promise.all([starts, values, names]);
return { starts: s, values: v, names: n };
};
for (const metric of metrics) {
if (this._loaded.has(metric)) {
continue;
}
this._loaded.add(metric);
const missing = [...metrics].filter((metric) => !this._loaded.has(metric));
missing.forEach((metric) => this._loaded.add(metric));
await Promise.all(
missing.map(async (metric) => {
switch (metric) {
case 'speed':
statistics.speed = wasm.speed();
statistics.speed = await backend.speed();
break;
case 'hr':
statistics.hr = wasm.hr();
statistics.hr = await backend.hr();
break;
case 'cad':
statistics.cad = wasm.cad();
statistics.cad = await backend.cad();
break;
case 'atemp':
statistics.atemp = wasm.atemp();
statistics.atemp = await backend.atemp();
break;
case 'power':
statistics.power = wasm.power();
statistics.power = await backend.power();
break;
case 'slopeSegment':
statistics.slopeSegmentSlope = wasm.slope_segment_slope();
statistics.slopeSegmentDistance = wasm.slope_segment_distance();
[statistics.slopeSegmentSlope, statistics.slopeSegmentDistance] =
await Promise.all([
backend.slope_segment_slope(),
backend.slope_segment_distance(),
]);
break;
case 'surface':
statistics.surface = categories(
wasm.surface_starts(),
wasm.surface_values(),
wasm.surfaces
statistics.surface = await categories(
backend.surface_starts(),
backend.surface_values(),
backend.surfaces()
);
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 = {
indices: wasm.anchor_indices(),
zooms: wasm.anchor_zooms(),
segmentStarts: wasm.segment_starts(),
segmentIds: wasm.segment_ids(),
revision: wasm.routing_revision(),
indices,
zooms,
segmentStarts,
segmentIds,
revision,
};
break;
}
case 'highway':
statistics.highway = categories(
wasm.highway_starts(),
wasm.highway_values(),
wasm.highways
);
statistics.sacScale = categories(
wasm.sac_scale_starts(),
wasm.sac_scale_values(),
wasm.sac_scales
);
statistics.mtbScale = categories(
wasm.mtb_scale_starts(),
wasm.mtb_scale_values(),
wasm.mtb_scales
);
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
await Promise.all([
categories(
backend.highway_starts(),
backend.highway_values(),
backend.highways()
),
categories(
backend.sac_scale_starts(),
backend.sac_scale_values(),
backend.sac_scales()
),
categories(
backend.mtb_scale_starts(),
backend.mtb_scale_values(),
backend.mtb_scales()
),
]);
break;
}
}
})
);
}
/**
@@ -155,7 +183,7 @@ export class StatisticsReader {
for (const metric of metrics) {
wanted.add(metric);
}
this.loadMissing();
return this.loadMissing();
},
release: () => {
this._requests.delete(wanted);
@@ -164,16 +192,22 @@ export class StatisticsReader {
}
/** Reads the requested metrics that are not in the current statistics yet. */
private loadMissing() {
const wasm = this.wasm();
private async loadMissing() {
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;
}
// 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) };
this.readMetrics(wasm, statistics, metrics);
await this.readMetrics(backend, statistics, requested);
this._store.set(statistics);
});
}
}
+16 -12
View File
@@ -1,7 +1,7 @@
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/storage/legacy-import';
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/engine/file-visibility';
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. */
const STORAGE_NAME = 'gpx-studio';
@@ -11,7 +11,7 @@ const CHANNEL_NAME = 'gpx-studio-storage';
/** What opening the storage needs of the engine facade. */
export type StorageHost = {
wasm: Wasm;
backend: Backend;
/** The explicit visibility per file, to fill with the one that was saved. */
visibility: Map<string, Visibility>;
/** Applies what a call changed to what the facade keeps of the files. */
@@ -34,11 +34,11 @@ function followOtherTabs(host: StorageHost): {
behind: () => void;
newer: () => void;
} {
const { wasm } = host;
const { backend } = host;
let reading = Promise.resolve();
const refresh = () => {
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));
};
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.
*
* 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>> {
const { wasm } = host;
const { backend } = host;
let opened: StorageOpened;
const tabs = followOtherTabs(host);
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) {
console.error('The files cannot be kept in this browser', error);
return {};
@@ -83,16 +83,20 @@ export async function openStoredFiles(host: StorageHost): Promise<Record<string,
);
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)) };
await wasm.flush_storage();
await backend.flush_storage();
}
return settings;
}
/** Puts the files and the settings of the former database in the engine. */
async function importLegacyData(host: StorageHost): Promise<Record<string, string>> {
const { wasm } = host;
const { backend } = host;
const imported: Record<string, string> = {};
try {
const legacy = await readLegacyData();
@@ -108,7 +112,7 @@ async function importLegacyData(host: StorageHost): Promise<Record<string, strin
await host.deselect();
}
for (const [key, value] of Object.entries(legacy.settings)) {
wasm.set_setting(key, value);
await backend.set_setting(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);
return imported;
}
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
await backend.set_setting(LEGACY_IMPORTED_KEY, 'true');
imported[LEGACY_IMPORTED_KEY] = 'true';
return imported;
}
+4 -4
View File
@@ -229,7 +229,7 @@ export type SelectionStatistics = {
* 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.
*/
slice(start: number, end: number): GlobalStatistics | undefined;
slice(start: number, end: number): Promise<GlobalStatistics | undefined>;
};
export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
@@ -240,12 +240,12 @@ export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
slice: () => undefined,
slice: async () => undefined,
};
export type StatisticsRequest = {
/** Replaces the metrics needed by this consumer. */
set(metrics: Iterable<StatisticsMetric>): void;
/** Replaces the metrics needed by this consumer. Resolves when the missing ones are read. */
set(metrics: Iterable<StatisticsMetric>): Promise<void>;
/** The consumer does not need any metric anymore. */
release(): void;
};