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

No files matched your search

+12
View File
@@ -31,6 +31,7 @@
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8",
"@tauri-apps/api": "^2.12.3",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7",
@@ -2635,6 +2636,17 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.12.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.3.tgz",
"integrity": "sha512-9s0PKw0p4r3kFq92jhpivYSp+cSht15o0gXpZxuQd0+dV9KCexEVkNaLELVoLdJWyoH4KlfQo5h3W2LIM8siJg==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"dev": true,
+1
View File
@@ -24,6 +24,7 @@
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8",
"@tauri-apps/api": "^2.12.3",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7",
@@ -400,10 +400,13 @@ export class ElevationProfile {
} else if (startIndex !== endIndex) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const global = get(this._statistics).slice(start, end);
if (global) {
this._slicedStatistics.set({ global, start, end });
}
get(this._statistics)
.slice(start, end)
.then((global) => {
if (global) {
this._slicedStatistics.set({ global, start, end });
}
});
}
}
}
+26 -12
View File
@@ -54,18 +54,32 @@
return [];
});
// the data that none of them has is not offered
let hide: Record<keyof ExportOptions, boolean> = $derived.by(() => {
const available = engine.exportableData(fileIds);
return {
time: !available.time,
hr: !available.hr,
cad: !available.cad,
atemp: !available.atemp,
power: !available.power,
osm: !available.osm,
// always possible
asRoute: false,
};
let hide: Record<keyof ExportOptions, boolean> = $state({
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
osm: false,
asRoute: false,
});
$effect(() => {
const ids = fileIds;
engine.exportableData(ids).then((available) => {
// the files may have changed since
if (ids === fileIds) {
hide = {
time: !available.time,
hr: !available.hr,
cad: !available.cad,
atemp: !available.atemp,
power: !available.power,
osm: !available.osm,
// always possible
asRoute: false,
};
}
});
});
$effect(() => {
@@ -31,11 +31,15 @@ export function allFileIds(): string[] {
}
async function exportFiles(fileIds: string[], options: ExportOptions) {
const files = fileIds.flatMap((fileId) => {
const gpx = engine.exportFile(fileId, options);
const name = get(engine.files).get(fileId);
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
});
const files = (
await Promise.all(
fileIds.map(async (fileId) => {
const gpx = await engine.exportFile(fileId, options);
const name = get(engine.files).get(fileId);
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
})
)
).flat();
if (files.length === 1) {
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
@@ -152,9 +152,9 @@
engine.selectNode(node, 'toggle');
}
}}
onmouseenter={() => {
onmouseenter={async () => {
if (node.type === 'waypoint' && !isHidden(fileState, node.waypointId)) {
const waypoint = engine.waypoint(node.fileId, node.waypointId);
const waypoint = await engine.waypoint(node.fileId, node.waypointId);
if (waypoint) {
waypointPopup?.setItem({
item: waypoint,
@@ -393,13 +393,15 @@ export class GPXLayer {
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
}
layerOnMouseMove(e: any) {
async layerOnMouseMove(e: any) {
if (e.originalEvent.shiftKey) {
const { segmentId } = e.features[0].properties;
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
const index = closestPointIndex(engine.segmentCoordinates(segmentId), lngLat);
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat);
const trackpoint =
index === undefined ? undefined : engine.trackpoint(this.fileId, segmentId, index);
index === undefined
? undefined
: await engine.trackpoint(this.fileId, segmentId, index);
if (trackpoint) {
trackpointPopup?.setItem({
item: trackpoint,
@@ -469,8 +471,8 @@ export class GPXLayer {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
}
showWaypointPopup(waypointId: string) {
const waypoint = engine.waypoint(this.fileId, waypointId);
async showWaypointPopup(waypointId: string) {
const waypoint = await engine.waypoint(this.fileId, waypointId);
if (waypoint) {
waypointPopup?.setItem({ item: waypoint, kind: 'waypoint', fileId: this.fileId });
}
@@ -30,9 +30,14 @@
let reducedLayer = new ReducedLayer();
// read again only when the selection or the files change, not with the tolerance
let distances = $derived.by(() => {
void $statistics;
return engine.reductionDistances();
let distances = $state<Float64Array>(new Float64Array());
$effect(() => {
const current = $statistics;
engine.reductionDistances().then((values) => {
if (current === $statistics) {
distances = values;
}
});
});
$effect(() => {
@@ -61,8 +61,12 @@
function updateSlicedStatistics([startStep, endStep]: number[]) {
if (validSelection && (startStep !== 0 || endStep !== steps)) {
const [start, end] = [toIndex(startStep), toIndex(endStep)];
const global = $statistics.slice(start, end);
slicedStatistics.set(global && { global, start, end });
$statistics.slice(start, end).then((global) => {
// the slider may have moved since
if (validSelection) {
slicedStatistics.set(global && { global, start, end });
}
});
} else {
slicedStatistics.set(undefined);
}
@@ -92,8 +92,8 @@ export class WaypointSelection {
}
// read again each time the file changes
const read = () => {
const waypoint = engine.waypoint(target.fileId, target.id);
const read = async () => {
const waypoint = await engine.waypoint(target.fileId, target.id);
this._selection.set(waypoint ? { ...target, waypoint } : undefined);
};
const file = get(engine.files).get(target.fileId);
+8 -8
View File
@@ -5,8 +5,8 @@ import { exampleData } from '$lib/data/example/example-data';
import { exampleStatistics } from '$lib/data/example';
describe('exampleStatistics', () => {
it('has the statistics of the whole example for the whole range', () => {
const whole = exampleStatistics.slice(0, exampleStatistics.length - 1);
it('has the statistics of the whole example for the whole range', async () => {
const whole = await exampleStatistics.slice(0, exampleStatistics.length - 1);
const global = exampleStatistics.global;
assert.ok(whole);
for (const key of [
@@ -25,17 +25,17 @@ describe('exampleStatistics', () => {
}
});
it('has no speed for a range that does not last', () => {
const point = exampleStatistics.slice(20, 20);
it('has no speed for a range that does not last', async () => {
const point = await exampleStatistics.slice(20, 20);
assert.equal(point?.totalTime, 0);
assert.equal(point?.totalSpeed, undefined);
assert.equal(point?.movingSpeed, undefined);
});
it('has nothing for ranges that are not in the example', () => {
assert.equal(exampleStatistics.slice(5, 500), undefined);
assert.equal(exampleStatistics.slice(-1, 3), undefined);
assert.equal(exampleStatistics.slice(10, 5), undefined);
it('has nothing for ranges that are not in the example', async () => {
assert.equal(await exampleStatistics.slice(5, 500), undefined);
assert.equal(await exampleStatistics.slice(-1, 3), undefined);
assert.equal(await exampleStatistics.slice(10, 5), undefined);
});
it(
+1 -1
View File
@@ -20,7 +20,7 @@ const cumulative = {
};
/** The statistics of the trackpoints from `start` to `end` (both included), as the engine does. */
function slice(start: number, end: number): GlobalStatistics | undefined {
async function slice(start: number, end: number): Promise<GlobalStatistics | undefined> {
if (!(start >= 0 && start <= end && end < length)) {
return undefined;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Backend } from './types';
export type { Backend } from './types';
/** Whether the page runs in the desktop app, which has the engine in its own process. */
function inDesktopApp() {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
/**
* Starts the engine of the files: the one of the desktop app when the page runs in it, the
* WebAssembly module otherwise.
*/
export async function loadBackend(): Promise<Backend> {
return inDesktopApp()
? (await import('./tauri')).createTauriBackend()
: (await import('./wasm')).createWasmBackend();
}
+217
View File
@@ -0,0 +1,217 @@
import { invoke } from '@tauri-apps/api/core';
import type { RouteAttributes, StorageOpened } from 'gpx-rs';
import type { Backend } from './types';
/** An array that a command returns as its bytes. */
const array = async <T>(
command: string,
type: { new (buffer: ArrayBuffer): T },
args?: Record<string, unknown>
) => new type(await invoke<ArrayBuffer>(command, args));
/** Like `array`, for the arrays that can be absent: the first byte tells whether they are there. */
const optionalArray = async <T>(
command: string,
type: { new (buffer: ArrayBuffer): T },
args?: Record<string, unknown>
) => {
const bytes = await invoke<ArrayBuffer>(command, args);
return new Uint8Array(bytes)[0] === 1 ? new type(bytes.slice(1)) : undefined;
};
const category = (c?: RouteAttributes['surface']) =>
c && { starts: Array.from(c.starts), values: Array.from(c.values), names: c.names };
/**
* The engine in the Rust process of the desktop app, reached through its commands (see
* `tauri/src/commands`). Typed arrays are returned as bytes, little-endian.
*/
export async function createTauriBackend(): Promise<Backend> {
const f64 = (command: string, args?: Record<string, unknown>) =>
array(command, Float64Array, args);
const u32 = (command: string) => array(command, Uint32Array);
const u8 = (command: string) => array(command, Uint8Array);
const f64Optional = (command: string) => optionalArray(command, Float64Array);
return {
select: (fileIds, mode) => invoke('select', { fileIds, mode }),
select_all: () => invoke('select_all'),
arrow_select: (down, add) => invoke('arrow_select', { down, add }),
select_tracks: (fileId, trackIds, mode) =>
invoke('select_tracks', { fileId, trackIds, mode }),
select_segments: (fileId, trackId, segmentIds, mode) =>
invoke('select_segments', { fileId, trackId, segmentIds, mode }),
select_waypoint_group: (fileId, mode) => invoke('select_waypoint_group', { fileId, mode }),
select_waypoints: (fileId, waypointIds, mode) =>
invoke('select_waypoints', { fileId, waypointIds, mode }),
undo: () => invoke('undo'),
redo: () => invoke('redo'),
copy: () => invoke('copy'),
cut: () => invoke('cut'),
paste: () => invoke('paste'),
new_file: (name, lng, lat, ele) => invoke('new_file', { name, lng, lat, ele }),
load_files: (files) => {
const data = new Uint8Array(files.reduce((sum, file) => sum + file.data.length, 0));
let offset = 0;
for (const file of files) {
data.set(file.data, offset);
offset += file.data.length;
}
// headers are ASCII
const header = (value: unknown) => encodeURIComponent(JSON.stringify(value));
return invoke('load_files', data, {
headers: {
lengths: header(files.map((file) => file.data.length)),
names: header(files.map((file) => file.name)),
},
});
},
duplicate: () => invoke('duplicate'),
delete: (wholeFiles) => invoke('delete', { wholeFiles }),
delete_all: () => invoke('delete_all'),
reorder: (fileIds, index) => invoke('reorder', { fileIds, index }),
// an infinite index is the end of the list, which JSON cannot tell
move_elements: (what, to) =>
invoke('move_elements', {
what,
to: { ...to, index: Math.min(to.index, Number.MAX_SAFE_INTEGER) },
}),
metadata: (name, desc) => invoke('metadata', { name, desc }),
style: (color, opacity, width) => invoke('style', { color, opacity, width }),
new_track: () => invoke('new_track'),
new_track_segment: () => invoke('new_track_segment'),
new_waypoint: (lng, lat, ele, name, desc, icon, link) =>
invoke('new_waypoint', { lng, lat, ele, name, desc, icon, link }),
update_waypoint: (fileId, waypointId, lng, lat, ele, name, desc, icon, link) =>
invoke('update_waypoint', {
fileId,
waypointId,
lng,
lat,
ele,
name,
desc,
icon,
link,
}),
move_waypoint: (fileId, waypointId, lng, lat, ele) =>
invoke('move_waypoint', { fileId, waypointId, lng, lat, ele }),
delete_waypoint: (fileId, waypointId) => invoke('delete_waypoint', { fileId, waypointId }),
reverse: () => invoke('reverse'),
extract: () => invoke('extract'),
round_trip: () => invoke('round_trip'),
elevation: (ele) => invoke('elevation', { ele: Array.from(ele) }),
change_timestamps: (startTime, speed, ratio) =>
invoke('change_timestamps', { startTime, speed, ratio }),
create_artificial_timestamps: (startTime, totalTime) =>
invoke('create_artificial_timestamps', { startTime, totalTime }),
clean: (west, south, east, north, inside, trkpt, wpt) =>
invoke('clean', {
west,
south,
east,
north,
cleanType: inside ? 'inside' : 'outside',
trkpt,
wpt,
}),
merge: (mergeType, removeGaps) => invoke('merge', { mergeType, removeGaps }),
reduce: (tolerance) => invoke('reduce', { tolerance }),
crop: (start, end) => invoke('crop', { start, end }),
split: (revision, at, splitType) => invoke('split', { revision, at, splitType }),
route: (revision, start, end, lng, lat, ele, attributes, anchors) =>
invoke('route', {
revision,
start,
end,
lng: Array.from(lng),
lat: Array.from(lat),
ele: Array.from(ele),
attributes: {
surface: category(attributes.surface),
highway: category(attributes.highway),
sacScale: category(attributes.sacScale),
mtbScale: category(attributes.mtbScale),
},
anchors: Array.from(anchors),
}),
insert_anchor: (revision, lng, lat) => invoke('insert_anchor', { revision, lng, lat }),
change_loop_start: (revision, index) => invoke('change_loop_start', { revision, index }),
can_undo: () => invoke('can_undo'),
can_redo: () => invoke('can_redo'),
can_paste: () => invoke('can_paste'),
clipboard: async () => (await invoke('clipboard')) ?? undefined,
selection: () => invoke('selection'),
file_order: () => invoke('file_order'),
file_structure: async (fileId) => (await invoke('file_structure', { fileId })) ?? undefined,
file_statistics: async (fileId) =>
(await invoke('file_statistics', { fileId })) ?? undefined,
waypoint: async (fileId, waypointId) =>
(await invoke('waypoint', { fileId, waypointId })) ?? undefined,
trackpoint: async (fileId, segmentId, index) =>
(await invoke('trackpoint', { fileId, segmentId, index })) ?? undefined,
export_file: async (fileId, time, hr, cad, atemp, power, osm, asRoute) => {
const file = await optionalArray('export_file', Uint8Array, {
fileId,
time,
hr,
cad,
atemp,
power,
osm,
asRoute,
});
return file as Uint8Array<ArrayBuffer> | undefined;
},
exportable_data: (fileIds) => invoke('exportable_data', { fileIds }),
reduction_distances: () => f64('reduction_distances'),
segment_coordinates: (segmentId) => f64('segment_coordinates', { segmentId }),
waypoint_coordinates: (fileId) => f64('waypoint_coordinates', { fileId }),
selection_statistics: () => invoke('selection_statistics'),
slice_statistics: async (start, end) =>
(await invoke('slice_statistics', { start, end })) ?? undefined,
total_distance: () => f64('total_distance'),
slope: () => f64('slope'),
lng: () => f64('lng'),
lat: () => f64('lat'),
ele: () => f64('ele'),
timestamps: () => optionalArray('timestamps', BigInt64Array),
speed: () => f64Optional('speed'),
hr: () => f64Optional('hr'),
cad: () => f64Optional('cad'),
atemp: () => f64Optional('atemp'),
power: () => f64Optional('power'),
slope_segment_slope: () => f64('slope_segment_slope'),
slope_segment_distance: () => f64('slope_segment_distance'),
surface_starts: () => u32('surface_starts'),
surface_values: () => u8('surface_values'),
surfaces: () => invoke('surfaces'),
highway_starts: () => u32('highway_starts'),
highway_values: () => u8('highway_values'),
highways: () => invoke('highways'),
sac_scale_starts: () => u32('sac_scale_starts'),
sac_scale_values: () => u8('sac_scale_values'),
sac_scales: () => invoke('sac_scales'),
mtb_scale_starts: () => u32('mtb_scale_starts'),
mtb_scale_values: () => u8('mtb_scale_values'),
mtb_scales: () => invoke('mtb_scales'),
anchor_indices: () => u32('anchor_indices'),
anchor_zooms: () => u8('anchor_zooms'),
segment_starts: () => u32('segment_starts'),
segment_ids: () => invoke('segment_ids'),
routing_revision: () => invoke('routing_revision'),
importsLegacyData: false,
open_storage: () => invoke<StorageOpened>('open_storage'),
flush_storage: () => invoke('flush_storage'),
sync_storage: () => invoke('sync_storage'),
set_setting: (key, value) => invoke('set_setting', { key, value }),
delete_setting: (key) => invoke('delete_setting', { key }),
};
}
+211
View File
@@ -0,0 +1,211 @@
import type {
Clipboard,
FileStructure,
GlobalStatistics,
MoveTarget,
Outcome,
RouteAttributes,
Selection,
StorageOpened,
TrackpointDetails,
WaypointDetails,
} from 'gpx-rs';
import type { MergeType, SelectMode, SplitType } from '../types';
/**
* What the facade needs of the engine of the files, wherever it runs: the WASM module in the
* browser (`wasm.ts`), or the Rust process of the desktop app, through its commands (`tauri.ts`).
* Both give the same results: see `gpx-rs/wasm` for what each function does.
*
* Everything is asynchronous, because the desktop app is reached through messages. Ids are
* hyphenated UUID strings, the arrays of numbers are typed arrays, and what the functions that
* edit the files or change the selection return is an `Outcome`.
*/
export interface Backend {
// Selection
select(fileIds: string[], mode: SelectMode): Promise<Outcome>;
select_all(): Promise<Outcome>;
arrow_select(down: boolean, add: boolean): Promise<Outcome>;
select_tracks(fileId: string, trackIds: string[], mode: SelectMode): Promise<Outcome>;
select_segments(
fileId: string,
trackId: string,
segmentIds: string[],
mode: SelectMode
): Promise<Outcome>;
select_waypoint_group(fileId: string, mode: SelectMode): Promise<Outcome>;
select_waypoints(fileId: string, waypointIds: string[], mode: SelectMode): Promise<Outcome>;
// History and clipboard
undo(): Promise<Outcome>;
redo(): Promise<Outcome>;
copy(): Promise<Outcome>;
cut(): Promise<Outcome>;
paste(): Promise<Outcome>;
// Files
new_file(name: string, lng?: number, lat?: number, ele?: number): Promise<Outcome>;
load_files(files: { data: Uint8Array; name: string }[]): Promise<Outcome>;
duplicate(): Promise<Outcome>;
delete(wholeFiles: boolean): Promise<Outcome>;
delete_all(): Promise<Outcome>;
reorder(fileIds: string[], index: number): Promise<Outcome>;
move_elements(what: Selection, to: MoveTarget): Promise<Outcome>;
metadata(name: string, desc: string): Promise<Outcome>;
style(color?: string, opacity?: number, width?: number): Promise<Outcome>;
new_track(): Promise<Outcome>;
new_track_segment(): Promise<Outcome>;
// Waypoints
new_waypoint(
lng: number,
lat: number,
ele: number,
name: string,
desc: string,
icon: string,
link: string
): Promise<Outcome>;
update_waypoint(
fileId: string,
waypointId: string,
lng: number,
lat: number,
ele: number,
name: string,
desc: string,
icon: string,
link: string
): Promise<Outcome>;
move_waypoint(
fileId: string,
waypointId: string,
lng: number,
lat: number,
ele: number
): Promise<Outcome>;
delete_waypoint(fileId: string, waypointId: string): Promise<Outcome>;
// Tools
reverse(): Promise<Outcome>;
extract(): Promise<Outcome>;
round_trip(): Promise<Outcome>;
elevation(ele: Float64Array): Promise<Outcome>;
change_timestamps(startTime: number, speed: number, ratio: number): Promise<Outcome>;
create_artificial_timestamps(startTime: number, totalTime: number): Promise<Outcome>;
clean(
west: number,
south: number,
east: number,
north: number,
inside: boolean,
trackpoints: boolean,
waypoints: boolean
): Promise<Outcome>;
merge(type: MergeType, removeGaps: boolean): Promise<Outcome>;
reduce(tolerance: number): Promise<Outcome>;
crop(start: number, end: number): Promise<Outcome>;
split(revision: number, at: number, type: SplitType): Promise<Outcome>;
route(
revision: number,
start: number,
end: number,
lng: Float64Array,
lat: Float64Array,
ele: Float64Array,
attributes: RouteAttributes,
anchors: Uint32Array
): Promise<Outcome>;
insert_anchor(revision: number, lng: number, lat: number): Promise<Outcome>;
change_loop_start(revision: number, index: number): Promise<Outcome>;
// The files and the selection
can_undo(): Promise<boolean>;
can_redo(): Promise<boolean>;
can_paste(): Promise<boolean>;
clipboard(): Promise<Clipboard | undefined>;
selection(): Promise<Selection>;
file_order(): Promise<string[]>;
file_structure(fileId: string): Promise<FileStructure | undefined>;
file_statistics(fileId: string): Promise<GlobalStatistics | undefined>;
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined>;
trackpoint(
fileId: string,
segmentId: string,
index: number
): Promise<TrackpointDetails | undefined>;
/** A file as GPX (UTF-8 bytes), `undefined` if it does not exist. */
export_file(
fileId: string,
time: boolean,
hr: boolean,
cad: boolean,
atemp: boolean,
power: boolean,
osm: boolean,
asRoute: boolean
): Promise<Uint8Array<ArrayBuffer> | undefined>;
exportable_data(fileIds: string[]): Promise<number>;
reduction_distances(): Promise<Float64Array>;
segment_coordinates(segmentId: string): Promise<Float64Array>;
waypoint_coordinates(fileId: string): Promise<Float64Array>;
// The statistics of the selection
selection_statistics(): Promise<GlobalStatistics | undefined>;
slice_statistics(start: number, end: number): Promise<GlobalStatistics | undefined>;
total_distance(): Promise<Float64Array>;
slope(): Promise<Float64Array>;
lng(): Promise<Float64Array>;
lat(): Promise<Float64Array>;
ele(): Promise<Float64Array>;
timestamps(): Promise<BigInt64Array | undefined>;
speed(): Promise<Float64Array | undefined>;
hr(): Promise<Float64Array | undefined>;
cad(): Promise<Float64Array | undefined>;
atemp(): Promise<Float64Array | undefined>;
power(): Promise<Float64Array | undefined>;
slope_segment_slope(): Promise<Float64Array>;
slope_segment_distance(): Promise<Float64Array>;
surface_starts(): Promise<Uint32Array>;
surface_values(): Promise<Uint8Array>;
surfaces(): Promise<string[]>;
highway_starts(): Promise<Uint32Array>;
highway_values(): Promise<Uint8Array>;
highways(): Promise<string[]>;
sac_scale_starts(): Promise<Uint32Array>;
sac_scale_values(): Promise<Uint8Array>;
sac_scales(): Promise<string[]>;
mtb_scale_starts(): Promise<Uint32Array>;
mtb_scale_values(): Promise<Uint8Array>;
mtb_scales(): Promise<string[]>;
anchor_indices(): Promise<Uint32Array>;
anchor_zooms(): Promise<Uint8Array>;
segment_starts(): Promise<Uint32Array>;
segment_ids(): Promise<string[]>;
routing_revision(): Promise<number>;
// Storage
/**
* Whether the files that the previous versions of the app kept in the browser can be imported
* (they are in the IndexedDB of the browser).
*/
readonly importsLegacyData: boolean;
/**
* Opens the storage: the files that it holds are put in the engine, and the changes of the
* files are kept from then on. `onSaved` is called after the files were written, `onBehind`
* when a write was refused because another window wrote since (call `sync_storage` then), and
* `onNewer` when what is stored comes from a newer version of the app.
*/
open_storage(
name: string,
onSaved: () => void,
onBehind: () => void,
onNewer: () => void
): Promise<StorageOpened>;
/** Resolves when what was changed has been written to the storage. */
flush_storage(): Promise<void>;
/** Reads the files again if another window wrote them since, and tells what changed. */
sync_storage(): Promise<Outcome>;
set_setting(key: string, json: string): Promise<void>;
delete_setting(key: string): Promise<void>;
}
+172
View File
@@ -0,0 +1,172 @@
import type { Backend } from './types';
import type { MergeType, SelectMode, SplitType } from '../types';
type Wasm = typeof import('gpx-rs');
/** A call of the module, which is synchronous, as an asynchronous one. */
const lift =
<A extends unknown[], R>(f: (...args: A) => R) =>
async (...args: A): Promise<R> =>
f(...args);
/** Hyphenated UUID strings to the concatenated 16-byte form the module takes. */
function idsToBytes(ids: string[]): Uint8Array {
const bytes = new Uint8Array(ids.length * 16);
ids.forEach((id, i) => {
const hex = id.replaceAll('-', '');
for (let j = 0; j < 16; j++) {
bytes[i * 16 + j] = parseInt(hex.slice(2 * j, 2 * j + 2), 16);
}
});
return bytes;
}
/** The engine in the page, as the WebAssembly module of `gpx-rs`. */
export async function createWasmBackend(): Promise<Backend> {
const w: Wasm = await import('gpx-rs');
w.start();
const mode = (m: SelectMode) =>
({ replace: w.SelectMode.Replace, add: w.SelectMode.Add, toggle: w.SelectMode.Toggle })[m];
const splitType = (t: SplitType) =>
({ files: w.SplitType.Files, tracks: w.SplitType.Tracks, segments: w.SplitType.Segments })[
t
];
const mergeType = (t: MergeType) => (t === 'connect' ? w.MergeType.Connect : w.MergeType.Group);
return {
select: lift((ids, m) => w.select(idsToBytes(ids), mode(m))),
select_all: lift(w.select_all),
arrow_select: lift(w.arrow_select),
select_tracks: lift((file, ids, m) => w.select_tracks(file, idsToBytes(ids), mode(m))),
select_segments: lift((file, track, ids, m) =>
w.select_segments(file, track, idsToBytes(ids), mode(m))
),
select_waypoint_group: lift((file, m) => w.select_waypoint_group(file, mode(m))),
select_waypoints: lift((file, ids, m) =>
w.select_waypoints(file, idsToBytes(ids), mode(m))
),
undo: lift(w.undo),
redo: lift(w.redo),
copy: lift(w.copy),
cut: lift(w.cut),
paste: lift(w.paste),
new_file: lift(w.new_file),
load_files: lift((files) => {
const lengths = Uint32Array.from(files.map((file) => file.data.length));
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
let offset = 0;
for (const file of files) {
data.set(file.data, offset);
offset += file.data.length;
}
return w.load_files(
data,
lengths,
files.map((file) => file.name)
);
}),
duplicate: lift(w.duplicate),
delete: lift(w._delete),
delete_all: lift(w.delete_all),
reorder: lift((ids, index) => w.reorder(idsToBytes(ids), index)),
move_elements: lift(w.move_elements),
metadata: lift(w.metadata),
style: lift(w.style),
new_track: lift(w.new_track),
new_track_segment: lift(w.new_track_segment),
new_waypoint: lift(w.new_waypoint),
update_waypoint: lift(w.update_waypoint),
move_waypoint: lift(w.move_waypoint),
delete_waypoint: lift(w.delete_waypoint),
reverse: lift(w.reverse),
extract: lift(w.extract),
round_trip: lift(w.round_trip),
elevation: lift(w.elevation),
change_timestamps: lift(w.changeTimestamps),
create_artificial_timestamps: lift(w.createArtificialTimestamps),
clean: lift((west, south, east, north, inside, trackpoints, waypoints) =>
w.clean(
west,
south,
east,
north,
inside ? w.CleanType.Inside : w.CleanType.Outside,
trackpoints,
waypoints
)
),
merge: lift((type, removeGaps) => w.merge(mergeType(type), removeGaps)),
reduce: lift(w.reduce),
crop: lift(w.crop),
split: lift((revision, at, type) => w.split(revision, at, splitType(type))),
route: lift(w.route),
insert_anchor: lift(w.insert_anchor),
change_loop_start: lift(w.change_loop_start),
can_undo: lift(w.can_undo),
can_redo: lift(w.can_redo),
can_paste: lift(w.can_paste),
clipboard: lift(w.clipboard),
selection: lift(w.selection),
file_order: lift(w.file_order),
file_structure: lift(w.file_structure),
file_statistics: lift(w.file_statistics),
waypoint: lift(w.waypoint),
trackpoint: lift(w.trackpoint),
// a copy out of the memory of the engine, so a plain ArrayBuffer
export_file: lift(
(...args: Parameters<Backend['export_file']>) =>
w.export_file(...args) as Uint8Array<ArrayBuffer> | undefined
),
exportable_data: lift((ids) => w.exportable_data(idsToBytes(ids))),
reduction_distances: lift(w.reduction_distances),
segment_coordinates: lift(w.segment_coordinates),
waypoint_coordinates: lift(w.waypoint_coordinates),
selection_statistics: lift(w.selection_statistics),
slice_statistics: lift(w.slice_statistics),
total_distance: lift(w.total_distance),
slope: lift(w.slope),
lng: lift(w.lng),
lat: lift(w.lat),
ele: lift(w.ele),
timestamps: lift(w.timestamps),
speed: lift(w.speed),
hr: lift(w.hr),
cad: lift(w.cad),
atemp: lift(w.atemp),
power: lift(w.power),
slope_segment_slope: lift(w.slope_segment_slope),
slope_segment_distance: lift(w.slope_segment_distance),
surface_starts: lift(w.surface_starts),
surface_values: lift(w.surface_values),
surfaces: lift(w.surfaces),
highway_starts: lift(w.highway_starts),
highway_values: lift(w.highway_values),
highways: lift(w.highways),
sac_scale_starts: lift(w.sac_scale_starts),
sac_scale_values: lift(w.sac_scale_values),
sac_scales: lift(w.sac_scales),
mtb_scale_starts: lift(w.mtb_scale_starts),
mtb_scale_values: lift(w.mtb_scale_values),
mtb_scales: lift(w.mtb_scales),
anchor_indices: lift(w.anchor_indices),
anchor_zooms: lift(w.anchor_zooms),
segment_starts: lift(w.segment_starts),
segment_ids: lift(w.segment_ids),
routing_revision: lift(w.routing_revision),
importsLegacyData: true,
open_storage: (name, onSaved, onBehind, onNewer) =>
w.open_storage(name, onSaved, onBehind, onNewer),
flush_storage: () => w.flush_storage(),
sync_storage: () => w.sync_storage(),
set_setting: lift(w.set_setting),
delete_setting: lift(w.delete_setting),
};
}
-25
View File
@@ -1,16 +1,3 @@
import type { SelectMode } from './types';
export type Wasm = typeof import('gpx-rs');
/** The enumeration of the engine for a way to combine a selection. */
export function selectMode(wasm: Wasm, mode: SelectMode) {
return {
replace: wasm.SelectMode.Replace,
add: wasm.SelectMode.Add,
toggle: wasm.SelectMode.Toggle,
}[mode];
}
/** A flat `[lng, lat, ...]` array as positions. */
export function toPositions(flat: Float64Array): [number, number][] {
const positions: [number, number][] = new Array(flat.length / 2);
@@ -19,15 +6,3 @@ export function toPositions(flat: Float64Array): [number, number][] {
}
return positions;
}
export /** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */
function idsToBytes(ids: string[]): Uint8Array {
const bytes = new Uint8Array(ids.length * 16);
ids.forEach((id, i) => {
const hex = id.replaceAll('-', '');
for (let j = 0; j < 16; j++) {
bytes[i * 16 + j] = parseInt(hex.slice(2 * j, 2 * j + 2), 16);
}
});
return bytes;
}
+17 -15
View File
@@ -218,7 +218,7 @@ describe('statistics', () => {
assert.equal(get(engine.statistics).hr, undefined);
const request = engine.requestStatistics();
request.set(['hr', 'anchors']);
await request.set(['hr', 'anchors']);
const statistics = get(engine.statistics);
assert.equal(statistics.hr!.length, statistics.length);
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
@@ -239,14 +239,14 @@ describe('statistics', () => {
it('gives the statistics of a part of the selection', async () => {
await load('simple');
const statistics = get(engine.statistics);
const part = statistics.slice(2, 5)!;
const part = (await statistics.slice(2, 5))!;
assert.ok(part.totalDistance > 0);
assert.ok(part.totalDistance < statistics.global.totalDistance);
assert.equal(statistics.slice(0, statistics.length + 10), undefined);
assert.equal(await statistics.slice(0, statistics.length + 10), undefined);
// a slice is only valid for the selection it was made on
await engine.select([]);
assert.equal(statistics.slice(2, 5), undefined);
assert.equal(await statistics.slice(2, 5), undefined);
});
});
@@ -304,7 +304,7 @@ describe('file state', () => {
const file = fileStates()[0];
assert.ok(file.waypoints.features.length > 0);
const feature = file.waypoints.features[0];
const details = engine.waypoint(file.structure.id, feature.properties.waypointId)!;
const details = (await engine.waypoint(file.structure.id, feature.properties.waypointId))!;
assert.deepEqual(feature.geometry.coordinates, [details.lng, details.lat]);
assert.equal(feature.properties.name, details.name);
assert.equal(details.links?.[0].href, 'https://gpx.studio');
@@ -330,23 +330,23 @@ describe('waypoints', () => {
const id = file.waypoints.features[0].properties.waypointId;
const fileId = file.structure.id;
let details = engine.waypoint(fileId, id)!;
let details = (await engine.waypoint(fileId, id))!;
assert.equal(details.name, 'summit');
assert.equal(details.sym, 'Summit');
assert.equal(details.links![0].href, 'https://example.com');
await engine.updateWaypoint(fileId, id, { ...waypoint, name: 'peak', link: '' });
details = engine.waypoint(fileId, id)!;
details = (await engine.waypoint(fileId, id))!;
assert.equal(details.name, 'peak');
assert.equal(details.links, undefined);
await engine.moveWaypoint(fileId, id, 5, 51, 200);
assert.deepEqual(fileStates()[0].waypoints.features[0].geometry.coordinates, [5, 51]);
assert.equal(engine.waypoint(fileId, id)!.ele, 200);
assert.equal((await engine.waypoint(fileId, id))!.ele, 200);
await engine.deleteWaypoint(fileId, id);
assert.equal(fileStates()[0].waypoints.features.length, 0);
assert.equal(engine.waypoint(fileId, id), undefined);
assert.equal(await engine.waypoint(fileId, id), undefined);
assert.equal(await engine.deleteWaypoint(fileId, id), false);
});
});
@@ -395,8 +395,10 @@ describe('observer of the files', () => {
});
describe('export', () => {
const text = (id: string, options: Partial<ExportOptions> = {}) =>
new TextDecoder().decode(engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options }));
const text = async (id: string, options: Partial<ExportOptions> = {}) =>
new TextDecoder().decode(
await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options })
);
it('writes tracks, or routes when asked to', async () => {
await load('with_tracks_and_segments');
@@ -407,11 +409,11 @@ describe('export', () => {
);
const id = file.structure.id;
const tracks = text(id);
const tracks = await text(id);
assert.ok(tracks.includes('<trk>') && !tracks.includes('<rte>'));
assert.equal(tracks.match(/<trkseg>/g)?.length, segments);
const routes = text(id, { asRoute: true });
const routes = await text(id, { asRoute: true });
assert.ok(routes.includes('<rte>') && !routes.includes('<trk>'));
assert.equal(routes.match(/<rte>/g)?.length, segments);
assert.ok(routes.includes('<rtept'));
@@ -419,7 +421,7 @@ describe('export', () => {
it('does not count the way a file is written among its data', async () => {
await load('simple');
const data = engine.exportableData(get(engine.order));
const data = await engine.exportableData(get(engine.order));
assert.equal(data.asRoute, false);
});
@@ -427,7 +429,7 @@ describe('export', () => {
await load('with_tracks_and_segments');
const id = get(engine.order)[0];
const before = fileStates()[0].segments.features.length;
const routes = engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true })!;
const routes = (await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true }))!;
await engine.loadFiles([{ data: routes, name: 'routes' }]);
const [, loaded] = get(engine.order);
const state = get(get(engine.files).get(loaded)!);
+146 -133
View File
@@ -10,7 +10,7 @@ import {
import { categoryIntervals } from '$lib/engine/trackpoint-categories';
import { selectedElementIds, type FileTreeNode } from '$lib/engine/selection-helpers';
import type { RouteAttributes } from 'gpx-rs';
import { idsToBytes, selectMode, type Wasm } from './convert';
import { loadBackend, type Backend } from './backend';
import { readFileState } from './file-state';
import { StatisticsReader } from './statistics';
import { openStoredFiles } from './storage';
@@ -34,7 +34,8 @@ import {
} from './types';
/**
* Thin wrapper around the WASM engine.
* Thin wrapper around the engine of the files (the WASM module in the browser, the Rust process
* in the desktop app: see `backend`).
*
* The engine owns the files; this class mirrors what the UI needs in stores. After each action,
* only what the engine reports as changed is read again: the order of the files, and the state
@@ -42,7 +43,9 @@ import {
* segments and waypoints that did not change are reused.
*/
class Engine {
private wasm: Wasm | null = null;
private backend: Backend | null = null;
/** The calls to the engine, one after the other: each one is applied before the next starts. */
private _queue: Promise<unknown> = Promise.resolve();
/** Resolves once the engine can be used (never on the server). */
readonly ready: Promise<void>;
@@ -61,7 +64,7 @@ class Engine {
private _canRedo = writable(false);
private _canPaste = writable(false);
private _clipboard = writable<Clipboard | undefined>(undefined);
private _statistics = new StatisticsReader(() => this.wasm);
private _statistics = new StatisticsReader((task) => this.enqueue(task));
/** Ids of the files in display order. */
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
@@ -89,9 +92,8 @@ class Engine {
constructor() {
this.ready = browser
? import('gpx-rs').then((wasm) => {
wasm.start();
this.wasm = wasm;
? loadBackend().then((backend) => {
this.backend = backend;
})
: new Promise(() => {});
}
@@ -110,9 +112,9 @@ class Engine {
openStorage(): Promise<Record<string, string>> {
this._storage ??= this.ready.then(() =>
openStoredFiles({
wasm: this.wasm!,
backend: this.backend!,
visibility: this._visibility,
sync: (update) => this.sync(this.wasm!, update),
sync: (update) => this.enqueue(() => this.sync(this.backend!, update)),
outdated: () => this._outdated.set(true),
hasFiles: () => get(this._order).length > 0,
loadFiles: (files) => this.loadFiles(files),
@@ -124,11 +126,11 @@ class Engine {
/** Keeps a setting, `json` being its JSON. Nothing is kept before `openStorage` is done. */
setSetting(key: string, json: string) {
this.wasm?.set_setting(key, json);
void this.backend?.set_setting(key, json);
}
deleteSetting(key: string) {
this.wasm?.delete_setting(key);
void this.backend?.delete_setting(key);
}
// Selection. Like the edits, each action resolves to whether the engine changed something.
@@ -138,7 +140,7 @@ class Engine {
* `SelectMode` for how the files combine with the selection.
*/
select(fileIds: string[], mode: SelectMode = 'replace') {
return this.run((w) => w.select(idsToBytes(fileIds), selectMode(w, mode)));
return this.run((b) => b.select(fileIds, mode));
}
/**
@@ -147,7 +149,7 @@ class Engine {
* the files when nothing is selected.
*/
selectAll() {
return this.run((w) => w.select_all());
return this.run((b) => b.select_all());
}
/**
@@ -156,13 +158,13 @@ class Engine {
* it replaces it.
*/
arrowSelect(down: boolean, add: boolean) {
return this.run((w) => w.arrow_select(down, add));
return this.run((b) => b.arrow_select(down, add));
}
// Selecting elements inside a file. Ids that do not exist are ignored.
selectTracks(fileId: string, trackIds: string[], mode: SelectMode = 'replace') {
return this.run((w) => w.select_tracks(fileId, idsToBytes(trackIds), selectMode(w, mode)));
return this.run((b) => b.select_tracks(fileId, trackIds, mode));
}
selectSegments(
@@ -171,20 +173,16 @@ class Engine {
segmentIds: string[],
mode: SelectMode = 'replace'
) {
return this.run((w) =>
w.select_segments(fileId, trackId, idsToBytes(segmentIds), selectMode(w, mode))
);
return this.run((b) => b.select_segments(fileId, trackId, segmentIds, mode));
}
/** Selects the node standing for all the waypoints of the file. */
selectWaypointGroup(fileId: string, mode: SelectMode = 'replace') {
return this.run((w) => w.select_waypoint_group(fileId, selectMode(w, mode)));
return this.run((b) => b.select_waypoint_group(fileId, mode));
}
selectWaypoints(fileId: string, waypointIds: string[], mode: SelectMode = 'replace') {
return this.run((w) =>
w.select_waypoints(fileId, idsToBytes(waypointIds), selectMode(w, mode))
);
return this.run((b) => b.select_waypoints(fileId, waypointIds, mode));
}
/** Selects a node of the file tree. */
@@ -206,23 +204,23 @@ class Engine {
// History
undo() {
return this.run((w) => w.undo());
return this.run((b) => b.undo());
}
redo() {
return this.run((w) => w.redo());
return this.run((b) => b.redo());
}
// Clipboard
/** Puts the selected elements in the clipboard. */
copy() {
return this.run((w) => w.copy());
return this.run((b) => b.copy());
}
/** Like `copy`, but the elements are moved when they are pasted. */
cut() {
return this.run((w) => w.cut());
return this.run((b) => b.cut());
}
// Edits of the files
@@ -232,7 +230,7 @@ class Engine {
* segment that hold it, in the same undo step.
*/
newFile(name: string, trackpoint?: { lng: number; lat: number; ele?: number }) {
return this.run((w) => w.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele));
return this.run((b) => b.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele));
}
/**
@@ -241,24 +239,11 @@ class Engine {
* the first one that was read is selected.
*/
loadFiles(files: { data: Uint8Array; name: string }[]) {
const lengths = Uint32Array.from(files.map((file) => file.data.length));
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
let offset = 0;
for (const file of files) {
data.set(file.data, offset);
offset += file.data.length;
}
return this.run((w) =>
w.load_files(
data,
lengths,
files.map((file) => file.name)
)
);
return this.run((b) => b.load_files(files));
}
duplicate() {
return this.run((w) => w.duplicate());
return this.run((b) => b.duplicate());
}
/**
@@ -266,16 +251,16 @@ class Engine {
* are deleted instead, even if only a track or a waypoint is selected.
*/
delete(wholeFiles = false) {
return this.run((w) => w._delete(wholeFiles));
return this.run((b) => b.delete(wholeFiles));
}
deleteAll() {
return this.run((w) => w.delete_all());
return this.run((b) => b.delete_all());
}
/** Moves the files, in the given order, to `index` among the other files. */
reorder(fileIds: string[], index: number) {
return this.run((w) => w.reorder(idsToBytes(fileIds), index));
return this.run((b) => b.reorder(fileIds, index));
}
/**
@@ -289,7 +274,7 @@ class Engine {
* copied again, what was cut is moved the first time and copied after that.
*/
paste() {
return this.run((w) => w.paste());
return this.run((b) => b.paste());
}
/**
@@ -303,7 +288,7 @@ class Engine {
* node), among the waypoints of a file.
*/
move(what: Selection, to: MoveTarget) {
return this.run((w) => w.move_elements(what, to));
return this.run((b) => b.move_elements(what, to));
}
// Edits of the waypoints
@@ -313,8 +298,8 @@ class Engine {
* strings are fields that the waypoint does not have.
*/
newWaypoint(waypoint: NewWaypoint) {
return this.run((w) =>
w.new_waypoint(
return this.run((b) =>
b.new_waypoint(
waypoint.lng,
waypoint.lat,
waypoint.ele,
@@ -328,8 +313,8 @@ class Engine {
/** Changes a waypoint, selected or not. Empty strings remove the field. */
updateWaypoint(fileId: string, waypointId: string, waypoint: NewWaypoint) {
return this.run((w) =>
w.update_waypoint(
return this.run((b) =>
b.update_waypoint(
fileId,
waypointId,
waypoint.lng,
@@ -345,22 +330,22 @@ class Engine {
/** Moves a waypoint, selected or not: only its position and elevation change. */
moveWaypoint(fileId: string, waypointId: string, lng: number, lat: number, ele: number) {
return this.run((w) => w.move_waypoint(fileId, waypointId, lng, lat, ele));
return this.run((b) => b.move_waypoint(fileId, waypointId, lng, lat, ele));
}
/** Deletes a waypoint, selected or not. */
deleteWaypoint(fileId: string, waypointId: string) {
return this.run((w) => w.delete_waypoint(fileId, waypointId));
return this.run((b) => b.delete_waypoint(fileId, waypointId));
}
// Edits of the selection
newTrack() {
return this.run((w) => w.new_track());
return this.run((b) => b.new_track());
}
newTrackSegment() {
return this.run((w) => w.new_track_segment());
return this.run((b) => b.new_track_segment());
}
/**
@@ -369,7 +354,7 @@ class Engine {
* nothing to reverse (fewer than two trackpoints).
*/
reverse() {
return this.run((w) => w.reverse());
return this.run((b) => b.reverse());
}
/**
@@ -378,7 +363,7 @@ class Engine {
* Resolves to `false` when there is nothing to extract.
*/
extract() {
return this.run((w) => w.extract());
return this.run((b) => b.extract());
}
/**
@@ -387,7 +372,7 @@ class Engine {
* number of elevations is not the number of trackpoints.
*/
setElevation(ele: Float64Array) {
return this.run((w) => w.elevation(ele));
return this.run((b) => b.elevation(ele));
}
/**
@@ -396,7 +381,7 @@ class Engine {
* `speed` (km/h). Resolves to `false` when there is nothing to set, or the values are invalid.
*/
changeTimestamps(startTime: Date, speed: number, ratio: number) {
return this.run((w) => w.changeTimestamps(startTime.getTime(), speed, ratio));
return this.run((b) => b.change_timestamps(startTime.getTime(), speed, ratio));
}
/**
@@ -405,7 +390,7 @@ class Engine {
* nothing to set.
*/
createArtificialTimestamps(startTime: Date, totalTime: number) {
return this.run((w) => w.createArtificialTimestamps(startTime.getTime(), totalTime));
return this.run((b) => b.create_artificial_timestamps(startTime.getTime(), totalTime));
}
/**
@@ -418,13 +403,13 @@ class Engine {
trackpoints: boolean,
waypoints: boolean
) {
return this.run((w) =>
w.clean(
return this.run((b) =>
b.clean(
bounds.west,
bounds.south,
bounds.east,
bounds.north,
inside ? w.CleanType.Inside : w.CleanType.Outside,
inside,
trackpoints,
waypoints
)
@@ -437,9 +422,7 @@ class Engine {
* Resolves to `false` when there is nothing to merge.
*/
merge(type: MergeType, removeGaps = false) {
return this.run((w) =>
w.merge(type === 'connect' ? w.MergeType.Connect : w.MergeType.Group, removeGaps)
);
return this.run((b) => b.merge(type, removeGaps));
}
/**
@@ -448,7 +431,7 @@ class Engine {
* is nothing to remove.
*/
reduce(tolerance: number) {
return this.run((w) => w.reduce(tolerance));
return this.run((b) => b.reduce(tolerance));
}
/**
@@ -458,17 +441,7 @@ class Engine {
* one of `SelectionAnchors`, which `at` is an index of.
*/
split(revision: number, at: number, splitType: SplitType) {
return this.run((w) =>
w.split(
revision,
at,
{
files: w.SplitType.Files,
tracks: w.SplitType.Tracks,
segments: w.SplitType.Segments,
}[splitType]
)
);
return this.run((b) => b.split(revision, at, splitType));
}
/**
@@ -478,7 +451,7 @@ class Engine {
* the range covers the whole selection.
*/
crop(start: number, end: number) {
return this.run((w) => w.crop(Math.max(start, 0), Math.max(end, 0)));
return this.run((b) => b.crop(Math.max(start, 0), Math.max(end, 0)));
}
/**
@@ -487,16 +460,16 @@ class Engine {
* repeated), and goes on in time. Resolves to `false` when no segment has the two trackpoints that a way back needs.
*/
roundTrip() {
return this.run((w) => w.round_trip());
return this.run((b) => b.round_trip());
}
metadata(name: string, desc: string) {
return this.run((w) => w.metadata(name, desc));
return this.run((b) => b.metadata(name, desc));
}
/** Only the given fields are changed. */
style(style: { color?: string; opacity?: number; width?: number }) {
return this.run((w) => w.style(style.color, style.opacity, style.width));
return this.run((b) => b.style(style.color, style.opacity, style.width));
}
// Routing
@@ -529,8 +502,8 @@ class Engine {
routeAttributes[key] = intervals;
}
}
return this.run((w) =>
w.route(
return this.run((b) =>
b.route(
revision,
start,
end,
@@ -549,7 +522,7 @@ class Engine {
* one of `SelectionAnchors`.
*/
insertAnchor(revision: number, lng: number, lat: number) {
return this.run((w) => w.insert_anchor(revision, lng, lat));
return this.run((b) => b.insert_anchor(revision, lng, lat));
}
/**
@@ -557,7 +530,7 @@ class Engine {
* the trackpoints before it go to the end, and the loop closes on it. `revision` is the one of `SelectionAnchors`.
*/
changeLoopStart(revision: number, index: number) {
return this.run((w) => w.change_loop_start(revision, index));
return this.run((b) => b.change_loop_start(revision, index));
}
// Visibility, which is kept by the frontend and not by the engine
@@ -601,16 +574,20 @@ class Engine {
}
}
// Requests, which do not change anything
// Requests, which do not change anything. They wait for the calls made before them.
/** All the data of a waypoint, `undefined` if it does not exist (or before the engine is loaded). */
waypoint(fileId: string, waypointId: string): WaypointDetails | undefined {
return this.wasm?.waypoint(fileId, waypointId);
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined> {
return this.read((b) => b.waypoint(fileId, waypointId), undefined);
}
/** A trackpoint of a segment, `undefined` if it does not exist. */
trackpoint(fileId: string, segmentId: string, index: number): TrackpointDetails | undefined {
return this.wasm?.trackpoint(fileId, segmentId, index);
trackpoint(
fileId: string,
segmentId: string,
index: number
): Promise<TrackpointDetails | undefined> {
return this.read((b) => b.trackpoint(fileId, segmentId, index), undefined);
}
/**
@@ -620,23 +597,26 @@ class Engine {
exportFile(
fileId: string,
options: ExportOptions = ALL_EXPORT_OPTIONS
): Uint8Array<ArrayBuffer> | undefined {
// a copy out of the memory of the engine, so a plain ArrayBuffer
return this.wasm?.export_file(
fileId,
options.time,
options.hr,
options.cad,
options.atemp,
options.power,
options.osm,
options.asRoute
) as Uint8Array<ArrayBuffer> | undefined;
): Promise<Uint8Array<ArrayBuffer> | undefined> {
return this.read(
(b) =>
b.export_file(
fileId,
options.time,
options.hr,
options.cad,
options.atemp,
options.power,
options.osm,
options.asRoute
),
undefined
);
}
/** The data that some files have, which the options of `exportFile` can leave out. */
exportableData(fileIds: string[]): ExportOptions {
const bits = this.wasm?.exportable_data(idsToBytes(fileIds)) ?? 0;
async exportableData(fileIds: string[]): Promise<ExportOptions> {
const bits = await this.read((b) => b.exportable_data(fileIds), 0);
return {
time: (bits & 1) !== 0,
hr: (bits & 2) !== 0,
@@ -654,19 +634,19 @@ class Engine {
* (m) of `reduce` that keeps it: infinite for the ends of the segments. Not requested with
* the statistics: ask when they changed.
*/
reductionDistances(): Float64Array {
return this.wasm?.reduction_distances() ?? new Float64Array();
reductionDistances(): Promise<Float64Array> {
return this.read((b) => b.reduction_distances(), new Float64Array());
}
// Coordinates, as flat [lng, lat, ...] arrays. The getters copy them out of the WASM memory.
// Coordinates, as flat [lng, lat, ...] arrays: copies.
// Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed.
segmentCoordinates(segmentId: string): Float64Array {
return this.wasm?.segment_coordinates(segmentId) ?? new Float64Array();
segmentCoordinates(segmentId: string): Promise<Float64Array> {
return this.read((b) => b.segment_coordinates(segmentId), new Float64Array());
}
waypointCoordinates(fileId: string): Float64Array {
return this.wasm?.waypoint_coordinates(fileId) ?? new Float64Array();
waypointCoordinates(fileId: string): Promise<Float64Array> {
return this.read((b) => b.waypoint_coordinates(fileId), new Float64Array());
}
/**
@@ -682,17 +662,34 @@ class Engine {
// Plumbing
/** Does `task` after the ones that were queued before it, whatever they do. */
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = this._queue.then(task, task);
this._queue = result.catch(() => {});
return result;
}
/**
* Makes a request to the engine, once the calls made before it are done. Resolves to
* `fallback` when the engine is not loaded.
*/
private read<T>(request: (backend: Backend) => Promise<T>, fallback: T): Promise<T> {
return this.enqueue(async () => (this.backend ? request(this.backend) : fallback));
}
/**
* Makes a call to the engine and applies its outcome to what is kept of the files. Resolves to
* whether anything changed. When nothing did, `lastError` tells why.
*/
private async run(action: (wasm: Wasm) => Outcome): Promise<boolean> {
private async run(action: (backend: Backend) => Promise<Outcome>): Promise<boolean> {
await this.ready;
const wasm = this.wasm!;
const outcome = action(wasm);
this.sync(wasm, outcome);
this.report(outcome);
return outcome.changed;
return this.enqueue(async () => {
const backend = this.backend!;
const outcome = await action(backend);
await this.sync(backend, outcome);
this.report(outcome);
return outcome.changed;
});
}
/** Logs why a call did nothing, and keeps it in `lastError`. */
@@ -706,26 +703,34 @@ class Engine {
}
/** Updates what is kept of the engine with what a call changed. */
private sync(wasm: Wasm, update: Outcome) {
this._canUndo.set(wasm.can_undo());
this._canRedo.set(wasm.can_redo());
this._canPaste.set(wasm.can_paste());
private async sync(backend: Backend, update: Outcome) {
const [canUndo, canRedo, canPaste] = await Promise.all([
backend.can_undo(),
backend.can_redo(),
backend.can_paste(),
]);
this._canUndo.set(canUndo);
this._canRedo.set(canRedo);
this._canPaste.set(canPaste);
if (update.clipboardChanged) {
this._clipboard.set(wasm.clipboard());
this._clipboard.set(await backend.clipboard());
}
if (update.selectionChanged) {
this._selection.set(wasm.selection());
this._selection.set(await backend.selection());
}
if (update.orderChanged) {
this._order.set(wasm.file_order());
this._order.set(await backend.file_order());
}
const added: [string, FileState][] = [];
for (const id of update.added) {
const state = readFileState(wasm, id, this._colors, this._visibility);
const addedStates = await Promise.all(
update.added.map((id) => readFileState(backend, id, this._colors, this._visibility))
);
update.added.forEach((id, i) => {
const state = addedStates[i];
if (state) {
added.push([id, state]);
}
}
});
if (added.length > 0 || update.removed.length > 0) {
this._files.update((files) => {
const next = new Map(files);
@@ -739,14 +744,22 @@ class Engine {
}
if (update.modified.length > 0) {
const files = get(this._files);
for (const id of update.modified) {
const states = await Promise.all(
update.modified.map((id) => {
const store = files.get(id);
return (
store &&
readFileState(backend, id, this._colors, this._visibility, get(store))
);
})
);
update.modified.forEach((id, i) => {
const store = files.get(id);
const state =
store && readFileState(wasm, id, this._colors, this._visibility, get(store));
const state = states[i];
if (store && state) {
store.set(state);
}
}
});
}
// the statistics depend on the selection, on its order, and on the files
@@ -755,7 +768,7 @@ class Engine {
update.orderChanged ||
update.added.length + update.modified.length + update.removed.length > 0
) {
this._statistics.refresh(wasm);
await this._statistics.refresh(backend);
}
}
}
+18 -12
View File
@@ -1,18 +1,19 @@
import type { Feature, LineString } from 'geojson';
import { normalizeColor, type FileColorAllocator } from '$lib/engine/file-colors';
import type { Visibility } from '$lib/engine/file-visibility';
import { toPositions, type Wasm } from './convert';
import type { Backend } from './backend';
import { toPositions } from './convert';
import { EMPTY_STATISTICS, type FileState, type SegmentProperties } from './types';
/** Reads the state of a file, reusing from `previous` what did not change. */
export function readFileState(
wasm: Wasm,
export async function readFileState(
backend: Backend,
id: string,
colors: FileColorAllocator,
visibility: ReadonlyMap<string, Visibility>,
previous?: FileState
): FileState | null {
const structure = wasm.file_structure(id);
): Promise<FileState | null> {
const structure = await backend.file_structure(id);
if (!structure) {
return null;
}
@@ -25,7 +26,11 @@ export function readFileState(
const previousSegments = new Map(
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
);
const segments: Feature<LineString, SegmentProperties>[] = [];
// the coordinates of the segments that changed, read together
const segments: (
| Feature<LineString, SegmentProperties>
| Promise<Feature<LineString, SegmentProperties>>
)[] = [];
structure.tracks.forEach((track, trackIndex) => {
track.segments.forEach((segment, segmentIndex) => {
const properties: SegmentProperties = {
@@ -48,17 +53,18 @@ export function readFileState(
segments.push(
unchanged
? old
: {
: backend.segment_coordinates(segment.id).then((coordinates) => ({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: toPositions(wasm.segment_coordinates(segment.id)),
coordinates: toPositions(coordinates),
},
properties,
}
}))
);
});
});
const segmentFeatures = await Promise.all(segments);
const previousStructure = previous?.structure;
const waypointsUnchanged =
@@ -73,7 +79,7 @@ export function readFileState(
);
let waypoints = previous?.waypoints;
if (!waypoints || !waypointsUnchanged) {
const coordinates = toPositions(wasm.waypoint_coordinates(id));
const coordinates = toPositions(await backend.waypoint_coordinates(id));
waypoints = {
type: 'FeatureCollection',
features: structure.waypoints.map((waypoint, index) => ({
@@ -93,8 +99,8 @@ export function readFileState(
return {
structure,
color,
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments },
statistics: (await backend.file_statistics(id)) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segmentFeatures },
waypoints,
visibility: visibility.get(id) ?? new Map(),
};
+129 -95
View File
@@ -1,6 +1,6 @@
import { get, writable, type Readable } from 'svelte/store';
import { categoryIntervals, type CategoryIntervals } from '$lib/engine/trackpoint-categories';
import type { Wasm } from './convert';
import type { Backend } from './backend';
import {
EMPTY_SELECTION_STATISTICS,
EMPTY_STATISTICS,
@@ -23,35 +23,49 @@ export class StatisticsReader {
/** The metrics that were read for the current statistics. */
private _loaded = new Set<StatisticsMetric>();
/** @param wasm The engine, `null` before it is loaded. */
constructor(private wasm: () => Wasm | null) {}
/** The engine, as of the last time the statistics were read. */
private _backend: Backend | null = null;
/** @param enqueue Does a task after the calls to the engine that were made before it. */
constructor(private enqueue: <T>(task: () => Promise<T>) => Promise<T>) {}
/** Reads the statistics of the selection again, after it or the files changed. */
refresh(wasm: Wasm) {
this._store.set(this.read(wasm));
async refresh(backend: Backend) {
this._backend = backend;
this._store.set(await this.read(backend));
}
/**
* Reads the statistics buffers of the engine (the getters copy them), but only the metrics
* that were requested.
*/
private read(wasm: Wasm): SelectionStatistics {
private async read(backend: Backend): Promise<SelectionStatistics> {
const version = ++this._version;
this._loaded.clear();
const totalDistance = wasm.total_distance();
const [global, totalDistance, slope, lng, lat, ele, timestamps] = await Promise.all([
backend.selection_statistics(),
backend.total_distance(),
backend.slope(),
backend.lng(),
backend.lat(),
backend.ele(),
backend.timestamps(),
]);
const statistics: SelectionStatistics = {
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
global: global ?? EMPTY_STATISTICS,
length: totalDistance.length,
totalDistance,
slope: wasm.slope(),
lng: wasm.lng(),
lat: wasm.lat(),
ele: wasm.ele(),
timestamps: wasm.timestamps(),
slope,
lng,
lat,
ele,
timestamps,
slice: (start, end) =>
version === this._version ? wasm.slice_statistics(start, end) : undefined,
this.enqueue(async () =>
version === this._version ? backend.slice_statistics(start, end) : undefined
),
};
this.readMetrics(wasm, statistics, this.requestedMetrics());
await this.readMetrics(backend, statistics, this.requestedMetrics());
return statistics;
}
@@ -62,81 +76,95 @@ export class StatisticsReader {
}
/** Reads the metrics that were not read yet into `statistics`, which is the current one. */
private readMetrics(
wasm: Wasm,
private async readMetrics(
backend: Backend,
statistics: SelectionStatistics,
metrics: Set<StatisticsMetric>
) {
const categories = (
starts: Uint32Array,
values: Uint8Array,
names: () => string[]
): CategoryIntervals => ({
starts,
values,
names: names(),
});
const categories = async (
starts: Promise<Uint32Array>,
values: Promise<Uint8Array>,
names: Promise<string[]>
): Promise<CategoryIntervals> => {
const [s, v, n] = await Promise.all([starts, values, names]);
return { starts: s, values: v, names: n };
};
for (const metric of metrics) {
if (this._loaded.has(metric)) {
continue;
}
this._loaded.add(metric);
switch (metric) {
case 'speed':
statistics.speed = wasm.speed();
break;
case 'hr':
statistics.hr = wasm.hr();
break;
case 'cad':
statistics.cad = wasm.cad();
break;
case 'atemp':
statistics.atemp = wasm.atemp();
break;
case 'power':
statistics.power = wasm.power();
break;
case 'slopeSegment':
statistics.slopeSegmentSlope = wasm.slope_segment_slope();
statistics.slopeSegmentDistance = wasm.slope_segment_distance();
break;
case 'surface':
statistics.surface = categories(
wasm.surface_starts(),
wasm.surface_values(),
wasm.surfaces
);
break;
case 'anchors':
statistics.anchors = {
indices: wasm.anchor_indices(),
zooms: wasm.anchor_zooms(),
segmentStarts: wasm.segment_starts(),
segmentIds: wasm.segment_ids(),
revision: wasm.routing_revision(),
};
break;
case 'highway':
statistics.highway = categories(
wasm.highway_starts(),
wasm.highway_values(),
wasm.highways
);
statistics.sacScale = categories(
wasm.sac_scale_starts(),
wasm.sac_scale_values(),
wasm.sac_scales
);
statistics.mtbScale = categories(
wasm.mtb_scale_starts(),
wasm.mtb_scale_values(),
wasm.mtb_scales
);
break;
}
}
const missing = [...metrics].filter((metric) => !this._loaded.has(metric));
missing.forEach((metric) => this._loaded.add(metric));
await Promise.all(
missing.map(async (metric) => {
switch (metric) {
case 'speed':
statistics.speed = await backend.speed();
break;
case 'hr':
statistics.hr = await backend.hr();
break;
case 'cad':
statistics.cad = await backend.cad();
break;
case 'atemp':
statistics.atemp = await backend.atemp();
break;
case 'power':
statistics.power = await backend.power();
break;
case 'slopeSegment':
[statistics.slopeSegmentSlope, statistics.slopeSegmentDistance] =
await Promise.all([
backend.slope_segment_slope(),
backend.slope_segment_distance(),
]);
break;
case 'surface':
statistics.surface = await categories(
backend.surface_starts(),
backend.surface_values(),
backend.surfaces()
);
break;
case 'anchors': {
const [indices, zooms, segmentStarts, segmentIds, revision] =
await Promise.all([
backend.anchor_indices(),
backend.anchor_zooms(),
backend.segment_starts(),
backend.segment_ids(),
backend.routing_revision(),
]);
statistics.anchors = {
indices,
zooms,
segmentStarts,
segmentIds,
revision,
};
break;
}
case 'highway':
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
await Promise.all([
categories(
backend.highway_starts(),
backend.highway_values(),
backend.highways()
),
categories(
backend.sac_scale_starts(),
backend.sac_scale_values(),
backend.sac_scales()
),
categories(
backend.mtb_scale_starts(),
backend.mtb_scale_values(),
backend.mtb_scales()
),
]);
break;
}
})
);
}
/**
@@ -155,7 +183,7 @@ export class StatisticsReader {
for (const metric of metrics) {
wanted.add(metric);
}
this.loadMissing();
return this.loadMissing();
},
release: () => {
this._requests.delete(wanted);
@@ -164,16 +192,22 @@ export class StatisticsReader {
}
/** Reads the requested metrics that are not in the current statistics yet. */
private loadMissing() {
const wasm = this.wasm();
private async loadMissing() {
const metrics = this.requestedMetrics();
if (!wasm || [...metrics].every((metric) => this._loaded.has(metric))) {
if ([...metrics].every((metric) => this._loaded.has(metric))) {
return;
}
// the buffers of the engine still are the ones of the current statistics, as they are
// read after every action
const statistics = { ...get(this._store) };
this.readMetrics(wasm, statistics, metrics);
this._store.set(statistics);
// queued, so that the buffers of the engine still are the ones of the current statistics:
// they are read after every action
await this.enqueue(async () => {
const backend = this._backend;
const requested = this.requestedMetrics();
if (!backend || [...requested].every((metric) => this._loaded.has(metric))) {
return;
}
const statistics = { ...get(this._store) };
await this.readMetrics(backend, statistics, requested);
this._store.set(statistics);
});
}
}
+16 -12
View File
@@ -1,7 +1,7 @@
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/storage/legacy-import';
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/engine/file-visibility';
import type { Outcome, StorageOpened } from 'gpx-rs';
import type { Wasm } from './convert';
import type { Backend } from './backend';
/** Name of the IndexedDB database that keeps the files and the settings. */
const STORAGE_NAME = 'gpx-studio';
@@ -11,7 +11,7 @@ const CHANNEL_NAME = 'gpx-studio-storage';
/** What opening the storage needs of the engine facade. */
export type StorageHost = {
wasm: Wasm;
backend: Backend;
/** The explicit visibility per file, to fill with the one that was saved. */
visibility: Map<string, Visibility>;
/** Applies what a call changed to what the facade keeps of the files. */
@@ -34,11 +34,11 @@ function followOtherTabs(host: StorageHost): {
behind: () => void;
newer: () => void;
} {
const { wasm } = host;
const { backend } = host;
let reading = Promise.resolve();
const refresh = () => {
reading = reading
.then(async () => host.sync(await wasm.sync_storage()))
.then(async () => host.sync(await backend.sync_storage()))
.catch((error) => console.error('The files could not be read again', error));
};
const channel =
@@ -61,17 +61,17 @@ function followOtherTabs(host: StorageHost): {
}
/**
* Opens the storage of the browser: the files that it holds are put in the engine, and the
* Opens the storage: the files that it holds are put in the engine, and the
* changes of the files are kept from then on. Resolves to the settings, as JSON strings by key.
*
* The first time, what the previous versions of the app kept in the browser is imported.
*/
export async function openStoredFiles(host: StorageHost): Promise<Record<string, string>> {
const { wasm } = host;
const { backend } = host;
let opened: StorageOpened;
const tabs = followOtherTabs(host);
try {
opened = await wasm.open_storage(STORAGE_NAME, tabs.saved, tabs.behind, tabs.newer);
opened = await backend.open_storage(STORAGE_NAME, tabs.saved, tabs.behind, tabs.newer);
} catch (error) {
console.error('The files cannot be kept in this browser', error);
return {};
@@ -83,16 +83,20 @@ export async function openStoredFiles(host: StorageHost): Promise<Record<string,
);
host.sync(opened.outcome);
if (settings[LEGACY_IMPORTED_KEY] === undefined && !host.hasFiles()) {
if (
backend.importsLegacyData &&
settings[LEGACY_IMPORTED_KEY] === undefined &&
!host.hasFiles()
) {
settings = { ...settings, ...(await importLegacyData(host)) };
await wasm.flush_storage();
await backend.flush_storage();
}
return settings;
}
/** Puts the files and the settings of the former database in the engine. */
async function importLegacyData(host: StorageHost): Promise<Record<string, string>> {
const { wasm } = host;
const { backend } = host;
const imported: Record<string, string> = {};
try {
const legacy = await readLegacyData();
@@ -108,7 +112,7 @@ async function importLegacyData(host: StorageHost): Promise<Record<string, strin
await host.deselect();
}
for (const [key, value] of Object.entries(legacy.settings)) {
wasm.set_setting(key, value);
await backend.set_setting(key, value);
imported[key] = value;
}
}
@@ -117,7 +121,7 @@ async function importLegacyData(host: StorageHost): Promise<Record<string, strin
console.error('The files of the previous version could not be imported', error);
return imported;
}
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
await backend.set_setting(LEGACY_IMPORTED_KEY, 'true');
imported[LEGACY_IMPORTED_KEY] = 'true';
return imported;
}
+4 -4
View File
@@ -229,7 +229,7 @@ export type SelectionStatistics = {
* the part of the elevation profile that was dragged over. `undefined` if the range is not
* in the selection, or if the selection is not the current one anymore.
*/
slice(start: number, end: number): GlobalStatistics | undefined;
slice(start: number, end: number): Promise<GlobalStatistics | undefined>;
};
export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
@@ -240,12 +240,12 @@ export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
slice: () => undefined,
slice: async () => undefined,
};
export type StatisticsRequest = {
/** Replaces the metrics needed by this consumer. */
set(metrics: Iterable<StatisticsMetric>): void;
/** Replaces the metrics needed by this consumer. Resolves when the missing ones are read. */
set(metrics: Iterable<StatisticsMetric>): Promise<void>;
/** The consumer does not need any metric anymore. */
release(): void;
};