mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
Migration to new Rust-based engine (#401)
* wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
No files matched your search
@@ -0,0 +1,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();
|
||||
@@ -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);
|
||||
@@ -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, {});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, {});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user