mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
extension api v2, ref #395
This commit is contained in:
1 parent
7474f892ce
commit
4050420b3c
8 files changed
+989
-58
No files matched your search
@@ -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;
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
<input
|
||||
@@ -74,25 +90,20 @@
|
||||
/>
|
||||
{/if}
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if Object.hasOwn($customLayers, id)}
|
||||
{$customLayers[id].name}
|
||||
{:else if $isLayerFromExtension(id)}
|
||||
{$getLayerName(id)}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
{/if}
|
||||
{getLabel(id)}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if anySelectedLayer(node[id])}
|
||||
<CollapsibleTreeNode {id}>
|
||||
{#snippet trigger()}
|
||||
<span>{i18n._(`layers.label.${id}`, id)}</span>
|
||||
<span>{getGroupLabel(id)}</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="ml-2">
|
||||
<Self
|
||||
node={node[id] as LayerTreeType}
|
||||
parent={id}
|
||||
{name}
|
||||
{selected}
|
||||
{onselect}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { get } from 'svelte/store';
|
||||
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||
import { engine } from '$lib/engine';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
||||
import { isSelected } from './utils';
|
||||
import { map } from '$lib/components/map/map';
|
||||
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 };
|
||||
|
||||
@@ -41,6 +43,8 @@ beforeEach(() => {
|
||||
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> = {}): 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 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<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 {
|
||||
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() {
|
||||
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<void> {
|
||||
let unsubscribe: () => void;
|
||||
const promise = new Promise<void>((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<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) {
|
||||
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<string, boolean> = {};
|
||||
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) => {
|
||||
|
||||
@@ -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<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 { 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) {
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user