Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

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

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

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

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
import { get } from 'svelte/store';
import { LngLatBounds } from 'maplibre-gl';
import { map } from '$lib/components/map/map';
import { page } from '$app/state';
import { engine, FileStateCollectionObserver, type FileState } from '$lib/engine';
const { statistics } = engine;
export class BoundsManager {
private _bounds: LngLatBounds = new LngLatBounds();
private _files: Set<string> = new Set();
private _fileStateCollectionObserver: FileStateCollectionObserver | null = null;
private _unsubscribes: (() => void)[] = [];
constructor() {
this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => {
if (page.url.hash.length == 0) {
this.fitBoundsOnLoad(Array.from(newFiles.keys()));
}
},
() => {},
() => {}
);
}
fitBoundsOnLoad(files: string[]) {
this.reset();
this._files = new Set(files);
this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
if (this._files.has(fileId)) {
this._unsubscribes.push(
fileState.subscribe((state) => {
this.addBoundsFromFile(fileId, state);
})
);
}
});
},
() => {},
() => {}
);
}
addBoundsFromFile(fileId: string, file: FileState) {
if (!file || !this._files.has(fileId)) return;
this._files.delete(fileId);
if (file.statistics.bounds) {
const bounds = new LngLatBounds([
file.statistics.bounds.west,
file.statistics.bounds.south,
file.statistics.bounds.east,
file.statistics.bounds.north,
]);
if (!this.validBounds(bounds)) return;
this._bounds.extend(bounds);
}
if (this._files.size === 0) {
this.finalizeFitBounds();
}
}
finalizeFitBounds() {
if (
this._bounds.getSouth() >= this._bounds.getNorth() &&
this._bounds.getWest() >= this._bounds.getEast()
) {
return;
}
this._unsubscribes.push(
map.subscribe((map_) => {
if (!map_) return;
map_.fitBounds(this._bounds, { padding: 80, linear: true, animate: false });
this.reset();
})
);
}
reset() {
if (this._fileStateCollectionObserver) {
this._fileStateCollectionObserver.destroy();
}
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
this._bounds = new LngLatBounds([180, 90, -180, -90]);
}
centerMapOnSelection() {
const bounds = new LngLatBounds([180, 90, -180, -90]);
const stats = get(statistics);
if (stats.global.bounds) {
bounds.extend([
[stats.global.bounds.west, stats.global.bounds.south],
[stats.global.bounds.east, stats.global.bounds.north],
]);
}
// the waypoints are not part of the statistics of the selection
const selection = get(engine.selection);
let waypointIds: Set<string> | undefined; // all the waypoints of the file if undefined
let fileIds: string[] = [];
switch (selection.type) {
case 'file':
fileIds = selection.fileIds;
break;
case 'waypoints':
fileIds = [selection.fileId];
break;
case 'waypoint':
fileIds = [selection.fileId];
waypointIds = new Set(selection.waypointIds);
break;
}
const files = get(engine.files);
for (const fileId of fileIds) {
const file = files.get(fileId);
if (!file) continue;
for (const feature of get(file).waypoints.features) {
if (waypointIds && !waypointIds.has(feature.properties.waypointId)) continue;
bounds.extend(feature.geometry.coordinates as [number, number]);
}
}
if (!this.validBounds(bounds)) return;
get(map)?.fitBounds(bounds, {
padding: 80,
easing: () => 1,
maxZoom: 15,
});
}
validBounds(bounds: LngLatBounds) {
return (
bounds.getSouthWest().lat !== 90 ||
bounds.getSouthWest().lng !== 180 ||
bounds.getNorthEast().lat !== -90 ||
bounds.getNorthEast().lng !== -180
);
}
}
export const boundsManager = new BoundsManager();
+61
View File
@@ -0,0 +1,61 @@
import { map } from '$lib/components/map/map';
import { get, writable, type Writable } from 'svelte/store';
export enum MapCursorState {
DEFAULT,
LAYER_HOVER,
TOOL_WITH_CROSSHAIR,
WAYPOINT_HOVER,
WAYPOINT_DRAGGING,
ANCHOR_HOVER,
ANCHOR_DRAGGING,
SCISSORS,
SPLIT_CONTROL,
MAPILLARY_HOVER,
STREET_VIEW_CROSSHAIR,
}
const scissorsCursor = `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1"><path d="M 3.200 3.200 C 0.441 5.959, 2.384 9.516, 7 10.154 C 10.466 10.634, 10.187 13.359, 6.607 13.990 C 2.934 14.637, 1.078 17.314, 2.612 19.750 C 4.899 23.380, 10 21.935, 10 17.657 C 10 16.445, 12.405 13.128, 15.693 9.805 C 18.824 6.641, 21.066 3.732, 20.674 3.341 C 20.283 2.950, 18.212 4.340, 16.072 6.430 C 12.019 10.388, 10 10.458, 10 6.641 C 10 2.602, 5.882 0.518, 3.200 3.200 M 4.446 5.087 C 3.416 6.755, 5.733 8.667, 7.113 7.287 C 8.267 6.133, 7.545 4, 6 4 C 5.515 4, 4.816 4.489, 4.446 5.087 M 14 14.813 C 14 16.187, 19.935 21.398, 20.667 20.667 C 21.045 20.289, 20.065 18.634, 18.490 16.990 C 15.661 14.036, 14 13.231, 14 14.813 M 4.446 17.087 C 3.416 18.755, 5.733 20.667, 7.113 19.287 C 8.267 18.133, 7.545 16, 6 16 C 5.515 16, 4.816 16.489, 4.446 17.087" stroke="black" stroke-width="1.2" fill="white" fill-rule="evenodd"/></svg>') 12 12, auto`;
const cursorStyles = {
[MapCursorState.DEFAULT]: 'default',
[MapCursorState.LAYER_HOVER]: 'pointer',
[MapCursorState.WAYPOINT_HOVER]: 'pointer',
[MapCursorState.WAYPOINT_DRAGGING]: 'grabbing',
[MapCursorState.ANCHOR_HOVER]: 'pointer',
[MapCursorState.ANCHOR_DRAGGING]: 'grabbing',
[MapCursorState.TOOL_WITH_CROSSHAIR]: 'crosshair',
[MapCursorState.SCISSORS]: scissorsCursor,
[MapCursorState.SPLIT_CONTROL]: 'pointer',
[MapCursorState.MAPILLARY_HOVER]: 'pointer',
[MapCursorState.STREET_VIEW_CROSSHAIR]: 'crosshair',
};
export class MapCursor {
private _states: Writable<Set<MapCursorState>>;
constructor() {
this._states = writable(new Set());
this._states.subscribe((states) => {
const state = Array.from(states.values()).reduce((max, value) => {
return value > max ? value : max;
}, MapCursorState.DEFAULT);
const canvas = get(map)?.getCanvas();
if (canvas) {
canvas.style.cursor = cursorStyles[state];
}
});
}
notify(cursorState: MapCursorState, isActive: boolean) {
this._states.update((states) => {
if (isActive) {
states.add(cursorState);
} else {
states.delete(cursorState);
}
return states;
});
}
}
export const mapCursor = new MapCursor();
@@ -0,0 +1,20 @@
import { writable, type Writable } from 'svelte/store';
import { engine, type GlobalStatistics } from '$lib/engine';
import type { Coordinates } from '$lib/geo/coordinates';
/** The part of the selection that is highlighted in the elevation profile. */
export type SlicedStatistics = {
/** Statistics of the part. */
global: GlobalStatistics;
/** Indices of its first and last trackpoints in the statistics of the selection. */
start: number;
end: number;
};
export const slicedStatistics = writable<SlicedStatistics | undefined>(undefined);
// the indices only make sense for the selection they were made on
engine.statistics.subscribe(() => slicedStatistics.set(undefined));
/** The point of the elevation profile that the pointer is over, shown on the map. */
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
+313
View File
@@ -0,0 +1,313 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { isCustomLayer, type LayerTreeType } from '$lib/data/layers';
import {
filterLayerTree,
getArrayValidator,
getLayerTreeValidator,
getLayerValidator,
getListValidator,
getRecordValidator,
getValueValidator,
isNumber,
isString,
Setting,
SettingInitOnFirstRead,
storedValue,
useSettingsStorage,
} from '$lib/state/setting';
/** What the settings were told to keep. */
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = {};
useSettingsStorage({
save: (key, json) => {
saved[key] = json;
},
remove: (key) => {
saved[key] = undefined;
},
});
});
afterEach(() => useSettingsStorage(undefined));
describe('reading saved values', () => {
it('reads JSON, and nothing when it cannot', () => {
assert.deepEqual(storedValue({ a: '3' }, 'a'), { value: 3 });
assert.deepEqual(storedValue({ a: '"text"' }, 'a'), { value: 'text' });
assert.deepEqual(storedValue({ a: 'null' }, 'a'), { value: null });
assert.equal(storedValue({ a: '{broken' }, 'a'), undefined);
assert.equal(storedValue({}, 'a'), undefined);
// a key of Object.prototype is not a saved key
assert.equal(storedValue({}, 'toString'), undefined);
});
});
describe('validators', () => {
it('keeps an allowed value, else the fallback', () => {
const validate = getValueValidator(['metric', 'imperial'], 'metric');
assert.equal(validate('imperial'), 'imperial');
assert.equal(validate('nautical'), 'metric');
assert.equal(validate(undefined), 'metric');
assert.equal(validate(null), 'metric');
assert.equal(validate(3), 'metric');
const optional = getValueValidator<string | undefined>(['slope', undefined], undefined);
assert.equal(optional('slope'), 'slope');
assert.equal(optional('other'), undefined);
});
it('keeps the allowed values of a list', () => {
const validate = getArrayValidator(['hr', 'cad', 'power']);
assert.deepEqual(validate(['power', 'speed', 'hr', 'hr']), ['power', 'hr', 'hr']);
assert.deepEqual(validate([]), []);
});
it('keeps nothing of a saved value that is not a list', () => {
const validate = getArrayValidator(['hr', 'cad', 'power']);
for (const saved of [null, 'hr', 3, { 0: 'hr' }]) {
assert.deepEqual(validate(saved), []);
}
});
it('keeps the items of a list that are what it holds', () => {
const validate = getListValidator(isString);
assert.deepEqual(validate(['a', 3, null, 'b']), ['a', 'b']);
assert.deepEqual(validate('a'), []);
});
it('keeps the entries of a record that are what it holds, else takes the fallback', () => {
const fallback = { a: 0.5 };
const validate = getRecordValidator(isNumber, fallback);
assert.deepEqual(validate({ a: 0.4, b: 'x', c: 1 }), { a: 0.4, c: 1 });
for (const saved of [null, 3, ['a'], 'a']) {
assert.equal(validate(saved), fallback);
}
});
it('recognizes a custom layer', () => {
const layer = {
id: 'custom-0',
name: 'Layer',
tileUrls: ['https://tiles/{z}/{x}/{y}.png'],
maxZoom: 20,
layerType: 'basemap',
resourceType: 'raster',
value: { version: 8, sources: {}, layers: [] },
};
assert.ok(isCustomLayer(layer));
assert.ok(isCustomLayer({ ...layer, resourceType: 'vector', value: 'https://style.json' }));
assert.ok(!isCustomLayer({ ...layer, layerType: 'other' }));
assert.ok(!isCustomLayer({ ...layer, tileUrls: [3] }));
assert.ok(!isCustomLayer({ ...layer, value: null }));
const { name: _name, ...nameless } = layer;
assert.ok(!isCustomLayer(nameless));
assert.ok(!isCustomLayer(null));
});
it('accepts the known layers, and the custom and extension ones', () => {
const validate = getLayerValidator({ libertyTopo: 'url', osm: 'url' }, 'libertyTopo');
assert.equal(validate('osm'), 'osm');
assert.equal(validate('custom-1234'), 'custom-1234');
assert.equal(validate('extension-some-id'), 'extension-some-id');
assert.equal(validate('gone'), 'libertyTopo');
assert.equal(validate('toString'), 'libertyTopo');
for (const saved of [null, 3, ['osm'], { id: 'osm' }]) {
assert.equal(validate(saved), 'libertyTopo');
}
});
});
describe('layer trees', () => {
const allowed: LayerTreeType = {
world: { a: true, b: false },
countries: { france: { c: false } },
};
it('keeps the choices made on the layers that are allowed', () => {
const filtered = filterLayerTree({ world: { a: false, b: true } }, allowed);
assert.deepEqual(filtered.world as LayerTreeType, { a: false, b: true });
});
it('adds the layers that are new, with their default', () => {
const filtered = filterLayerTree({ world: { a: false } }, allowed);
assert.deepEqual(filtered, {
world: { a: false, b: false },
countries: { france: { c: false } },
});
assert.deepEqual(filterLayerTree({}, allowed), allowed);
});
it('drops the layers that are not allowed anymore, but not the custom ones', () => {
const filtered = filterLayerTree(
{
world: { a: true, gone: true, 'custom-1': true, 'extension-x': false },
old: { x: true },
},
allowed
);
assert.deepEqual(filtered.world, {
a: true,
b: false,
'custom-1': true,
'extension-x': false,
});
assert.equal('old' in filtered, true);
assert.deepEqual(filtered.old, {});
});
it('does not take a branch for a layer, or a layer for a branch', () => {
const filtered = filterLayerTree({ world: true, countries: { france: false } }, allowed);
assert.deepEqual(filtered.world, { a: true, b: false });
assert.deepEqual((filtered.countries as LayerTreeType).france, { c: false });
assert.deepEqual(getLayerTreeValidator(allowed)({ world: { a: false } }).world, {
a: false,
b: false,
});
// a branch saved for a layer, and values that are not choices
assert.deepEqual(filterLayerTree({ world: { a: { x: true }, b: 'yes' } }, allowed).world, {
a: true,
b: false,
});
});
it('takes the defaults when what was saved is not a tree', () => {
for (const saved of [null, true, 'world', 3, ['world']]) {
assert.deepEqual(filterLayerTree(saved, allowed), allowed);
}
assert.deepEqual(
filterLayerTree({ world: null, old: null, other: [true] }, allowed),
allowed
);
});
it('does not share its branches with the allowed tree', () => {
const filtered = filterLayerTree({}, allowed);
(filtered.world as LayerTreeType).a = false;
assert.equal((allowed.world as LayerTreeType).a, true);
});
});
describe('Setting', () => {
it('has its initial value until it is connected', () => {
const setting = new Setting('key', 'initial', getValueValidator(['initial'], 'initial'));
assert.equal(get(setting), 'initial');
setting.connect({});
assert.equal(get(setting), 'initial');
setting.connect({ other: '"x"' });
assert.equal(get(setting), 'initial');
});
it('takes the saved value, validated', () => {
const setting = new Setting(
'key',
'metric',
getValueValidator(['metric', 'imperial'], 'metric')
);
setting.connect({ key: '"imperial"' });
assert.equal(get(setting), 'imperial');
setting.connect({ key: '"furlongs"' });
assert.equal(get(setting), 'metric');
setting.connect({ key: '{broken' });
assert.equal(get(setting), 'metric');
// connecting does not save anything
assert.deepEqual(saved, {});
});
it('takes a saved boolean or number of the type of its initial value', () => {
const flag = new Setting('flag', false);
flag.connect({ flag: 'true' });
assert.equal(get(flag), true);
flag.connect({ flag: '"yes"' });
assert.equal(get(flag), false);
const size = new SettingInitOnFirstRead('size', 170);
size.connect({ size: '240' });
assert.equal(get(size), 240);
size.connect({ size: 'null' });
assert.equal(get(size), 170);
});
it('saves what it is set to, when it changes', () => {
const setting = new Setting('key', 1);
setting.set(2);
assert.equal(get(setting), 2);
assert.deepEqual(saved, { key: '2' });
saved = {};
setting.set(2);
assert.deepEqual(saved, {});
setting.update((value) => value + 1);
assert.equal(get(setting), 3);
assert.deepEqual(saved, { key: '3' });
});
it('always saves objects, which may have been changed in place', () => {
const value = { a: 1 };
const setting = new Setting('key', value, getRecordValidator(isNumber, value));
setting.set(value);
assert.deepEqual(saved, { key: '{"a":1}' });
});
it('forgets what it saved when it has no value', () => {
const setting = new Setting<string | undefined>(
'key',
'x',
getValueValidator<string | undefined>(['x', undefined], undefined)
);
setting.set(undefined);
assert.equal(get(setting), undefined);
assert.ok('key' in saved);
assert.equal(saved.key, undefined);
});
it('tells its subscribers', () => {
const setting = new Setting('key', 1);
const seen: number[] = [];
const unsubscribe = setting.subscribe((value) => seen.push(value));
setting.set(2);
setting.set(2);
unsubscribe();
setting.set(3);
assert.deepEqual(seen, [1, 2]);
});
});
describe('SettingInitOnFirstRead', () => {
it('has no value until it is connected', () => {
const setting = new SettingInitOnFirstRead(
'key',
{ a: true },
getLayerTreeValidator({ a: true })
);
assert.equal(get(setting), undefined);
setting.connect({});
assert.deepEqual(get(setting), { a: true });
});
it('takes the saved value, validated, instead of the initial one', () => {
const setting = new SettingInitOnFirstRead('key', ['hr'], getArrayValidator(['hr', 'cad']));
setting.connect({ key: '["cad","nope"]' });
assert.deepEqual(get(setting), ['cad']);
// nothing is saved when it is connected, that is what `initialize` is for
assert.deepEqual(saved, {});
});
it('can be initialized, which saves the initial value', () => {
const setting = new SettingInitOnFirstRead('key', 5);
setting.initialize();
assert.equal(get(setting), 5);
assert.deepEqual(saved, { key: '5' });
});
});
describe('Setting without storage', () => {
it('changes, but keeps nothing', () => {
useSettingsStorage(undefined);
const setting = new Setting('key', 1);
setting.set(2);
assert.equal(get(setting), 2);
assert.deepEqual(saved, {});
});
});
+230
View File
@@ -0,0 +1,230 @@
import { get, writable, type Writable } from 'svelte/store';
import type { LayerTreeType } from '$lib/data/layers';
/** What the settings were saved as, by key: JSON strings (see `Engine.openStorage`). */
export type StoredSettings = Record<string, string>;
/** Where the settings are kept. */
export type SettingsStorage = {
/** Keeps the JSON of a setting. */
save(key: string, json: string): void;
/** Forgets a setting. */
remove(key: string): void;
};
let storage: SettingsStorage | undefined;
/** Sets where the settings are kept: until then, changes are not saved. */
export function useSettingsStorage(newStorage: SettingsStorage | undefined) {
storage = newStorage;
}
/** Reads the saved value of a setting, `undefined` if there is none or if it cannot be read. */
export function storedValue(stored: StoredSettings, key: string): { value: unknown } | undefined {
if (!Object.hasOwn(stored, key)) {
return undefined;
}
try {
return { value: JSON.parse(stored[key]) };
} catch {
return undefined;
}
}
/** Keeps a value; `undefined` is not something JSON can hold, it means no value. */
function save(key: string, value: unknown) {
const json = JSON.stringify(value);
if (json === undefined) {
storage?.remove(key);
} else {
storage?.save(key, json);
}
}
/**
* Makes a saved value one that the setting can hold: what was saved can be anything (an old
* format, a value that is not allowed anymore), the validator checks it.
*/
export type Validator<V> = (value: unknown) => V;
/**
* The validator of a setting: a boolean or a number can do without one, its saved value is then
* checked against the type of its initial value. The other settings say what they can hold: a
* string is often one of a few, an object has a shape.
*/
type ValidatorArgument<V> = [V] extends [boolean] | [number]
? [validator?: Validator<V>]
: [validator: Validator<V>];
/** Keeps a saved value that has the type of the initial one (a boolean or a number). */
function sameTypeAs<V>(initial: V): Validator<V> {
return (value) => (typeof value === typeof initial ? (value as V) : initial);
}
/**
* A setting: a store whose value is kept when it changes. `S` is what the store holds, which is
* `V` or, for a setting that has no value until it is read, `V | undefined`.
*/
abstract class SettingBase<V, S extends V | undefined> {
protected _key: string;
protected _value: Writable<S>;
protected _validator: Validator<V>;
protected constructor(key: string, initial: S, validator: Validator<V>) {
this._key = key;
this._value = writable(initial);
this._validator = validator;
}
/** The saved value, validated, `undefined` if there is none. */
protected saved(stored: StoredSettings): { value: V } | undefined {
const saved = storedValue(stored, this._key);
return saved && { value: this._validator(saved.value) };
}
/** Takes the saved value, if there is one. */
abstract connect(stored: StoredSettings): void;
subscribe(run: (value: S) => void, invalidate?: (value?: S) => void) {
return this._value.subscribe(run, invalidate);
}
/** Sets the value and keeps it. An object is always kept: it may have been changed in place. */
set(value: V) {
if (typeof value === 'object' || value !== get(this._value)) {
this._value.set(value as S);
save(this._key, value);
}
}
update(callback: (value: any) => any) {
this.set(callback(get(this._value)));
}
}
export class Setting<V> extends SettingBase<V, V> {
constructor(key: string, initial: V, ...[validator]: ValidatorArgument<V>) {
super(key, initial, validator ?? sameTypeAs(initial));
}
connect(stored: StoredSettings) {
const saved = this.saved(stored);
if (saved) {
this._value.set(saved.value);
}
}
}
/** A setting that has no value until it is connected: then the saved one, or the initial one. */
export class SettingInitOnFirstRead<V> extends SettingBase<V, V | undefined> {
private _initial: V;
constructor(key: string, initial: V, ...[validator]: ValidatorArgument<V>) {
super(key, undefined, validator ?? sameTypeAs(initial));
this._initial = initial;
}
connect(stored: StoredSettings) {
this._value.set(this.saved(stored)?.value ?? this._initial);
}
/** Takes the initial value and keeps it, for a setting that was never saved. */
initialize() {
this.set(this._initial);
}
}
/** Whether a value is one of the allowed ones. */
function oneOf<V>(allowed: V[]) {
const dict = new Set<unknown>(allowed);
return (value: unknown): value is V => dict.has(value);
}
export function getValueValidator<V>(allowed: V[], fallback: V): Validator<V> {
const isAllowed = oneOf(allowed);
return (value) => (isAllowed(value) ? value : fallback);
}
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
/** Keeps the items of a saved list that are what it holds, in their order; nothing if it is not a list. */
export function getListValidator<V>(isItem: (value: unknown) => value is V): Validator<V[]> {
return (value) => (Array.isArray(value) ? value.filter(isItem) : []);
}
/** Keeps the allowed values of a list, in their order; nothing if what was saved is not a list. */
export function getArrayValidator<V>(allowed: V[]): Validator<V[]> {
return getListValidator(oneOf(allowed));
}
/** Keeps the entries of a saved record that are what it holds; `fallback` if it is not a record. */
export function getRecordValidator<V>(
isEntry: (value: unknown) => value is V,
fallback: Record<string, V>
): Validator<Record<string, V>> {
return (value) =>
isRecord(value)
? Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, V] => isEntry(entry[1]))
)
: fallback;
}
export function getLayerValidator(
allowed: Record<string, unknown>,
fallback: string
): Validator<string> {
return (layer) =>
typeof layer === 'string' &&
(Object.hasOwn(allowed, layer) ||
layer.startsWith('custom-') ||
layer.startsWith('extension-'))
? layer
: fallback;
}
/** Whether a value is a record, which can hold the layers of a tree: an object, not null or a list. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* The saved tree `t`, with the layers of `allowed`: the choices saved for them are kept, the new
* ones have their default, and of the others only the custom and extension layers are kept.
*/
export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined): LayerTreeType {
const tree = isRecord(t) ? t : {};
const filtered: LayerTreeType = {};
if (allowed) {
Object.entries(allowed).forEach(([key, value]) => {
const saved = Object.hasOwn(tree, key) ? tree[key] : undefined;
if (typeof value === 'boolean') {
filtered[key] = typeof saved === 'boolean' ? saved : value;
} else {
filtered[key] = filterLayerTree(saved, value);
}
});
}
Object.entries(tree).forEach(([key, value]) => {
if (!Object.hasOwn(filtered, key)) {
if (typeof value === 'boolean') {
if (key.startsWith('custom-') || key.startsWith('extension-')) {
filtered[key] = value;
}
} else if (isRecord(value)) {
filtered[key] = filterLayerTree(value, undefined);
}
}
});
return filtered;
}
export function getLayerTreeValidator(allowed: LayerTreeType): Validator<LayerTreeType> {
return (value) => filterLayerTree(value, allowed);
}
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { engine } from '$lib/engine';
import { settings } from '$lib/state/settings';
/** What the engine was told to keep. */
let saved: Record<string, string | undefined>;
/** What was kept, seen without what the assertions made of its type. */
const kept = (): Record<string, string | undefined> => saved;
const original = { set: engine.setSetting, delete: engine.deleteSetting };
beforeEach(() => {
saved = {};
engine.setSetting = (key, json) => {
saved[key] = json;
};
engine.deleteSetting = (key) => {
saved[key] = undefined;
};
});
afterEach(() => {
engine.setSetting = original.set;
engine.deleteSetting = original.delete;
});
describe('settings', () => {
it('connects every setting', () => {
settings.connect({
distanceUnits: '"imperial"',
velocityUnits: '"pace"',
temperatureUnits: '"fahrenheit"',
routingProfile: '"foot"',
currentOverlays: '{"cyclOSM":true}',
additionalDatasets: '["hr","bogus"]',
});
assert.equal(get(settings.distanceUnits), 'imperial');
assert.equal(get(settings.velocityUnits), 'pace');
assert.equal(get(settings.temperatureUnits), 'fahrenheit');
assert.equal(get(settings.routingProfile), 'foot');
assert.deepEqual(get(settings.additionalDatasets), ['hr']);
assert.ok(get(settings.currentOverlays));
// values that are not valid anymore are replaced
settings.connect({ distanceUnits: '"cubits"', routingProfile: '"hovercraft"' });
assert.equal(get(settings.distanceUnits), 'metric');
assert.equal(get(settings.routingProfile), 'bike');
});
it('initializes the settings that have no value until they are read', () => {
settings.connect({});
saved = {};
settings.initialize();
assert.ok('currentOverlays' in saved);
assert.ok('currentOverpassQueries' in saved);
assert.equal(Object.keys(saved).length, 2);
});
it('are kept by the engine when they change', () => {
settings.connect({});
settings.distanceUnits.set('nautical');
assert.deepEqual(saved, { distanceUnits: '"nautical"' });
// a setting that has no value is forgotten
settings.elevationFill.set('slope');
assert.equal(kept().elevationFill, '"slope"');
settings.elevationFill.set(undefined);
assert.ok('elevationFill' in kept() && kept().elevationFill === undefined);
saved = {};
// the same value is not kept again
settings.distanceUnits.set('nautical');
assert.deepEqual(saved, {});
});
});
+189
View File
@@ -0,0 +1,189 @@
import {
basemaps,
defaultBasemap,
defaultBasemapTree,
defaultOpacities,
defaultOverlays,
defaultOverlayTree,
defaultOverpassQueries,
defaultOverpassTree,
defaultTerrainSource,
isCustomLayer,
overpassTree,
} from '$lib/data/layers';
import { browser } from '$app/environment';
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions';
import { engine } from '$lib/engine';
import { languages } from '$lib/i18n/languages';
import {
getArrayValidator,
getLayerTreeValidator,
getLayerValidator,
getListValidator,
getRecordValidator,
getValueValidator,
isNumber,
isString,
Setting,
SettingInitOnFirstRead,
useSettingsStorage,
type StoredSettings,
} from '$lib/state/setting';
export type { StoredSettings };
type AdditionalDataset = 'speed' | 'hr' | 'cad' | 'atemp' | 'power';
type ElevationFill = 'slope' | 'surface' | 'highway' | undefined;
type RoutingProfile =
| 'bike'
| 'racing_bike'
| 'gravel_bike'
| 'mountain_bike'
| 'foot'
| 'motorcycle'
| 'water'
| 'railway';
type TerrainSource = 'mapterhorn';
type StreetViewSource = 'mapillary' | 'google';
// The settings are kept by the engine, in the storage of the browser.
useSettingsStorage({
save: (key, json) => engine.setSetting(key, json),
remove: (key) => engine.deleteSetting(key),
});
export const settings = {
distanceUnits: new Setting<DistanceUnits>(
'distanceUnits',
'metric',
getValueValidator<DistanceUnits>(['metric', 'imperial', 'nautical'], 'metric')
),
velocityUnits: new Setting<VelocityUnits>(
'velocityUnits',
'speed',
getValueValidator<VelocityUnits>(['speed', 'pace'], 'speed')
),
temperatureUnits: new Setting<TemperatureUnits>(
'temperatureUnits',
'celsius',
getValueValidator<TemperatureUnits>(['celsius', 'fahrenheit'], 'celsius')
),
elevationProfile: new Setting<boolean>('elevationProfile', true),
additionalDatasets: new Setting<AdditionalDataset[]>(
'additionalDatasets',
[],
getArrayValidator<AdditionalDataset>(['speed', 'hr', 'cad', 'atemp', 'power'])
),
elevationFill: new Setting<ElevationFill>(
'elevationFill',
undefined,
getValueValidator(['slope', 'surface', 'highway', undefined], undefined)
),
treeFileView: new Setting<boolean>('fileView', false),
minimizeRoutingMenu: new Setting('minimizeRoutingMenu', false),
routing: new Setting('routing', true),
routingProfile: new Setting<RoutingProfile>(
'routingProfile',
'bike',
getValueValidator<RoutingProfile>(
[
'bike',
'racing_bike',
'gravel_bike',
'mountain_bike',
'foot',
'motorcycle',
'water',
'railway',
],
'bike'
)
),
privateRoads: new Setting('privateRoads', false),
currentBasemap: new Setting(
'currentBasemap',
defaultBasemap,
getLayerValidator(basemaps, defaultBasemap)
),
previousBasemap: new Setting(
'previousBasemap',
defaultBasemap,
getLayerValidator(basemaps, defaultBasemap)
),
selectedBasemapTree: new Setting(
'selectedBasemapTree',
defaultBasemapTree,
getLayerTreeValidator(defaultBasemapTree)
),
currentOverlays: new SettingInitOnFirstRead(
'currentOverlays',
defaultOverlays,
getLayerTreeValidator(defaultOverlayTree)
),
previousOverlays: new Setting(
'previousOverlays',
defaultOverlays,
getLayerTreeValidator(defaultOverlayTree)
),
selectedOverlayTree: new Setting(
'selectedOverlayTree',
defaultOverlayTree,
getLayerTreeValidator(defaultOverlayTree)
),
currentOverpassQueries: new SettingInitOnFirstRead(
'currentOverpassQueries',
defaultOverpassQueries,
getLayerTreeValidator(overpassTree)
),
selectedOverpassTree: new Setting(
'selectedOverpassTree',
defaultOverpassTree,
getLayerTreeValidator(overpassTree)
),
opacities: new Setting(
'opacities',
defaultOpacities,
getRecordValidator(isNumber, defaultOpacities)
),
customLayers: new Setting('customLayers', {}, getRecordValidator(isCustomLayer, {})),
customBasemapOrder: new Setting('customBasemapOrder', [], getListValidator(isString)),
customOverlayOrder: new Setting('customOverlayOrder', [], getListValidator(isString)),
terrainSource: new Setting<TerrainSource>(
'terrainSource',
defaultTerrainSource,
getValueValidator(['mapterhorn'], defaultTerrainSource)
),
directionMarkers: new Setting('directionMarkers', false),
distanceMarkers: new Setting('distanceMarkers', false),
streetViewSource: new Setting<StreetViewSource>(
'streetViewSource',
'mapillary',
getValueValidator<StreetViewSource>(['mapillary', 'google'], 'mapillary')
),
defaultOpacity: new Setting('defaultOpacity', 0.7),
defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
bottomPanelSize: new Setting('bottomPanelSize', 170),
rightPanelSize: new Setting('rightPanelSize', 240),
explorerPanelSize: new Setting('explorerPanelSize', 0),
/** The language chosen last, for the desktop app, which always opens the same page. */
language: new Setting<string | undefined>(
'language',
undefined,
getValueValidator<string | undefined>(Object.keys(languages), undefined)
),
/** Takes the saved values (see `Engine.openStorage`). */
connect(stored: StoredSettings) {
for (const setting of Object.values(settings)) {
if (setting instanceof Setting || setting instanceof SettingInitOnFirstRead) {
setting.connect(stored);
}
}
},
initialize() {
for (const setting of Object.values(settings)) {
if (setting instanceof SettingInitOnFirstRead) {
setting.initialize();
}
}
},
};