diff --git a/website/src/lib/components/map/layer-control/extension-api.test.ts b/website/src/lib/components/map/layer-control/extension-api.test.ts index cf51c0b2e..93afe9050 100644 --- a/website/src/lib/components/map/layer-control/extension-api.test.ts +++ b/website/src/lib/components/map/layer-control/extension-api.test.ts @@ -9,6 +9,15 @@ import { ExtensionAPI, type CustomOverlay } from './extension-api'; import { isSelected } from './utils'; 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 overlayIdsBefore = Object.keys(overlays).sort(); @@ -45,10 +54,7 @@ describe('ExtensionAPI', () => { it('adds an overlay as a layer, in the tree of its extension', () => { api.addOrUpdateOverlay(overlay('one')); - const layer = overlays['extension-one']; - assert.ok(layer && typeof layer === 'object'); - const source = (layer as any).sources['extension-one']; - assert.equal(source.type, 'raster'); + const source = rasterSource('extension-one'); assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']); assert.equal(source.tileSize, 256); assert.equal(source.maxzoom, 22); @@ -69,7 +75,7 @@ describe('ExtensionAPI', () => { tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'], 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.maxzoom, 15); }); @@ -100,9 +106,7 @@ describe('ExtensionAPI', () => { tileUrls: ['https://other/{z}'], }); assert.equal(get(api.getLayerName)('extension-one'), 'Renamed'); - assert.deepEqual((overlays['extension-one'] as any).sources['extension-one'].tiles, [ - 'https://other/{z}', - ]); + assert.deepEqual(rasterSource('extension-one').tiles, ['https://other/{z}']); assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true); }); diff --git a/website/src/lib/components/map/map.ts b/website/src/lib/components/map/map.ts index ed4d80a3f..cc4c97c30 100644 --- a/website/src/lib/components/map/map.ts +++ b/website/src/lib/components/map/map.ts @@ -85,15 +85,23 @@ export class MapLibreGLMap { try { const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`; const response = await fetch(request); - const geojson = await response.json(); - results.features = geojson.map((result: any) => { + const places: { + lat: string; + lon: string; + type: string; + display_name: string; + }[] = await response.json(); + results.features = places.map((result) => { return { type: 'Feature', geometry: { 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_type: [result.type], }; }); } catch {} diff --git a/website/src/lib/data/layers.ts b/website/src/lib/data/layers.ts index 83d1aeded..37f22bc36 100644 --- a/website/src/lib/data/layers.ts +++ b/website/src/lib/data/layers.ts @@ -1178,6 +1178,25 @@ export type CustomLayer = { 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> = 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 = { icon: { svg: string; diff --git a/website/src/lib/engine/engine.test.ts b/website/src/lib/engine/engine.test.ts index a5493a2fa..1a4d13935 100644 --- a/website/src/lib/engine/engine.test.ts +++ b/website/src/lib/engine/engine.test.ts @@ -143,19 +143,25 @@ describe('errors', () => { }); 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 () => { await load('simple', 'with_tracks', 'with_waypoint'); const [a, b, c] = get(engine.order); await engine.select([a]); 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'); - assert.deepEqual((get(engine.selection) as any).fileIds, [b]); + assert.deepEqual(selectedFiles(), [b]); await engine.select([]); assert.deepEqual(get(engine.selection), { type: 'empty' }); 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 () => { diff --git a/website/src/lib/i18n/keys.test.ts b/website/src/lib/i18n/keys.test.ts index 1d878ad5a..074657611 100644 --- a/website/src/lib/i18n/keys.test.ts +++ b/website/src/lib/i18n/keys.test.ts @@ -19,18 +19,23 @@ function sources(dir: string): string[] { }); } -function has(dictionary: any, key: string): boolean { - let value = dictionary; +/** The value at a key (`a.b.c`) of a dictionary, `undefined` if there is none. */ +function valueAt(dictionary: object, key: string): unknown { + let value: unknown = dictionary; for (const part of key.split('.')) { if (value === null || typeof value !== 'object' || !(part in value)) { - return false; + return undefined; } - value = value[part]; + value = (value as Record)[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]) => typeof value === 'object' && value !== null ? keysOf(value, `${prefix}${key}.`) @@ -60,7 +65,7 @@ describe('translations', () => { it('have no empty text in english', () => { 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() === ''; }); assert.deepEqual(empty, []); diff --git a/website/src/lib/scripts/pwa-manifest.ts b/website/src/lib/scripts/pwa-manifest.ts index 62dbcf2bd..b611cbd09 100644 --- a/website/src/lib/scripts/pwa-manifest.ts +++ b/website/src/lib/scripts/pwa-manifest.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import { languages } from '$lib/i18n/languages'; -function localizeManifest(manifestTemplateData: any, language: string) { +function localizeManifest(manifestTemplateData: Record, language: string) { const localizedManifestFile = `static/${language}.manifest.webmanifest`; const localizedStringsFile = `src/locales/${language}.json`; const localizedStrings = JSON.parse(fs.readFileSync(localizedStringsFile, 'utf8')); diff --git a/website/src/lib/state/setting.test.ts b/website/src/lib/state/setting.test.ts index 3b2002c99..3a5e6686a 100644 --- a/website/src/lib/state/setting.test.ts +++ b/website/src/lib/state/setting.test.ts @@ -1,13 +1,17 @@ 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 { isCustomLayer, type LayerTreeType } from '$lib/data/layers'; import { filterLayerTree, getArrayValidator, getLayerTreeValidator, getLayerValidator, + getListValidator, + getRecordValidator, getValueValidator, + isNumber, + isString, Setting, SettingInitOnFirstRead, storedValue, @@ -48,7 +52,9 @@ describe('validators', () => { const validate = getValueValidator(['metric', 'imperial'], 'metric'); assert.equal(validate('imperial'), 'imperial'); 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(['slope', undefined], undefined); assert.equal(optional('slope'), 'slope'); assert.equal(optional('other'), undefined); @@ -60,6 +66,48 @@ describe('validators', () => { assert.deepEqual(validate([]), []); }); + it('keeps nothing of a saved value that is not a list', () => { + const validate = getArrayValidator(['hr', 'cad', 'power']); + for (const saved of [null, 'hr', 3, { 0: 'hr' }]) { + assert.deepEqual(validate(saved), []); + } + }); + + it('keeps the items of a list that are what it holds', () => { + const validate = getListValidator(isString); + assert.deepEqual(validate(['a', 3, null, 'b']), ['a', 'b']); + assert.deepEqual(validate('a'), []); + }); + + it('keeps the entries of a record that are what it holds, else takes the fallback', () => { + const fallback = { a: 0.5 }; + const validate = getRecordValidator(isNumber, fallback); + assert.deepEqual(validate({ a: 0.4, b: 'x', c: 1 }), { a: 0.4, c: 1 }); + for (const saved of [null, 3, ['a'], 'a']) { + assert.equal(validate(saved), fallback); + } + }); + + it('recognizes a custom layer', () => { + const layer = { + id: 'custom-0', + name: 'Layer', + tileUrls: ['https://tiles/{z}/{x}/{y}.png'], + maxZoom: 20, + layerType: 'basemap', + resourceType: 'raster', + value: { version: 8, sources: {}, layers: [] }, + }; + assert.ok(isCustomLayer(layer)); + assert.ok(isCustomLayer({ ...layer, resourceType: 'vector', value: 'https://style.json' })); + assert.ok(!isCustomLayer({ ...layer, layerType: 'other' })); + assert.ok(!isCustomLayer({ ...layer, tileUrls: [3] })); + assert.ok(!isCustomLayer({ ...layer, value: null })); + const { name: _name, ...nameless } = layer; + assert.ok(!isCustomLayer(nameless)); + assert.ok(!isCustomLayer(null)); + }); + it('accepts the known layers, and the custom and extension ones', () => { const validate = getLayerValidator({ libertyTopo: 'url', osm: 'url' }, 'libertyTopo'); assert.equal(validate('osm'), 'osm'); @@ -67,6 +115,9 @@ describe('validators', () => { assert.equal(validate('extension-some-id'), 'extension-some-id'); assert.equal(validate('gone'), 'libertyTopo'); assert.equal(validate('toString'), 'libertyTopo'); + for (const saved of [null, 3, ['osm'], { id: 'osm' }]) { + assert.equal(validate(saved), 'libertyTopo'); + } }); }); @@ -116,12 +167,33 @@ describe('layer trees', () => { a: false, b: false, }); + // a branch saved for a layer, and values that are not choices + assert.deepEqual(filterLayerTree({ world: { a: { x: true }, b: 'yes' } }, allowed).world, { + a: true, + b: false, + }); + }); + + it('takes the defaults when what was saved is not a tree', () => { + for (const saved of [null, true, 'world', 3, ['world']]) { + assert.deepEqual(filterLayerTree(saved, allowed), allowed); + } + assert.deepEqual( + filterLayerTree({ world: null, old: null, other: [true] }, allowed), + allowed + ); + }); + + it('does not share its branches with the allowed tree', () => { + const filtered = filterLayerTree({}, allowed); + (filtered.world as LayerTreeType).a = false; + assert.equal((allowed.world as LayerTreeType).a, true); }); }); describe('Setting', () => { it('has its initial value until it is connected', () => { - const setting = new Setting('key', 'initial'); + const setting = new Setting('key', 'initial', getValueValidator(['initial'], 'initial')); assert.equal(get(setting), 'initial'); setting.connect({}); assert.equal(get(setting), 'initial'); @@ -145,6 +217,19 @@ describe('Setting', () => { assert.deepEqual(saved, {}); }); + it('takes a saved boolean or number of the type of its initial value', () => { + const flag = new Setting('flag', false); + flag.connect({ flag: 'true' }); + assert.equal(get(flag), true); + flag.connect({ flag: '"yes"' }); + assert.equal(get(flag), false); + const size = new SettingInitOnFirstRead('size', 170); + size.connect({ size: '240' }); + assert.equal(get(size), 240); + size.connect({ size: 'null' }); + assert.equal(get(size), 170); + }); + it('saves what it is set to, when it changes', () => { const setting = new Setting('key', 1); setting.set(2); @@ -160,13 +245,17 @@ describe('Setting', () => { it('always saves objects, which may have been changed in place', () => { const value = { a: 1 }; - const setting = new Setting('key', value); + const setting = new Setting('key', value, getRecordValidator(isNumber, value)); setting.set(value); assert.deepEqual(saved, { key: '{"a":1}' }); }); it('forgets what it saved when it has no value', () => { - const setting = new Setting('key', 'x'); + const setting = new Setting( + 'key', + 'x', + getValueValidator(['x', undefined], undefined) + ); setting.set(undefined); assert.equal(get(setting), undefined); assert.ok('key' in saved); @@ -187,7 +276,11 @@ describe('Setting', () => { describe('SettingInitOnFirstRead', () => { 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); setting.connect({}); assert.deepEqual(get(setting), { a: true }); diff --git a/website/src/lib/state/setting.ts b/website/src/lib/state/setting.ts index 61cb131a4..ca6234500 100644 --- a/website/src/lib/state/setting.ts +++ b/website/src/lib/state/setting.ts @@ -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. */ -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)) { 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 = (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] extends [boolean] | [number] + ? [validator?: Validator] + : [validator: Validator]; + +/** Keeps a saved value that has the type of the initial one (a boolean or a number). */ +function sameTypeAs(initial: V): Validator { + return (value) => (typeof value === typeof initial ? (value as V) : initial); +} + /** * A setting: a store whose value is kept when it changes. `S` is what the store holds, which is * `V` or, for a setting that has no value until it is read, `V | undefined`. @@ -48,9 +68,9 @@ function save(key: string, value: unknown) { abstract class SettingBase { protected _key: string; protected _value: Writable; - protected _validator?: (value: V) => V; + protected _validator: Validator; - protected constructor(key: string, initial: S, validator?: (value: V) => V) { + protected constructor(key: string, initial: S, validator: Validator) { this._key = key; this._value = writable(initial); this._validator = validator; @@ -59,7 +79,7 @@ abstract class SettingBase { /** 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 }; + return saved && { value: this._validator(saved.value) }; } /** Takes the saved value, if there is one. */ @@ -83,8 +103,8 @@ abstract class SettingBase { } export class Setting extends SettingBase { - constructor(key: string, initial: V, validator?: (value: V) => V) { - super(key, initial, validator); + constructor(key: string, initial: V, ...[validator]: ValidatorArgument) { + super(key, initial, validator ?? sameTypeAs(initial)); } connect(stored: StoredSettings) { @@ -99,8 +119,8 @@ export class Setting extends SettingBase { export class SettingInitOnFirstRead extends SettingBase { private _initial: V; - constructor(key: string, initial: V, validator?: (value: V) => V) { - super(key, undefined, validator); + constructor(key: string, initial: V, ...[validator]: ValidatorArgument) { + super(key, undefined, validator ?? sameTypeAs(initial)); this._initial = initial; } @@ -114,53 +134,90 @@ export class SettingInitOnFirstRead extends SettingBase { } } -export function getValueValidator(allowed: V[], fallback: V) { - const dict = new Set(allowed); - return (value: V) => (dict.has(value) ? value : fallback); +/** Whether a value is one of the allowed ones. */ +function oneOf(allowed: V[]) { + const dict = new Set(allowed); + return (value: unknown): value is V => dict.has(value); } -export function getArrayValidator(allowed: V[]) { - const dict = new Set(allowed); - return (value: V[]) => value.filter((v) => dict.has(v)); +export function getValueValidator(allowed: V[], fallback: V): Validator { + const isAllowed = oneOf(allowed); + return (value) => (isAllowed(value) ? value : fallback); } -export function getLayerValidator(allowed: Record, fallback: string) { - return (layer: string) => - Object.hasOwn(allowed, layer) || - layer.startsWith('custom-') || - layer.startsWith('extension-') +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +export function isNumber(value: unknown): value is number { + return typeof value === 'number'; +} + +/** Keeps the items of a saved list that are what it holds, in their order; nothing if it is not a list. */ +export function getListValidator(isItem: (value: unknown) => value is V): Validator { + 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(allowed: V[]): Validator { + 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( + isEntry: (value: unknown) => value is V, + fallback: Record +): Validator> { + return (value) => + isRecord(value) + ? Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, V] => isEntry(entry[1])) + ) + : fallback; +} + +export function getLayerValidator( + allowed: Record, + fallback: string +): Validator { + return (layer) => + typeof layer === 'string' && + (Object.hasOwn(allowed, layer) || + layer.startsWith('custom-') || + layer.startsWith('extension-')) ? layer : fallback; } -export function filterLayerTree( - t: LayerTreeType, - allowed: LayerTreeType | undefined -): LayerTreeType { +/** Whether a value is a record, which can hold the layers of a tree: an object, not null or a list. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The saved tree `t`, with the layers of `allowed`: the choices saved for them are kept, the new + * ones have their default, and of the others only the custom and extension layers are kept. + */ +export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined): LayerTreeType { + const tree = isRecord(t) ? t : {}; const filtered: LayerTreeType = {}; if (allowed) { Object.entries(allowed).forEach(([key, value]) => { - 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 - ); - } + const saved = Object.hasOwn(tree, key) ? tree[key] : undefined; + if (typeof value === 'boolean') { + filtered[key] = typeof saved === 'boolean' ? saved : value; } 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 (typeof value === 'boolean') { if (key.startsWith('custom-') || key.startsWith('extension-')) { filtered[key] = value; } - } else if (typeof value === 'object') { + } else if (isRecord(value)) { filtered[key] = filterLayerTree(value, undefined); } } @@ -168,6 +225,6 @@ export function filterLayerTree( return filtered; } -export function getLayerTreeValidator(allowed: LayerTreeType) { - return (value: LayerTreeType) => filterLayerTree(value, allowed); +export function getLayerTreeValidator(allowed: LayerTreeType): Validator { + return (value) => filterLayerTree(value, allowed); } diff --git a/website/src/lib/state/settings.ts b/website/src/lib/state/settings.ts index 7904dc778..a67a452ba 100644 --- a/website/src/lib/state/settings.ts +++ b/website/src/lib/state/settings.ts @@ -8,8 +8,8 @@ import { defaultOverpassQueries, defaultOverpassTree, defaultTerrainSource, + isCustomLayer, overpassTree, - type CustomLayer, } from '$lib/data/layers'; import { browser } from '$app/environment'; import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions'; @@ -19,7 +19,11 @@ import { getArrayValidator, getLayerTreeValidator, getLayerValidator, + getListValidator, + getRecordValidator, getValueValidator, + isNumber, + isString, Setting, SettingInitOnFirstRead, useSettingsStorage, @@ -136,10 +140,14 @@ export const settings = { defaultOverpassTree, getLayerTreeValidator(overpassTree) ), - opacities: new Setting('opacities', defaultOpacities), - customLayers: new Setting>('customLayers', {}), - customBasemapOrder: new Setting('customBasemapOrder', []), - customOverlayOrder: new Setting('customOverlayOrder', []), + opacities: new Setting( + 'opacities', + defaultOpacities, + getRecordValidator(isNumber, defaultOpacities) + ), + customLayers: new Setting('customLayers', {}, getRecordValidator(isCustomLayer, {})), + customBasemapOrder: new Setting('customBasemapOrder', [], getListValidator(isString)), + customOverlayOrder: new Setting('customOverlayOrder', [], getListValidator(isString)), terrainSource: new Setting( 'terrainSource', defaultTerrainSource, diff --git a/website/src/routes/[[language]]/help/[...guide]/+page.svelte b/website/src/routes/[[language]]/help/[...guide]/+page.svelte index 4bedb62a4..a2778099c 100644 --- a/website/src/routes/[[language]]/help/[...guide]/+page.svelte +++ b/website/src/routes/[[language]]/help/[...guide]/+page.svelte @@ -7,8 +7,12 @@ import { getURLForLanguage } from '$lib/utils'; import { ChevronLeft, ChevronRight, PenLine, CornerDownRight } from '@lucide/svelte'; import { i18n } from '$lib/i18n/i18n.svelte'; + import type { Component } from 'svelte'; - let { data }: { data: { guideModule: any; guideTitles: Record } } = $props(); + let { + data, + }: { data: { guideModule: { default: Component }; guideTitles: Record } } = + $props(); let previousGuide = $derived(getPreviousGuide(page.params.guide ?? '')); let nextGuide = $derived(getNextGuide(page.params.guide ?? ''));