diff --git a/website/src/lib/components/map/layer-control/LayerControlSettings.svelte b/website/src/lib/components/map/layer-control/LayerControlSettings.svelte index ff6ec29fc..312b2866a 100644 --- a/website/src/lib/components/map/layer-control/LayerControlSettings.svelte +++ b/website/src/lib/components/map/layer-control/LayerControlSettings.svelte @@ -17,7 +17,6 @@ } from '$lib/data/layers'; import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { i18n } from '$lib/i18n/i18n.svelte'; - import { map } from '$lib/components/map/map'; import CustomLayers from './CustomLayers.svelte'; import { settings } from '$lib/state/settings'; import { untrack } from 'svelte'; @@ -209,19 +208,6 @@ disabled={selectedOverlay === undefined} onValueChange={(value) => { if (selectedOverlay) { - if ( - $map && - $currentOverlays && - isSelected($currentOverlays, selectedOverlay) - ) { - try { - if ($map.getLayer(selectedOverlay)) { - $map.removeLayer(selectedOverlay); - } - } catch { - // No reliable way to check if the map is ready to remove sources and layers - } - } $opacities[selectedOverlay] = value; } }} diff --git a/website/src/lib/components/map/layer-control/LayerTreeNode.svelte b/website/src/lib/components/map/layer-control/LayerTreeNode.svelte index dcce20aad..b01ba68b7 100644 --- a/website/src/lib/components/map/layer-control/LayerTreeNode.svelte +++ b/website/src/lib/components/map/layer-control/LayerTreeNode.svelte @@ -12,6 +12,7 @@ let { name, node, + parent = '', selected = '', onselect = () => {}, multiple = false, @@ -19,6 +20,7 @@ }: { name: string; node: LayerTreeType; + parent?: string; selected?: string; onselect?: (value: string) => void; multiple: boolean; @@ -26,7 +28,21 @@ } = $props(); const { customLayers } = settings; - const { isLayerFromExtension, getLayerName } = extensionAPI; + const { isLayerFromExtension, getLayerName, isExtensionGroup } = extensionAPI; + + /** The groups of extensions are right below the overlays, with the name they were given. */ + function getGroupLabel(id: string) { + return parent === 'overlays' && $isExtensionGroup(id) + ? id + : i18n._(`layers.label.${id}`, id); + } + + function getLabel(id: string) { + if (Object.hasOwn($customLayers, id)) { + return $customLayers[id].name; + } + return $isLayerFromExtension(id) ? $getLayerName(id) : i18n._(`layers.label.${id}`); + } $effect.pre(() => { if (checked !== undefined) { @@ -57,7 +73,7 @@ () => checked[id] === true, (value) => (checked[id] = value) } class="scale-90" - aria-label={i18n._(`layers.label.${id}`)} + aria-label={getLabel(id)} /> {:else} {/if} {/if} {:else if anySelectedLayer(node[id])} {#snippet trigger()} - {i18n._(`layers.label.${id}`, id)} + {getGroupLabel(id)} {/snippet} {#snippet content()}
{ afterEach(() => { // the layers are shared by the whole app: leave them as they were api.filterOverlays([]); + api.setOverlays('Heatmaps', []); + api.setOverlays('Other', []); for (const extension of ['My extension', 'Other extension']) { delete (overlayTree.overlays as LayerTreeType)[extension]; } @@ -151,3 +155,316 @@ describe('ExtensionAPI', () => { assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two'); }); }); + +const rasterStyle = (url = 'https://tiles.example.com/{z}/{x}/{y}.png'): StyleSpecification => ({ + version: 8, + sources: { tiles: { type: 'raster', tiles: [url], tileSize: 256 } }, + layers: [{ id: 'tiles', type: 'raster', source: 'tiles' }], +}); + +const v2 = (id: string, extra: Partial = {}): ExtensionOverlay => ({ + id, + name: `Overlay ${id}`, + style: rasterStyle(), + ...extra, +}); + +const id = (overlayId: string, extension = 'Heatmaps') => + `extension-${encodeURIComponent(extension)}/${encodeURIComponent(overlayId)}`; + +/** The overlays of an extension in a tree, with their state. */ +const group = (tree: LayerTreeType | undefined, extension = 'Heatmaps') => + (tree!.overlays as LayerTreeType)[extension] as LayerTreeType | undefined; + +/** What the user does in the layer menu. */ +function setShown(overlayId: string, shown: boolean) { + settings.currentOverlays.update((tree) => { + group(tree)![overlayId] = shown; + return tree; + }); +} + +describe('prefixStyle', () => { + it('prefixes the sources, the layers, and the source of the layers', () => { + const prefixed = prefixStyle('overlay', { + version: 8, + sources: { dem: { type: 'raster-dem', tiles: ['https://dem/{z}/{x}/{y}.png'] } }, + layers: [ + { id: 'relief', type: 'color-relief', source: 'dem' }, + { id: 'background', type: 'background' }, + ], + }); + assert.deepEqual(Object.keys(prefixed.sources), ['overlay/dem']); + assert.deepEqual( + prefixed.layers.map((layer) => layer.id), + ['overlay/relief', 'overlay/background'] + ); + assert.equal((prefixed.layers[0] as { source: string }).source, 'overlay/dem'); + assert.equal('source' in prefixed.layers[1], false); + }); + + it('refuses a style whose layers cannot work', () => { + const style = (layers: unknown) => + ({ version: 8, sources: {}, layers }) as StyleSpecification; + assert.throws(() => prefixStyle('o', style(undefined)), /list of layers/); + assert.throws( + () => prefixStyle('o', style([{ id: 'a', type: 'raster', source: 'none' }])), + /unknown source/ + ); + assert.throws( + () => + prefixStyle( + 'o', + style([ + { id: 'a', type: 'background' }, + { id: 'a', type: 'background' }, + ]) + ), + /repeated layer id/ + ); + }); +}); + +describe('ExtensionAPI v2', () => { + it('adds the overlays of an extension, in the order of the list, hidden by default', () => { + api.setOverlays('Heatmaps', [v2('b'), v2('a', { name: 'A' }), v2('c', { visible: true })]); + + const expected = [id('b'), id('a'), id('c')]; + assert.deepEqual(Object.keys(group(overlayTree)!), expected); + assert.deepEqual(Object.keys(group(get(settings.selectedOverlayTree))!), expected); + assert.deepEqual(group(get(settings.currentOverlays)), { + [id('b')]: false, + [id('a')]: false, + [id('c')]: true, + }); + assert.ok(get(api.isLayerFromExtension)(id('a'))); + assert.equal(get(api.getLayerName)(id('a')), 'A'); + assert.equal(api.version, 2); + }); + + it('uses the style as is, with prefixed ids', () => { + api.setOverlays('Heatmaps', [v2('a')]); + const style = overlays[id('a')] as StyleSpecification; + assert.deepEqual(Object.keys(style.sources), [`${id('a')}/tiles`]); + assert.equal(style.layers[0].id, `${id('a')}/tiles`); + assert.equal((style.layers[0] as { source: string }).source, `${id('a')}/tiles`); + }); + + it('names its overlays after the extension, the way the v1 API does without it', () => { + api.setOverlays('My heatmap', [v2('all')]); + assert.ok(isSelected(overlayTree, 'extension-My%20heatmap/all')); + api.setOverlays('My heatmap', []); + }); + + it('keeps two extensions with the same overlay ids, or similar names, apart', () => { + api.setOverlays('Heatmaps', [v2('a')]); + api.setOverlays('Other', [v2('a')]); + assert.deepEqual(Object.keys(group(overlayTree)!), [id('a')]); + assert.deepEqual(Object.keys(group(overlayTree, 'Other')!), [id('a', 'Other')]); + + api.setOverlays('Heatmaps/a', [v2('b')]); + api.setOverlays('Heatmaps', [v2('a'), v2('a/b')]); + assert.notEqual(id('a/b'), id('b', 'Heatmaps/a')); + assert.ok(get(api.isLayerFromExtension)(id('a/b'))); + assert.ok(get(api.isLayerFromExtension)(id('b', 'Heatmaps/a'))); + + api.setOverlays('Other', []); + assert.deepEqual(Object.keys(group(overlayTree)!).length, 2); + api.setOverlays('Heatmaps/a', []); + }); + + it('knows the groups of the extensions, of either version', () => { + api.setOverlays('Heatmaps', [v2('a')]); + api.addOrUpdateOverlay(overlay('one')); + assert.equal(get(api.isExtensionGroup)('Heatmaps'), true); + assert.equal(get(api.isExtensionGroup)('My extension'), true); + assert.equal(get(api.isExtensionGroup)('france'), false); + api.setOverlays('Heatmaps', []); + assert.equal(get(api.isExtensionGroup)('Heatmaps'), false); + }); + + it('removes what is not in the list anymore, with its group when it is empty', () => { + api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b')]); + api.setOverlays('Heatmaps', [v2('b')]); + assert.ok(!(id('a') in overlays)); + assert.equal(get(api.isLayerFromExtension)(id('a')), false); + assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false); + assert.deepEqual(Object.keys(group(overlayTree)!), [id('b')]); + + api.setOverlays('Heatmaps', []); + assert.equal(group(overlayTree), undefined); + assert.equal(group(get(settings.selectedOverlayTree)), undefined); + assert.equal(group(get(settings.currentOverlays)), undefined); + }); + + it('keeps the state of an overlay unless told otherwise, and the opacity and menu choices', () => { + api.setOverlays('Heatmaps', [v2('a'), v2('b')]); + setShown(id('a'), true); + settings.opacities.update((opacities) => ({ ...opacities, [id('a')]: 0.3 })); + settings.selectedOverlayTree.update((tree) => { + group(tree)![id('b')] = false; + return tree; + }); + + api.setOverlays('Heatmaps', [v2('b'), v2('a')]); + assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), true); + assert.equal(isSelected(get(settings.selectedOverlayTree), id('b')), false); + assert.equal(get(settings.opacities)[id('a')], 0.3); + assert.deepEqual(Object.keys(group(get(settings.selectedOverlayTree))!), [ + id('b'), + id('a'), + ]); + + api.setOverlays('Heatmaps', [v2('a', { visible: false }), v2('b', { visible: true })]); + assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false); + // the user took it out of the menu, where it could not be hidden again + assert.equal(isSelected(get(settings.currentOverlays)!, id('b')), false); + }); + + it('changes nothing when the list is not valid', () => { + api.setOverlays('Heatmaps', [v2('a')]); + const invalid: unknown[] = [ + [v2('b'), v2('b')], + [v2('b'), { id: 'c', name: 'C' }], + [v2('b'), { ...v2('c'), style: { version: 8, sources: {} } }], + 'not a list', + ]; + for (const list of invalid) { + assert.throws(() => api.setOverlays('Heatmaps', list as ExtensionOverlay[])); + } + assert.deepEqual(Object.keys(group(overlayTree)!), [id('a')]); + assert.ok(!(id('b') in overlays)); + }); + + it('refuses the names that cannot be the group of an extension', () => { + for (const name of ['', 'world', 'countries', '__proto__']) { + assert.throws(() => api.setOverlays(name, [v2('a')])); + } + }); + + it('waits for the settings', () => { + const current = get(settings.currentOverlays); + (settings.currentOverlays as unknown as { _value: { set(v: unknown): void } })._value.set( + undefined + ); + assert.throws(() => api.setOverlays('Heatmaps', [v2('a')]), /gpxstudio\.ready/); + settings.currentOverlays.set(current!); + }); + + it('tells the extension about the actions of the user only', () => { + const calls: Record[] = []; + const stop = api.onVisibilityChange('Heatmaps', (visible) => calls.push(visible)); + const others: unknown[] = []; + api.onVisibilityChange('Other', (visible) => others.push(visible)); + api.setOverlays('Other', [v2('x')]); + + api.setOverlays('Heatmaps', [v2('a'), v2('b', { visible: true })]); + api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b', { visible: false })]); + assert.deepEqual(calls, []); + + setShown(id('b'), true); + assert.deepEqual(calls, [{ a: true, b: true }]); + // nothing changed + setShown(id('b'), true); + assert.equal(calls.length, 1); + assert.deepEqual(others, []); + + stop(); + setShown(id('b'), false); + assert.equal(calls.length, 1); + }); + + it('tells the extension when the overlays are all hidden from the menu', () => { + const calls: Record[] = []; + api.onVisibilityChange('Heatmaps', (visible) => calls.push(visible)); + api.setOverlays('Heatmaps', [v2('a', { visible: true })]); + settings.currentOverlays.set({ overlays: {} }); + assert.deepEqual(calls, [{ a: false }]); + }); + + it('survives a callback that throws, or that sets the overlays', () => { + const error = console.error; + console.error = () => {}; + const calls: unknown[] = []; + api.onVisibilityChange('Heatmaps', () => { + throw new Error('from the extension'); + }); + api.onVisibilityChange('Heatmaps', (visible) => { + calls.push(visible); + api.setOverlays('Heatmaps', [v2('a', { visible: visible.a })]); + }); + api.setOverlays('Heatmaps', [v2('a')]); + setShown(id('a'), true); + console.error = error; + assert.deepEqual(calls, [{ a: true }]); + }); + + it('does not tell the extension that all is hidden when the page closes', () => { + const calls: unknown[] = []; + api.onVisibilityChange('Heatmaps', (visible) => calls.push(visible)); + api.setOverlays('Heatmaps', [v2('a', { visible: true })]); + (api as unknown as { destroy(): void }).destroy(); + assert.deepEqual(calls, []); + assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false); + }); + + it('leaves a style URL for the map to fetch', () => { + const fetchBefore = globalThis.fetch; + globalThis.fetch = (() => assert.fail('fetched')) as typeof fetch; + try { + const url = 'https://example.com/style.json'; + api.setOverlays('Heatmaps', [v2('a', { style: url })]); + assert.equal(overlays[id('a')], url); + } finally { + globalThis.fetch = fetchBefore; + } + }); + + it('removes a style that changes from the map, layers first, but not one that stays', () => { + const calls: string[] = []; + const prefix = id('a'); + const fakeMap = { + getStyle: () => ({ + sources: { [`${prefix}/tiles`]: {}, [`${prefix}2/tiles`]: {}, core: {} }, + layers: [{ id: `${prefix}/tiles` }, { id: `${prefix}2/tiles` }, { id: 'core' }], + }), + removeLayer: (layer: string) => calls.push(`layer ${layer}`), + removeSource: (source: string) => calls.push(`source ${source}`), + }; + const store = (map as unknown as { _mapStore: { set(value: unknown): void } })._mapStore; + store.set(fakeMap); + try { + api.setOverlays('Heatmaps', [v2('a', { visible: true })]); + api.setOverlays('Heatmaps', [v2('a', { name: 'Renamed' })]); + assert.deepEqual(calls, []); + + api.setOverlays('Heatmaps', [v2('a', { style: rasterStyle('https://other/{z}') })]); + assert.deepEqual(calls, [`layer ${prefix}/tiles`, `source ${prefix}/tiles`]); + + calls.length = 0; + api.setOverlays('Heatmaps', [v2('a', { style: 'https://example.com/style.json' })]); + assert.equal(calls.length, 2); + + calls.length = 0; + api.setOverlays('Heatmaps', []); + assert.equal(calls.length, 2); + } finally { + store.set(null); + } + }); + + it('is not affected by the v1 API, and can live next to it', () => { + api.setOverlays('Heatmaps', [v2('a')]); + api.addOrUpdateOverlay(overlay('one', 'Heatmaps')); + api.filterOverlays([]); + assert.ok(id('a') in overlays); + assert.ok(get(api.isLayerFromExtension)(id('a'))); + assert.ok(isSelected(overlayTree, id('a'))); + + api.addOrUpdateOverlay(overlay('one', 'Heatmaps')); + api.setOverlays('Heatmaps', [v2('a'), v2('b')]); + assert.ok(isSelected(overlayTree, 'extension-one')); + api.setOverlays('Heatmaps', []); + assert.ok(isSelected(overlayTree, 'extension-one')); + }); +}); diff --git a/website/src/lib/components/map/layer-control/extension-api.ts b/website/src/lib/components/map/layer-control/extension-api.ts index b20285099..ac08bf550 100644 --- a/website/src/lib/components/map/layer-control/extension-api.ts +++ b/website/src/lib/components/map/layer-control/extension-api.ts @@ -1,12 +1,14 @@ import { settings } from '$lib/state/settings'; +import type { StyleSpecification } from 'maplibre-gl'; import { derived, get, writable, type Writable } from 'svelte/store'; -import { isSelected, remove, removeAll } from './utils'; +import { isSelected, prefixStyle, remove, removeAll } from './utils'; import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers'; import { browser } from '$app/environment'; import { map } from '$lib/components/map/map'; const { currentOverlays, previousOverlays, selectedOverlayTree } = settings; +/** An overlay of the v1 API. */ export type CustomOverlay = { extensionName: string; id: string; @@ -15,11 +17,87 @@ export type CustomOverlay = { maxZoom?: number; }; +/** An overlay of the v2 API. */ +export type ExtensionOverlay = { + /** Unique within the extension. Keep it stable: the opacity and menu choices are tied to it. */ + id: string; + name: string; + /** + * The sources and layers of the overlay, or the URL of a style JSON (which must allow + * cross-origin requests). It is used as is, with MapLibre's defaults: a `raster` source has + * tiles of 512 pixels unless its `tileSize` says otherwise. Layers can only use the sources of + * this style, and the rest of a style (`sprite`, `glyphs`, `terrain`...) is ignored. + */ + style: StyleSpecification | string; + /** Applied if given, otherwise the current state is kept (hidden for a new overlay). */ + visible?: boolean; +}; + +/** What the API knows of an overlay. */ +type RegisteredOverlay = { + extensionName: string; + name: string; + /** The style given by the extension, to tell when it changes; only for the v2 API. */ + signature?: string; +}; + +type VisibilityCallback = (visible: Record) => void; + +/** The groups of the overlay tree that an extension cannot put its overlays in. */ +const reservedGroups = new Set(['__proto__', ...Object.keys(layerGroup(overlayTree.overlays))]); + +/** The id of an overlay of the v1 API, whose methods do not all get the name of the extension. */ +function getOverlayId(id: string): string { + return `extension-${id}`; +} + +/** The id of an overlay of the v2 API. It is encoded so that two extensions cannot clash. */ +function getExtensionOverlayId(extensionName: string, id: string): string { + return getOverlayId(`${encodeURIComponent(extensionName)}/${encodeURIComponent(id)}`); +} + +/** + * Sets the overlays of an extension in a tree, in order. Its other entries are dropped, except for + * those of `keep`: its overlays of the v1 API, which can be in the same group. An extension that + * has nothing left has no group anymore. + */ +function setGroup( + tree: LayerTreeType, + extensionName: string, + entries: Record, + keep: (id: string) => boolean +) { + const groups = layerGroup(tree.overlays); + const group = groups[extensionName]; + const kept = + typeof group === 'object' + ? Object.entries(group).filter(([id]) => keep(id) && !Object.hasOwn(entries, id)) + : []; + const updated = { ...entries, ...Object.fromEntries(kept) }; + if (Object.keys(updated).length > 0) { + groups[extensionName] = updated; + } else { + delete groups[extensionName]; + } + return tree; +} + export class ExtensionAPI { - private _overlays: Writable> = writable(new Map()); + /** The version of the API; the methods of v1 are still there, until extensions have moved. */ + readonly version = 2; + /** Resolved once overlays can be set. `gpxstudio:ready` is sent on `window` at that moment. */ + ready!: Promise; + + private _overlays: Writable> = writable(new Map()); + /** What each extension knows of the visibility of its overlays, by its own overlay ids. */ + private _visibility: Map> = new Map(); + private _visibilityCallbacks: Map> = new Map(); + private _watchingVisibility = false; init() { if (browser && !Object.hasOwn(window, 'gpxstudio')) { + this.ready = this.ensureLoaded(); + this.ready.then(() => window.dispatchEvent(new Event('gpxstudio:ready'))); Object.defineProperty(window, 'gpxstudio', { value: this, }); @@ -29,6 +107,7 @@ export class ExtensionAPI { } } + /** @deprecated v1: use `ready`. */ ensureLoaded(): Promise { let unsubscribe: () => void; const promise = new Promise((resolve) => { @@ -46,6 +125,168 @@ export class ExtensionAPI { return promise; } + /** + * Replaces the overlays of an extension: the order of the list is the order in the layer menu, + * and the overlays that are not in the list are removed. The opacity and the choices of the + * layer menu are kept for the ones that stay. Throws, changing nothing, if the list is invalid; + * a style URL is only fetched when the overlay is shown, and a failure is then only logged. + */ + setOverlays(extensionName: string, newOverlays: ExtensionOverlay[]) { + if (typeof extensionName !== 'string' || !extensionName) { + throw new Error('An extension must have a name.'); + } + if (reservedGroups.has(extensionName)) { + throw new Error(`The extension name ${extensionName} is reserved.`); + } + if (!Array.isArray(newOverlays)) { + throw new Error('The overlays must be a list.'); + } + const current = get(currentOverlays); + if (!current) { + throw new Error('Overlays cannot be set yet: wait for `gpxstudio.ready`.'); + } + + const previous = this.getOverlaysOf(extensionName); + const next = new Map(); + const styles = new Map(); + for (const overlay of newOverlays) { + if (!overlay?.id || !overlay.name || !overlay.style) { + throw new Error('Overlay must have an id, a name and a style.'); + } + const id = getExtensionOverlayId(extensionName, overlay.id); + if (next.has(id)) { + throw new Error(`Overlay ${overlay.id} is in the list twice.`); + } + const signature = JSON.stringify(overlay.style); + // the ids of the style at a URL are prefixed when the map fetches it + styles.set( + id, + typeof overlay.style === 'string' + ? overlay.style + : prefixStyle(id, JSON.parse(signature)) + ); + next.set(id, { extensionName, name: overlay.name, signature }); + } + + // Nothing can fail from here. The map does not replace a source that it already has, so + // what has changed has to be removed first; it is then added back with the new style. + const removedIds: string[] = []; + previous.forEach((overlay, id) => { + if (next.get(id)?.signature !== overlay.signature) { + this.removeFromMap(id); + } + if (!next.has(id)) { + removedIds.push(id); + delete overlays[id]; + } + }); + styles.forEach((style, id) => { + overlays[id] = style; + }); + this._overlays.update(($overlays) => { + removedIds.forEach((id) => $overlays.delete(id)); + next.forEach((overlay, id) => $overlays.set(id, overlay)); + return $overlays; + }); + + const available: Record = {}; + const selected: Record = {}; + const shown: Record = {}; + const visibility: Record = {}; + const selectedGroup = layerGroup(get(selectedOverlayTree).overlays)[extensionName]; + newOverlays.forEach((overlay) => { + const id = getExtensionOverlayId(extensionName, overlay.id); + available[id] = true; + // an overlay that the user took out of the layer menu could not be hidden again + selected[id] = + typeof selectedGroup !== 'object' || + !Object.hasOwn(selectedGroup, id) || + selectedGroup[id] === true; + shown[id] = selected[id] && Boolean(overlay.visible ?? isSelected(current, id)); + visibility[overlay.id] = shown[id]; + }); + + const keep = (id: string) => { + const overlay = get(this._overlays).get(id); + return overlay !== undefined && overlay.signature === undefined; + }; + setGroup(overlayTree, extensionName, available, keep); + selectedOverlayTree.update((tree) => setGroup(tree, extensionName, selected, keep)); + previousOverlays.update((tree) => removeAll(tree, removedIds)); + // this is not an action of the user: there must be nothing to tell the extension about + this._visibility.set(extensionName, visibility); + currentOverlays.update((tree) => setGroup(tree, extensionName, shown, keep)); + } + + /** + * Calls `callback` with the visibility of all the overlays of the extension, by id, when the + * user shows or hides one of them (not when `setOverlays` does). Returns how to stop. + */ + onVisibilityChange(extensionName: string, callback: VisibilityCallback): () => void { + const callbacks = this._visibilityCallbacks.get(extensionName) ?? new Set(); + this._visibilityCallbacks.set(extensionName, callbacks); + callbacks.add(callback); + if (!this._watchingVisibility) { + this._watchingVisibility = true; + currentOverlays.subscribe(() => this.notifyVisibility()); + } + return () => { + callbacks.delete(callback); + }; + } + + private notifyVisibility() { + const current = get(currentOverlays); + if (!current) return; + this._visibilityCallbacks.forEach((callbacks, extensionName) => { + const known = this._visibility.get(extensionName); + if (!known) return; + const visibility = Object.fromEntries( + Object.keys(known).map((id) => [ + id, + isSelected(current, getExtensionOverlayId(extensionName, id)), + ]) + ); + if (Object.keys(known).every((id) => known[id] === visibility[id])) return; + this._visibility.set(extensionName, visibility); + callbacks.forEach((callback) => { + try { + callback({ ...visibility }); + } catch (e) { + // an extension must not break the updates of the store for the others + console.error(e); + } + }); + }); + } + + private getOverlaysOf(extensionName: string): Map { + return new Map( + Array.from(get(this._overlays)).filter( + ([, overlay]) => + overlay.extensionName === extensionName && overlay.signature !== undefined + ) + ); + } + + /** Removes the layers and sources of an overlay: their ids start with the one of the overlay. */ + private removeFromMap(overlayId: string) { + const map_ = get(map); + if (!map_) return; + try { + const style = map_.getStyle(); + style.layers + .filter((layer) => layer.id.startsWith(`${overlayId}/`)) + .forEach((layer) => map_.removeLayer(layer.id)); + Object.keys(style.sources) + .filter((source) => source.startsWith(`${overlayId}/`)) + .forEach((source) => map_.removeSource(source)); + } catch { + // No reliable way to check if the map is ready to remove sources and layers + } + } + + /** @deprecated v1: use `setOverlays`. */ addOrUpdateOverlay(overlay: CustomOverlay) { if ( !overlay.extensionName || @@ -58,7 +299,7 @@ export class ExtensionAPI { 'Overlay must have an extensionName, id, name, and at least one tile URL.' ); } - overlay.id = this.getOverlayId(overlay.id); + overlay.id = getOverlayId(overlay.id); this._overlays.update(($overlays) => { $overlays.set(overlay.id, overlay); @@ -119,11 +360,12 @@ export class ExtensionAPI { }); } + /** @deprecated v1: use `setOverlays`. Leaves the overlays of the v2 API alone. */ filterOverlays(ids: string[]) { - ids = ids.map((id) => this.getOverlayId(id)); - const idsToRemove = Array.from(get(this._overlays).keys()).filter( - (id) => !ids.includes(id) - ); + ids = ids.map((id) => getOverlayId(id)); + const idsToRemove = Array.from(get(this._overlays)) + .filter(([id, overlay]) => overlay.signature === undefined && !ids.includes(id)) + .map(([id]) => id); currentOverlays.update((current) => { removeAll(current, idsToRemove); @@ -153,8 +395,9 @@ export class ExtensionAPI { }); } + /** @deprecated v1: use `setOverlays`. */ updateOverlaysOrder(ids: string[]) { - ids = ids.map((id) => this.getOverlayId(id)); + ids = ids.map((id) => getOverlayId(id)); selectedOverlayTree.update((selected) => { const isSelected: Record = {}; ids.forEach((id) => { @@ -184,11 +427,15 @@ export class ExtensionAPI { return (id: string) => $overlays.get(id)?.name || ''; }); - private getOverlayId(id: string): string { - return `extension-${id}`; - } + /** Whether a group of the layer trees is an extension, whose name is shown as it was given. */ + isExtensionGroup = derived(this._overlays, ($overlays) => { + const names = new Set(Array.from($overlays.values(), (overlay) => overlay.extensionName)); + return (id: string) => names.has(id); + }); private destroy() { + // the page is closing: the user has not hidden anything, which extensions would then save + this._visibilityCallbacks.clear(); const ids = Array.from(get(this._overlays).keys()); currentOverlays.update((current) => { ids.forEach((id) => { diff --git a/website/src/lib/components/map/layer-control/utils.test.ts b/website/src/lib/components/map/layer-control/utils.test.ts index ca3b791a2..6807e6c16 100644 --- a/website/src/lib/components/map/layer-control/utils.test.ts +++ b/website/src/lib/components/map/layer-control/utils.test.ts @@ -1,7 +1,16 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { LayerTreeType } from '$lib/data/layers'; -import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils'; +import type { LayerSpecification } from 'maplibre-gl'; +import { + anySelectedLayer, + getLayers, + getOpacityPaint, + isSelected, + remove, + removeAll, + toggle, +} from './utils'; // roads: bike (selected), foot // satellite: esri, ign: fr (selected) @@ -72,3 +81,39 @@ describe('layer trees', () => { assert.deepEqual(removeAll(tree(), []), tree()); }); }); + +describe('getOpacityPaint', () => { + const layer = (type: string, paint?: Record) => + ({ id: 'layer', type, source: 'source', paint }) as LayerSpecification; + + it('uses the opacity properties of each type of layer', () => { + assert.deepEqual(getOpacityPaint(layer('raster'), 0.5), { 'raster-opacity': 0.5 }); + assert.deepEqual(getOpacityPaint(layer('color-relief'), 0.5), { + 'color-relief-opacity': 0.5, + }); + assert.deepEqual(getOpacityPaint(layer('symbol'), 0.5), { + 'icon-opacity': 0.5, + 'text-opacity': 0.5, + }); + assert.deepEqual(getOpacityPaint(layer('circle'), 0.5), { + 'circle-opacity': 0.5, + 'circle-stroke-opacity': 0.5, + }); + assert.deepEqual(getOpacityPaint(layer('hillshade'), 0.5), { + 'hillshade-exaggeration': 0.25, + }); + }); + + it('scales the opacity that the layer already has', () => { + assert.deepEqual(getOpacityPaint(layer('line', { 'line-opacity': 0.5 }), 0.5), { + 'line-opacity': 0.25, + }); + }); + + it('replaces an expression, unless the opacity is left at 1', () => { + const expression = ['interpolate', ['linear'], ['zoom'], 5, 0, 10, 1]; + const paint = { 'fill-opacity': expression }; + assert.deepEqual(getOpacityPaint(layer('fill', paint), 0.5), { 'fill-opacity': 0.5 }); + assert.deepEqual(getOpacityPaint(layer('fill', paint), 1), { 'fill-opacity': expression }); + }); +}); diff --git a/website/src/lib/components/map/layer-control/utils.ts b/website/src/lib/components/map/layer-control/utils.ts index 422a49b5e..a95d781ae 100644 --- a/website/src/lib/components/map/layer-control/utils.ts +++ b/website/src/lib/components/map/layer-control/utils.ts @@ -1,4 +1,89 @@ import type { LayerTreeType } from '$lib/data/layers'; +import type { LayerSpecification, StyleSpecification } from 'maplibre-gl'; + +/** Parts of a style that apply to a whole map, not to an overlay. */ +const ignoredStyleProperties = ['sprite', 'glyphs', 'terrain', 'sky', 'light', 'projection']; +/** The overlays that were warned about those: a style at a URL is read again and again. */ +const warnedOverlays = new Set(); + +/** + * A copy of the style of an overlay whose sources and layers have ids of their own, so that they + * cannot clash with the ones of gpx.studio or of another overlay. + */ +export function prefixStyle(overlayId: string, style: StyleSpecification): StyleSpecification { + if (typeof style !== 'object' || style === null || !Array.isArray(style.layers)) { + throw new Error(`The style of overlay ${overlayId} must have a list of layers.`); + } + const ignored = ignoredStyleProperties.filter((property) => Object.hasOwn(style, property)); + if (ignored.length > 0 && !warnedOverlays.has(overlayId)) { + warnedOverlays.add(overlayId); + console.warn(`Overlay ${overlayId} ignores these style properties: ${ignored.join(', ')}.`); + } + + const prefixed = (id: string) => `${overlayId}/${id}`; + const sources = style.sources ?? {}; + const layerIds = new Set(); + const layers = style.layers.map((layer) => { + if (!layer.id || layerIds.has(layer.id)) { + throw new Error(`Overlay ${overlayId} has a missing or repeated layer id.`); + } + layerIds.add(layer.id); + if ('source' in layer) { + if (!Object.hasOwn(sources, layer.source)) { + throw new Error( + `Layer ${layer.id} of overlay ${overlayId} uses unknown source ${layer.source}.` + ); + } + return { ...layer, id: prefixed(layer.id), source: prefixed(layer.source) }; + } + return { ...layer, id: prefixed(layer.id) }; + }) as LayerSpecification[]; + + return { + ...style, + sources: Object.fromEntries( + Object.entries(sources).map(([id, source]) => [prefixed(id), source]) + ), + layers, + }; +} + +/** The paint properties that make a layer transparent, by type of layer. */ +const opacityProperties: Partial> = { + background: ['background-opacity'], + circle: ['circle-opacity', 'circle-stroke-opacity'], + 'color-relief': ['color-relief-opacity'], + fill: ['fill-opacity'], + 'fill-extrusion': ['fill-extrusion-opacity'], + heatmap: ['heatmap-opacity'], + line: ['line-opacity'], + raster: ['raster-opacity'], + symbol: ['icon-opacity', 'text-opacity'], +}; + +/** + * The paint properties that give a layer an opacity. The opacity of an overlay applies on top of + * those of its layers, so that they stay different from one another. A value that is computed + * (an expression) cannot always be scaled: the opacity replaces it, unless it is left at 1. + */ +export function getOpacityPaint( + layer: LayerSpecification, + opacity: number +): Record { + if (layer.type === 'hillshade') { + return { 'hillshade-exaggeration': opacity / 2 }; + } + const paint = (layer.paint ?? {}) as Record; + return Object.fromEntries( + (opacityProperties[layer.type] ?? []).map((property) => { + const value = paint[property]; + if (typeof value === 'number') { + return [property, value * opacity]; + } + return [property, value !== undefined && opacity === 1 ? value : opacity]; + }) + ); +} export function anySelectedLayer(node: LayerTreeType) { return ( diff --git a/website/src/lib/components/map/style.test.ts b/website/src/lib/components/map/style.test.ts new file mode 100644 index 000000000..446d24762 --- /dev/null +++ b/website/src/lib/components/map/style.test.ts @@ -0,0 +1,220 @@ +// How the map shows the overlays: here, those of extensions that give the URL of their style. +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import type { Map as MapLibreMap, StyleSpecification } from 'maplibre-gl'; +import { writable } from 'svelte/store'; +import { overlays, type LayerTreeType } from '$lib/data/layers'; +import { engine } from '$lib/engine'; +import { settings } from '$lib/state/settings'; +import { StyleManager } from './style'; + +const URL_A = 'https://example.com/a.json'; +const URL_B = 'https://example.com/b.json'; +const ID = 'extension-Heatmaps/a'; + +const style = (tiles: string): StyleSpecification => ({ + version: 8, + sources: { tiles: { type: 'raster', tiles: [tiles], tileSize: 256 } }, + layers: [{ id: 'tiles', type: 'raster', source: 'tiles' }], +}); + +const original = { + set: engine.setSetting, + delete: engine.deleteSetting, + fetch: globalThis.fetch, + error: console.error, +}; + +/** A map that only knows of its overlays. */ +function fakeMap() { + const sources = new Map([['empty-source', { tiles: [] }]]); + const layers = new Map }>(); + return { + sources, + layers, + on: () => {}, + setStyle: () => {}, + getPitch: () => 0, + getTerrain: () => null, + setTerrain: () => {}, + getSource: (id: string) => sources.get(id), + addSource: (id: string, source: { tiles: string[] }) => sources.set(id, source), + getLayer: (id: string) => layers.get(id), + addLayer: (layer: { id: string; source: string; paint: Record }) => + layers.set(layer.id, layer), + removeLayer: (id: string) => layers.delete(id), + setPaintProperty: (id: string, name: string, value: unknown) => { + layers.get(id)!.paint[name] = value; + }, + }; +} + +/** Shows the overlay, and gives the manager of the map. */ +function setUp(fake: ReturnType) { + settings.currentOverlays.update((tree) => { + (tree.overlays as LayerTreeType).Heatmaps = { [ID]: true }; + return tree; + }); + const store = writable(null); + const manager = new StyleManager(store, ''); + store.set(fake as unknown as MapLibreMap); + return manager; +} + +beforeEach(() => { + engine.setSetting = () => {}; + engine.deleteSetting = () => {}; + settings.connect({}); + settings.initialize(); +}); + +afterEach(() => { + delete overlays[ID]; + delete overlays.plainUrl; + settings.currentOverlays.update((tree) => { + delete (tree.overlays as LayerTreeType).Heatmaps; + delete (tree.overlays as LayerTreeType).plainUrl; + return tree; + }); + engine.setSetting = original.set; + engine.deleteSetting = original.delete; + globalThis.fetch = original.fetch; + console.error = original.error; +}); + +/** Answers the style requests with the styles of `responses`, by URL. */ +function answerWith(responses: Record) { + globalThis.fetch = (async (url: string) => + Object.hasOwn(responses, url) + ? new Response(JSON.stringify(responses[url])) + : new Response(JSON.stringify(style('https://basemap/{z}')))) as typeof fetch; +} + +describe('StyleManager, with the style URL of an extension', () => { + it('fetches it, and prefixes the ids of its sources and layers', async () => { + answerWith({ [URL_A]: style('https://a/{z}') }); + overlays[ID] = URL_A; + const fake = fakeMap(); + await setUp(fake).updateOverlays(); + + assert.deepEqual(fake.sources.get(`${ID}/tiles`)?.tiles, ['https://a/{z}']); + assert.equal(fake.layers.get(`${ID}/tiles`)?.source, `${ID}/tiles`); + assert.ok(!fake.sources.has('tiles')); + }); + + it('uses the opacity of the overlay', async () => { + answerWith({ [URL_A]: style('https://a/{z}') }); + overlays[ID] = URL_A; + settings.opacities.update((opacities) => ({ ...opacities, [ID]: 0.3 })); + const fake = fakeMap(); + await setUp(fake).updateOverlays(); + assert.equal(fake.layers.get(`${ID}/tiles`)?.paint['raster-opacity'], 0.3); + }); + + it('removes its layers when it is hidden', async () => { + answerWith({ [URL_A]: style('https://a/{z}') }); + overlays[ID] = URL_A; + const fake = fakeMap(); + const manager = setUp(fake); + await manager.updateOverlays(); + assert.ok(fake.layers.has(`${ID}/tiles`)); + + settings.currentOverlays.update((tree) => { + (tree.overlays as LayerTreeType).Heatmaps = { [ID]: false }; + return tree; + }); + await manager.updateOverlays(); + assert.ok(!fake.layers.has(`${ID}/tiles`)); + }); + + it('does not fetch it while the overlay is hidden', async () => { + const urls: string[] = []; + answerWith({}); + const answer = globalThis.fetch; + globalThis.fetch = ((url: string) => { + urls.push(url); + return answer(url); + }) as typeof fetch; + overlays[ID] = URL_A; + settings.currentOverlays.update((tree) => { + (tree.overlays as LayerTreeType).Heatmaps = { [ID]: false }; + return tree; + }); + const store = writable(null); + const manager = new StyleManager(store, ''); + store.set(fakeMap() as unknown as MapLibreMap); + await manager.updateOverlays(); + assert.ok(!urls.includes(URL_A)); + }); + + it('ignores a style that was replaced or removed while it was fetched', async () => { + for (const next of [URL_B, undefined]) { + let respond: (response: Response) => void = () => {}; + const answers: Record = { + [URL_B]: style('https://b/{z}'), + }; + answerWith(answers); + const answer = globalThis.fetch; + globalThis.fetch = ((url: string) => + url === URL_A + ? new Promise((resolve) => (respond = resolve)) + : answer(url)) as typeof fetch; + + overlays[ID] = URL_A; + const fake = fakeMap(); + const manager = setUp(fake); + const pending = manager.updateOverlays(); + await new Promise((resolve) => setTimeout(resolve)); + if (next) { + overlays[ID] = next; + } else { + delete overlays[ID]; + } + respond(new Response(JSON.stringify(style('https://a/{z}')))); + await pending; + assert.ok(!fake.sources.has(`${ID}/tiles`)); + + await manager.updateOverlays(); + assert.deepEqual( + fake.sources.get(`${ID}/tiles`)?.tiles, + next ? ['https://b/{z}'] : undefined + ); + } + }); + + it('logs a style that cannot be used, which leaves the others alone', async () => { + const logged: unknown[] = []; + console.error = (...args) => logged.push(args); + for (const answer of [ + new Response('', { status: 404 }), + new Response(JSON.stringify({ version: 8, sources: {} })), + ]) { + answerWith({}); + const basemap = globalThis.fetch; + globalThis.fetch = ((url: string) => + url === URL_A ? Promise.resolve(answer.clone()) : basemap(url)) as typeof fetch; + overlays[ID] = URL_A; + const fake = fakeMap(); + logged.length = 0; + await setUp(fake).updateOverlays(); + assert.ok(logged.length > 0); + assert.equal(fake.layers.size, 0); + } + }); + + it('does not prefix the style URL of anything else', async () => { + answerWith({ [URL_A]: style('https://a/{z}') }); + overlays.plainUrl = URL_A; + settings.currentOverlays.update((tree) => { + (tree.overlays as LayerTreeType).plainUrl = { plainUrl: true }; + return tree; + }); + const fake = fakeMap(); + const store = writable(null); + const manager = new StyleManager(store, ''); + store.set(fake as unknown as MapLibreMap); + await manager.updateOverlays(); + assert.ok(fake.sources.has('tiles')); + assert.ok(fake.layers.has('tiles')); + }); +}); diff --git a/website/src/lib/components/map/style.ts b/website/src/lib/components/map/style.ts index 9b202017e..1696977b3 100644 --- a/website/src/lib/components/map/style.ts +++ b/website/src/lib/components/map/style.ts @@ -14,7 +14,7 @@ import { overlays, terrainSources, } from '$lib/data/layers'; -import { getLayers } from '$lib/components/map/layer-control/utils'; +import { getLayers, getOpacityPaint, prefixStyle } from '$lib/components/map/layer-control/utils'; import { i18n } from '$lib/i18n/i18n.svelte'; const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } = @@ -147,13 +147,18 @@ export class StyleManager { const custom = get(customLayers); const overlayOpacities = get(opacities); try { + // an overlay on the map can be gone from the tree, which has been replaced as a whole const layers = getLayers(get(currentOverlays) ?? {}); + this._pastOverlays.forEach((id) => (layers[id] ??= false)); for (const overlay in layers) { - if (!layers[overlay]) { + const overlayInfo = custom[overlay]?.value ?? overlays[overlay]; + if (!overlayInfo) { + // removed, together with what it had on the map + this._pastOverlays.delete(overlay); + } else if (!layers[overlay]) { if (this._pastOverlays.has(overlay)) { - const overlayInfo = custom[overlay]?.value ?? overlays[overlay]; try { - const overlayStyle = await this.get(overlayInfo); + const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo); for (const layer of overlayStyle.layers ?? []) { if (map_.getLayer(layer.id)) { map_.removeLayer(layer.id); @@ -165,9 +170,10 @@ export class StyleManager { this._pastOverlays.delete(overlay); } } else { - const overlayInfo = custom[overlay]?.value ?? overlays[overlay]; try { - const overlayStyle = await this.get(overlayInfo); + const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo); + // replaced or removed while it was fetched: its new style has its own turn + if (!custom[overlay] && overlays[overlay] !== overlayInfo) continue; const opacity = overlayOpacities[overlay]; for (const sourceId in overlayStyle.sources) { @@ -177,21 +183,24 @@ export class StyleManager { } for (const layer of overlayStyle.layers ?? []) { - if (!map_.getLayer(layer.id)) { - if (opacity !== undefined) { - if (layer.type === 'raster') { - if (!layer.paint) { - layer.paint = {}; - } - layer.paint['raster-opacity'] = opacity; - } else if (layer.type === 'hillshade') { - if (!layer.paint) { - layer.paint = {}; - } - layer.paint['hillshade-exaggeration'] = opacity / 2; - } + const paint = + opacity === undefined ? {} : getOpacityPaint(layer, opacity); + if (map_.getLayer(layer.id)) { + for (const property in paint) { + map_.setPaintProperty( + layer.id, + property as never, + paint[property] as never + ); } - map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays); + } else { + map_.addLayer( + { + ...layer, + paint: { ...layer.paint, ...paint }, + } as LayerSpecification, + ANCHOR_LAYER_KEY.overlays + ); } } this._pastOverlays.add(overlay); @@ -221,6 +230,17 @@ export class StyleManager { } } + /** The style of an overlay. The ids of one from an extension's URL still have to be prefixed. */ + private async getOverlayStyle( + overlay: string, + styleInfo: StyleSpecification | string + ): Promise { + const style = await this.get(styleInfo); + return typeof styleInfo === 'string' && overlay.startsWith('extension-') + ? prefixStyle(overlay, style) + : style; + } + async get(styleInfo: StyleSpecification | string): Promise { if (typeof styleInfo === 'string') { const styleUrl = styleInfo as string;