improve typings

This commit is contained in:
vcoppe committed 2026-10-31 17:47:26 +01:00
1 parent 46563ef283
commit 972510bfbc
10 files changed
+275 -71

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);
}); });
+11 -3
View File
@@ -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 {}
+19
View File
@@ -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;
+9 -3
View File
@@ -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 () => {
+12 -7
View File
@@ -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 -1
View File
@@ -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'));
+99 -6
View File
@@ -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 });
+94 -37
View File
@@ -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) || }
layer.startsWith('custom-') ||
layer.startsWith('extension-') 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 ? 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);
} }
+13 -5
View File
@@ -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 ?? ''));