mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
progress
This commit is contained in:
1 parent
ce8398f749
commit
41b9116c6a
60 files changed
+7525
-340
No files matched your search
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user