mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
progress
This commit is contained in:
1 parent
41b9116c6a
commit
97d597e139
181 files changed
+1970
-2643
No files matched your search
@@ -0,0 +1 @@
|
||||
pkg/
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "gpx-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1.7"
|
||||
gpx-engine = { path = "../gpx-rs" }
|
||||
idb = "0.6.5"
|
||||
js-sys = "0.3.106"
|
||||
serde = "1"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
uuid = { version = "1.26.1", features = ["js"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4.79"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
@@ -0,0 +1,20 @@
|
||||
# wasm
|
||||
|
||||
The engine of [`../gpx-rs`](../gpx-rs) for the browser: WebAssembly, exposed to the page through
|
||||
`wasm-bindgen`. Each exported function is a thin wrapper over `gpx_engine::api` (see `../gpx-rs/src/api`)
|
||||
that parses what the page sends and turns the result into JS objects (`src/convert.rs`, with
|
||||
`serde-wasm-bindgen`) or typed arrays. The storage in the browser (IndexedDB) is here too, as it
|
||||
is specific to the browser.
|
||||
|
||||
```
|
||||
wasm-pack build --target bundler
|
||||
```
|
||||
|
||||
The website imports the result as `gpx-wasm` (`pkg/`). The functions are tested in Node (it needs
|
||||
`wasm-pack`, which fetches the test runner the first time):
|
||||
|
||||
```
|
||||
wasm-pack test --node
|
||||
```
|
||||
|
||||
`src/ts.rs` declares the TypeScript types of the objects that cross.
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Editing the metadata, the style and the structure of the files.
|
||||
|
||||
use gpx_engine::api;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::session::*;
|
||||
use crate::ts::Outcome;
|
||||
|
||||
// Edit commands
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn metadata(name: &str, desc: &str) -> Outcome {
|
||||
run(|e| api::metadata(e, name, desc))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn style(color: Option<String>, opacity: Option<f64>, width: Option<f64>) -> Outcome {
|
||||
run(|e| api::style(e, color.as_deref(), opacity, width))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn new_track() -> Outcome {
|
||||
run(api::new_track)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn new_track_segment() -> Outcome {
|
||||
run(api::new_track_segment)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Creating, loading, deleting, moving, copying and pasting files and their elements.
|
||||
|
||||
use gpx_engine::api;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
|
||||
// File commands
|
||||
|
||||
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that
|
||||
/// hold that trackpoint (at `ele`, 0 by default).
|
||||
#[wasm_bindgen]
|
||||
pub fn new_file(name: &str, lng: Option<f64>, lat: Option<f64>, ele: Option<f64>) -> Outcome {
|
||||
run(|e| api::new_file(e, name, lng, lat, ele))
|
||||
}
|
||||
|
||||
/// Loads files as a single command (one undo step). The files are in `data`, one after the
|
||||
/// other, `lengths` being the number of bytes of each one. `names`: the name of each file when
|
||||
/// its data has none (the name on disk, without extension). The files that cannot be read are
|
||||
/// skipped, the first one that was read is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn load_files(data: &[u8], lengths: &[u32], names: Vec<String>) -> Outcome {
|
||||
let lengths: Vec<usize> = lengths.iter().map(|length| *length as usize).collect();
|
||||
run(|e| api::load_files(e, data, &lengths, &names))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn duplicate() -> Outcome {
|
||||
run(api::duplicate)
|
||||
}
|
||||
|
||||
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements are
|
||||
/// deleted instead, even if only a track or a waypoint is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn delete(whole_files: bool) -> Outcome {
|
||||
run(|e| api::delete(e, whole_files))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn delete_all() -> Outcome {
|
||||
run(api::delete_all)
|
||||
}
|
||||
|
||||
/// Moves the files to `index` among the other files, in the given order (not undoable).
|
||||
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
|
||||
#[wasm_bindgen]
|
||||
pub fn reorder(file_ids_bytes: &[u8], index: usize) -> Outcome {
|
||||
match uuid_list(file_ids_bytes) {
|
||||
Some(ids) => run(|e| api::reorder(e, &ids, index)),
|
||||
None => fail("the file ids are not a list of UUIDs"),
|
||||
}
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
|
||||
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn copy() -> Outcome {
|
||||
run(api::copy)
|
||||
}
|
||||
|
||||
/// Like `copy`, but the elements are moved when they are pasted.
|
||||
#[wasm_bindgen]
|
||||
pub fn cut() -> Outcome {
|
||||
run(api::cut)
|
||||
}
|
||||
|
||||
/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always
|
||||
/// the elements as they were when they were copied or cut, even if they were changed or deleted
|
||||
/// since. It can be pasted several times: what was copied is copied again, what was cut is moved
|
||||
/// the first time (and the clipboard is not cut after that), then it is copied.
|
||||
#[wasm_bindgen]
|
||||
pub fn paste() -> Outcome {
|
||||
run(api::paste)
|
||||
}
|
||||
|
||||
/// Whether the clipboard can be pasted with the current selection.
|
||||
#[wasm_bindgen]
|
||||
pub fn can_paste() -> bool {
|
||||
with_engine(api::can_paste).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Moves elements (given as a `Selection`) to a place of the file tree, keeping their ids, like
|
||||
/// dragging and dropping them does. The moved elements are selected.
|
||||
///
|
||||
/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks
|
||||
/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes
|
||||
/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the
|
||||
/// waypoints of a file.
|
||||
#[wasm_bindgen]
|
||||
pub fn move_elements(what: Selection, to: MoveTarget) -> Outcome {
|
||||
// a negative index is the start, an infinite one is the end
|
||||
match from_js::<api::Selection>(&what).zip(from_js::<api::MoveTarget>(&to)) {
|
||||
Some((what, to)) => run(|e| api::move_elements(e, what, to)),
|
||||
None => fail("the elements or the target are not valid"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Undo and redo.
|
||||
|
||||
use gpx_engine::api;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::session::*;
|
||||
use crate::ts::Outcome;
|
||||
|
||||
// Undo-redo
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn undo() -> Outcome {
|
||||
run(api::undo)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn redo() -> Outcome {
|
||||
run(api::redo)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn can_undo() -> bool {
|
||||
with_engine(api::can_undo).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn can_redo() -> bool {
|
||||
with_engine(api::can_redo).unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub(crate) mod edit;
|
||||
pub(crate) mod files;
|
||||
pub(crate) mod history;
|
||||
pub(crate) mod routing;
|
||||
pub(crate) mod tools;
|
||||
pub(crate) mod waypoints;
|
||||
@@ -0,0 +1,66 @@
|
||||
//! The commands that refer to trackpoints by their index in the routing buffers.
|
||||
|
||||
use gpx_engine::api;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
use crate::types::*;
|
||||
|
||||
// Routing
|
||||
//
|
||||
// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only
|
||||
// mean something for the `revision` they were read with (`routing_revision`), and the commands
|
||||
// that use them do nothing if the selection is not the one of that revision anymore.
|
||||
|
||||
/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment
|
||||
/// (see `RoutingBuffer`). A pure insertion if `start == end`, a pure removal without new points.
|
||||
/// `attributes` holds the surface, highway, SAC scale and MTB scale of the new points, as
|
||||
/// intervals, and `anchors` the indices among the new points of the ones that become anchors.
|
||||
/// Does nothing, and says why, if the revision of the routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn route(
|
||||
revision: u32,
|
||||
start: u32,
|
||||
end: u32,
|
||||
lng: &[f64],
|
||||
lat: &[f64],
|
||||
ele: &[f64],
|
||||
attributes: RouteAttributes,
|
||||
anchors: &[u32],
|
||||
) -> Outcome {
|
||||
let Some(attributes) = from_js::<api::RouteAttributes>(&attributes) else {
|
||||
return fail("the attributes of the route are not valid");
|
||||
};
|
||||
if !same_len(lng, lat, ele) {
|
||||
return fail("lng, lat and ele differ in length");
|
||||
}
|
||||
run(|e| api::route(e, revision, start, end, lng, lat, ele, &attributes, anchors))
|
||||
}
|
||||
|
||||
/// Makes an anchor of the point of the selected segments that is the closest to the coordinates,
|
||||
/// inserting a trackpoint on the path if none is there. Does nothing, and says why, if the
|
||||
/// revision of the routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
pub fn insert_anchor(revision: u32, lng: f64, lat: f64) -> Outcome {
|
||||
run(|e| api::insert_anchor(e, revision, lng, lat))
|
||||
}
|
||||
|
||||
/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the
|
||||
/// trackpoints before it go to the end, and the loop closes on it). Does nothing, and says why, if the revision of the
|
||||
/// routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
pub fn change_loop_start(revision: u32, index: u32) -> Outcome {
|
||||
run(|e| api::change_loop_start(e, revision, index))
|
||||
}
|
||||
|
||||
/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
|
||||
/// selection in two, there: the trackpoint ends the first part and starts the second one. Does
|
||||
/// nothing, and returns false, if the revision of the routing buffers is not the given one.
|
||||
#[wasm_bindgen]
|
||||
pub fn split(revision: u32, at: u32, split_type: SplitType) -> Outcome {
|
||||
run(|e| api::split(e, revision, at, split_type.into()))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! The tools: reversing, cropping, reducing, timestamps, merging, cleaning...
|
||||
|
||||
use gpx_engine::api;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::Outcome;
|
||||
|
||||
use crate::session::*;
|
||||
use crate::types::*;
|
||||
|
||||
// Tools
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn reverse() -> Outcome {
|
||||
run(api::reverse)
|
||||
}
|
||||
|
||||
/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so that it goes back to where it
|
||||
/// started.
|
||||
#[wasm_bindgen]
|
||||
pub fn round_trip() -> Outcome {
|
||||
run(api::round_trip)
|
||||
}
|
||||
|
||||
/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its
|
||||
/// segments one after the other, and removes the others (and what is left empty).
|
||||
#[wasm_bindgen]
|
||||
pub fn crop(start: u32, end: u32) -> Outcome {
|
||||
run(|e| api::crop(e, start, end))
|
||||
}
|
||||
|
||||
/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the
|
||||
/// line of their neighbours, except the ends of the segments.
|
||||
#[wasm_bindgen]
|
||||
pub fn reduce(tolerance: f64) -> Outcome {
|
||||
run(|e| api::reduce(e, tolerance))
|
||||
}
|
||||
|
||||
/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the
|
||||
/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the
|
||||
/// previous one at `speed` (km/h).
|
||||
#[wasm_bindgen(js_name = changeTimestamps)]
|
||||
pub fn change_timestamps(start_time: f64, speed: f64, ratio: f64) -> Outcome {
|
||||
run(|e| api::change_timestamps(e, start_time, speed, ratio))
|
||||
}
|
||||
|
||||
/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and
|
||||
/// lasts `total_time` seconds.
|
||||
#[wasm_bindgen(js_name = createArtificialTimestamps)]
|
||||
pub fn create_artificial_timestamps(start_time: f64, total_time: f64) -> Outcome {
|
||||
run(|e| api::create_artificial_timestamps(e, start_time, total_time))
|
||||
}
|
||||
|
||||
/// Merges the selection into its first element (see `MergeType`). `remove_gaps` brings the parts
|
||||
/// that are connected closer in time, if they have timestamps.
|
||||
#[wasm_bindgen]
|
||||
pub fn merge(type_: MergeType, remove_gaps: bool) -> Outcome {
|
||||
run(|e| api::merge(e, type_.into(), remove_gaps))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn extract() -> Outcome {
|
||||
run(api::extract)
|
||||
}
|
||||
|
||||
/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.
|
||||
#[wasm_bindgen]
|
||||
pub fn elevation(ele: &[f64]) -> Outcome {
|
||||
run(|e| api::elevation(e, ele))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn clean(
|
||||
west: f64,
|
||||
south: f64,
|
||||
east: f64,
|
||||
north: f64,
|
||||
type_: CleanType,
|
||||
trkpt: bool,
|
||||
wpt: bool,
|
||||
) -> Outcome {
|
||||
run(|e| api::clean(e, west, south, east, north, type_.into(), trkpt, wpt))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Creating, editing, moving and deleting waypoints.
|
||||
|
||||
use gpx_engine::api;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::Outcome;
|
||||
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn new_waypoint(
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
icon: &str,
|
||||
link: &str,
|
||||
) -> Outcome {
|
||||
run(|e| api::new_waypoint(e, lng, lat, ele, name, desc, icon, link))
|
||||
}
|
||||
|
||||
/// Moves a waypoint of a file, whatever is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn move_waypoint(file_id: &str, waypoint_id: &str, lng: f64, lat: f64, ele: f64) -> Outcome {
|
||||
match parse_uuid(file_id).zip(parse_uuid(waypoint_id)) {
|
||||
Some((file_id, waypoint_id)) => {
|
||||
run(|e| api::move_waypoint(e, file_id, waypoint_id, lng, lat, ele))
|
||||
}
|
||||
None => fail("the file or waypoint id is not a UUID"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a waypoint of a file, whatever is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn delete_waypoint(file_id: &str, waypoint_id: &str) -> Outcome {
|
||||
match parse_uuid(file_id).zip(parse_uuid(waypoint_id)) {
|
||||
Some((file_id, waypoint_id)) => run(|e| api::delete_waypoint(e, file_id, waypoint_id)),
|
||||
None => fail("the file or waypoint id is not a UUID"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link, position
|
||||
/// and elevation. The strings that are empty remove the field.
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update_waypoint(
|
||||
file_id: &str,
|
||||
waypoint_id: &str,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
icon: &str,
|
||||
link: &str,
|
||||
) -> Outcome {
|
||||
match parse_uuid(file_id).zip(parse_uuid(waypoint_id)) {
|
||||
Some((file_id, waypoint_id)) => run(|e| {
|
||||
api::update_waypoint(
|
||||
e,
|
||||
file_id,
|
||||
waypoint_id,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
name,
|
||||
desc,
|
||||
icon,
|
||||
link,
|
||||
)
|
||||
}),
|
||||
None => fail("the file or waypoint id is not a UUID"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Turning what the shared interface (`gpx_engine::api`) gives into JS objects and back.
|
||||
|
||||
use gpx_engine::api;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ts::Outcome;
|
||||
|
||||
/// A plain JS object (absent fields are `undefined`) with what `value` holds.
|
||||
pub(crate) fn to_js<T: Serialize + ?Sized>(value: &T) -> JsValue {
|
||||
value
|
||||
.serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true))
|
||||
.expect("the data can be turned into a JS value")
|
||||
}
|
||||
|
||||
/// What `value` holds, `None` if it is not what `T` is.
|
||||
pub(crate) fn from_js<T: DeserializeOwned>(value: &JsValue) -> Option<T> {
|
||||
serde_wasm_bindgen::from_value(value.clone()).ok()
|
||||
}
|
||||
|
||||
/// `undefined` when there is nothing.
|
||||
pub(crate) fn optional_js<T: Serialize>(value: Option<T>) -> JsValue {
|
||||
value.as_ref().map_or(JsValue::UNDEFINED, to_js)
|
||||
}
|
||||
|
||||
pub(crate) fn outcome_js(outcome: &api::Outcome) -> Outcome {
|
||||
to_js(outcome).unchecked_into()
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
//! The storage of the engine in the browser: IndexedDB.
|
||||
//!
|
||||
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
|
||||
//! store per kind of data. A batch is a single transaction, so that the stored files always are
|
||||
//! ones that the engine had. The head is a number in the meta store, which the transaction of a
|
||||
//! batch checks before it writes anything (so that a tab that is behind cannot write over what
|
||||
//! another tab saved).
|
||||
|
||||
use gpx_engine::{
|
||||
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||
};
|
||||
use idb::TransactionMode;
|
||||
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
|
||||
use js_sys::Uint8Array;
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
const FILES: &str = "files";
|
||||
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
|
||||
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
|
||||
/// The order of the files and the head, under their own names.
|
||||
const META: &str = "meta";
|
||||
const SETTINGS: &str = "settings";
|
||||
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
|
||||
/// The stores of the files, which a commit writes to together with the head.
|
||||
const COMMIT_STORES: [&str; 4] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META];
|
||||
|
||||
const ORDER_KEY: &str = "order";
|
||||
const HEAD_KEY: &str = "head";
|
||||
/// Version of the database.
|
||||
const VERSION: u32 = 1;
|
||||
|
||||
pub struct IdbStorage {
|
||||
database: Database,
|
||||
}
|
||||
|
||||
fn error(error: impl std::fmt::Display) -> StorageError {
|
||||
StorageError::Failed(error.to_string())
|
||||
}
|
||||
|
||||
/// The object store of a kind of chunks.
|
||||
fn chunk_store(kind: ChunkKind) -> &'static str {
|
||||
match kind {
|
||||
ChunkKind::Trackpoints => TRACKPOINT_CHUNKS,
|
||||
ChunkKind::Waypoints => WAYPOINT_CHUNKS,
|
||||
}
|
||||
}
|
||||
|
||||
fn key(id: Uuid) -> JsValue {
|
||||
JsValue::from_str(&id.to_string())
|
||||
}
|
||||
|
||||
fn bytes(value: &[u8]) -> JsValue {
|
||||
Uint8Array::from(value).into()
|
||||
}
|
||||
|
||||
fn to_vec(value: &JsValue) -> Vec<u8> {
|
||||
Uint8Array::new(value).to_vec()
|
||||
}
|
||||
|
||||
fn to_uuid(key: &JsValue) -> Result<Uuid, StorageError> {
|
||||
key.as_string()
|
||||
.and_then(|key| Uuid::parse_str(&key).ok())
|
||||
.ok_or_else(|| error("a key is not an id"))
|
||||
}
|
||||
|
||||
impl IdbStorage {
|
||||
/// Opens the database, which is created if it does not exist.
|
||||
pub async fn open(name: &str) -> Result<Self, StorageError> {
|
||||
let factory = Factory::new().map_err(error)?;
|
||||
let mut request = factory.open(name, Some(VERSION)).map_err(error)?;
|
||||
request.on_upgrade_needed(|event| {
|
||||
if let Ok(database) = event.database() {
|
||||
for store in STORES {
|
||||
let _ = database.create_object_store(store, ObjectStoreParams::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
let database = request.await.map_err(error)?;
|
||||
Ok(Self { database })
|
||||
}
|
||||
|
||||
/// The head, 0 for a database that was never written to.
|
||||
async fn read_head(meta: &ObjectStore) -> Result<Revision, StorageError> {
|
||||
let head = meta
|
||||
.get(JsValue::from_str(HEAD_KEY))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
Ok(Revision(
|
||||
head.and_then(|head| head.as_f64()).unwrap_or_default() as u64,
|
||||
))
|
||||
}
|
||||
|
||||
fn transaction(
|
||||
&self,
|
||||
stores: &[&str],
|
||||
mode: TransactionMode,
|
||||
) -> Result<Transaction, StorageError> {
|
||||
self.database.transaction(stores, mode).map_err(error)
|
||||
}
|
||||
|
||||
/// Everything stored in the object store, as `(key, bytes)`.
|
||||
async fn read_all(store: &ObjectStore) -> Result<Vec<(JsValue, Vec<u8>)>, StorageError> {
|
||||
let keys = store.get_all_keys(None, None).map_err(error)?;
|
||||
let values = store.get_all(None, None).map_err(error)?;
|
||||
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
|
||||
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
|
||||
}
|
||||
|
||||
/// Writes a batch in a transaction, if the head is the one it is based on. Gives the new head.
|
||||
async fn write(
|
||||
&self,
|
||||
transaction: &Transaction,
|
||||
batch: &Batch,
|
||||
) -> Result<Revision, CommitError> {
|
||||
let meta = transaction.object_store(META).map_err(error)?;
|
||||
let head = Self::read_head(&meta).await?;
|
||||
if head != batch.expect_head {
|
||||
return Err(CommitError::Stale { head });
|
||||
}
|
||||
let new_head = head.next();
|
||||
let files = transaction.object_store(FILES).map_err(error)?;
|
||||
|
||||
// the requests are all made before waiting for them
|
||||
let mut puts = vec![];
|
||||
let mut deletes = vec![];
|
||||
for (ChunkKey { kind, id }, data) in &batch.put_chunks {
|
||||
let store = transaction
|
||||
.object_store(chunk_store(*kind))
|
||||
.map_err(error)?;
|
||||
puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
||||
}
|
||||
for (FileId(id), data) in &batch.put_files {
|
||||
puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
||||
}
|
||||
if let Some(order) = &batch.order {
|
||||
let value = bytes(order);
|
||||
puts.push(
|
||||
meta.put(&value, Some(&JsValue::from_str(ORDER_KEY)))
|
||||
.map_err(error)?,
|
||||
);
|
||||
}
|
||||
puts.push(
|
||||
meta.put(
|
||||
&JsValue::from_f64(new_head.0 as f64),
|
||||
Some(&JsValue::from_str(HEAD_KEY)),
|
||||
)
|
||||
.map_err(error)?,
|
||||
);
|
||||
for FileId(id) in &batch.delete_files {
|
||||
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
|
||||
}
|
||||
for ChunkKey { kind, id } in &batch.delete_chunks {
|
||||
let store = transaction
|
||||
.object_store(chunk_store(*kind))
|
||||
.map_err(error)?;
|
||||
deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?);
|
||||
}
|
||||
for put in puts {
|
||||
put.await.map_err(error)?;
|
||||
}
|
||||
for delete in deletes {
|
||||
delete.await.map_err(error)?;
|
||||
}
|
||||
Ok(new_head)
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage for IdbStorage {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
let transaction = self.transaction(
|
||||
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
|
||||
TransactionMode::ReadOnly,
|
||||
)?;
|
||||
let store = |name| transaction.object_store(name).map_err(error);
|
||||
let files = Self::read_all(&store(FILES)?).await?;
|
||||
let trackpoint_chunks = Self::read_all(&store(TRACKPOINT_CHUNKS)?).await?;
|
||||
let waypoint_chunks = Self::read_all(&store(WAYPOINT_CHUNKS)?).await?;
|
||||
let meta = store(META)?;
|
||||
let order = meta
|
||||
.get(JsValue::from_str(ORDER_KEY))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
let head = Self::read_head(&meta).await?;
|
||||
transaction.await.map_err(error)?;
|
||||
|
||||
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
|
||||
records
|
||||
.into_iter()
|
||||
.map(|(k, v)| Ok((to_uuid(&k)?, v)))
|
||||
.collect()
|
||||
};
|
||||
Ok(StoredData {
|
||||
files: ids(files)?
|
||||
.into_iter()
|
||||
.map(|(id, data)| (FileId(id), data))
|
||||
.collect(),
|
||||
chunks: ids(trackpoint_chunks)?
|
||||
.into_iter()
|
||||
.map(|(id, data)| (ChunkKey::trackpoints(id), data))
|
||||
.chain(
|
||||
ids(waypoint_chunks)?
|
||||
.into_iter()
|
||||
.map(|(id, data)| (ChunkKey::waypoints(id), data)),
|
||||
)
|
||||
.collect(),
|
||||
order: order.as_ref().map(to_vec),
|
||||
head,
|
||||
})
|
||||
}
|
||||
|
||||
async fn head(&self) -> Result<Revision, StorageError> {
|
||||
let transaction = self.transaction(&[META], TransactionMode::ReadOnly)?;
|
||||
let meta = transaction.object_store(META).map_err(error)?;
|
||||
let head = Self::read_head(&meta).await?;
|
||||
transaction.await.map_err(error)?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
let transaction = self.transaction(&COMMIT_STORES, TransactionMode::ReadWrite)?;
|
||||
match self.write(&transaction, batch).await {
|
||||
Ok(head) => {
|
||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||
Ok(head)
|
||||
}
|
||||
Err(err) => {
|
||||
// nothing of the batch is kept
|
||||
let _ = transaction.abort();
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadOnly)?;
|
||||
let store = transaction.object_store(SETTINGS).map_err(error)?;
|
||||
let keys = store.get_all_keys(None, None).map_err(error)?;
|
||||
let values = store.get_all(None, None).map_err(error)?;
|
||||
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
|
||||
transaction.await.map_err(error)?;
|
||||
Ok(keys
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.filter_map(|(key, value)| Some((key.as_string()?, value.as_string()?)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
|
||||
let store = transaction.object_store(SETTINGS).map_err(error)?;
|
||||
store
|
||||
.put(&JsValue::from_str(value), Some(&JsValue::from_str(key)))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
|
||||
let store = transaction.object_store(SETTINGS).map_err(error)?;
|
||||
store
|
||||
.delete(Query::Key(JsValue::from_str(key)))
|
||||
.map_err(error)?
|
||||
.await
|
||||
.map_err(error)?;
|
||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Ids, which cross as strings (one id) or as concatenated 16-byte UUIDs (several).
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) fn uuid_list(bytes: &[u8]) -> Option<Vec<Uuid>> {
|
||||
let (chunks, rest) = bytes.as_chunks::<16>();
|
||||
rest.is_empty()
|
||||
.then(|| chunks.iter().map(|c| Uuid::from_bytes(*c)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_uuid(id: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(id).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool {
|
||||
a.len() == b.len() && b.len() == c.len()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Frontier between the SvelteKit frontend and the engine.
|
||||
//!
|
||||
//! Conventions, chosen to keep calls cheap:
|
||||
//! - Coordinates and elevations cross as `Float64Array` (`&[f64]`): one memcpy into linear
|
||||
//! memory, no per-point calls. `lng`/`lat`/`ele` arrays must have the same length.
|
||||
//! - File bytes cross as `Uint8Array` (`&[u8]`), strings as `&str`.
|
||||
//! - File ids cross as one flat `Uint8Array` of concatenated 16-byte UUIDs (no `Array<string>`).
|
||||
//! - Rectangles cross as four numbers instead of an object.
|
||||
//! - Ids read from the file tree are hyphenated UUID strings; the functions reading buffers take
|
||||
//! them as such.
|
||||
//! - The functions that edit the files or change the selection return an `Outcome`: whether they
|
||||
//! changed anything, what changed (to update what the page keeps), and why nothing was done when
|
||||
//! that is so (the command had nothing to do, the data is invalid, an argument is wrong).
|
||||
//!
|
||||
//! The functions are grouped by what they do: `commands` edit the files, `queries` read the state
|
||||
//! of the engine, `selection` changes what is selected, and `persistence` keeps the files and the
|
||||
//! settings in the browser. `session` holds the engine they all talk to, `convert` turns engine
|
||||
//! types into JS objects and back, and `ts` declares the TypeScript types of those objects.
|
||||
|
||||
mod command;
|
||||
mod convert;
|
||||
mod idb;
|
||||
mod ids;
|
||||
mod persistence;
|
||||
mod queries;
|
||||
mod selection;
|
||||
mod session;
|
||||
mod ts;
|
||||
mod types;
|
||||
|
||||
#[cfg(all(test, target_arch = "wasm32"))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Keeping the files and the settings in the browser.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpx_engine::{self as engine, Engine, Persister, Storage, api};
|
||||
use serde::Serialize;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::{outcome_js, to_js};
|
||||
use crate::idb::IdbStorage;
|
||||
use crate::session::*;
|
||||
use crate::ts::{Outcome, StorageOpened};
|
||||
|
||||
// Storage
|
||||
//
|
||||
// The files are saved in IndexedDB after each change that touches them, without making the change
|
||||
// wait: `schedule_save` starts a loop that writes what changed since the last write, until
|
||||
// nothing has. When another tab wrote in between, the write is refused (the storage has a newer
|
||||
// head than this tab last saw): the tab reads the storage again and overwrites what it holds, see
|
||||
// `sync_storage`. When what is stored was written by a newer version of the app, the write is
|
||||
// refused too, and the frontend is told, so that it makes the user reload the page. The settings are opaque to the engine, the frontend keeps its own copy of them.
|
||||
|
||||
thread_local! {
|
||||
static STORAGE: RefCell<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
||||
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
|
||||
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
||||
/// Resolve functions of the promises of `flush_storage`.
|
||||
/// Called after the files were written, and when a write was refused because they were
|
||||
/// written by someone else since: see `open_storage`.
|
||||
static LISTENERS: RefCell<Option<Listeners>> = const { RefCell::new(None) };
|
||||
static WAITERS: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// What the frontend is told: the files were written, a write was refused because another tab
|
||||
/// wrote since, a write was refused because what is stored comes from a newer version.
|
||||
struct Listeners {
|
||||
saved: js_sys::Function,
|
||||
behind: js_sys::Function,
|
||||
newer: js_sys::Function,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console, js_name = error)]
|
||||
fn console_error(message: &str);
|
||||
}
|
||||
|
||||
pub(crate) fn storage() -> Option<Rc<IdbStorage>> {
|
||||
STORAGE.with(|storage| storage.borrow().clone())
|
||||
}
|
||||
|
||||
pub(crate) fn schedule_save() {
|
||||
if storage().is_none() {
|
||||
return;
|
||||
}
|
||||
DIRTY.set(true);
|
||||
if SAVING.replace(true) {
|
||||
// the running loop will go around again
|
||||
return;
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let (mut saved, mut behind, mut newer) = (false, false, false);
|
||||
while DIRTY.replace(false) {
|
||||
let Some(storage) = storage() else { break };
|
||||
let Some(snapshot) = with_engine(Engine::snapshot) else {
|
||||
break;
|
||||
};
|
||||
let mut persister = PERSISTER
|
||||
.with(|persister| persister.borrow_mut().take())
|
||||
.unwrap_or_default();
|
||||
let result = persister.save(&*storage, snapshot).await;
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
match result {
|
||||
Ok(()) => saved = true,
|
||||
// nothing was written, so that the files of the other tab are not damaged: this
|
||||
// tab has to read them, which replaces what it did
|
||||
Err(engine::CommitError::Stale { .. }) => {
|
||||
DIRTY.set(false);
|
||||
behind = true;
|
||||
}
|
||||
// nothing is written, so that the newer version finds its data again
|
||||
Err(engine::CommitError::Failed(engine::StorageError::Newer)) => {
|
||||
DIRTY.set(false);
|
||||
newer = true;
|
||||
}
|
||||
// what failed is written with the next change
|
||||
Err(error) => console_error(&error.to_string()),
|
||||
}
|
||||
}
|
||||
SAVING.set(false);
|
||||
if saved {
|
||||
notify(|listeners| &listeners.saved);
|
||||
}
|
||||
if behind {
|
||||
notify(|listeners| &listeners.behind);
|
||||
}
|
||||
if newer {
|
||||
notify(|listeners| &listeners.newer);
|
||||
}
|
||||
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
||||
let _ = resolve.call0(&JsValue::UNDEFINED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn notify(pick: impl FnOnce(&Listeners) -> &js_sys::Function) {
|
||||
let callback = LISTENERS.with(|l| l.borrow().as_ref().map(|l| pick(l).clone()));
|
||||
if let Some(callback) = callback {
|
||||
let _ = callback.call0(&JsValue::UNDEFINED);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
|
||||
/// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the
|
||||
/// outcome of putting the files in. The changes of the files that follow are saved, unless some of
|
||||
/// what is stored was written by a newer version of the app: then nothing is read and nothing is
|
||||
/// ever written, so that the newer version finds its data again.
|
||||
///
|
||||
/// `on_saved` is called after the files were written (to tell the other tabs), `on_behind` when
|
||||
/// a write was refused because another tab wrote since (call `sync_storage` then), and `on_newer`
|
||||
/// when what is stored comes from a newer version (now, or when it is read again): the page has to
|
||||
/// be reloaded, the changes are not kept.
|
||||
#[wasm_bindgen]
|
||||
pub async fn open_storage(
|
||||
name: &str,
|
||||
on_saved: js_sys::Function,
|
||||
on_behind: js_sys::Function,
|
||||
on_newer: js_sys::Function,
|
||||
) -> Result<StorageOpened, JsValue> {
|
||||
let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false);
|
||||
if !empty {
|
||||
return Err(JsValue::from_str("the engine already has files"));
|
||||
}
|
||||
let storage = IdbStorage::open(name).await.map_err(to_js_error)?;
|
||||
let mut persister = Persister::default();
|
||||
let outcome = restore_engine(persister.restore(&storage).await).map_err(to_js_error)?;
|
||||
let newer = outcome.is_none();
|
||||
let outcome = outcome.unwrap_or_else(api::Outcome::failed_none);
|
||||
let settings = storage.load_settings().await.map_err(to_js_error)?;
|
||||
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
|
||||
LISTENERS.with(|slot| {
|
||||
*slot.borrow_mut() = Some(Listeners {
|
||||
saved: on_saved,
|
||||
behind: on_behind,
|
||||
newer: on_newer,
|
||||
})
|
||||
});
|
||||
if newer {
|
||||
notify(|listeners| &listeners.newer);
|
||||
}
|
||||
|
||||
/// What opening the storage found, see `StorageOpened` in `ts`.
|
||||
#[derive(Serialize)]
|
||||
struct Opened {
|
||||
settings: std::collections::BTreeMap<String, String>,
|
||||
outcome: api::Outcome,
|
||||
}
|
||||
Ok(to_js(&Opened {
|
||||
settings: settings.into_iter().collect(),
|
||||
outcome,
|
||||
})
|
||||
.unchecked_into())
|
||||
}
|
||||
|
||||
/// Overwrites the files of the engine with what was read, and tells what that did. If what is
|
||||
/// stored comes from a newer version of the app, the files are left as they are and `None` is
|
||||
/// returned: the persister does not write anymore (see `Persister::save`).
|
||||
fn restore_engine(
|
||||
read: Result<engine::Restored, engine::StorageError>,
|
||||
) -> Result<Option<api::Outcome>, engine::StorageError> {
|
||||
match read {
|
||||
Ok(restored) => Ok(Some(
|
||||
match with_engine_mut(|engine| engine.restore(restored)) {
|
||||
Some(outcome) => api::Outcome::new(&outcome, None),
|
||||
None => api::Outcome::failed_none(),
|
||||
},
|
||||
)),
|
||||
Err(engine::StorageError::Newer) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
|
||||
JsValue::from_str(&error.to_string())
|
||||
}
|
||||
|
||||
/// Resolves when what was changed has been written to the storage.
|
||||
#[wasm_bindgen]
|
||||
pub async fn flush_storage() -> Result<(), JsValue> {
|
||||
if !SAVING.get() {
|
||||
return Ok(());
|
||||
}
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
|
||||
});
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If another tab wrote the files since this one last did, reads them again and overwrites the
|
||||
/// files of the engine with them (what was changed here and not saved is lost, and the history
|
||||
/// starts over). Returns what that changed, to apply like the outcome of any other call.
|
||||
#[wasm_bindgen]
|
||||
pub async fn sync_storage() -> Result<Outcome, JsValue> {
|
||||
// the writes are done first, and none starts before this is done
|
||||
while SAVING.get() {
|
||||
flush_storage().await?;
|
||||
}
|
||||
let (Some(storage), Some(mut persister)) = (
|
||||
storage(),
|
||||
PERSISTER.with(|persister| persister.borrow_mut().take()),
|
||||
) else {
|
||||
return Ok(outcome_js(&api::Outcome::failed_none()));
|
||||
};
|
||||
SAVING.set(true);
|
||||
let result = async {
|
||||
if !persister.is_behind(&*storage).await? {
|
||||
return Ok(outcome_js(&api::Outcome::failed_none()));
|
||||
}
|
||||
let outcome = restore_engine(persister.restore(&*storage).await)?;
|
||||
DIRTY.set(false);
|
||||
if outcome.is_none() {
|
||||
notify(|listeners| &listeners.newer);
|
||||
}
|
||||
Ok(outcome_js(
|
||||
&outcome.unwrap_or_else(api::Outcome::failed_none),
|
||||
))
|
||||
}
|
||||
.await;
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
SAVING.set(false);
|
||||
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
||||
let _ = resolve.call0(&JsValue::UNDEFINED);
|
||||
}
|
||||
if DIRTY.get() {
|
||||
// edited while reading, and nothing was overwritten
|
||||
schedule_save();
|
||||
}
|
||||
result.map_err(to_js_error)
|
||||
}
|
||||
|
||||
/// Saves a setting, `value` being its JSON.
|
||||
#[wasm_bindgen]
|
||||
pub fn set_setting(key: String, value: String) {
|
||||
if let Some(storage) = storage() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(error) = storage.put_setting(&key, &value).await {
|
||||
console_error(&error.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn delete_setting(key: String) {
|
||||
if let Some(storage) = storage() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(error) = storage.delete_setting(&key).await {
|
||||
console_error(&error.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The coordinates of the trackpoints and waypoints, as flat typed arrays.
|
||||
|
||||
use gpx_engine::api;
|
||||
use js_sys::Float64Array;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
|
||||
// Coordinates buffers
|
||||
//
|
||||
// Like the statistics buffers, these are copies. They are flat `[lng, lat, ...]` arrays. Compare `rev` / `waypointsRev` of the file structures with the previous ones to know which
|
||||
// buffers actually changed.
|
||||
|
||||
fn coordinates(
|
||||
id: Option<uuid::Uuid>,
|
||||
f: impl FnOnce(&gpx_engine::Engine, uuid::Uuid) -> &[f64],
|
||||
) -> Float64Array {
|
||||
id.and_then(|id| with_engine(|e| Float64Array::from(f(e, id))))
|
||||
.unwrap_or_else(|| Float64Array::new_with_length(0))
|
||||
}
|
||||
|
||||
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
|
||||
#[wasm_bindgen]
|
||||
pub fn segment_coordinates(segment_id: &str) -> Float64Array {
|
||||
coordinates(parse_uuid(segment_id), api::segment_coordinates)
|
||||
}
|
||||
|
||||
/// Coordinates of the waypoints of a file (empty if the id is unknown).
|
||||
#[wasm_bindgen]
|
||||
pub fn waypoint_coordinates(file_id: &str) -> Float64Array {
|
||||
coordinates(parse_uuid(file_id), api::waypoint_coordinates)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! The files, their structure and statistics, the selection and the clipboard.
|
||||
|
||||
// File order and structures
|
||||
//
|
||||
// The files are described by one ordered list of ids and one structure object per file, so that
|
||||
// the UI can react to what changed only: after each action, the outcome tells which structures to
|
||||
// (re)read or drop and whether the order changed. Ids are UUID strings. The trackpoints of a
|
||||
// segment and the waypoints of a file are in the same order as the coordinates of their buffers
|
||||
// (see below).
|
||||
|
||||
use gpx_engine::{ExportOptions, api};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
|
||||
/// Ids of the files in display order.
|
||||
#[wasm_bindgen]
|
||||
pub fn file_order() -> FileOrder {
|
||||
to_js(&with_engine(api::file_order).unwrap_or_default()).unchecked_into()
|
||||
}
|
||||
|
||||
/// Structure of a file, `undefined` if the id is unknown.
|
||||
#[wasm_bindgen]
|
||||
pub fn file_structure(file_id: &str) -> FileStructure {
|
||||
let structure =
|
||||
parse_uuid(file_id).and_then(|id| with_engine(|e| api::file_structure(e, id)).flatten());
|
||||
optional_js(structure).unchecked_into()
|
||||
}
|
||||
|
||||
/// Global statistics of a file, `undefined` if the id is unknown.
|
||||
#[wasm_bindgen]
|
||||
pub fn file_statistics(file_id: &str) -> GlobalStatistics {
|
||||
let statistics =
|
||||
parse_uuid(file_id).and_then(|id| with_engine(|e| api::file_statistics(e, id)).flatten());
|
||||
optional_js(statistics).unchecked_into()
|
||||
}
|
||||
|
||||
/// Global statistics of the whole selection (the arrays of the statistics buffers cover it).
|
||||
#[wasm_bindgen]
|
||||
pub fn selection_statistics() -> GlobalStatistics {
|
||||
optional_js(with_engine(api::selection_statistics)).unchecked_into()
|
||||
}
|
||||
|
||||
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
|
||||
/// to keep, and with `as_route` that the segments are written as routes (`rte`) instead of tracks:
|
||||
/// the anchors are the route points, and the trackpoints between them the points of their path.
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn export_file(
|
||||
file_id: &str,
|
||||
time: bool,
|
||||
hr: bool,
|
||||
cad: bool,
|
||||
atemp: bool,
|
||||
power: bool,
|
||||
osm: bool,
|
||||
as_route: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let id = parse_uuid(file_id)?;
|
||||
let options = ExportOptions {
|
||||
time,
|
||||
hr,
|
||||
cad,
|
||||
atemp,
|
||||
power,
|
||||
osm,
|
||||
as_route,
|
||||
};
|
||||
with_engine(|e| api::export_file(e, id, options)).flatten()
|
||||
}
|
||||
|
||||
/// The data that some files have, so that the ones that none has are not offered when exporting
|
||||
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
|
||||
/// OpenStreetMap data (32). `file_ids` are concatenated UUIDs.
|
||||
#[wasm_bindgen]
|
||||
pub fn exportable_data(file_ids: &[u8]) -> u8 {
|
||||
let Some(ids) = uuid_list(file_ids) else {
|
||||
return 0;
|
||||
};
|
||||
with_engine(|e| api::exportable_data(e, &ids)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
|
||||
/// as indexed by the statistics buffers. `undefined` when the range is not in the selection.
|
||||
#[wasm_bindgen]
|
||||
pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics {
|
||||
optional_js(with_engine(|e| api::slice_statistics(e, start, end)).flatten()).unchecked_into()
|
||||
}
|
||||
|
||||
/// The current selection (ids are UUID strings).
|
||||
#[wasm_bindgen]
|
||||
pub fn selection() -> Selection {
|
||||
to_js(&with_engine(api::selection).unwrap_or(api::Selection::Empty)).unchecked_into()
|
||||
}
|
||||
|
||||
/// All the data of a waypoint of a file, `undefined` if it does not exist.
|
||||
#[wasm_bindgen]
|
||||
pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails {
|
||||
let details = parse_uuid(file_id)
|
||||
.zip(parse_uuid(waypoint_id))
|
||||
.and_then(|(file_id, id)| with_engine(|e| api::waypoint(e, file_id, id)).flatten());
|
||||
optional_js(details).unchecked_into()
|
||||
}
|
||||
|
||||
/// A trackpoint of a segment of a file, `undefined` if it does not exist.
|
||||
#[wasm_bindgen]
|
||||
pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails {
|
||||
let details =
|
||||
parse_uuid(file_id)
|
||||
.zip(parse_uuid(segment_id))
|
||||
.and_then(|(file_id, segment_id)| {
|
||||
with_engine(|e| api::trackpoint(e, file_id, segment_id, index)).flatten()
|
||||
});
|
||||
optional_js(details).unchecked_into()
|
||||
}
|
||||
|
||||
/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the
|
||||
/// elements and their ids (UUID strings).
|
||||
#[wasm_bindgen]
|
||||
pub fn clipboard() -> Clipboard {
|
||||
optional_js(with_engine(api::clipboard).flatten()).unchecked_into()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod coordinates;
|
||||
pub(crate) mod files;
|
||||
pub(crate) mod routing;
|
||||
pub(crate) mod statistics;
|
||||
@@ -0,0 +1,48 @@
|
||||
//! The anchors of the routing tool among the trackpoints of the selection.
|
||||
|
||||
use gpx_engine::api;
|
||||
use js_sys::{Uint8Array, Uint32Array};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
|
||||
// Routing buffers
|
||||
//
|
||||
// The anchors of the routing tool among the trackpoints of the selection (the numbering is the one
|
||||
// of the statistics buffers). The ends of the segments are always anchors.
|
||||
|
||||
/// The index in the selection of each anchor.
|
||||
#[wasm_bindgen]
|
||||
pub fn anchor_indices() -> Uint32Array {
|
||||
with_engine(|e| Uint32Array::from(api::anchor_indices(e)))
|
||||
.unwrap_or_else(|| Uint32Array::new_with_length(0))
|
||||
}
|
||||
|
||||
/// The lowest map zoom level at which each anchor is shown.
|
||||
#[wasm_bindgen]
|
||||
pub fn anchor_zooms() -> Uint8Array {
|
||||
with_engine(|e| Uint8Array::from(api::anchor_zooms(e)))
|
||||
.unwrap_or_else(|| Uint8Array::new_with_length(0))
|
||||
}
|
||||
|
||||
/// Index in the selection of the first trackpoint of each selected segment that has some.
|
||||
#[wasm_bindgen]
|
||||
pub fn segment_starts() -> Uint32Array {
|
||||
with_engine(|e| Uint32Array::from(api::segment_starts(e)))
|
||||
.unwrap_or_else(|| Uint32Array::new_with_length(0))
|
||||
}
|
||||
|
||||
/// The id of each selected segment that has trackpoints, as `segment_starts`.
|
||||
#[wasm_bindgen]
|
||||
pub fn segment_ids() -> StringList {
|
||||
to_js(&with_engine(api::segment_ids).unwrap_or_default()).unchecked_into()
|
||||
}
|
||||
|
||||
/// Changes when the selected segments or their trackpoints change: the indices in the selection
|
||||
/// are only valid while it stays the same.
|
||||
#[wasm_bindgen]
|
||||
pub fn routing_revision() -> u32 {
|
||||
with_engine(api::routing_revision).unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The statistics of the selection, as typed arrays, and the categories they refer to.
|
||||
|
||||
use gpx_engine::api;
|
||||
use js_sys::{BigInt64Array, Float64Array, Uint8Array, Uint32Array};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::*;
|
||||
|
||||
// Statistics buffers
|
||||
//
|
||||
// Each getter returns a copy of the buffer, as a new JS typed array.
|
||||
|
||||
macro_rules! stats_getter {
|
||||
($name:ident, $array:ident) => {
|
||||
#[wasm_bindgen]
|
||||
pub fn $name() -> $array {
|
||||
with_engine(|e| $array::from(api::$name(e)))
|
||||
.unwrap_or_else(|| $array::new_with_length(0))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
stats_getter!(total_distance, Float64Array);
|
||||
stats_getter!(elevation_gain, Float64Array);
|
||||
stats_getter!(elevation_loss, Float64Array);
|
||||
stats_getter!(slope, Float64Array);
|
||||
stats_getter!(slope_segment_slope, Float64Array);
|
||||
stats_getter!(slope_segment_distance, Float64Array);
|
||||
stats_getter!(lng, Float64Array);
|
||||
stats_getter!(lat, Float64Array);
|
||||
stats_getter!(ele, Float64Array);
|
||||
|
||||
// The optional values are `undefined` when no trackpoint of the selection has one, else they have
|
||||
// an entry per trackpoint, the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the
|
||||
// first trackpoint that has them for the cumulative distances and durations).
|
||||
macro_rules! optional_stats_getter {
|
||||
($name:ident, $array:ident) => {
|
||||
#[wasm_bindgen]
|
||||
pub fn $name() -> Option<$array> {
|
||||
with_engine(|e| api::$name(e).map($array::from)).flatten()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
optional_stats_getter!(moving_distance, Float64Array);
|
||||
// Durations since the start of the selection, in milliseconds.
|
||||
optional_stats_getter!(total_time, BigInt64Array);
|
||||
optional_stats_getter!(moving_time, BigInt64Array);
|
||||
optional_stats_getter!(speed, Float64Array);
|
||||
// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing).
|
||||
optional_stats_getter!(timestamps, BigInt64Array);
|
||||
|
||||
/// Value of the timestamps of the trackpoints that have none.
|
||||
#[wasm_bindgen]
|
||||
pub fn no_time() -> i64 {
|
||||
gpx_engine::NO_TIME
|
||||
}
|
||||
|
||||
optional_stats_getter!(hr, Float64Array);
|
||||
optional_stats_getter!(cad, Float64Array);
|
||||
optional_stats_getter!(atemp, Float64Array);
|
||||
optional_stats_getter!(power, Float64Array);
|
||||
|
||||
// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that
|
||||
// have the same value: `<name>_starts` holds the index of the first trackpoint of each interval,
|
||||
// and `<name>_values` its value (0 when unknown, else 1 + the index in `surfaces()`,
|
||||
// `highways()`, `sac_scales()` or `mtb_scales()`). A value that is unknown for every trackpoint is
|
||||
// a single interval.
|
||||
macro_rules! categories_getters {
|
||||
($starts:ident, $values:ident, $names:ident) => {
|
||||
#[wasm_bindgen]
|
||||
pub fn $starts() -> Uint32Array {
|
||||
with_engine(|e| Uint32Array::from(api::$starts(e)))
|
||||
.unwrap_or_else(|| Uint32Array::new_with_length(0))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn $values() -> Uint8Array {
|
||||
with_engine(|e| Uint8Array::from(api::$values(e)))
|
||||
.unwrap_or_else(|| Uint8Array::new_with_length(0))
|
||||
}
|
||||
|
||||
/// The names of the category, in the order of their codes. The list only grows: a name
|
||||
/// keeps its index.
|
||||
#[wasm_bindgen]
|
||||
pub fn $names() -> StringList {
|
||||
to_js(&with_engine(|e| api::$names(e).to_vec()).unwrap_or_default()).unchecked_into()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
categories_getters!(surface_starts, surface_values, surfaces);
|
||||
categories_getters!(highway_starts, highway_values, highways);
|
||||
categories_getters!(sac_scale_starts, sac_scale_values, sac_scales);
|
||||
categories_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales);
|
||||
|
||||
/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance
|
||||
/// (m) of `reduce` that keeps it: infinite for the ends of the segments.
|
||||
#[wasm_bindgen]
|
||||
pub fn reduction_distances() -> Float64Array {
|
||||
with_engine(|e| Float64Array::from(&api::reduction_distances(e)[..]))
|
||||
.unwrap_or_else(|| Float64Array::new_with_length(0))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Changing what is selected.
|
||||
|
||||
use gpx_engine::api;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::ids::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::Outcome;
|
||||
use crate::types::SelectMode;
|
||||
|
||||
// Selection
|
||||
|
||||
/// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and
|
||||
/// selecting nothing deselects everything. See `SelectMode`.
|
||||
#[wasm_bindgen]
|
||||
pub fn select(file_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
|
||||
match uuid_list(file_ids_bytes) {
|
||||
Some(ids) => run(|e| api::select(e, &ids, mode.into())),
|
||||
None => fail("the ids are not UUIDs"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
|
||||
/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the
|
||||
/// files when nothing is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn select_all() -> Outcome {
|
||||
run(api::select_all)
|
||||
}
|
||||
|
||||
/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow
|
||||
/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection,
|
||||
/// otherwise it replaces it.
|
||||
#[wasm_bindgen]
|
||||
pub fn arrow_select(down: bool, add: bool) -> Outcome {
|
||||
run(|e| api::arrow_select(e, down, add))
|
||||
}
|
||||
|
||||
// The elements below cross as the id of their file (UUID string) and, like the files, as
|
||||
// concatenated 16-byte UUIDs. What does not exist is ignored. See `SelectMode`: elements are merged
|
||||
// with the selection when it holds elements of the same kind in the same place (same file, same
|
||||
// track for segments), otherwise they replace it.
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
|
||||
match parse_uuid(file_id).zip(uuid_list(track_ids_bytes)) {
|
||||
Some((file_id, ids)) => run(|e| api::select_tracks(e, file_id, &ids, mode.into())),
|
||||
None => fail("the ids are not UUIDs"),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn select_segments(
|
||||
file_id: &str,
|
||||
track_id: &str,
|
||||
segment_ids_bytes: &[u8],
|
||||
mode: SelectMode,
|
||||
) -> Outcome {
|
||||
match parse_uuid(file_id)
|
||||
.zip(parse_uuid(track_id))
|
||||
.zip(uuid_list(segment_ids_bytes))
|
||||
{
|
||||
Some(((file_id, track_id), ids)) => {
|
||||
run(|e| api::select_segments(e, file_id, track_id, &ids, mode.into()))
|
||||
}
|
||||
None => fail("the ids are not UUIDs"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the node standing for all the waypoints of a file.
|
||||
#[wasm_bindgen]
|
||||
pub fn select_waypoint_group(file_id: &str, mode: SelectMode) -> Outcome {
|
||||
match parse_uuid(file_id) {
|
||||
Some(file_id) => run(|e| api::select_waypoint_group(e, file_id, mode.into())),
|
||||
None => fail("the ids are not UUIDs"),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
|
||||
match parse_uuid(file_id).zip(uuid_list(waypoint_ids_bytes)) {
|
||||
Some((file_id, ids)) => run(|e| api::select_waypoints(e, file_id, &ids, mode.into())),
|
||||
None => fail("the ids are not UUIDs"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! The engine that every function talks to, and the way commands reach it.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use gpx_engine::{Engine, api};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::convert::outcome_js;
|
||||
use crate::persistence::schedule_save;
|
||||
use crate::ts::Outcome;
|
||||
|
||||
thread_local! {
|
||||
pub(crate) static ENGINE: RefCell<Option<Engine>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// What a call that does nothing returns, when the reason is in its arguments rather than in the
|
||||
/// engine: the call is not made.
|
||||
pub(crate) fn fail(message: impl Into<String>) -> Outcome {
|
||||
outcome_js(&api::Outcome::failed(message))
|
||||
}
|
||||
|
||||
/// Makes a call of the shared interface that edits the files or changes the selection, and tells
|
||||
/// what it did, see `Outcome` in `ts`. What changed in the files is saved.
|
||||
pub(crate) fn run(call: impl FnOnce(&mut Engine) -> api::Outcome) -> Outcome {
|
||||
let Some(outcome) = with_engine_mut(call) else {
|
||||
return fail("the engine is not started");
|
||||
};
|
||||
if outcome.saves {
|
||||
schedule_save();
|
||||
}
|
||||
outcome_js(&outcome)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn start() {
|
||||
console_error_panic_hook::set_once();
|
||||
ENGINE.with(|engine| {
|
||||
*engine.borrow_mut() = Some(Engine::default());
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn with_engine<T>(f: impl FnOnce(&Engine) -> T) -> Option<T> {
|
||||
ENGINE.with(|engine| engine.borrow().as_ref().map(f))
|
||||
}
|
||||
|
||||
pub(crate) fn with_engine_mut<T>(f: impl FnOnce(&mut Engine) -> T) -> Option<T> {
|
||||
ENGINE.with(|engine| engine.borrow_mut().as_mut().map(f))
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Tests of the JS interface, run with `wasm-pack test --node wasm` (they need a JS environment).
|
||||
//!
|
||||
//! Every test starts a new engine, which is what the page does once.
|
||||
|
||||
#![allow(clippy::needless_borrows_for_generic_args)]
|
||||
|
||||
use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
use crate::command::history::*;
|
||||
use crate::session::*;
|
||||
use crate::ts::Outcome;
|
||||
use crate::types::*;
|
||||
|
||||
const SIMPLE: &[u8] = include_bytes!("../../gpx-rs/data/simple.gpx");
|
||||
const WITH_TIME: &[u8] = include_bytes!("../../gpx-rs/data/with_time.gpx");
|
||||
const WITH_SURFACE: &[u8] = include_bytes!("../../gpx-rs/data/with_surface.gpx");
|
||||
|
||||
thread_local! {
|
||||
static LAST: std::cell::RefCell<JsValue> = const { std::cell::RefCell::new(JsValue::UNDEFINED) };
|
||||
}
|
||||
|
||||
/// Keeps the outcome of a call for the checks that follow it, and tells whether it changed anything.
|
||||
fn run(outcome: Outcome) -> bool {
|
||||
let outcome = JsValue::from(outcome);
|
||||
let changed = get(&outcome, "changed").as_bool().unwrap();
|
||||
LAST.with(|last| *last.borrow_mut() = outcome);
|
||||
changed
|
||||
}
|
||||
|
||||
fn last_outcome() -> JsValue {
|
||||
LAST.with(|last| last.borrow().clone())
|
||||
}
|
||||
|
||||
fn last_error() -> Option<String> {
|
||||
get(&last_outcome(), "error").as_string()
|
||||
}
|
||||
|
||||
fn get(object: impl AsRef<JsValue>, key: &str) -> JsValue {
|
||||
Reflect::get(object.as_ref(), &key.into()).unwrap()
|
||||
}
|
||||
|
||||
fn array(value: impl Into<JsValue>) -> Array {
|
||||
Array::from(&value.into())
|
||||
}
|
||||
|
||||
fn strings(value: impl Into<JsValue>) -> Vec<String> {
|
||||
array(value)
|
||||
.iter()
|
||||
.map(|v| v.as_string().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Starts a new engine and loads the given files (one command).
|
||||
fn started_with(files: &[(&[u8], &str)]) {
|
||||
start();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let data: Vec<u8> = files
|
||||
.iter()
|
||||
.flat_map(|(data, _)| data.iter().copied())
|
||||
.collect();
|
||||
let lengths: Vec<u32> = files.iter().map(|(data, _)| data.len() as u32).collect();
|
||||
let names = files.iter().map(|(_, name)| name.to_string()).collect();
|
||||
assert!(run(crate::command::files::load_files(
|
||||
&data, &lengths, names
|
||||
)));
|
||||
}
|
||||
|
||||
fn order() -> Vec<String> {
|
||||
strings(crate::queries::files::file_order())
|
||||
}
|
||||
|
||||
fn uuid_bytes(ids: &[String]) -> Vec<u8> {
|
||||
ids.iter()
|
||||
.flat_map(|id| *uuid::Uuid::parse_str(id).unwrap().as_bytes())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_new_file_is_listed_and_selected() {
|
||||
start();
|
||||
assert!(run(crate::command::files::new_file(
|
||||
"route", None, None, None
|
||||
)));
|
||||
assert_eq!(last_error(), None);
|
||||
|
||||
let ids = order();
|
||||
assert_eq!(ids.len(), 1);
|
||||
let structure = crate::queries::files::file_structure(&ids[0]);
|
||||
assert_eq!(
|
||||
get(&structure, "name").as_string().as_deref(),
|
||||
Some("route")
|
||||
);
|
||||
assert_eq!(array(get(&structure, "tracks")).length(), 0);
|
||||
|
||||
let selection = crate::queries::files::selection();
|
||||
assert_eq!(get(&selection, "type").as_string().as_deref(), Some("file"));
|
||||
assert_eq!(strings(get(&selection, "fileIds")), ids);
|
||||
|
||||
let update = last_outcome();
|
||||
assert_eq!(strings(get(&update, "added")), ids);
|
||||
assert_eq!(get(&update, "orderChanged").as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_unknown_file_has_no_structure() {
|
||||
started_with(&[]);
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
assert!(crate::queries::files::file_structure(&id).is_undefined());
|
||||
assert!(crate::queries::files::file_structure("not an id").is_undefined());
|
||||
assert!(crate::queries::files::file_statistics(&id).is_undefined());
|
||||
assert!(
|
||||
crate::queries::files::export_file(&id, true, true, true, true, true, true, false)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
crate::queries::coordinates::segment_coordinates(&id).length(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
crate::queries::coordinates::waypoint_coordinates("nope").length(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_loaded_file_has_statistics_and_coordinates() {
|
||||
started_with(&[(SIMPLE, "simple")]);
|
||||
let id = order().remove(0);
|
||||
|
||||
let stats = crate::queries::files::file_statistics(&id);
|
||||
assert!(get(&stats, "totalDistance").as_f64().unwrap() > 0.0);
|
||||
// the file is selected: the buffers describe it
|
||||
let selection_stats = crate::queries::files::selection_statistics();
|
||||
assert_eq!(
|
||||
get(&selection_stats, "totalDistance").as_f64(),
|
||||
get(&stats, "totalDistance").as_f64()
|
||||
);
|
||||
let n = crate::queries::statistics::total_distance().length();
|
||||
assert!(n > 0);
|
||||
assert_eq!(crate::queries::statistics::lng().length(), n);
|
||||
assert_eq!(crate::queries::statistics::ele().length(), n);
|
||||
// no timestamps in this file
|
||||
assert!(crate::queries::statistics::timestamps().is_none());
|
||||
|
||||
let structure = crate::queries::files::file_structure(&id);
|
||||
let segment = get(
|
||||
&array(get(&array(get(&structure, "tracks")).get(0), "segments")).get(0),
|
||||
"id",
|
||||
);
|
||||
let coordinates =
|
||||
crate::queries::coordinates::segment_coordinates(&segment.as_string().unwrap());
|
||||
assert_eq!(coordinates.length(), 2 * n);
|
||||
|
||||
// the routing buffers: one segment, anchored at both ends
|
||||
assert_eq!(crate::queries::routing::segment_starts().to_vec(), vec![0]);
|
||||
let anchors = crate::queries::routing::anchor_indices().to_vec();
|
||||
assert_eq!(anchors.first(), Some(&0));
|
||||
assert_eq!(anchors.last(), Some(&(n - 1)));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_timestamps_and_categories() {
|
||||
started_with(&[(WITH_TIME, "time")]);
|
||||
let times: BigInt64Array = crate::queries::statistics::timestamps().unwrap();
|
||||
assert!(times.length() > 0);
|
||||
assert!(crate::queries::statistics::total_time().is_some());
|
||||
|
||||
started_with(&[(WITH_SURFACE, "surface")]);
|
||||
let surfaces = strings(crate::queries::statistics::surfaces());
|
||||
assert!(!surfaces.is_empty());
|
||||
let values = crate::queries::statistics::surface_values().to_vec();
|
||||
assert!(values.iter().any(|v| *v > 0));
|
||||
assert!(values.iter().all(|v| (*v as usize) <= surfaces.len()));
|
||||
assert_eq!(
|
||||
crate::queries::statistics::surface_starts().length(),
|
||||
values.len() as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_the_reason_of_a_failure_can_be_read() {
|
||||
started_with(&[]);
|
||||
|
||||
// nothing to do
|
||||
assert!(!run(crate::command::tools::reverse()));
|
||||
assert_eq!(last_error().as_deref(), Some("nothing to do"));
|
||||
// the next call starts afresh
|
||||
assert!(run(crate::command::files::new_file("a", None, None, None)));
|
||||
assert_eq!(last_error(), None);
|
||||
|
||||
// invalid data
|
||||
assert!(!run(crate::command::files::load_files(
|
||||
b"<gpx><trk></gpx>",
|
||||
&[16],
|
||||
vec!["bad".into()]
|
||||
)));
|
||||
assert!(last_error().unwrap().starts_with("invalid data"));
|
||||
assert_eq!(order().len(), 1);
|
||||
|
||||
// arguments that do not make sense
|
||||
assert!(!run(crate::command::files::load_files(
|
||||
b"",
|
||||
&[1, 2],
|
||||
vec!["a".into()]
|
||||
)));
|
||||
assert!(last_error().unwrap().contains("lengths"));
|
||||
assert!(!run(crate::command::files::load_files(
|
||||
b"abc",
|
||||
&[10],
|
||||
vec!["a".into()]
|
||||
)));
|
||||
assert!(last_error().unwrap().contains("shorter"));
|
||||
assert!(!run(crate::command::files::reorder(&[1, 2, 3], 0)));
|
||||
assert!(last_error().unwrap().contains("UUIDs"));
|
||||
assert!(!run(crate::command::tools::crop(5, 1)));
|
||||
assert!(last_error().unwrap().contains("reversed"));
|
||||
assert!(!run(crate::command::waypoints::delete_waypoint("x", "y")));
|
||||
assert!(last_error().unwrap().contains("UUID"));
|
||||
assert!(!run(crate::selection::select_tracks(
|
||||
"x",
|
||||
&[],
|
||||
SelectMode::Replace
|
||||
)));
|
||||
assert!(last_error().unwrap().contains("UUIDs"));
|
||||
|
||||
// a stale revision
|
||||
let stale = crate::queries::routing::routing_revision().wrapping_add(1);
|
||||
assert!(!run(crate::command::routing::insert_anchor(
|
||||
stale, 0.0, 0.0
|
||||
)));
|
||||
assert!(last_error().unwrap().contains("routing buffers"));
|
||||
assert!(!run(crate::command::routing::change_loop_start(stale, 0)));
|
||||
assert!(!run(crate::command::routing::split(
|
||||
stale,
|
||||
0,
|
||||
SplitType::Files
|
||||
)));
|
||||
assert!(last_error().is_some());
|
||||
|
||||
// answers are not failures
|
||||
assert!(run(crate::command::files::new_file("b", None, None, None)));
|
||||
assert!(!crate::command::files::can_paste());
|
||||
assert_eq!(last_error(), None);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_undo_and_redo() {
|
||||
started_with(&[]);
|
||||
assert!(!can_undo() && !can_redo());
|
||||
assert!(run(crate::command::files::new_file("a", None, None, None)));
|
||||
assert!(can_undo() && !can_redo());
|
||||
assert!(run(undo()));
|
||||
assert!(order().is_empty());
|
||||
assert!(can_redo());
|
||||
assert!(run(redo()));
|
||||
assert_eq!(order().len(), 1);
|
||||
// nothing to redo is not an error of the engine
|
||||
assert!(!run(redo()));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_export_and_exportable_data() {
|
||||
started_with(&[(SIMPLE, "plain"), (WITH_TIME, "timed")]);
|
||||
let ids = order();
|
||||
|
||||
let bytes =
|
||||
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true, false)
|
||||
.unwrap();
|
||||
assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
|
||||
|
||||
// the time is the first bit
|
||||
assert_eq!(
|
||||
crate::queries::files::exportable_data(&uuid_bytes(&ids[..1])) & 1,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
crate::queries::files::exportable_data(&uuid_bytes(&ids[1..])) & 1,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
crate::queries::files::exportable_data(&uuid_bytes(&ids)) & 1,
|
||||
1
|
||||
);
|
||||
assert_eq!(crate::queries::files::exportable_data(&[1, 2, 3]), 0);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_export_as_route() {
|
||||
started_with(&[(SIMPLE, "plain")]);
|
||||
let id = order().remove(0);
|
||||
let text = |as_route| {
|
||||
let bytes =
|
||||
crate::queries::files::export_file(&id, true, true, true, true, true, true, as_route)
|
||||
.unwrap();
|
||||
String::from_utf8(bytes).unwrap()
|
||||
};
|
||||
let tracks = text(false);
|
||||
assert!(tracks.contains("<trk>") && !tracks.contains("<rte>"));
|
||||
let routes = text(true);
|
||||
assert!(routes.contains("<rte>") && !routes.contains("<trk>"));
|
||||
assert!(routes.contains("<rtept"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_select_and_reorder_files() {
|
||||
started_with(&[(SIMPLE, "a"), (WITH_TIME, "b")]);
|
||||
let ids = order();
|
||||
|
||||
assert!(run(crate::selection::select(
|
||||
&uuid_bytes(&ids[1..]),
|
||||
SelectMode::Replace
|
||||
)));
|
||||
assert_eq!(
|
||||
strings(get(&crate::queries::files::selection(), "fileIds")),
|
||||
ids[1..]
|
||||
);
|
||||
assert!(run(crate::selection::select(
|
||||
&uuid_bytes(&ids[..1]),
|
||||
SelectMode::Add
|
||||
)));
|
||||
assert_eq!(
|
||||
array(get(&crate::queries::files::selection(), "fileIds")).length(),
|
||||
2
|
||||
);
|
||||
assert!(run(crate::selection::select(
|
||||
&uuid_bytes(&ids[..1]),
|
||||
SelectMode::Toggle
|
||||
)));
|
||||
assert_eq!(
|
||||
strings(get(&crate::queries::files::selection(), "fileIds")),
|
||||
ids[1..]
|
||||
);
|
||||
|
||||
// reordering is a change of the order, nothing else
|
||||
let reversed: Vec<String> = ids.iter().rev().cloned().collect();
|
||||
assert!(run(crate::command::files::reorder(
|
||||
&uuid_bytes(&reversed[..1]),
|
||||
0
|
||||
)));
|
||||
assert_eq!(order(), reversed);
|
||||
assert_eq!(get(&last_outcome(), "orderChanged").as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_move_elements_takes_js_objects() {
|
||||
started_with(&[(SIMPLE, "a"), (WITH_TIME, "b")]);
|
||||
let ids = order();
|
||||
assert!(run(crate::selection::select(
|
||||
&uuid_bytes(&ids[1..]),
|
||||
SelectMode::Replace
|
||||
)));
|
||||
|
||||
let target = Object::new();
|
||||
Reflect::set(&target, &"type".into(), &"files".into()).unwrap();
|
||||
Reflect::set(&target, &"index".into(), &0.into()).unwrap();
|
||||
let what = JsValue::from(crate::queries::files::selection());
|
||||
assert!(run(crate::command::files::move_elements(
|
||||
what.clone().unchecked_into(),
|
||||
JsValue::from(target).unchecked_into()
|
||||
)));
|
||||
assert_eq!(order(), vec![ids[1].clone(), ids[0].clone()]);
|
||||
|
||||
// an unknown target
|
||||
let bad = Object::new();
|
||||
Reflect::set(&bad, &"type".into(), &"nowhere".into()).unwrap();
|
||||
Reflect::set(&bad, &"index".into(), &0.into()).unwrap();
|
||||
assert!(!run(crate::command::files::move_elements(
|
||||
what.unchecked_into(),
|
||||
JsValue::from(bad).unchecked_into()
|
||||
)));
|
||||
assert!(last_error().unwrap().contains("not valid"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_waypoints() {
|
||||
started_with(&[]);
|
||||
assert!(run(crate::command::files::new_file(
|
||||
"file", None, None, None
|
||||
)));
|
||||
let file = order().remove(0);
|
||||
assert!(run(crate::command::waypoints::new_waypoint(
|
||||
4.0, 50.0, 100.0, "summit", "", "", ""
|
||||
)));
|
||||
|
||||
let structure = crate::queries::files::file_structure(&file);
|
||||
let waypoints = array(get(&structure, "waypoints"));
|
||||
assert_eq!(waypoints.length(), 1);
|
||||
let id = get(&waypoints.get(0), "id").as_string().unwrap();
|
||||
let details = crate::queries::files::waypoint(&file, &id);
|
||||
assert_eq!(get(&details, "name").as_string().as_deref(), Some("summit"));
|
||||
assert_eq!(get(&details, "ele").as_f64(), Some(100.0));
|
||||
assert_eq!(
|
||||
crate::queries::coordinates::waypoint_coordinates(&file).to_vec(),
|
||||
vec![4.0, 50.0]
|
||||
);
|
||||
|
||||
assert!(run(crate::command::waypoints::update_waypoint(
|
||||
&file, &id, 5.0, 51.0, 120.0, "peak", "", "", ""
|
||||
)));
|
||||
let details = crate::queries::files::waypoint(&file, &id);
|
||||
assert_eq!(get(&details, "name").as_string().as_deref(), Some("peak"));
|
||||
assert!(run(crate::command::waypoints::move_waypoint(
|
||||
&file, &id, 6.0, 52.0, 130.0
|
||||
)));
|
||||
assert_eq!(
|
||||
crate::queries::coordinates::waypoint_coordinates(&file).to_vec(),
|
||||
vec![6.0, 52.0]
|
||||
);
|
||||
|
||||
// the structure tells the coordinates changed
|
||||
let rev = get(
|
||||
&crate::queries::files::file_structure(&file),
|
||||
"waypointsRev",
|
||||
);
|
||||
assert!(run(crate::command::waypoints::move_waypoint(
|
||||
&file, &id, 7.0, 52.0, 130.0
|
||||
)));
|
||||
assert_ne!(
|
||||
get(
|
||||
&crate::queries::files::file_structure(&file),
|
||||
"waypointsRev"
|
||||
),
|
||||
rev
|
||||
);
|
||||
|
||||
assert!(run(crate::command::waypoints::delete_waypoint(&file, &id)));
|
||||
assert!(crate::queries::files::waypoint(&file, &id).is_undefined());
|
||||
// already gone
|
||||
assert!(!run(crate::command::waypoints::delete_waypoint(&file, &id)));
|
||||
assert!(last_error().is_some());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_clipboard() {
|
||||
started_with(&[(SIMPLE, "a")]);
|
||||
assert!(crate::queries::files::clipboard().is_undefined());
|
||||
assert!(run(crate::command::files::copy()));
|
||||
let clipboard = crate::queries::files::clipboard();
|
||||
assert_eq!(
|
||||
get(&clipboard, "type").as_string().as_deref(),
|
||||
Some("files")
|
||||
);
|
||||
assert_eq!(get(&clipboard, "cut").as_bool(), Some(false));
|
||||
assert!(crate::command::files::can_paste());
|
||||
assert!(run(crate::command::files::paste()));
|
||||
assert_eq!(order().len(), 2);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_editing_tools_change_the_statistics() {
|
||||
started_with(&[(SIMPLE, "a")]);
|
||||
let before = crate::queries::statistics::total_distance().length();
|
||||
assert!(run(crate::command::tools::crop(0, 9)));
|
||||
assert_eq!(crate::queries::statistics::total_distance().length(), 10);
|
||||
assert!(run(undo()));
|
||||
assert_eq!(
|
||||
crate::queries::statistics::total_distance().length(),
|
||||
before
|
||||
);
|
||||
|
||||
// one elevation per trackpoint
|
||||
let ele = Float64Array::new_with_length(before);
|
||||
assert!(run(crate::command::tools::elevation(&ele.to_vec())));
|
||||
assert!(
|
||||
crate::queries::statistics::ele()
|
||||
.to_vec()
|
||||
.iter()
|
||||
.all(|e| *e == 0.0)
|
||||
);
|
||||
assert!(!run(crate::command::tools::elevation(&[1.0])));
|
||||
assert!(last_error().unwrap().contains("elevations"));
|
||||
|
||||
assert!(
|
||||
run(crate::command::tools::reduce(5.0)) || last_error().as_deref() == Some("nothing to do")
|
||||
);
|
||||
assert!(crate::queries::statistics::total_distance().length() <= before);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_last_update_describes_the_last_call_only() {
|
||||
started_with(&[]);
|
||||
assert!(run(crate::command::files::new_file("a", None, None, None)));
|
||||
let update = last_outcome();
|
||||
assert_eq!(array(get(&update, "added")).length(), 1);
|
||||
assert_eq!(get(&update, "orderChanged").as_bool(), Some(true));
|
||||
|
||||
// a call that does nothing replaces it, whether the engine refuses or the arguments are wrong
|
||||
assert!(!run(crate::command::tools::reverse()));
|
||||
let update = last_outcome();
|
||||
assert_eq!(array(get(&update, "added")).length(), 0);
|
||||
assert_eq!(get(&update, "orderChanged").as_bool(), Some(false));
|
||||
assert_eq!(get(&update, "selectionChanged").as_bool(), Some(false));
|
||||
|
||||
assert!(run(crate::command::files::new_file("b", None, None, None)));
|
||||
assert!(!run(crate::command::tools::crop(3, 1)));
|
||||
let update = last_outcome();
|
||||
assert_eq!(array(get(&update, "added")).length(), 0);
|
||||
assert_eq!(get(&update, "orderChanged").as_bool(), Some(false));
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
//! The TypeScript types of the JS objects, which the functions declare in their signatures.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const ROUTE_TS: &str = r#"
|
||||
/**
|
||||
* An OSM attribute of the new trackpoints of a route, as the intervals of trackpoints that share
|
||||
* a value: `starts[i]` is the index of the first trackpoint of the interval `i`, and `values[i]`
|
||||
* its value, 0 when unknown, else 1 + the index of its name in `names`.
|
||||
*/
|
||||
export interface RouteCategory {
|
||||
starts: Uint32Array;
|
||||
values: Uint8Array;
|
||||
names: string[];
|
||||
}
|
||||
/** The attributes of the new trackpoints of a route. The ones that are missing are unknown. */
|
||||
export interface RouteAttributes {
|
||||
surface?: RouteCategory;
|
||||
highway?: RouteCategory;
|
||||
sacScale?: RouteCategory;
|
||||
mtbScale?: RouteCategory;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const FILE_STRUCTURE_TS: &str = r#"
|
||||
export type Selection =
|
||||
| { type: 'empty' }
|
||||
| { type: 'file'; fileIds: string[] }
|
||||
| { type: 'track'; fileId: string; trackIds: string[] }
|
||||
| { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
|
||||
| { type: 'waypoints'; fileId: string }
|
||||
| { type: 'waypoint'; fileId: string; waypointIds: string[] };
|
||||
/**
|
||||
* Where moved elements go: a list of the file tree and the position in it, counted among the
|
||||
* elements of the list that are not moved.
|
||||
*/
|
||||
export type MoveTarget =
|
||||
| { type: 'files'; index: number }
|
||||
| { type: 'tracks'; fileId: string; index: number }
|
||||
| { type: 'segments'; fileId: string; trackId: string; index: number }
|
||||
| { type: 'waypoints'; fileId: string; index: number };
|
||||
/** All the data of a waypoint. The fields it does not have are absent. */
|
||||
export interface WaypointDetails {
|
||||
id: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
ele: number;
|
||||
/** ms since epoch */
|
||||
time?: number;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
cmt?: string;
|
||||
/** The links of the waypoint, absent when it has none. */
|
||||
links?: { href: string; text?: string }[];
|
||||
sym?: string;
|
||||
}
|
||||
/** The position, elevation and time of a trackpoint. */
|
||||
export interface TrackpointDetails {
|
||||
lng: number;
|
||||
lat: number;
|
||||
ele: number;
|
||||
/** ms since epoch */
|
||||
time?: number;
|
||||
}
|
||||
/** What was copied or cut, to be pasted: the kind of the elements and their ids. */
|
||||
export interface Clipboard {
|
||||
type: 'files' | 'tracks' | 'segments' | 'waypoints';
|
||||
ids: string[];
|
||||
cut: boolean;
|
||||
}
|
||||
/** What changed in the files, the selection and the clipboard. */
|
||||
export interface FilesUpdate {
|
||||
orderChanged: boolean;
|
||||
selectionChanged: boolean;
|
||||
clipboardChanged: boolean;
|
||||
/** Files to read the structure of. */
|
||||
added: string[];
|
||||
/** Files whose structure changed: reread it. */
|
||||
modified: string[];
|
||||
/** Files that do not exist anymore: drop their structure. */
|
||||
removed: string[];
|
||||
}
|
||||
/**
|
||||
* What a call that edits the files or changes the selection did. `changed` is whether anything
|
||||
* did; `error` tells why not (nothing to do, invalid data, or what is wrong with the arguments).
|
||||
* Apply the update to what is kept of the files before the next call.
|
||||
*/
|
||||
export interface Outcome extends FilesUpdate {
|
||||
changed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
/** What opening the storage found. */
|
||||
export interface StorageOpened {
|
||||
/** The settings, as JSON strings by key. */
|
||||
settings: Record<string, string>;
|
||||
/** The files that were restored are the added ones. */
|
||||
outcome: Outcome;
|
||||
}
|
||||
export interface FileStructure {
|
||||
id: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
tracks: TrackNode[];
|
||||
waypoints: WaypointNode[];
|
||||
/** Changes when the waypoints of the file change: refetch their coordinates. */
|
||||
waypointsRev: string;
|
||||
}
|
||||
/**
|
||||
* Global statistics of a file, of the selection, or of a part of it. The optional fields are
|
||||
* absent when there is no data for them (no timestamps...).
|
||||
*/
|
||||
export interface GlobalStatistics {
|
||||
/** km */
|
||||
totalDistance: number;
|
||||
movingDistance?: number;
|
||||
/** seconds */
|
||||
totalTime?: number;
|
||||
movingTime?: number;
|
||||
elevationGain: number;
|
||||
elevationLoss: number;
|
||||
/** ms since epoch */
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
/** km/h */
|
||||
totalSpeed?: number;
|
||||
movingSpeed?: number;
|
||||
/** Absent when there are no trackpoints. */
|
||||
bounds?: { west: number; south: number; east: number; north: number };
|
||||
}
|
||||
export interface TrackNode {
|
||||
id: string;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
/** Style of the track, only present when the file defines it. */
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
width?: number;
|
||||
segments: SegmentNode[];
|
||||
}
|
||||
export interface SegmentNode {
|
||||
id: string;
|
||||
/** Changes when the trackpoints of the segment change: refetch their coordinates. */
|
||||
rev: string;
|
||||
/** Number of trackpoints. */
|
||||
length: number;
|
||||
}
|
||||
export interface WaypointNode {
|
||||
id: string;
|
||||
name?: string;
|
||||
sym?: string;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(typescript_type = "Selection")]
|
||||
pub type Selection;
|
||||
#[wasm_bindgen(typescript_type = "WaypointDetails | undefined")]
|
||||
pub type WaypointDetails;
|
||||
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
|
||||
pub type TrackpointDetails;
|
||||
#[wasm_bindgen(typescript_type = "MoveTarget")]
|
||||
pub type MoveTarget;
|
||||
|
||||
#[wasm_bindgen(typescript_type = "RouteAttributes")]
|
||||
pub type RouteAttributes;
|
||||
#[wasm_bindgen(typescript_type = "Clipboard | undefined")]
|
||||
pub type Clipboard;
|
||||
#[wasm_bindgen(typescript_type = "Outcome")]
|
||||
pub type Outcome;
|
||||
#[wasm_bindgen(typescript_type = "StorageOpened")]
|
||||
pub type StorageOpened;
|
||||
#[wasm_bindgen(typescript_type = "FileStructure | undefined")]
|
||||
pub type FileStructure;
|
||||
#[wasm_bindgen(typescript_type = "GlobalStatistics | undefined")]
|
||||
pub type GlobalStatistics;
|
||||
#[wasm_bindgen(typescript_type = "string[]")]
|
||||
pub type FileOrder;
|
||||
#[wasm_bindgen(typescript_type = "string[]")]
|
||||
pub type StringList;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The enumerations that cross to JS, and their engine counterparts.
|
||||
|
||||
use gpx_engine as engine;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum MergeType {
|
||||
Connect,
|
||||
Group,
|
||||
}
|
||||
|
||||
/// What a split cuts in two.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy)]
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum CleanType {
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// How a selection combines with the current one: `Replace` it, `Add` to it, or `Toggle` the
|
||||
/// elements (the ones already selected are removed).
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy)]
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MergeType> for engine::MergeType {
|
||||
fn from(t: MergeType) -> Self {
|
||||
match t {
|
||||
MergeType::Connect => Self::Connect,
|
||||
MergeType::Group => Self::Group,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CleanType> for engine::CleanType {
|
||||
fn from(t: CleanType) -> Self {
|
||||
match t {
|
||||
CleanType::Inside => Self::Inside,
|
||||
CleanType::Outside => Self::Outside,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user