extension api v2, ref #395

This commit is contained in:
vcoppe committed 2026-11-08 17:23:22 +01:00
1 parent 7474f892ce
commit 4050420b3c
8 files changed
+989 -58

No files matched your search

@@ -17,7 +17,6 @@
} from '$lib/data/layers'; } from '$lib/data/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n/i18n.svelte'; import { i18n } from '$lib/i18n/i18n.svelte';
import { map } from '$lib/components/map/map';
import CustomLayers from './CustomLayers.svelte'; import CustomLayers from './CustomLayers.svelte';
import { settings } from '$lib/state/settings'; import { settings } from '$lib/state/settings';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -209,19 +208,6 @@
disabled={selectedOverlay === undefined} disabled={selectedOverlay === undefined}
onValueChange={(value) => { onValueChange={(value) => {
if (selectedOverlay) { 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; $opacities[selectedOverlay] = value;
} }
}} }}
@@ -12,6 +12,7 @@
let { let {
name, name,
node, node,
parent = '',
selected = '', selected = '',
onselect = () => {}, onselect = () => {},
multiple = false, multiple = false,
@@ -19,6 +20,7 @@
}: { }: {
name: string; name: string;
node: LayerTreeType; node: LayerTreeType;
parent?: string;
selected?: string; selected?: string;
onselect?: (value: string) => void; onselect?: (value: string) => void;
multiple: boolean; multiple: boolean;
@@ -26,7 +28,21 @@
} = $props(); } = $props();
const { customLayers } = settings; 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(() => { $effect.pre(() => {
if (checked !== undefined) { if (checked !== undefined) {
@@ -57,7 +73,7 @@
() => checked[id] === true, (value) => (checked[id] = value) () => checked[id] === true, (value) => (checked[id] = value)
} }
class="scale-90" class="scale-90"
aria-label={i18n._(`layers.label.${id}`)} aria-label={getLabel(id)}
/> />
{:else} {:else}
<input <input
@@ -74,25 +90,20 @@
/> />
{/if} {/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1"> <Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if Object.hasOwn($customLayers, id)} {getLabel(id)}
{$customLayers[id].name}
{:else if $isLayerFromExtension(id)}
{$getLayerName(id)}
{:else}
{i18n._(`layers.label.${id}`)}
{/if}
</Label> </Label>
</div> </div>
{/if} {/if}
{:else if anySelectedLayer(node[id])} {:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}> <CollapsibleTreeNode {id}>
{#snippet trigger()} {#snippet trigger()}
<span>{i18n._(`layers.label.${id}`, id)}</span> <span>{getGroupLabel(id)}</span>
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="ml-2"> <div class="ml-2">
<Self <Self
node={node[id] as LayerTreeType} node={node[id] as LayerTreeType}
parent={id}
{name} {name}
{selected} {selected}
{onselect} {onselect}
@@ -5,8 +5,10 @@ import { get } from 'svelte/store';
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers'; import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { settings } from '$lib/state/settings'; import { settings } from '$lib/state/settings';
import { ExtensionAPI, type CustomOverlay } from './extension-api'; import { map } from '$lib/components/map/map';
import { isSelected } from './utils'; import type { StyleSpecification } from 'maplibre-gl';
import { ExtensionAPI, type CustomOverlay, type ExtensionOverlay } from './extension-api';
import { isSelected, prefixStyle } from './utils';
const original = { set: engine.setSetting, delete: engine.deleteSetting }; const original = { set: engine.setSetting, delete: engine.deleteSetting };
@@ -41,6 +43,8 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
// the layers are shared by the whole app: leave them as they were // the layers are shared by the whole app: leave them as they were
api.filterOverlays([]); api.filterOverlays([]);
api.setOverlays('Heatmaps', []);
api.setOverlays('Other', []);
for (const extension of ['My extension', 'Other extension']) { for (const extension of ['My extension', 'Other extension']) {
delete (overlayTree.overlays as LayerTreeType)[extension]; delete (overlayTree.overlays as LayerTreeType)[extension];
} }
@@ -151,3 +155,316 @@ describe('ExtensionAPI', () => {
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two'); 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> = {}): 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<string, boolean>[] = [];
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<string, boolean>[] = [];
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'));
});
});
@@ -1,12 +1,14 @@
import { settings } from '$lib/state/settings'; import { settings } from '$lib/state/settings';
import type { StyleSpecification } from 'maplibre-gl';
import { derived, get, writable, type Writable } from 'svelte/store'; 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 { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings; const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
/** An overlay of the v1 API. */
export type CustomOverlay = { export type CustomOverlay = {
extensionName: string; extensionName: string;
id: string; id: string;
@@ -15,11 +17,87 @@ export type CustomOverlay = {
maxZoom?: number; 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<string, boolean>) => 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<string, boolean>,
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 { export class ExtensionAPI {
private _overlays: Writable<Map<string, CustomOverlay>> = 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<void>;
private _overlays: Writable<Map<string, RegisteredOverlay>> = writable(new Map());
/** What each extension knows of the visibility of its overlays, by its own overlay ids. */
private _visibility: Map<string, Record<string, boolean>> = new Map();
private _visibilityCallbacks: Map<string, Set<VisibilityCallback>> = new Map();
private _watchingVisibility = false;
init() { init() {
if (browser && !Object.hasOwn(window, 'gpxstudio')) { if (browser && !Object.hasOwn(window, 'gpxstudio')) {
this.ready = this.ensureLoaded();
this.ready.then(() => window.dispatchEvent(new Event('gpxstudio:ready')));
Object.defineProperty(window, 'gpxstudio', { Object.defineProperty(window, 'gpxstudio', {
value: this, value: this,
}); });
@@ -29,6 +107,7 @@ export class ExtensionAPI {
} }
} }
/** @deprecated v1: use `ready`. */
ensureLoaded(): Promise<void> { ensureLoaded(): Promise<void> {
let unsubscribe: () => void; let unsubscribe: () => void;
const promise = new Promise<void>((resolve) => { const promise = new Promise<void>((resolve) => {
@@ -46,6 +125,168 @@ export class ExtensionAPI {
return promise; 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<string, RegisteredOverlay>();
const styles = new Map<string, StyleSpecification | string>();
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<string, boolean> = {};
const selected: Record<string, boolean> = {};
const shown: Record<string, boolean> = {};
const visibility: Record<string, boolean> = {};
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<string, RegisteredOverlay> {
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) { addOrUpdateOverlay(overlay: CustomOverlay) {
if ( if (
!overlay.extensionName || !overlay.extensionName ||
@@ -58,7 +299,7 @@ export class ExtensionAPI {
'Overlay must have an extensionName, id, name, and at least one tile URL.' '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) => { this._overlays.update(($overlays) => {
$overlays.set(overlay.id, overlay); $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[]) { filterOverlays(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id)); ids = ids.map((id) => getOverlayId(id));
const idsToRemove = Array.from(get(this._overlays).keys()).filter( const idsToRemove = Array.from(get(this._overlays))
(id) => !ids.includes(id) .filter(([id, overlay]) => overlay.signature === undefined && !ids.includes(id))
); .map(([id]) => id);
currentOverlays.update((current) => { currentOverlays.update((current) => {
removeAll(current, idsToRemove); removeAll(current, idsToRemove);
@@ -153,8 +395,9 @@ export class ExtensionAPI {
}); });
} }
/** @deprecated v1: use `setOverlays`. */
updateOverlaysOrder(ids: string[]) { updateOverlaysOrder(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id)); ids = ids.map((id) => getOverlayId(id));
selectedOverlayTree.update((selected) => { selectedOverlayTree.update((selected) => {
const isSelected: Record<string, boolean> = {}; const isSelected: Record<string, boolean> = {};
ids.forEach((id) => { ids.forEach((id) => {
@@ -184,11 +427,15 @@ export class ExtensionAPI {
return (id: string) => $overlays.get(id)?.name || ''; return (id: string) => $overlays.get(id)?.name || '';
}); });
private getOverlayId(id: string): string { /** Whether a group of the layer trees is an extension, whose name is shown as it was given. */
return `extension-${id}`; isExtensionGroup = derived(this._overlays, ($overlays) => {
} const names = new Set(Array.from($overlays.values(), (overlay) => overlay.extensionName));
return (id: string) => names.has(id);
});
private destroy() { 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()); const ids = Array.from(get(this._overlays).keys());
currentOverlays.update((current) => { currentOverlays.update((current) => {
ids.forEach((id) => { ids.forEach((id) => {
@@ -1,7 +1,16 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import type { LayerTreeType } from '$lib/data/layers'; 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 // roads: bike (selected), foot
// satellite: esri, ign: fr (selected) // satellite: esri, ign: fr (selected)
@@ -72,3 +81,39 @@ describe('layer trees', () => {
assert.deepEqual(removeAll(tree(), []), tree()); assert.deepEqual(removeAll(tree(), []), tree());
}); });
}); });
describe('getOpacityPaint', () => {
const layer = (type: string, paint?: Record<string, unknown>) =>
({ 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 });
});
});
@@ -1,4 +1,89 @@
import type { LayerTreeType } from '$lib/data/layers'; 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<string>();
/**
* 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<string>();
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<Record<LayerSpecification['type'], string[]>> = {
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<string, unknown> {
if (layer.type === 'hillshade') {
return { 'hillshade-exaggeration': opacity / 2 };
}
const paint = (layer.paint ?? {}) as Record<string, unknown>;
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) { export function anySelectedLayer(node: LayerTreeType) {
return ( return (
@@ -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<string, { tiles: string[] }>([['empty-source', { tiles: [] }]]);
const layers = new Map<string, { source: string; paint: Record<string, unknown> }>();
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<string, unknown> }) =>
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<typeof fakeMap>) {
settings.currentOverlays.update((tree) => {
(tree.overlays as LayerTreeType).Heatmaps = { [ID]: true };
return tree;
});
const store = writable<MapLibreMap | null>(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<string, StyleSpecification>) {
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<MapLibreMap | null>(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<string, StyleSpecification> = {
[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<MapLibreMap | null>(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'));
});
});
+40 -20
View File
@@ -14,7 +14,7 @@ import {
overlays, overlays,
terrainSources, terrainSources,
} from '$lib/data/layers'; } 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'; import { i18n } from '$lib/i18n/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } = const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
@@ -147,13 +147,18 @@ export class StyleManager {
const custom = get(customLayers); const custom = get(customLayers);
const overlayOpacities = get(opacities); const overlayOpacities = get(opacities);
try { try {
// an overlay on the map can be gone from the tree, which has been replaced as a whole
const layers = getLayers(get(currentOverlays) ?? {}); const layers = getLayers(get(currentOverlays) ?? {});
this._pastOverlays.forEach((id) => (layers[id] ??= false));
for (const overlay in layers) { 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)) { if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try { try {
const overlayStyle = await this.get(overlayInfo); const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo);
for (const layer of overlayStyle.layers ?? []) { for (const layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) { if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id); map_.removeLayer(layer.id);
@@ -165,9 +170,10 @@ export class StyleManager {
this._pastOverlays.delete(overlay); this._pastOverlays.delete(overlay);
} }
} else { } else {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try { 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]; const opacity = overlayOpacities[overlay];
for (const sourceId in overlayStyle.sources) { for (const sourceId in overlayStyle.sources) {
@@ -177,21 +183,24 @@ export class StyleManager {
} }
for (const layer of overlayStyle.layers ?? []) { for (const layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) { const paint =
if (opacity !== undefined) { opacity === undefined ? {} : getOpacityPaint(layer, opacity);
if (layer.type === 'raster') { if (map_.getLayer(layer.id)) {
if (!layer.paint) { for (const property in paint) {
layer.paint = {}; map_.setPaintProperty(
} layer.id,
layer.paint['raster-opacity'] = opacity; property as never,
} else if (layer.type === 'hillshade') { paint[property] as never
if (!layer.paint) { );
layer.paint = {};
}
layer.paint['hillshade-exaggeration'] = opacity / 2;
}
} }
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); 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<StyleSpecification> {
const style = await this.get(styleInfo);
return typeof styleInfo === 'string' && overlay.startsWith('extension-')
? prefixStyle(overlay, style)
: style;
}
async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> { async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> {
if (typeof styleInfo === 'string') { if (typeof styleInfo === 'string') {
const styleUrl = styleInfo as string; const styleUrl = styleInfo as string;