mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 12:17:18 +00:00
improve typings
This commit is contained in:
1 parent
46563ef283
commit
972510bfbc
10 files changed
+273
-69
No files matched your search
@@ -9,6 +9,15 @@ import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
|||||||
import { isSelected } from './utils';
|
import { isSelected } from './utils';
|
||||||
|
|
||||||
const original = { set: engine.setSetting, delete: engine.deleteSetting };
|
const original = { set: engine.setSetting, delete: engine.deleteSetting };
|
||||||
|
|
||||||
|
/** The raster source that an overlay added by an extension uses. */
|
||||||
|
function rasterSource(id: string) {
|
||||||
|
const style = overlays[id];
|
||||||
|
assert.ok(typeof style === 'object');
|
||||||
|
const source = style.sources[id];
|
||||||
|
assert.ok(source?.type === 'raster');
|
||||||
|
return source;
|
||||||
|
}
|
||||||
const overlayTreeBefore = JSON.stringify(overlayTree);
|
const overlayTreeBefore = JSON.stringify(overlayTree);
|
||||||
const overlayIdsBefore = Object.keys(overlays).sort();
|
const overlayIdsBefore = Object.keys(overlays).sort();
|
||||||
|
|
||||||
@@ -45,10 +54,7 @@ describe('ExtensionAPI', () => {
|
|||||||
it('adds an overlay as a layer, in the tree of its extension', () => {
|
it('adds an overlay as a layer, in the tree of its extension', () => {
|
||||||
api.addOrUpdateOverlay(overlay('one'));
|
api.addOrUpdateOverlay(overlay('one'));
|
||||||
|
|
||||||
const layer = overlays['extension-one'];
|
const source = rasterSource('extension-one');
|
||||||
assert.ok(layer && typeof layer === 'object');
|
|
||||||
const source = (layer as any).sources['extension-one'];
|
|
||||||
assert.equal(source.type, 'raster');
|
|
||||||
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
|
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
|
||||||
assert.equal(source.tileSize, 256);
|
assert.equal(source.tileSize, 256);
|
||||||
assert.equal(source.maxzoom, 22);
|
assert.equal(source.maxzoom, 22);
|
||||||
@@ -69,7 +75,7 @@ describe('ExtensionAPI', () => {
|
|||||||
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
|
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
});
|
});
|
||||||
const source = (overlays['extension-big'] as any).sources['extension-big'];
|
const source = rasterSource('extension-big');
|
||||||
assert.equal(source.tileSize, 512);
|
assert.equal(source.tileSize, 512);
|
||||||
assert.equal(source.maxzoom, 15);
|
assert.equal(source.maxzoom, 15);
|
||||||
});
|
});
|
||||||
@@ -100,9 +106,7 @@ describe('ExtensionAPI', () => {
|
|||||||
tileUrls: ['https://other/{z}'],
|
tileUrls: ['https://other/{z}'],
|
||||||
});
|
});
|
||||||
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
|
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
|
||||||
assert.deepEqual((overlays['extension-one'] as any).sources['extension-one'].tiles, [
|
assert.deepEqual(rasterSource('extension-one').tiles, ['https://other/{z}']);
|
||||||
'https://other/{z}',
|
|
||||||
]);
|
|
||||||
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
|
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -85,15 +85,23 @@ export class MapLibreGLMap {
|
|||||||
try {
|
try {
|
||||||
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
|
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
const geojson = await response.json();
|
const places: {
|
||||||
results.features = geojson.map((result: any) => {
|
lat: string;
|
||||||
|
lon: string;
|
||||||
|
type: string;
|
||||||
|
display_name: string;
|
||||||
|
}[] = await response.json();
|
||||||
|
results.features = places.map((result) => {
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: {
|
geometry: {
|
||||||
type: 'Point',
|
type: 'Point',
|
||||||
coordinates: [result.lon, result.lat],
|
coordinates: [Number(result.lon), Number(result.lat)],
|
||||||
},
|
},
|
||||||
|
properties: {},
|
||||||
|
text: result.display_name,
|
||||||
place_name: result.display_name,
|
place_name: result.display_name,
|
||||||
|
place_type: [result.type],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -1178,6 +1178,25 @@ export type CustomLayer = {
|
|||||||
value: string | StyleSpecification;
|
value: string | StyleSpecification;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Whether a saved value is a custom layer, with all that the layers have had from the start. */
|
||||||
|
export function isCustomLayer(value: unknown): value is CustomLayer {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const layer: Partial<Record<keyof CustomLayer, unknown>> = value;
|
||||||
|
return (
|
||||||
|
typeof layer.id === 'string' &&
|
||||||
|
typeof layer.name === 'string' &&
|
||||||
|
Array.isArray(layer.tileUrls) &&
|
||||||
|
layer.tileUrls.every((url) => typeof url === 'string') &&
|
||||||
|
typeof layer.maxZoom === 'number' &&
|
||||||
|
(layer.layerType === 'basemap' || layer.layerType === 'overlay') &&
|
||||||
|
(layer.resourceType === 'raster' || layer.resourceType === 'vector') &&
|
||||||
|
(typeof layer.value === 'string' ||
|
||||||
|
(typeof layer.value === 'object' && layer.value !== null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type OverpassQueryData = {
|
type OverpassQueryData = {
|
||||||
icon: {
|
icon: {
|
||||||
svg: string;
|
svg: string;
|
||||||
|
|||||||
@@ -143,19 +143,25 @@ describe('errors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('selection', () => {
|
describe('selection', () => {
|
||||||
|
/** The selected files, when files are selected. */
|
||||||
|
const selectedFiles = () => {
|
||||||
|
const selection = get(engine.selection);
|
||||||
|
return selection.type === 'file' ? selection.fileIds : [];
|
||||||
|
};
|
||||||
|
|
||||||
it('adds and toggles files', async () => {
|
it('adds and toggles files', async () => {
|
||||||
await load('simple', 'with_tracks', 'with_waypoint');
|
await load('simple', 'with_tracks', 'with_waypoint');
|
||||||
const [a, b, c] = get(engine.order);
|
const [a, b, c] = get(engine.order);
|
||||||
await engine.select([a]);
|
await engine.select([a]);
|
||||||
await engine.select([b], 'add');
|
await engine.select([b], 'add');
|
||||||
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b]));
|
assert.deepEqual(new Set(selectedFiles()), new Set([a, b]));
|
||||||
await engine.select([a], 'toggle');
|
await engine.select([a], 'toggle');
|
||||||
assert.deepEqual((get(engine.selection) as any).fileIds, [b]);
|
assert.deepEqual(selectedFiles(), [b]);
|
||||||
await engine.select([]);
|
await engine.select([]);
|
||||||
assert.deepEqual(get(engine.selection), { type: 'empty' });
|
assert.deepEqual(get(engine.selection), { type: 'empty' });
|
||||||
|
|
||||||
assert.equal(await engine.selectAll(), true);
|
assert.equal(await engine.selectAll(), true);
|
||||||
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b, c]));
|
assert.deepEqual(new Set(selectedFiles()), new Set([a, b, c]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects tracks and segments of a file', async () => {
|
it('selects tracks and segments of a file', async () => {
|
||||||
|
|||||||
@@ -19,18 +19,23 @@ function sources(dir: string): string[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function has(dictionary: any, key: string): boolean {
|
/** The value at a key (`a.b.c`) of a dictionary, `undefined` if there is none. */
|
||||||
let value = dictionary;
|
function valueAt(dictionary: object, key: string): unknown {
|
||||||
|
let value: unknown = dictionary;
|
||||||
for (const part of key.split('.')) {
|
for (const part of key.split('.')) {
|
||||||
if (value === null || typeof value !== 'object' || !(part in value)) {
|
if (value === null || typeof value !== 'object' || !(part in value)) {
|
||||||
return false;
|
return undefined;
|
||||||
}
|
}
|
||||||
value = value[part];
|
value = (value as Record<string, unknown>)[part];
|
||||||
}
|
}
|
||||||
return typeof value === 'string';
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function keysOf(dictionary: any, prefix = ''): string[] {
|
function has(dictionary: object, key: string): boolean {
|
||||||
|
return typeof valueAt(dictionary, key) === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function keysOf(dictionary: object, prefix = ''): string[] {
|
||||||
return Object.entries(dictionary).flatMap(([key, value]) =>
|
return Object.entries(dictionary).flatMap(([key, value]) =>
|
||||||
typeof value === 'object' && value !== null
|
typeof value === 'object' && value !== null
|
||||||
? keysOf(value, `${prefix}${key}.`)
|
? keysOf(value, `${prefix}${key}.`)
|
||||||
@@ -60,7 +65,7 @@ describe('translations', () => {
|
|||||||
|
|
||||||
it('have no empty text in english', () => {
|
it('have no empty text in english', () => {
|
||||||
const empty = keysOf(english).filter((key) => {
|
const empty = keysOf(english).filter((key) => {
|
||||||
const value = key.split('.').reduce((v: any, part) => v[part], english);
|
const value = valueAt(english, key);
|
||||||
return typeof value === 'string' && value.trim() === '';
|
return typeof value === 'string' && value.trim() === '';
|
||||||
});
|
});
|
||||||
assert.deepEqual(empty, []);
|
assert.deepEqual(empty, []);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { languages } from '$lib/i18n/languages';
|
import { languages } from '$lib/i18n/languages';
|
||||||
|
|
||||||
function localizeManifest(manifestTemplateData: any, language: string) {
|
function localizeManifest(manifestTemplateData: Record<string, unknown>, language: string) {
|
||||||
const localizedManifestFile = `static/${language}.manifest.webmanifest`;
|
const localizedManifestFile = `static/${language}.manifest.webmanifest`;
|
||||||
const localizedStringsFile = `src/locales/${language}.json`;
|
const localizedStringsFile = `src/locales/${language}.json`;
|
||||||
const localizedStrings = JSON.parse(fs.readFileSync(localizedStringsFile, 'utf8'));
|
const localizedStrings = JSON.parse(fs.readFileSync(localizedStringsFile, 'utf8'));
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { LayerTreeType } from '$lib/data/layers';
|
import { isCustomLayer, type LayerTreeType } from '$lib/data/layers';
|
||||||
import {
|
import {
|
||||||
filterLayerTree,
|
filterLayerTree,
|
||||||
getArrayValidator,
|
getArrayValidator,
|
||||||
getLayerTreeValidator,
|
getLayerTreeValidator,
|
||||||
getLayerValidator,
|
getLayerValidator,
|
||||||
|
getListValidator,
|
||||||
|
getRecordValidator,
|
||||||
getValueValidator,
|
getValueValidator,
|
||||||
|
isNumber,
|
||||||
|
isString,
|
||||||
Setting,
|
Setting,
|
||||||
SettingInitOnFirstRead,
|
SettingInitOnFirstRead,
|
||||||
storedValue,
|
storedValue,
|
||||||
@@ -48,7 +52,9 @@ describe('validators', () => {
|
|||||||
const validate = getValueValidator(['metric', 'imperial'], 'metric');
|
const validate = getValueValidator(['metric', 'imperial'], 'metric');
|
||||||
assert.equal(validate('imperial'), 'imperial');
|
assert.equal(validate('imperial'), 'imperial');
|
||||||
assert.equal(validate('nautical'), 'metric');
|
assert.equal(validate('nautical'), 'metric');
|
||||||
assert.equal(validate(undefined as any), 'metric');
|
assert.equal(validate(undefined), 'metric');
|
||||||
|
assert.equal(validate(null), 'metric');
|
||||||
|
assert.equal(validate(3), 'metric');
|
||||||
const optional = getValueValidator<string | undefined>(['slope', undefined], undefined);
|
const optional = getValueValidator<string | undefined>(['slope', undefined], undefined);
|
||||||
assert.equal(optional('slope'), 'slope');
|
assert.equal(optional('slope'), 'slope');
|
||||||
assert.equal(optional('other'), undefined);
|
assert.equal(optional('other'), undefined);
|
||||||
@@ -60,6 +66,48 @@ describe('validators', () => {
|
|||||||
assert.deepEqual(validate([]), []);
|
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', () => {
|
it('accepts the known layers, and the custom and extension ones', () => {
|
||||||
const validate = getLayerValidator({ libertyTopo: 'url', osm: 'url' }, 'libertyTopo');
|
const validate = getLayerValidator({ libertyTopo: 'url', osm: 'url' }, 'libertyTopo');
|
||||||
assert.equal(validate('osm'), 'osm');
|
assert.equal(validate('osm'), 'osm');
|
||||||
@@ -67,6 +115,9 @@ describe('validators', () => {
|
|||||||
assert.equal(validate('extension-some-id'), 'extension-some-id');
|
assert.equal(validate('extension-some-id'), 'extension-some-id');
|
||||||
assert.equal(validate('gone'), 'libertyTopo');
|
assert.equal(validate('gone'), 'libertyTopo');
|
||||||
assert.equal(validate('toString'), 'libertyTopo');
|
assert.equal(validate('toString'), 'libertyTopo');
|
||||||
|
for (const saved of [null, 3, ['osm'], { id: 'osm' }]) {
|
||||||
|
assert.equal(validate(saved), 'libertyTopo');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,12 +167,33 @@ describe('layer trees', () => {
|
|||||||
a: false,
|
a: false,
|
||||||
b: 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', () => {
|
describe('Setting', () => {
|
||||||
it('has its initial value until it is connected', () => {
|
it('has its initial value until it is connected', () => {
|
||||||
const setting = new Setting('key', 'initial');
|
const setting = new Setting('key', 'initial', getValueValidator(['initial'], 'initial'));
|
||||||
assert.equal(get(setting), 'initial');
|
assert.equal(get(setting), 'initial');
|
||||||
setting.connect({});
|
setting.connect({});
|
||||||
assert.equal(get(setting), 'initial');
|
assert.equal(get(setting), 'initial');
|
||||||
@@ -145,6 +217,19 @@ describe('Setting', () => {
|
|||||||
assert.deepEqual(saved, {});
|
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', () => {
|
it('saves what it is set to, when it changes', () => {
|
||||||
const setting = new Setting('key', 1);
|
const setting = new Setting('key', 1);
|
||||||
setting.set(2);
|
setting.set(2);
|
||||||
@@ -160,13 +245,17 @@ describe('Setting', () => {
|
|||||||
|
|
||||||
it('always saves objects, which may have been changed in place', () => {
|
it('always saves objects, which may have been changed in place', () => {
|
||||||
const value = { a: 1 };
|
const value = { a: 1 };
|
||||||
const setting = new Setting('key', value);
|
const setting = new Setting('key', value, getRecordValidator(isNumber, value));
|
||||||
setting.set(value);
|
setting.set(value);
|
||||||
assert.deepEqual(saved, { key: '{"a":1}' });
|
assert.deepEqual(saved, { key: '{"a":1}' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forgets what it saved when it has no value', () => {
|
it('forgets what it saved when it has no value', () => {
|
||||||
const setting = new Setting<string | undefined>('key', 'x');
|
const setting = new Setting<string | undefined>(
|
||||||
|
'key',
|
||||||
|
'x',
|
||||||
|
getValueValidator<string | undefined>(['x', undefined], undefined)
|
||||||
|
);
|
||||||
setting.set(undefined);
|
setting.set(undefined);
|
||||||
assert.equal(get(setting), undefined);
|
assert.equal(get(setting), undefined);
|
||||||
assert.ok('key' in saved);
|
assert.ok('key' in saved);
|
||||||
@@ -187,7 +276,11 @@ describe('Setting', () => {
|
|||||||
|
|
||||||
describe('SettingInitOnFirstRead', () => {
|
describe('SettingInitOnFirstRead', () => {
|
||||||
it('has no value until it is connected', () => {
|
it('has no value until it is connected', () => {
|
||||||
const setting = new SettingInitOnFirstRead('key', { a: true });
|
const setting = new SettingInitOnFirstRead(
|
||||||
|
'key',
|
||||||
|
{ a: true },
|
||||||
|
getLayerTreeValidator({ a: true })
|
||||||
|
);
|
||||||
assert.equal(get(setting), undefined);
|
assert.equal(get(setting), undefined);
|
||||||
setting.connect({});
|
setting.connect({});
|
||||||
assert.deepEqual(get(setting), { a: true });
|
assert.deepEqual(get(setting), { a: true });
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useSettingsStorage(newStorage: SettingsStorage | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the saved value of a setting, `undefined` if there is none or if it cannot be read. */
|
/** 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 {
|
export function storedValue(stored: StoredSettings, key: string): { value: unknown } | undefined {
|
||||||
if (!Object.hasOwn(stored, key)) {
|
if (!Object.hasOwn(stored, key)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,26 @@ function save(key: string, value: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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`.
|
* `V` or, for a setting that has no value until it is read, `V | undefined`.
|
||||||
@@ -48,9 +68,9 @@ function save(key: string, value: unknown) {
|
|||||||
abstract class SettingBase<V, S extends V | undefined> {
|
abstract class SettingBase<V, S extends V | undefined> {
|
||||||
protected _key: string;
|
protected _key: string;
|
||||||
protected _value: Writable<S>;
|
protected _value: Writable<S>;
|
||||||
protected _validator?: (value: V) => V;
|
protected _validator: Validator<V>;
|
||||||
|
|
||||||
protected constructor(key: string, initial: S, validator?: (value: V) => V) {
|
protected constructor(key: string, initial: S, validator: Validator<V>) {
|
||||||
this._key = key;
|
this._key = key;
|
||||||
this._value = writable(initial);
|
this._value = writable(initial);
|
||||||
this._validator = validator;
|
this._validator = validator;
|
||||||
@@ -59,7 +79,7 @@ abstract class SettingBase<V, S extends V | undefined> {
|
|||||||
/** The saved value, validated, `undefined` if there is none. */
|
/** The saved value, validated, `undefined` if there is none. */
|
||||||
protected saved(stored: StoredSettings): { value: V } | undefined {
|
protected saved(stored: StoredSettings): { value: V } | undefined {
|
||||||
const saved = storedValue(stored, this._key);
|
const saved = storedValue(stored, this._key);
|
||||||
return saved && { value: this._validator ? this._validator(saved.value) : saved.value };
|
return saved && { value: this._validator(saved.value) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Takes the saved value, if there is one. */
|
/** Takes the saved value, if there is one. */
|
||||||
@@ -83,8 +103,8 @@ abstract class SettingBase<V, S extends V | undefined> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Setting<V> extends SettingBase<V, V> {
|
export class Setting<V> extends SettingBase<V, V> {
|
||||||
constructor(key: string, initial: V, validator?: (value: V) => V) {
|
constructor(key: string, initial: V, ...[validator]: ValidatorArgument<V>) {
|
||||||
super(key, initial, validator);
|
super(key, initial, validator ?? sameTypeAs(initial));
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(stored: StoredSettings) {
|
connect(stored: StoredSettings) {
|
||||||
@@ -99,8 +119,8 @@ export class Setting<V> extends SettingBase<V, V> {
|
|||||||
export class SettingInitOnFirstRead<V> extends SettingBase<V, V | undefined> {
|
export class SettingInitOnFirstRead<V> extends SettingBase<V, V | undefined> {
|
||||||
private _initial: V;
|
private _initial: V;
|
||||||
|
|
||||||
constructor(key: string, initial: V, validator?: (value: V) => V) {
|
constructor(key: string, initial: V, ...[validator]: ValidatorArgument<V>) {
|
||||||
super(key, undefined, validator);
|
super(key, undefined, validator ?? sameTypeAs(initial));
|
||||||
this._initial = initial;
|
this._initial = initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,53 +134,90 @@ export class SettingInitOnFirstRead<V> extends SettingBase<V, V | undefined> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getValueValidator<V>(allowed: V[], fallback: V) {
|
/** Whether a value is one of the allowed ones. */
|
||||||
const dict = new Set<V>(allowed);
|
function oneOf<V>(allowed: V[]) {
|
||||||
return (value: V) => (dict.has(value) ? value : fallback);
|
const dict = new Set<unknown>(allowed);
|
||||||
|
return (value: unknown): value is V => dict.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getArrayValidator<V>(allowed: V[]) {
|
export function getValueValidator<V>(allowed: V[], fallback: V): Validator<V> {
|
||||||
const dict = new Set<V>(allowed);
|
const isAllowed = oneOf(allowed);
|
||||||
return (value: V[]) => value.filter((v) => dict.has(v));
|
return (value) => (isAllowed(value) ? value : fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLayerValidator(allowed: Record<string, any>, fallback: string) {
|
export function isString(value: unknown): value is string {
|
||||||
return (layer: string) =>
|
return typeof value === 'string';
|
||||||
Object.hasOwn(allowed, layer) ||
|
}
|
||||||
|
|
||||||
|
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('custom-') ||
|
||||||
layer.startsWith('extension-')
|
layer.startsWith('extension-'))
|
||||||
? layer
|
? layer
|
||||||
: fallback;
|
: fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterLayerTree(
|
/** Whether a value is a record, which can hold the layers of a tree: an object, not null or a list. */
|
||||||
t: LayerTreeType,
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
allowed: LayerTreeType | undefined
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
): LayerTreeType {
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {};
|
const filtered: LayerTreeType = {};
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
Object.entries(allowed).forEach(([key, value]) => {
|
Object.entries(allowed).forEach(([key, value]) => {
|
||||||
if (Object.hasOwn(t, key)) {
|
const saved = Object.hasOwn(tree, key) ? tree[key] : undefined;
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
filtered[key] = t[key];
|
filtered[key] = typeof saved === 'boolean' ? saved : value;
|
||||||
} else if (typeof value === 'object') {
|
|
||||||
filtered[key] = filterLayerTree(
|
|
||||||
typeof t[key] === 'object' ? t[key] : {},
|
|
||||||
value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
filtered[key] = value;
|
filtered[key] = filterLayerTree(saved, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Object.entries(t).forEach(([key, value]) => {
|
Object.entries(tree).forEach(([key, value]) => {
|
||||||
if (!Object.hasOwn(filtered, key)) {
|
if (!Object.hasOwn(filtered, key)) {
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
if (key.startsWith('custom-') || key.startsWith('extension-')) {
|
if (key.startsWith('custom-') || key.startsWith('extension-')) {
|
||||||
filtered[key] = value;
|
filtered[key] = value;
|
||||||
}
|
}
|
||||||
} else if (typeof value === 'object') {
|
} else if (isRecord(value)) {
|
||||||
filtered[key] = filterLayerTree(value, undefined);
|
filtered[key] = filterLayerTree(value, undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +225,6 @@ export function filterLayerTree(
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLayerTreeValidator(allowed: LayerTreeType) {
|
export function getLayerTreeValidator(allowed: LayerTreeType): Validator<LayerTreeType> {
|
||||||
return (value: LayerTreeType) => filterLayerTree(value, allowed);
|
return (value) => filterLayerTree(value, allowed);
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
defaultOverpassQueries,
|
defaultOverpassQueries,
|
||||||
defaultOverpassTree,
|
defaultOverpassTree,
|
||||||
defaultTerrainSource,
|
defaultTerrainSource,
|
||||||
|
isCustomLayer,
|
||||||
overpassTree,
|
overpassTree,
|
||||||
type CustomLayer,
|
|
||||||
} from '$lib/data/layers';
|
} from '$lib/data/layers';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions';
|
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions';
|
||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
getArrayValidator,
|
getArrayValidator,
|
||||||
getLayerTreeValidator,
|
getLayerTreeValidator,
|
||||||
getLayerValidator,
|
getLayerValidator,
|
||||||
|
getListValidator,
|
||||||
|
getRecordValidator,
|
||||||
getValueValidator,
|
getValueValidator,
|
||||||
|
isNumber,
|
||||||
|
isString,
|
||||||
Setting,
|
Setting,
|
||||||
SettingInitOnFirstRead,
|
SettingInitOnFirstRead,
|
||||||
useSettingsStorage,
|
useSettingsStorage,
|
||||||
@@ -136,10 +140,14 @@ export const settings = {
|
|||||||
defaultOverpassTree,
|
defaultOverpassTree,
|
||||||
getLayerTreeValidator(overpassTree)
|
getLayerTreeValidator(overpassTree)
|
||||||
),
|
),
|
||||||
opacities: new Setting('opacities', defaultOpacities),
|
opacities: new Setting(
|
||||||
customLayers: new Setting<Record<string, CustomLayer>>('customLayers', {}),
|
'opacities',
|
||||||
customBasemapOrder: new Setting<string[]>('customBasemapOrder', []),
|
defaultOpacities,
|
||||||
customOverlayOrder: new Setting<string[]>('customOverlayOrder', []),
|
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: new Setting<TerrainSource>(
|
||||||
'terrainSource',
|
'terrainSource',
|
||||||
defaultTerrainSource,
|
defaultTerrainSource,
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { ChevronLeft, ChevronRight, PenLine, CornerDownRight } from '@lucide/svelte';
|
import { ChevronLeft, ChevronRight, PenLine, CornerDownRight } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
let { data }: { data: { guideModule: any; guideTitles: Record<string, string> } } = $props();
|
let {
|
||||||
|
data,
|
||||||
|
}: { data: { guideModule: { default: Component }; guideTitles: Record<string, string> } } =
|
||||||
|
$props();
|
||||||
|
|
||||||
let previousGuide = $derived(getPreviousGuide(page.params.guide ?? ''));
|
let previousGuide = $derived(getPreviousGuide(page.params.guide ?? ''));
|
||||||
let nextGuide = $derived(getNextGuide(page.params.guide ?? ''));
|
let nextGuide = $derived(getNextGuide(page.params.guide ?? ''));
|
||||||
|
|||||||
Reference in new issue
Block a user