Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

* fix display of additional data with gaps, closes #361

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

+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. */
export 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();
}
+253
View File
@@ -0,0 +1,253 @@
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { Outcome, RouteAttributes, StorageOpened } from 'gpx-wasm';
import type { Backend, FolderEntry, WorkspaceState } 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_routes: (fileId, routeIds, mode) =>
invoke('select_routes', { fileId, routeIds, mode }),
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, route) =>
invoke('new_file', { name, lng, lat, ele, route }),
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 }),
convert: () => invoke('convert'),
new_route: () => invoke('new_route'),
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) => {
const file = await optionalArray('export_file', Uint8Array, {
fileId,
time,
hr,
cad,
atemp,
power,
osm,
});
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'),
lng: () => f64('lng'),
lat: () => f64('lat'),
ele: () => f64('ele'),
speed: () => f64Optional('speed'),
hr: () => f64Optional('hr'),
cad: () => f64Optional('cad'),
atemp: () => f64Optional('atemp'),
power: () => f64Optional('power'),
slope_segment_starts: () => u32('slope_segment_starts'),
slope_segment_slopes: () => f64('slope_segment_slopes'),
selection_point: async (index) => (await invoke('selection_point', { index })) ?? undefined,
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'),
desktop: {
workspace: () => invoke<WorkspaceState>('workspace_state'),
openFiles: () => invoke<Outcome>('open_files'),
openPaths: (paths, preview) => invoke<Outcome>('open_paths', { paths, preview }),
keepFiles: (fileIds) => invoke('keep_files', { fileIds }),
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
closeFiles: (fileIds, unsaved) => invoke<Outcome>('close_files', { fileIds, unsaved }),
saveFiles: (fileIds) => invoke<boolean>('save_files', { fileIds }),
saveFileAs: (fileId) => invoke<boolean>('save_file_as', { fileId }),
reloadFiles: (fileIds) => invoke<Outcome>('reload_files', { fileIds }),
exportFiles: (fileIds, options) =>
invoke<boolean>('export_files', { fileIds, options }),
listFolder: (path) => invoke<FolderEntry[]>('list_folder', { path }),
openUrl: (url) => invoke('open_url', { url }),
newFile: (name, folder, route) => invoke<Outcome>('new_file', { name, folder, route }),
createFolder: (parent, name) => invoke<string>('create_folder', { parent, name }),
renamePath: (path, name) => invoke('rename_path', { path, name }),
renameFile: (fileId, name) => invoke<boolean>('rename_file', { fileId, name }),
movePaths: (paths, folder) => invoke('move_paths', { paths, folder }),
deletePaths: (paths) => invoke<Outcome>('delete_paths', { paths }),
revealPath: (path) => invoke('reveal_path', { path }),
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened, onFolderChanged) => {
await listen<Outcome>('files-opened', (event) => onFilesOpened(event.payload));
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
await listen<WorkspaceState>('workspace-changed', (event) =>
onWorkspaceChanged(event.payload)
);
await listen<string[]>('folder-changed', () => onFolderChanged());
},
},
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 }),
};
}
+349
View File
@@ -0,0 +1,349 @@
import type {
Clipboard,
FileStructure,
GlobalStatistics,
MoveTarget,
Outcome,
RouteAttributes,
Selection,
SelectionPoint,
StorageOpened,
TrackpointDetails,
WaypointDetails,
} from 'gpx-wasm';
import type { ExportOptions, MergeType, SelectMode, SplitType } from '../types';
/** What the desktop app knows of a file of the engine and the file system. */
export type FileStatus = {
/** Where the file is on the disk, absent for a file that only is in the engine. */
path?: string;
/**
* The folder where the file is written the first time it is saved, for a file that is not on
* the disk and comes from (or was made in) the open folder.
*/
folder?: string;
/**
* The file is not the one on the disk: it has edits, is not on the disk, or the file on the
* disk is not the one that was read (it was replaced, or is gone).
*/
dirty: boolean;
/** The file on the disk was replaced by something else: saving overwrites it. */
replaced: boolean;
/** The file on the disk is gone. */
missing: boolean;
/** The file is open as a preview (a single click in the tree), which the next one replaces. */
preview: boolean;
};
/** An entry of the tree of the open folder: a folder, or a GPX file. */
export type FolderEntry = {
name: string;
path: string;
isDir: boolean;
};
/** What to do with the files of a folder that have unsaved edits when it is closed. */
export type UnsavedChoice = 'save' | 'discard';
export type WorkspaceState = {
/** The folder that is open, and watched. */
folder?: string;
files: Record<string, FileStatus>;
};
/**
* What the desktop app adds: its files are linked to files of the file system. Only the backend of
* the desktop app has it.
*/
export interface DesktopFiles {
workspace(): Promise<WorkspaceState>;
/** Opens the files that the user picks, which get selected. */
openFiles(): Promise<Outcome>;
/**
* Opens files of the file system by their path, like the ones of the tree of the folder. With
* `preview`, a single file is open as a preview: the next preview replaces it, until it is
* edited or opened for good. Otherwise they are open for good.
*/
openPaths(paths: string[], preview?: boolean): Promise<Outcome>;
/** The files are open for good: the preview among them is not a preview anymore. */
keepFiles(fileIds: string[]): Promise<void>;
/**
* Opens the folder that the user picks, which is watched. Its files are not opened: they are
* in its tree (`listFolder`). The folder that was open is closed with its files (see
* `closeFiles` for `unsaved`).
*/
openFolder(unsaved?: UnsavedChoice): Promise<Outcome>;
/** Closes the open folder: it is not watched anymore, and its files are closed. */
closeFolder(unsaved?: UnsavedChoice): Promise<Outcome>;
/**
* Closes files. What to do with the ones that have unsaved edits: `save` them first (the user
* is asked where to put the ones that have no place), `discard` the edits, or keep them open
* when it is not said.
*/
closeFiles(fileIds: string[], unsaved?: UnsavedChoice): Promise<Outcome>;
/**
* Writes files to the disk, replacing what is there (the user is asked where to put the ones
* that have no place: a file, or a folder for several of them). Resolves to false if the user
* cancelled.
*/
saveFiles(fileIds: string[]): Promise<boolean>;
/** Writes a file where the user says, which is where it is from then on. False if cancelled. */
saveFileAs(fileId: string): Promise<boolean>;
/** Reads files again from the disk: their edits are lost (one undo step). */
reloadFiles(fileIds: string[]): Promise<Outcome>;
/**
* Writes copies of files where the user says (a file, or a folder for several). The files stay
* as they are. False if the user cancelled.
*/
exportFiles(fileIds: string[], options: ExportOptions): Promise<boolean>;
/** The folders and GPX files in a folder of the open folder (the open folder by default). */
listFolder(path?: string): Promise<FolderEntry[]>;
/** Opens a web page (or an email to write) in the app of the system for it. */
openUrl(url: string): Promise<void>;
/**
* Makes a new file, written at once in `folder` (a folder of the open folder), and selects it.
* With `route`, it starts with an empty route.
*/
newFile(name: string, folder: string, route: boolean): Promise<Outcome>;
/** Makes a folder in a folder of the open folder (numbered if the name is taken): its path. */
createFolder(parent: string, name: string): Promise<string>;
/**
* Renames a file or a folder of the open folder, on the disk only (an open file keeps its name).
* Not undoable.
*/
renamePath(path: string, name: string): Promise<void>;
/**
* Renames the file on the disk of an open file, next to where it is; its name in the engine
* stays. Not undoable. False for a file that is not on the disk.
*/
renameFile(fileId: string, name: string): Promise<boolean>;
/** Moves files and folders of the open folder into a folder of it. */
movePaths(paths: string[], folder: string): Promise<void>;
/**
* Moves files and folders of the open folder to the trash. The open files among them are
* closed, unless they have edits.
*/
deletePaths(paths: string[]): Promise<Outcome>;
/** Shows a file or a folder in the file manager of the system. */
revealPath(path: string): Promise<void>;
/**
* Tells when the engine changed without being asked, because the files on the disk did (the
* outcome to apply), when the status of the files changed, when files were opened with the
* app from the system (the outcome to apply, which selected them), and when something changed
* in the open folder (the tree of its files is to be read again).
*/
listen(
onEngineChanged: (outcome: Outcome) => void,
onWorkspaceChanged: (workspace: WorkspaceState) => void,
onFilesOpened: (outcome: Outcome) => void,
onFolderChanged: () => void
): Promise<void>;
}
/**
* 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/src/api` 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_routes(fileId: string, routeIds: string[], mode: SelectMode): 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 | undefined,
lat: number | undefined,
ele: number | undefined,
route: boolean
): 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>;
convert(): Promise<Outcome>;
new_route(): 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
): 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>;
lng(): Promise<Float64Array>;
lat(): Promise<Float64Array>;
ele(): Promise<Float64Array>;
speed(): Promise<Float64Array | undefined>;
hr(): Promise<Float64Array | undefined>;
cad(): Promise<Float64Array | undefined>;
atemp(): Promise<Float64Array | undefined>;
power(): Promise<Float64Array | undefined>;
slope_segment_starts(): Promise<Uint32Array>;
slope_segment_slopes(): Promise<Float64Array>;
selection_point(index: number): Promise<SelectionPoint | undefined>;
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>;
/** Present when the files are linked to the file system (the desktop app). */
readonly desktop?: DesktopFiles;
// 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>;
}
+177
View File
@@ -0,0 +1,177 @@
import type { Backend } from './types';
import type { MergeType, SelectMode, SplitType } from '../types';
type Wasm = typeof import('gpx-wasm');
/** 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 `wasm`. */
export async function createWasmBackend(): Promise<Backend> {
const w: Wasm = await import('gpx-wasm');
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,
routes: w.SplitType.Routes,
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_routes: lift((file, ids, m) => w.select_routes(file, idsToBytes(ids), mode(m))),
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),
convert: lift(w.convert),
new_route: lift(w.new_route),
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),
lng: lift(w.lng),
lat: lift(w.lat),
ele: lift(w.ele),
speed: lift(w.speed),
hr: lift(w.hr),
cad: lift(w.cad),
atemp: lift(w.atemp),
power: lift(w.power),
slope_segment_starts: lift(w.slope_segment_starts),
slope_segment_slopes: lift(w.slope_segment_slopes),
selection_point: lift(w.selection_point),
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),
};
}