mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 12:17:18 +00:00
refactoring
This commit is contained in:
1 parent
4795513344
commit
890bcd765a
169 files changed
+263
-256
No files matched your search
@@ -0,0 +1,149 @@
|
||||
import { get } from 'svelte/store';
|
||||
import maplibregl 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: maplibregl.LngLatBounds = new maplibregl.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()));
|
||||
}
|
||||
},
|
||||
(fileId) => {},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
(fileId) => {},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
addBoundsFromFile(fileId: string, file: FileState) {
|
||||
if (!file || !this._files.has(fileId)) return;
|
||||
|
||||
this._files.delete(fileId);
|
||||
|
||||
if (file.statistics.bounds) {
|
||||
let bounds = new maplibregl.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 maplibregl.LngLatBounds([180, 90, -180, -90]);
|
||||
}
|
||||
|
||||
centerMapOnSelection() {
|
||||
const bounds = new maplibregl.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: maplibregl.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) => {
|
||||
let state = Array.from(states.values()).reduce((max, value) => {
|
||||
return value > max ? value : max;
|
||||
}, MapCursorState.DEFAULT);
|
||||
let 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,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import type { LayerTreeType } from '$lib/data/layers';
|
||||
import {
|
||||
filterLayerTree,
|
||||
getArrayValidator,
|
||||
getLayerTreeValidator,
|
||||
getLayerValidator,
|
||||
getValueValidator,
|
||||
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 as any), '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('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');
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Setting', () => {
|
||||
it('has its initial value until it is connected', () => {
|
||||
const setting = new Setting('key', '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('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);
|
||||
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');
|
||||
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 });
|
||||
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,173 @@
|
||||
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: any } | 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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?: (value: V) => V;
|
||||
|
||||
protected constructor(key: string, initial: S, validator?: (value: V) => 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 ? this._validator(saved.value) : 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?: (value: V) => V) {
|
||||
super(key, initial, validator);
|
||||
}
|
||||
|
||||
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?: (value: V) => V) {
|
||||
super(key, undefined, validator);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export function getValueValidator<V>(allowed: V[], fallback: V) {
|
||||
const dict = new Set<V>(allowed);
|
||||
return (value: V) => (dict.has(value) ? value : fallback);
|
||||
}
|
||||
|
||||
export function getArrayValidator<V>(allowed: V[]) {
|
||||
const dict = new Set<V>(allowed);
|
||||
return (value: V[]) => value.filter((v) => dict.has(v));
|
||||
}
|
||||
|
||||
export function getLayerValidator(allowed: Record<string, any>, fallback: string) {
|
||||
return (layer: string) =>
|
||||
allowed.hasOwnProperty(layer) ||
|
||||
layer.startsWith('custom-') ||
|
||||
layer.startsWith('extension-')
|
||||
? layer
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function filterLayerTree(
|
||||
t: LayerTreeType,
|
||||
allowed: LayerTreeType | undefined
|
||||
): LayerTreeType {
|
||||
const filtered: LayerTreeType = {};
|
||||
if (allowed) {
|
||||
Object.entries(allowed).forEach(([key, value]) => {
|
||||
if (Object.hasOwn(t, key)) {
|
||||
if (typeof value === 'boolean') {
|
||||
filtered[key] = t[key];
|
||||
} else if (typeof value === 'object') {
|
||||
filtered[key] = filterLayerTree(
|
||||
typeof t[key] === 'object' ? t[key] : {},
|
||||
value
|
||||
);
|
||||
}
|
||||
} else {
|
||||
filtered[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
Object.entries(t).forEach(([key, value]) => {
|
||||
if (!Object.hasOwn(filtered, key)) {
|
||||
if (typeof value === 'boolean') {
|
||||
if (key.startsWith('custom-') || key.startsWith('extension-')) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
filtered[key] = filterLayerTree(value, undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function getLayerTreeValidator(allowed: LayerTreeType) {
|
||||
return (value: LayerTreeType) => 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,176 @@
|
||||
import {
|
||||
basemaps,
|
||||
defaultBasemap,
|
||||
defaultBasemapTree,
|
||||
defaultOpacities,
|
||||
defaultOverlays,
|
||||
defaultOverlayTree,
|
||||
defaultOverpassQueries,
|
||||
defaultOverpassTree,
|
||||
defaultTerrainSource,
|
||||
overpassTree,
|
||||
type CustomLayer,
|
||||
type LayerTreeType,
|
||||
} from '$lib/data/layers';
|
||||
import { browser } from '$app/environment';
|
||||
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions';
|
||||
import { engine } from '$lib/engine';
|
||||
import {
|
||||
getArrayValidator,
|
||||
getLayerTreeValidator,
|
||||
getLayerValidator,
|
||||
getValueValidator,
|
||||
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),
|
||||
customLayers: new Setting<Record<string, CustomLayer>>('customLayers', {}),
|
||||
customBasemapOrder: new Setting<string[]>('customBasemapOrder', []),
|
||||
customOverlayOrder: new Setting<string[]>('customOverlayOrder', []),
|
||||
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),
|
||||
/** Takes the saved values (see `Engine.openStorage`). */
|
||||
connect(stored: StoredSettings) {
|
||||
for (const key in settings) {
|
||||
const setting = (settings as any)[key];
|
||||
if (setting instanceof Setting || setting instanceof SettingInitOnFirstRead) {
|
||||
setting.connect(stored);
|
||||
}
|
||||
}
|
||||
},
|
||||
initialize() {
|
||||
for (const key in settings) {
|
||||
const setting = (settings as any)[key];
|
||||
if (setting instanceof SettingInitOnFirstRead) {
|
||||
setting.initialize();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user