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';
|
} 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]) {
|
|
||||||
if (this._pastOverlays.has(overlay)) {
|
|
||||||
const overlayInfo = custom[overlay]?.value ?? overlays[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)) {
|
||||||
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,
|
||||||
|
property as never,
|
||||||
|
paint[property] as never
|
||||||
|
);
|
||||||
}
|
}
|
||||||
layer.paint['raster-opacity'] = opacity;
|
} else {
|
||||||
} else if (layer.type === 'hillshade') {
|
map_.addLayer(
|
||||||
if (!layer.paint) {
|
{
|
||||||
layer.paint = {};
|
...layer,
|
||||||
}
|
paint: { ...layer.paint, ...paint },
|
||||||
layer.paint['hillshade-exaggeration'] = opacity / 2;
|
} as LayerSpecification,
|
||||||
}
|
ANCHOR_LAYER_KEY.overlays
|
||||||
}
|
);
|
||||||
map_.addLayer(layer, 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;
|
||||||
|
|||||||
Reference in new issue
Block a user