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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user