refactoring and tests

This commit is contained in:
vcoppe committed 2026-10-09 08:58:00 +02:00
1 parent 3bb553d6b5
commit da9abbae5e
45 files changed
+3558 -1564

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
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);
for (let i = 0; i < positions.length; i++) {
positions[i] = [flat[2 * i], flat[2 * i + 1]];
}
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;
}
+405
View File
@@ -0,0 +1,405 @@
// The frontend side of the engine, against the real engine (the WASM module of `gpx-rs`, which has
// to be built: `wasm-pack build wasm --target bundler --out-dir ../pkg` in `gpx-rs`).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { isHidden } from '$lib/file-visibility';
import { engine, FileStateCollectionObserver, NO_TIME, type FileState } from '$lib/engine';
function fixture(name: string): Uint8Array {
return new Uint8Array(
readFileSync(new URL(`../../../../gpx-rs/engine/data/${name}.gpx`, import.meta.url))
);
}
async function load(...names: string[]) {
return engine.loadFiles(names.map((name) => ({ data: fixture(name), name })));
}
function fileStates(): FileState[] {
return [...get(engine.files).values()].map((store) => get(store));
}
function lastError() {
return get(engine.lastError);
}
beforeEach(async () => {
await engine.ready;
await engine.deleteAll();
});
describe('files', () => {
it('creates a file, selects it, and can take it back', async () => {
assert.equal(await engine.newFile('route'), true);
const [id] = get(engine.order);
assert.equal(get(engine.order).length, 1);
assert.equal(get(get(engine.files).get(id)!).structure.name, 'route');
assert.deepEqual(get(engine.selection), { type: 'file', fileIds: [id] });
assert.equal(get(engine.canUndo), true);
await engine.undo();
assert.deepEqual(get(engine.order), []);
assert.equal(get(engine.files).size, 0);
assert.equal(get(engine.canRedo), true);
await engine.redo();
assert.deepEqual(get(engine.order), [id]);
});
it('starts a new file with a track when given a trackpoint', async () => {
await engine.newFile('start', { lng: 4.4, lat: 50.8, ele: 120 });
const file = fileStates()[0];
assert.equal(file.structure.tracks.length, 1);
assert.equal(file.segments.features.length, 1);
assert.deepEqual(file.segments.features[0].geometry.coordinates, [[4.4, 50.8]]);
assert.equal(get(engine.statistics).length, 1);
assert.equal(get(engine.statistics).ele[0], 120);
});
it('loads several files in one undo step and selects the first one', async () => {
assert.equal(await load('simple', 'with_tracks'), true);
const ids = get(engine.order);
assert.equal(ids.length, 2);
assert.deepEqual(get(engine.selection), { type: 'file', fileIds: [ids[0]] });
// the names of the files are the ones of the data, or the given ones
assert.deepEqual(
fileStates().map((file) => file.structure.name),
['simple', 'with_tracks']
);
await engine.undo();
assert.equal(get(engine.files).size, 0);
});
it('skips what cannot be read, and tells why when nothing could', async () => {
const broken = { data: new TextEncoder().encode('<gpx><trk></gpx>'), name: 'broken' };
assert.equal(await engine.loadFiles([broken]), false);
assert.match(lastError() ?? '', /^invalid data/);
assert.equal(get(engine.files).size, 0);
assert.equal(
await engine.loadFiles([broken, { data: fixture('simple'), name: 'ok' }]),
true
);
assert.equal(get(engine.files).size, 1);
assert.equal(lastError(), undefined);
});
it('duplicates and deletes the selection', async () => {
await load('simple');
await engine.duplicate();
assert.equal(get(engine.order).length, 2);
const [original, copy] = get(engine.order);
assert.notEqual(original, copy);
await engine.delete();
assert.equal(get(engine.files).size, 1);
assert.equal(await engine.deleteAll(), true);
assert.equal(await engine.deleteAll(), false);
assert.equal(lastError(), 'nothing to do');
});
it('reorders the files', async () => {
await load('simple', 'with_tracks', 'with_waypoint');
const [a, b, c] = get(engine.order);
await engine.reorder([c], 0);
assert.deepEqual(get(engine.order), [c, a, b]);
assert.equal(get(engine.canUndo), true);
// an order is not an edit: only the loading is undone
await engine.undo();
assert.equal(get(engine.files).size, 0);
});
});
describe('errors', () => {
it('keeps the reason of the last call that did nothing', async () => {
assert.equal(lastError(), 'nothing to do');
await engine.newFile('a');
assert.equal(lastError(), undefined);
await engine.select([]);
assert.equal(await engine.reverse(), false);
assert.equal(lastError(), 'nothing to do');
// a call with wrong arguments says so
assert.equal(await engine.crop(5, 1), false);
assert.match(lastError() ?? '', /reversed/);
// the next success clears it
assert.equal(await engine.newFile('b'), true);
assert.equal(lastError(), undefined);
});
it('refuses a stale routing revision', async () => {
await engine.newFile('a', { lng: 4, lat: 50 });
assert.equal(await engine.insertAnchor(-1, 4, 50), false);
assert.match(lastError() ?? '', /selection changed/);
});
});
describe('selection', () => {
it('adds and toggles files', async () => {
await load('simple', 'with_tracks', 'with_waypoint');
const [a, b, c] = get(engine.order);
await engine.select([a]);
await engine.select([b], 'add');
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b]));
await engine.select([a], 'toggle');
assert.deepEqual((get(engine.selection) as any).fileIds, [b]);
await engine.select([]);
assert.deepEqual(get(engine.selection), { type: 'empty' });
assert.equal(await engine.selectAll(), true);
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b, c]));
});
it('selects tracks and segments of a file', async () => {
await load('with_tracks_and_segments');
const file = fileStates()[0];
const track = file.structure.tracks[0];
await engine.selectTracks(file.structure.id, [track.id]);
assert.deepEqual(get(engine.selection), {
type: 'track',
fileId: file.structure.id,
trackIds: [track.id],
});
// the statistics follow the selection
const trackLength = get(engine.statistics).length;
assert.ok(trackLength > 0);
await engine.select([file.structure.id]);
assert.ok(get(engine.statistics).length > trackLength);
});
it('ignores what does not exist', async () => {
await load('simple');
const before = get(engine.selection);
assert.equal(await engine.select(['00000000-0000-0000-0000-000000000000'], 'add'), false);
assert.deepEqual(get(engine.selection), before);
});
});
describe('statistics', () => {
it('describes the selection', async () => {
await load('simple');
const statistics = get(engine.statistics);
const file = fileStates()[0];
assert.ok(statistics.length > 1);
assert.equal(statistics.lng.length, statistics.length);
assert.equal(statistics.ele.length, statistics.length);
assert.equal(statistics.totalDistance[0], 0);
assert.ok(statistics.global.totalDistance > 0);
assert.equal(statistics.global.totalDistance, file.statistics.totalDistance);
assert.equal(statistics.timestamps, undefined);
assert.ok(statistics.global.bounds!.west <= statistics.global.bounds!.east);
await engine.select([]);
assert.equal(get(engine.statistics).length, 0);
assert.equal(get(engine.statistics).global.totalDistance, 0);
});
it('has timestamps when the file has some', async () => {
await load('with_time');
const statistics = get(engine.statistics);
assert.ok(statistics.timestamps);
assert.equal(statistics.timestamps!.length, statistics.length);
assert.ok(statistics.timestamps!.some((t) => t !== NO_TIME));
assert.ok(statistics.global.totalTime! > 0);
assert.ok(statistics.global.movingSpeed === undefined || statistics.global.movingSpeed > 0);
});
it('reads the metrics that are requested, and only these', async () => {
await load('with_hr');
assert.equal(get(engine.statistics).hr, undefined);
const request = engine.requestStatistics();
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)));
assert.equal(statistics.cad, undefined);
assert.equal(statistics.anchors!.indices[0], 0);
assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1);
// they are read again after a change, without being asked again
await engine.select([]);
await engine.select([get(engine.order)[0]]);
assert.ok(get(engine.statistics).hr);
request.release();
await engine.select([]);
await engine.select([get(engine.order)[0]]);
assert.equal(get(engine.statistics).hr, undefined);
});
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)!;
assert.ok(part.totalDistance > 0);
assert.ok(part.totalDistance < statistics.global.totalDistance);
assert.equal(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);
});
});
describe('file state', () => {
it('has one feature per segment and keeps the unchanged ones when editing', async () => {
await load('with_tracks_and_segments');
const before = fileStates()[0];
assert.equal(
before.segments.features.length,
before.structure.tracks.reduce((sum, track) => sum + track.segments.length, 0)
);
// renaming does not touch the segments: they are the same objects
await engine.metadata('renamed', 'description');
const renamed = fileStates()[0];
assert.equal(renamed.structure.name, 'renamed');
assert.equal(renamed.structure.desc, 'description');
renamed.segments.features.forEach((feature, i) => {
assert.equal(feature, before.segments.features[i]);
});
// reversing a file reverses its tracks and their points
await engine.reverse();
const reversed = fileStates()[0].segments.features.map((f) => f.geometry.coordinates);
const expected = [...renamed.segments.features]
.reverse()
.map((f) => [...f.geometry.coordinates].reverse());
assert.deepEqual(reversed, expected);
await engine.undo();
fileStates()[0].segments.features.forEach((feature, i) => {
assert.deepEqual(
feature.geometry.coordinates,
renamed.segments.features[i].geometry.coordinates
);
});
});
it('colors the files, and keeps the color of a file', async () => {
await load('simple', 'with_tracks');
const [a, b] = fileStates();
assert.notEqual(a.color, b.color);
await engine.select([a.structure.id]);
await engine.style({ color: 'ff0000' });
assert.equal(get(get(engine.files).get(a.structure.id)!).color, '#ff0000');
const styled = get(get(engine.files).get(a.structure.id)!).segments.features[0];
assert.equal(styled.properties.color, '#ff0000');
await engine.undo();
assert.equal(get(get(engine.files).get(a.structure.id)!).color, a.color);
});
it('has the waypoints as features', async () => {
await load('with_waypoint');
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)!;
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');
assert.equal(details.links?.[0].text, 'waypoint link text');
});
});
describe('waypoints', () => {
const waypoint = {
lng: 4.4,
lat: 50.8,
ele: 100,
name: 'summit',
desc: 'a nice view',
icon: 'Summit',
link: 'https://example.com',
};
it('creates, edits, moves and deletes a waypoint', async () => {
await engine.newFile('file');
assert.equal(await engine.newWaypoint(waypoint), true);
const file = fileStates()[0];
const id = file.waypoints.features[0].properties.waypointId;
const fileId = file.structure.id;
let details = 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)!;
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);
await engine.deleteWaypoint(fileId, id);
assert.equal(fileStates()[0].waypoints.features.length, 0);
assert.equal(engine.waypoint(fileId, id), undefined);
assert.equal(await engine.deleteWaypoint(fileId, id), false);
});
});
describe('clipboard', () => {
it('copies and pastes', async () => {
await load('simple');
assert.equal(get(engine.clipboard), undefined);
assert.equal(get(engine.canPaste), false);
await engine.copy();
assert.equal(get(engine.clipboard)?.type, 'files');
assert.equal(get(engine.canPaste), true);
await engine.paste();
assert.equal(get(engine.files).size, 2);
});
});
describe('observer of the files', () => {
it('tells which files appear and disappear', async () => {
const added: string[] = [];
const removed: string[] = [];
let destroyed = 0;
await load('simple');
const [first] = get(engine.order);
const observer = new FileStateCollectionObserver(
(files) => files.forEach((_, id) => added.push(id)),
(id) => removed.push(id),
() => destroyed++
);
// the files that exist are reported at once
assert.deepEqual(added, [first]);
await load('with_tracks');
const second = get(engine.order).find((id) => id !== first)!;
assert.deepEqual(added, [first, second]);
// editing a file does not make it appear again
await engine.metadata('x', '');
assert.equal(added.length, 2);
await engine.deleteAll();
assert.deepEqual(new Set(removed), new Set([first, second]));
observer.destroy();
assert.equal(destroyed, 1);
});
});
describe('visibility', () => {
it('hides the selection and shows it again', async () => {
await load('simple');
const id = get(engine.order)[0];
const state = () => get(get(engine.files).get(id)!);
assert.equal(isHidden(state(), id), false);
await engine.setSelectionHidden(true);
assert.equal(isHidden(state(), id), true);
// the statistics still count what is hidden
assert.ok(get(engine.statistics).length > 0);
await engine.setSelectionHidden(false);
assert.equal(isHidden(state(), id), false);
});
});
+745
View File
@@ -0,0 +1,745 @@
import { browser } from '$app/environment';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator } from '$lib/file-colors';
import {
setHidden,
stringifyVisibility,
VISIBILITY_KEY,
type Visibility,
} from '$lib/file-visibility';
import { categoryIntervals } from '$lib/trackpoint-categories';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { RouteAttributes } from 'gpx-rs';
import { idsToBytes, selectMode, type Wasm } from './convert';
import { readFileState } from './file-state';
import { StatisticsReader } from './statistics';
import { openStoredFiles } from './storage';
import {
ALL_EXPORT_OPTIONS,
type Clipboard,
type ExportOptions,
type FileState,
type FilesUpdate,
type MergeType,
type MoveTarget,
type NewWaypoint,
type Outcome,
type RoutePoints,
type Selection,
type SelectionStatistics,
type SelectMode,
type SplitType,
type StatisticsRequest,
type TrackpointDetails,
type WaypointDetails,
} from './types';
/**
* Thin wrapper around the WASM engine.
*
* 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
* of the files that were added or modified. Within a modified file, the GeoJSON features of the
* segments and waypoints that did not change are reused.
*/
class Engine {
private wasm: Wasm | null = null;
/** Resolves once the engine can be used (never on the server). */
readonly ready: Promise<void>;
private _order = writable<string[]>([]);
private _colors = new FileColorAllocator();
/**
* Explicit visibility per file. Not dropped with the files: it is back when a deletion is
* undone. UI state only, it is not part of the engine's history.
*/
private _visibility = new Map<string, Visibility>();
private _files = writable<Map<string, Writable<FileState>>>(new Map());
private _selection = writable<Selection>({ type: 'empty' });
private _lastError = writable<string | undefined>(undefined);
private _canUndo = writable(false);
private _canRedo = writable(false);
private _canPaste = writable(false);
private _clipboard = writable<Clipboard | undefined>(undefined);
private _statistics = new StatisticsReader(() => this.wasm);
/** Ids of the files in display order. */
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
/** Statistics of what is currently selected. */
readonly statistics: Readable<SelectionStatistics> = this._statistics.store;
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
/** Why the last call to the engine did nothing, `undefined` if it did something. */
readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe };
/** What was copied or cut, waiting to be pasted. */
readonly clipboard: Readable<Clipboard | undefined> = { subscribe: this._clipboard.subscribe };
/** Whether the clipboard can be pasted with the current selection. */
readonly canPaste: Readable<boolean> = { subscribe: this._canPaste.subscribe };
/** What is currently selected. */
readonly selection: Readable<Selection> = { subscribe: this._selection.subscribe };
/**
* The state of each file, in its own store. The map store only notifies when files are added
* or removed; editing a file only notifies the subscribers of that file's store.
*/
readonly files: Readable<Map<string, Readable<FileState>>> = {
subscribe: this._files.subscribe,
};
constructor() {
this.ready = browser
? import('gpx-rs').then((wasm) => {
wasm.start();
this.wasm = wasm;
})
: new Promise(() => {});
}
// Storage
private _storage?: Promise<Record<string, string>>;
/**
* Opens the storage of the browser: 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. Only the app does it (the embedded map has no files of its own), once.
*
* The first time, what the previous versions of the app kept in the browser is imported.
*/
openStorage(): Promise<Record<string, string>> {
this._storage ??= this.ready.then(() =>
openStoredFiles({
wasm: this.wasm!,
visibility: this._visibility,
sync: (update) => this.sync(this.wasm!, update),
hasFiles: () => get(this._order).length > 0,
loadFiles: (files) => this.loadFiles(files),
deselect: () => this.select([]),
})
);
return this._storage;
}
/** 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);
}
deleteSetting(key: string) {
this.wasm?.delete_setting(key);
}
// Actions. Each one resolves to whether the engine changed something.
/**
* Creates a file, which gets selected. With a `trackpoint`, the file starts with a track and a
* 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));
}
/**
* Loads files as a single command (one undo step). `name` is the name of a file when its data
* has none (the name on disk, without extension). The files that cannot be read are skipped,
* the first one that was read is selected.
*/
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)
)
);
}
duplicate() {
return this.run((w) => w.duplicate());
}
/**
* Deletes the selected elements. With `wholeFiles`, the files holding the selected elements
* are deleted instead, even if only a track or a waypoint is selected.
*/
delete(wholeFiles = false) {
return this.run((w) => w._delete(wholeFiles));
}
deleteAll() {
return this.run((w) => w.delete_all());
}
undo() {
return this.run((w) => w.undo());
}
redo() {
return this.run((w) => w.redo());
}
/**
* Selects files. Selecting nothing (or only unknown files) deselects everything. See
* `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)));
}
/**
* Selects all the elements of the same kind as the selected ones, in the same place: all the
* files, the tracks of the file, the segments of the track, the waypoints of the file. All
* the files when nothing is selected.
*/
selectAll() {
return this.run((w) => w.select_all());
}
/**
* Moves the selection to the next (`down`) or previous element of the same kind, like the
* arrow keys do. With `add` (shift + arrow), the element is added to the selection, otherwise
* it replaces it.
*/
arrowSelect(down: boolean, add: boolean) {
return this.run((w) => w.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)));
}
selectSegments(
fileId: string,
trackId: string,
segmentIds: string[],
mode: SelectMode = 'replace'
) {
return this.run((w) =>
w.select_segments(fileId, trackId, idsToBytes(segmentIds), selectMode(w, 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)));
}
selectWaypoints(fileId: string, waypointIds: string[], mode: SelectMode = 'replace') {
return this.run((w) =>
w.select_waypoints(fileId, idsToBytes(waypointIds), selectMode(w, mode))
);
}
/** 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));
}
/** Selects a node of the file tree. */
selectNode(node: FileTreeNode, mode: SelectMode = 'replace') {
switch (node.type) {
case 'file':
return this.select([node.fileId], mode);
case 'track':
return this.selectTracks(node.fileId, [node.trackId], mode);
case 'segment':
return this.selectSegments(node.fileId, node.trackId, [node.segmentId], mode);
case 'waypoints':
return this.selectWaypointGroup(node.fileId, mode);
case 'waypoint':
return this.selectWaypoints(node.fileId, [node.waypointId], mode);
}
}
// Clipboard
/** Puts the selected elements in the clipboard. */
copy() {
return this.run((w) => w.copy());
}
/** Like `copy`, but the elements are moved when they are pasted. */
cut() {
return this.run((w) => w.cut());
}
/**
* Pastes the clipboard, depending on the selection: the files, tracks and segments become new
* files when nothing is selected, tracks and waypoints are added to a selected file,
* segments to a selected track, and pasted after a selected track, segment or waypoint of the
* same kind. The pasted elements are selected.
*
* What is pasted is always the elements as they were when they were copied or cut, even if
* they were changed or deleted since. It can be pasted several times: what was copied is
* copied again, what was cut is moved the first time and copied after that.
*/
paste() {
return this.run((w) => w.paste());
}
/**
* Moves elements to a place of the file tree, keeping their ids, like dragging and dropping
* them does. The position of the target counts the elements of its list that are not moved.
* The moved elements are selected.
*
* Files go among the files; tracks among the files (each becomes a file) or the tracks of a
* file; segments among the files (each becomes a file), the tracks of a file (each becomes a
* track) or the segments of a track; waypoints, or all the waypoints of a file (the waypoints
* node), among the waypoints of a file.
*/
move(what: Selection, to: MoveTarget) {
return this.run((w) => w.move_elements(what, to));
}
// Waypoints
/** 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);
}
/**
* Adds a waypoint to each selected file (or to the file of the selected elements). Empty
* strings are fields that the waypoint does not have.
*/
newWaypoint(waypoint: NewWaypoint) {
return this.run((w) =>
w.new_waypoint(
waypoint.lng,
waypoint.lat,
waypoint.ele,
waypoint.name,
waypoint.desc,
waypoint.icon,
waypoint.link
)
);
}
/** 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(
fileId,
waypointId,
waypoint.lng,
waypoint.lat,
waypoint.ele,
waypoint.name,
waypoint.desc,
waypoint.icon,
waypoint.link
)
);
}
/** 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));
}
/** Deletes a waypoint, selected or not. */
deleteWaypoint(fileId: string, waypointId: string) {
return this.run((w) => w.delete_waypoint(fileId, waypointId));
}
/**
* Removes the trackpoints and/or waypoints of the selection inside (or outside) a rectangle.
* Resolves to `false` when nothing was removed.
*/
clean(
bounds: { west: number; south: number; east: number; north: number },
inside: boolean,
trackpoints: boolean,
waypoints: boolean
) {
return this.run((w) =>
w.clean(
bounds.west,
bounds.south,
bounds.east,
bounds.north,
inside ? w.CleanType.Inside : w.CleanType.Outside,
trackpoints,
waypoints
)
);
}
/**
* Replaces the trackpoints `start` to `end` (excluded) of the selection by `points` (a pure
* insertion if `start === end`, a pure removal without points), in a single segment: the
* indices are the ones of `SelectionAnchors`, and `revision` the one they were read with. The
* indices in `anchors`, among the new points, become anchors of the routing tool. Resolves to
* `false` if the selection changed since the revision, or if the range is not valid.
*/
route(
revision: number,
start: number,
end: number,
points: RoutePoints,
anchors: readonly number[] = []
) {
const attributes = (values?: readonly (string | undefined)[]) =>
values && { ...categoryIntervals(values) };
const routeAttributes: RouteAttributes = {};
for (const [key, values] of [
['surface', points.surface],
['highway', points.highway],
['sacScale', points.sacScale],
['mtbScale', points.mtbScale],
] as const) {
const intervals = attributes(values);
if (intervals && intervals.names.length > 0) {
routeAttributes[key] = intervals;
}
}
return this.run((w) =>
w.route(
revision,
start,
end,
Float64Array.from(points.lng),
Float64Array.from(points.lat),
Float64Array.from(points.ele),
routeAttributes,
Uint32Array.from(anchors)
)
);
}
/**
* Makes an anchor of the point of the selected segments that is the closest to the
* coordinates, inserting a trackpoint on the path if there is none there. `revision` is the
* one of `SelectionAnchors`.
*/
insertAnchor(revision: number, lng: number, lat: number) {
return this.run((w) => w.insert_anchor(revision, lng, lat));
}
/**
* Makes the trackpoint `index` of the selection the start of its segment, which is a loop:
* the trackpoints before it go to the end, and the loop closes on it. `revision` is the one of `SelectionAnchors`.
*/
changeLoopStart(revision: number, index: number) {
return this.run((w) => w.change_loop_start(revision, index));
}
/** 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);
}
// Edits of the selection
newTrack() {
return this.run((w) => w.new_track());
}
newTrackSegment() {
return this.run((w) => w.new_track_segment());
}
/**
* Reverses the direction of the selection: the whole of each selected file or track, or each
* selected segment on its own. The timestamps are mirrored. Resolves to `false` when there is
* nothing to reverse (fewer than two trackpoints).
*/
reverse() {
return this.run((w) => w.reverse());
}
/**
* Splits the selected files and tracks that have several segments into elements that have
* one each (one file per track or per segment, one track per segment), which get selected.
* Resolves to `false` when there is nothing to extract.
*/
extract() {
return this.run((w) => w.extract());
}
/**
* Sets the elevation of the trackpoints of the selection, one elevation (m) per trackpoint, in
* the order of the statistics. Resolves to `false` when there is nothing to set, or when the
* number of elevations is not the number of trackpoints.
*/
setElevation(ele: Float64Array) {
return this.run((w) => w.elevation(ele));
}
/**
* Sets the timestamps of the selection, which starts at `startTime`: the trackpoints that
* have some keep their durations multiplied by `ratio`, the others follow the previous one at
* `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));
}
/**
* Makes up timestamps for the selection, which starts at `startTime` and lasts `totalTime`
* seconds, longer and steeper intervals taking more time. Resolves to `false` when there is
* nothing to set.
*/
createArtificialTimestamps(startTime: Date, totalTime: number) {
return this.run((w) => w.createArtificialTimestamps(startTime.getTime(), totalTime));
}
/**
* A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the
* trackpoints to keep.
*/
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
) as Uint8Array<ArrayBuffer> | 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;
return {
time: (bits & 1) !== 0,
hr: (bits & 2) !== 0,
cad: (bits & 4) !== 0,
atemp: (bits & 8) !== 0,
power: (bits & 16) !== 0,
osm: (bits & 32) !== 0,
};
}
/**
* Merges the selected files, tracks or segments into the first of them (see `MergeType`). With
* `removeGaps`, the parts that are connected get closer in time if they have timestamps.
* 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)
);
}
/**
* Removes the trackpoints of the selection that are less than `tolerance` meters away from the
* line of their neighbours, except the ends of the segments. Resolves to `false` when there
* is nothing to remove.
*/
reduce(tolerance: number) {
return this.run((w) => w.reduce(tolerance));
}
/**
* For each trackpoint of the selection, in the order of the statistics, the highest tolerance
* (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();
}
/**
* Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
* selection in two, there: the trackpoint ends the first part and starts the second one. The
* first part keeps the ids, the second one (and what follows) gets new ones. `revision` is the
* 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]
)
);
}
/**
* Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its
* segments one after the other, and removes the others, with the segments, tracks and files
* that are left empty (tracks and files only if they are selected). Resolves to `false` when
* 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)));
}
/**
* Makes each selected segment (or the segments of the selected files and tracks) come back to
* where it started: a reversed copy of it is added after its last trackpoint (which is not
* 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());
}
metadata(name: string, desc: string) {
return this.run((w) => w.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));
}
/** Hides or shows the selected elements. */
setSelectionHidden(hidden: boolean) {
selectedElementIds(get(this._selection)).forEach(({ fileId, ids }) =>
this.setHidden(fileId, ids, hidden)
);
}
/**
* Hides or shows elements of a file (see `FileState.visibility`). What was set explicitly
* below these elements is reset, so that hiding a file then showing one of its segments
* leaves only that segment visible, and hiding the file again hides all of it.
*/
setHidden(fileId: string, ids: string[], hidden: boolean) {
const store = get(this._files).get(fileId);
if (!store) {
return;
}
const visibility = setHidden(
get(store).structure,
this._visibility.get(fileId) ?? new Map(),
ids,
hidden
);
this._visibility.set(fileId, visibility);
store.update((state) => ({ ...state, visibility }));
this.saveVisibility();
}
/** Keeps what is hidden, for the files that exist (the others are not coming back). */
private saveVisibility() {
const files = get(this._files);
const json = stringifyVisibility(this._visibility, (fileId) => files.has(fileId));
if (json === undefined) {
this.deleteSetting(VISIBILITY_KEY);
} else {
this.setSetting(VISIBILITY_KEY, json);
}
}
// Coordinates, as flat [lng, lat, ...] arrays. The getters copy them out of the WASM memory.
// 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();
}
waypointCoordinates(fileId: string): Float64Array {
return this.wasm?.waypoint_coordinates(fileId) ?? new Float64Array();
}
/**
* 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> {
await this.ready;
const wasm = this.wasm!;
const outcome = action(wasm);
this.sync(wasm, outcome);
this.report(outcome);
return outcome.changed;
}
/** Logs why a call did nothing, and keeps it in `lastError`. */
private report(outcome: Outcome) {
this._lastError.set(outcome.error);
if (outcome.error === 'nothing to do') {
console.debug('engine:', outcome.error);
} else if (outcome.error !== undefined) {
console.warn('engine:', outcome.error);
}
}
/**
* Registers a consumer of the statistics that needs some of the metrics that are not part of
* them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are
* added to `statistics`: the ones that are missing are read right away, and then after each
* change of the selection or of the files, and the store is updated. Call `release` when the
* consumer goes away.
*/
requestStatistics(): StatisticsRequest {
return this._statistics.request();
}
/** Updates what is kept of the engine with what a call changed. */
private sync(wasm: Wasm, update: FilesUpdate) {
this._canUndo.set(wasm.can_undo());
this._canRedo.set(wasm.can_redo());
this._canPaste.set(wasm.can_paste());
if (update.clipboardChanged) {
this._clipboard.set(wasm.clipboard());
}
if (update.selectionChanged) {
this._selection.set(wasm.selection());
}
if (update.orderChanged) {
this._order.set(wasm.file_order());
}
const added: [string, FileState][] = [];
for (const id of update.added) {
const state = readFileState(wasm, id, this._colors, this._visibility);
if (state) {
added.push([id, state]);
}
}
if (added.length > 0 || update.removed.length > 0) {
this._files.update((files) => {
const next = new Map(files);
update.removed.forEach((id) => {
next.delete(id);
this._colors.release(id);
});
added.forEach(([id, state]) => next.set(id, writable(state)));
return next;
});
}
if (update.modified.length > 0) {
const files = get(this._files);
for (const id of update.modified) {
const store = files.get(id);
const state =
store && readFileState(wasm, id, this._colors, this._visibility, get(store));
if (store && state) {
store.set(state);
}
}
}
// the statistics depend on the selection, on its order, and on the files
if (
update.selectionChanged ||
update.orderChanged ||
update.added.length + update.modified.length + update.removed.length > 0
) {
this._statistics.refresh(wasm);
}
}
}
export const engine = new Engine();
+101
View File
@@ -0,0 +1,101 @@
import type { Feature, LineString } from 'geojson';
import { normalizeColor, type FileColorAllocator } from '$lib/file-colors';
import type { Visibility } from '$lib/file-visibility';
import { toPositions, type Wasm } 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,
id: string,
colors: FileColorAllocator,
visibility: ReadonlyMap<string, Visibility>,
previous?: FileState
): FileState | null {
const structure = wasm.file_structure(id);
if (!structure) {
return null;
}
const color = colors.resolve(
id,
structure.tracks.map((track) => track.color)
);
const previousSegments = new Map(
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
);
const segments: Feature<LineString, SegmentProperties>[] = [];
structure.tracks.forEach((track, trackIndex) => {
track.segments.forEach((segment, segmentIndex) => {
const properties: SegmentProperties = {
fileId: id,
trackId: track.id,
segmentId: segment.id,
trackIndex,
segmentIndex,
rev: segment.rev,
color: track.color !== undefined ? normalizeColor(track.color) : color,
opacity: track.opacity,
width: track.width,
};
const old = previousSegments.get(segment.id);
const unchanged =
old !== undefined &&
(Object.keys(properties) as (keyof SegmentProperties)[]).every(
(key) => old.properties[key] === properties[key]
);
segments.push(
unchanged
? old
: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: toPositions(wasm.segment_coordinates(segment.id)),
},
properties,
}
);
});
});
const previousStructure = previous?.structure;
const waypointsUnchanged =
previousStructure !== undefined &&
previousStructure.waypointsRev === structure.waypointsRev &&
previousStructure.waypoints.length === structure.waypoints.length &&
previousStructure.waypoints.every(
(w, i) =>
w.id === structure.waypoints[i].id &&
w.name === structure.waypoints[i].name &&
w.sym === structure.waypoints[i].sym
);
let waypoints = previous?.waypoints;
if (!waypoints || !waypointsUnchanged) {
const coordinates = toPositions(wasm.waypoint_coordinates(id));
waypoints = {
type: 'FeatureCollection',
features: structure.waypoints.map((waypoint, index) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: coordinates[index] },
properties: {
fileId: id,
waypointId: waypoint.id,
index,
name: waypoint.name,
sym: waypoint.sym,
},
})),
};
}
return {
structure,
color,
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments },
waypoints,
visibility: visibility.get(id) ?? new Map(),
};
}
+13
View File
@@ -0,0 +1,13 @@
// The engine of the files, as the UI sees it: stores that follow what the WASM engine holds.
//
// - `engine.ts`: the facade, with the actions and the stores
// - `types.ts`: the types of what it gives
// - `file-state.ts`: reads the state of a file (GeoJSON features, statistics...) from the engine
// - `statistics.ts`: reads the statistics of the selection, and only the metrics that are needed
// - `storage.ts`: opens the storage of the browser, and imports what the old versions kept
// - `observer.ts`: tells when the stores of the files appear and disappear
// - `convert.ts`: conversions between the UI's values and the engine's
export * from './types';
export { engine } from './engine';
export { FileStateCollectionObserver } from './observer';
+50
View File
@@ -0,0 +1,50 @@
import type { Readable } from 'svelte/store';
import { engine } from './engine';
import type { FileState, FileStatesCallback } from './types';
/**
* Tells when file stores appear and disappear, so that their subscribers know when to
* subscribe and unsubscribe. `onFilesAdded` is called with the stores of the new files only
* (including the ones already present when the observer is created).
*/
export class FileStateCollectionObserver {
private _fileIds = new Set<string>();
private _onFilesAdded: FileStatesCallback;
private _onFileRemoved: (fileId: string) => void;
private _onDestroy: () => void;
private _unsubscribe: () => void;
constructor(
onFilesAdded: FileStatesCallback,
onFileRemoved: (fileId: string) => void,
onDestroy: () => void
) {
this._onFilesAdded = onFilesAdded;
this._onFileRemoved = onFileRemoved;
this._onDestroy = onDestroy;
this._unsubscribe = engine.files.subscribe((files) => {
this._fileIds.forEach((fileId) => {
if (!files.has(fileId)) {
this._onFileRemoved(fileId);
this._fileIds.delete(fileId);
}
});
const newFiles = new Map<string, Readable<FileState>>();
files.forEach((store, fileId) => {
if (!this._fileIds.has(fileId)) {
newFiles.set(fileId, store);
this._fileIds.add(fileId);
}
});
if (newFiles.size > 0) {
this._onFilesAdded(newFiles);
}
});
}
destroy() {
this._onDestroy();
this._unsubscribe();
}
}
+179
View File
@@ -0,0 +1,179 @@
import { get, writable, type Readable } from 'svelte/store';
import { categoryIntervals, type CategoryIntervals } from '$lib/trackpoint-categories';
import type { Wasm } from './convert';
import {
EMPTY_SELECTION_STATISTICS,
EMPTY_STATISTICS,
type SelectionStatistics,
type StatisticsMetric,
type StatisticsRequest,
} from './types';
/**
* The statistics of the selection, read from the buffers of the engine. Only the metrics that a
* consumer asked for are read (see `request`).
*/
export class StatisticsReader {
private _store = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS);
readonly store: Readable<SelectionStatistics> = { subscribe: this._store.subscribe };
/** Identifies the statistics currently in the engine's buffers. */
private _version = 0;
/** What each consumer of the statistics asked for. */
private _requests = new Set<Set<StatisticsMetric>>();
/** 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) {}
/** Reads the statistics of the selection again, after it or the files changed. */
refresh(wasm: Wasm) {
this._store.set(this.read(wasm));
}
/**
* Reads the statistics buffers of the engine (the getters copy them), but only the metrics
* that were requested.
*/
private read(wasm: Wasm): SelectionStatistics {
const version = ++this._version;
this._loaded.clear();
const totalDistance = wasm.total_distance();
const statistics: SelectionStatistics = {
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
length: totalDistance.length,
totalDistance,
slope: wasm.slope(),
lng: wasm.lng(),
lat: wasm.lat(),
ele: wasm.ele(),
timestamps: wasm.timestamps(),
slice: (start, end) =>
version === this._version ? wasm.slice_statistics(start, end) : undefined,
};
this.readMetrics(wasm, statistics, this.requestedMetrics());
return statistics;
}
private requestedMetrics(): Set<StatisticsMetric> {
const metrics = new Set<StatisticsMetric>();
this._requests.forEach((request) => request.forEach((m) => metrics.add(m)));
return metrics;
}
/** Reads the metrics that were not read yet into `statistics`, which is the current one. */
private readMetrics(
wasm: Wasm,
statistics: SelectionStatistics,
metrics: Set<StatisticsMetric>
) {
const categories = (
starts: Uint32Array,
values: Uint8Array,
names: () => string[]
): CategoryIntervals => ({
starts,
values,
names: names(),
});
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;
}
}
}
/**
* Registers a consumer of the statistics that needs some of the metrics that are not part of
* them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are
* added to `statistics`: the ones that are missing are read right away, and then after each
* change of the selection or of the files, and the store is updated. Call `release` when the
* consumer goes away.
*/
request(): StatisticsRequest {
const wanted = new Set<StatisticsMetric>();
this._requests.add(wanted);
return {
set: (metrics) => {
wanted.clear();
for (const metric of metrics) {
wanted.add(metric);
}
this.loadMissing();
},
release: () => {
this._requests.delete(wanted);
},
};
}
/** Reads the requested metrics that are not in the current statistics yet. */
private loadMissing() {
const wasm = this.wasm();
const metrics = this.requestedMetrics();
if (!wasm || [...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);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import';
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/file-visibility';
import type { FilesUpdate, StorageOpened } from 'gpx-rs';
import type { Wasm } from './convert';
/** Name of the IndexedDB database that keeps the files and the settings. */
const STORAGE_NAME = 'gpx-studio';
/** What opening the storage needs of the engine facade. */
export type StorageHost = {
wasm: Wasm;
/** 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. */
sync(update: FilesUpdate): void;
hasFiles(): boolean;
loadFiles(files: { data: Uint8Array; name: string }[]): Promise<boolean>;
deselect(): Promise<boolean>;
};
/**
* Opens the storage of the browser: 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;
let opened: StorageOpened;
try {
opened = await wasm.open_storage(STORAGE_NAME);
} catch (error) {
console.error('The files cannot be kept in this browser', error);
return {};
}
if (opened.readOnly) {
console.warn(
'The files were saved by a newer version of the app: they are not saved again by this one'
);
}
if (opened.unreadable > 0) {
console.warn(`${opened.unreadable} stored file(s) could not be read`);
}
let settings = opened.settings;
// what was hidden, which the files that were restored need when they are read
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
host.visibility.set(fileId, visibility)
);
host.sync(opened.outcome);
if (settings[LEGACY_IMPORTED_KEY] === undefined && !host.hasFiles()) {
settings = { ...settings, ...(await importLegacyData(host)) };
await wasm.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 imported: Record<string, string> = {};
try {
const legacy = await readLegacyData();
if (legacy) {
const encoder = new TextEncoder();
if (legacy.files.length > 0) {
await host.loadFiles(
legacy.files.map((file) => ({
data: encoder.encode(file.gpx),
name: file.name,
}))
);
await host.deselect();
}
for (const [key, value] of Object.entries(legacy.settings)) {
wasm.set_setting(key, value);
imported[key] = value;
}
}
} catch (error) {
// it is tried again next time
console.error('The files of the previous version could not be imported', error);
return imported;
}
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
imported[LEGACY_IMPORTED_KEY] = 'true';
return imported;
}
+247
View File
@@ -0,0 +1,247 @@
import type { Readable } from 'svelte/store';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { Visibility } from '$lib/file-visibility';
import type { CategoryIntervals } from '$lib/trackpoint-categories';
import type { FileStructure, GlobalStatistics } from 'gpx-rs';
export type {
Clipboard,
WaypointDetails,
TrackpointDetails,
MoveTarget,
GlobalStatistics,
FileStructure,
FilesUpdate,
Outcome,
Selection,
TrackNode,
SegmentNode,
WaypointNode,
} from 'gpx-rs';
/**
* How a merge puts the selected elements together: `connect` makes a single segment of their
* trackpoints, `group` keeps them as they are, in the first one.
*/
export type MergeType = 'connect' | 'group';
/** What a split cuts in two: the file, the track or the segment of the trackpoint. */
export type SplitType = 'files' | 'tracks' | 'segments';
/**
* How elements combine with the current selection: they `replace` it, are `add`ed to it, or are
* `toggle`d (the ones already selected are removed). Adding and toggling only merge with elements
* of the same kind in the same place (same file, same track for segments), otherwise the new
* elements replace the selection.
*/
export type SelectMode = 'replace' | 'add' | 'toggle';
export type SegmentProperties = {
fileId: string;
trackId: string;
segmentId: string;
trackIndex: number;
segmentIndex: number;
/** Changes when the trackpoints of the segment change. */
rev: string;
/** Color of the track, or else the base color of the file. */
color: string;
/** Only when the track defines it. */
opacity?: number;
width?: number;
};
export type WaypointProperties = {
fileId: string;
waypointId: string;
index: number;
name?: string;
sym?: string;
};
/** The data of a waypoint to create or to change. Empty strings are fields it does not have. */
export type NewWaypoint = {
lng: number;
lat: number;
ele: number;
name: string;
desc: string;
icon: string;
link: string;
};
/** Everything the UI knows about one file. */
export type FileState = {
structure: FileStructure;
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
color: string;
/** Global statistics of the file. */
statistics: GlobalStatistics;
/** One LineString feature per track segment, in file order. */
segments: FeatureCollection<LineString, SegmentProperties>;
/** One Point feature per waypoint, in file order. */
waypoints: FeatureCollection<Point, WaypointProperties>;
/**
* Visibility the user set explicitly (see `Visibility` and `isHidden`, `isSegmentHidden`,
* `isWaypointHidden` in `file-visibility`). Hidden elements are still part of the statistics.
*/
visibility: Visibility;
};
export const EMPTY_STATISTICS: GlobalStatistics = {
totalDistance: 0,
elevationGain: 0,
elevationLoss: 0,
};
/**
* New trackpoints for `Engine.route`, as arrays with an entry per trackpoint. The OSM attributes
* are the names of the values (`undefined` when unknown), they are left unknown if missing.
*/
export type RoutePoints = {
lng: ArrayLike<number>;
lat: ArrayLike<number>;
ele: ArrayLike<number>;
surface?: readonly (string | undefined)[];
highway?: readonly (string | undefined)[];
sacScale?: readonly (string | undefined)[];
mtbScale?: readonly (string | undefined)[];
};
/** Which data of the trackpoints a file is exported with (see `Engine.exportFile`). */
export type ExportOptions = {
time: boolean;
hr: boolean;
cad: boolean;
atemp: boolean;
power: boolean;
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
osm: boolean;
};
export const ALL_EXPORT_OPTIONS: ExportOptions = {
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
osm: true,
};
/**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine).
*/
export const NO_TIME = -(2n ** 63n);
/**
* Statistics of the selection that are only read from the engine when something asks for them
* (see `Engine.requestStatistics`). A metric can bring several arrays.
*/
export type StatisticsMetric =
| 'speed'
| 'hr'
| 'cad'
| 'atemp'
| 'power'
/** `slopeSegmentSlope` and `slopeSegmentDistance`. */
| 'slopeSegment'
| 'surface'
/** `highway`, `sacScale` and `mtbScale`. */
| 'highway'
/** `anchors`. */
| 'anchors';
/**
* The anchors of the routing tool among the trackpoints of the selection: the points that can be
* dragged to reroute a segment, which are few of them. The arrays are as long as the number of
* anchors, in the order of the trackpoints.
*/
export type SelectionAnchors = {
/** Index of the anchor in the trackpoints of the selection. */
indices: Uint32Array;
/** Lowest map zoom level at which the anchor is shown (0 for the ends of the segments). */
zooms: Uint8Array;
/**
* Index of the first trackpoint of each segment of the selection that has some: anchors that
* are between two consecutive starts belong to the same segment.
*/
segmentStarts: Uint32Array;
/** Id of each of these segments. */
segmentIds: string[];
/**
* Changes when the selected segments, or their trackpoints, change. The indices in the
* selection (of trackpoints, of anchors) only mean something for one revision, which commands
* that use them have to give.
*/
revision: number;
};
/**
* The statistics of the selection: its global statistics, and the values at each of its
* trackpoints (all the selected segments, one after the other). The arrays are copies.
*
* Distances are cumulative over the whole selection. Distances are in km, speeds in km/h, times in
* ms (timestamps since the epoch, as 64-bit integers), elevations in m and slopes in %.
*
* The values of the trackpoints that the selection does not have at all (for example the heart
* rates, when no trackpoint has any) are `undefined`, and so are the ones that were not requested
* (see `StatisticsMetric`). Otherwise they have one entry per trackpoint: missing timestamps are
* `NO_TIME`, missing measures are NaN.
*/
export type SelectionStatistics = {
global: GlobalStatistics;
/** Number of trackpoints. */
length: number;
totalDistance: Float64Array;
/** Slope at the trackpoint. */
slope: Float64Array;
lng: Float64Array;
lat: Float64Array;
ele: Float64Array;
timestamps?: BigInt64Array;
// the following need to be requested
speed?: Float64Array;
hr?: Float64Array;
cad?: Float64Array;
atemp?: Float64Array;
power?: Float64Array;
/** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */
slopeSegmentSlope?: Float64Array;
slopeSegmentDistance?: Float64Array;
surface?: CategoryIntervals;
highway?: CategoryIntervals;
/** SAC hiking scale. */
sacScale?: CategoryIntervals;
/** Mountain biking scale. */
mtbScale?: CategoryIntervals;
anchors?: SelectionAnchors;
/**
* Global statistics of the trackpoints from `start` to `end` (both included), for example
* 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;
};
export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
global: EMPTY_STATISTICS,
length: 0,
totalDistance: new Float64Array(),
slope: new Float64Array(),
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
slice: () => undefined,
};
export type StatisticsRequest = {
/** Replaces the metrics needed by this consumer. */
set(metrics: Iterable<StatisticsMetric>): void;
/** The consumer does not need any metric anymore. */
release(): void;
};
export type FileStatesCallback = (files: Map<string, Readable<FileState>>) => void;