mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
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:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user