Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 2 additions & 51 deletions apps/geolibre-desktop/src/hooks/usePlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
setEffectsSettings,
type EffectsSettings,
maplibreEarthdataGisPlugin,
SKETCHES_SOURCE_KIND,
setEarthdataCogSaver,
maplibreEnviroAtlasPlugin,
maplibreEsriWaybackPlugin,
Expand Down Expand Up @@ -94,7 +93,6 @@ import type {
GeoLibreExternalNativeLayerRegistration,
GeoLibreFileDialogOptions,
GeoLibreMapControlPosition,
GeoLibreSelection,
GeoLibreTileLayerOptions,
GeoLibreWmsLayerOptions,
GeoLibreZarrLayerOptions,
Expand Down Expand Up @@ -132,6 +130,7 @@ import { partitionProjectPluginManifestUrls } from "../lib/plugin-trust";
import { setTimeSliderOpenedByBinding, shouldCloseTimeSliderDock } from "../lib/time-slider-dock";
import { createWmsTileUrl, normalizeWmsVersion } from "../components/layout/add-data/helpers";
import { createExternalNativeStoreLayer } from "../lib/external-native-layer";
import { createPluginLayerQueries } from "../lib/plugin-layer-queries";
import { mergeStringLists } from "../lib/string-lists";
import {
browserSaveFallsBackToDownload,
Expand Down Expand Up @@ -831,21 +830,6 @@ export function useTimeSliderAutoClose(mapControllerRef: RefObject<MapController
}, [mapControllerRef]);
}

function readPluginSelection(): GeoLibreSelection {
const state = useAppStore.getState();
const layer = state.layers.find((item) => item.id === state.selectedLayerId);
if (!layer || state.selectedFeatureIds.length === 0) {
return { layerId: state.selectedLayerId, features: [] };
}
const selected = new Set(state.selectedFeatureIds);
const features = structuredClone(
(layer.geojson?.features ?? []).filter((feature, index) =>
selected.has(String(feature.id ?? index)),
),
);
return { layerId: state.selectedLayerId, features };
}

export function createAppAPI(mapControllerRef?: RefObject<MapController | null>) {
const store = useAppStore.getState();
// Captured so methods that delegate to plugin helpers taking the AppAPI
Expand All @@ -857,40 +841,7 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>)
const id = store.addGeoJsonLayer(name, data, sourcePath);
return id;
},
listLayers: () =>
useAppStore.getState().layers.map(({ id, name, type, visible, opacity }) => ({
id,
name,
type,
visible,
opacity,
})),
getLayerFeatures: (layerId: string) => {
const layer = useAppStore.getState().layers.find((item) => item.id === layerId);
if (!layer) throw new Error(`No layer with id "${layerId}"`);
return structuredClone(layer.geojson?.features ?? []);
},
getSelectedFeatures: () => readPluginSelection().features,
getSelectedLayerId: () => useAppStore.getState().selectedLayerId,
getDrawnFeatures: () =>
structuredClone(
useAppStore
.getState()
.layers.flatMap((layer) =>
layer.metadata.sourceKind === SKETCHES_SOURCE_KIND
? (layer.geojson?.features ?? [])
: [],
),
),
onSelectionChange: (callback: (selection: GeoLibreSelection) => void) =>
useAppStore.subscribe((state, previous) => {
if (
state.selectedLayerId !== previous.selectedLayerId ||
state.selectedFeatureIds !== previous.selectedFeatureIds
) {
callback(readPluginSelection());
}
}),
...createPluginLayerQueries(),
addTileLayer: (name: string, url: string, options?: GeoLibreTileLayerOptions) =>
store.addTileLayer(
name,
Expand Down
80 changes: 80 additions & 0 deletions apps/geolibre-desktop/src/lib/plugin-layer-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useAppStore } from "@geolibre/core";
import { SKETCHES_SOURCE_KIND } from "@geolibre/plugins/geo-editor-geometry";
import type { GeoLibreSelection } from "@geolibre/plugins";

/**
* The read-only half of the external plugin API: everything a plugin may ask
* about the layers currently on the map, and nothing that writes to them.
*
* Kept out of `usePlugins.ts` on purpose. That module imports the whole
* built-in plugin registry (and through it MapCanvas, CesiumCanvas, and every
* `maplibre-*` plugin), so a unit test reaching these queries through
* `createAppAPI` had to stub `maplibre-gl`, `window`, and `localStorage` just
* to load the module, and dragged 39 browser-only files into the coverage
* report along the way. These functions need only the store, so they live
* here and `createAppAPI` spreads them in. Same reasoning as
* `geo-editor-geometry.ts` in `@geolibre/plugins`.
*
* Every accessor hands back a `structuredClone`, so a plugin holding a
* returned feature cannot reach into store state and mutate it.
*/

/** The current selection as plugins see it: the layer id and its selected features. */
export function readPluginSelection(): GeoLibreSelection {
const state = useAppStore.getState();
const layer = state.layers.find((item) => item.id === state.selectedLayerId);
if (!layer || state.selectedFeatureIds.length === 0) {
return { layerId: state.selectedLayerId, features: [] };
}
const selected = new Set(state.selectedFeatureIds);
const features = structuredClone(
(layer.geojson?.features ?? []).filter((feature, index) =>
selected.has(String(feature.id ?? index)),
),
);
return { layerId: state.selectedLayerId, features };
}

/**
* Build the read-only query methods that `createAppAPI` exposes to plugins.
* Reads the store on every call rather than closing over a snapshot, so a
* plugin holding the API sees the map as it is now.
*/
export function createPluginLayerQueries() {
return {
listLayers: () =>
useAppStore.getState().layers.map(({ id, name, type, visible, opacity }) => ({
id,
name,
type,
visible,
opacity,
})),
getLayerFeatures: (layerId: string) => {
const layer = useAppStore.getState().layers.find((item) => item.id === layerId);
if (!layer) throw new Error(`No layer with id "${layerId}"`);
return structuredClone(layer.geojson?.features ?? []);
},
getSelectedFeatures: () => readPluginSelection().features,
getSelectedLayerId: () => useAppStore.getState().selectedLayerId,
getDrawnFeatures: () =>
structuredClone(
useAppStore
.getState()
.layers.flatMap((layer) =>
layer.metadata.sourceKind === SKETCHES_SOURCE_KIND
? (layer.geojson?.features ?? [])
: [],
),
),
onSelectionChange: (callback: (selection: GeoLibreSelection) => void) =>
useAppStore.subscribe((state, previous) => {
if (
state.selectedLayerId !== previous.selectedLayerId ||
state.selectedFeatureIds !== previous.selectedFeatureIds
) {
callback(readPluginSelection());
}
}),
};
}
1 change: 1 addition & 0 deletions packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"exports": {
".": "./src/index.ts",
"./cog-spectral-profile": "./src/plugins/cog-spectral-profile.ts",
"./geo-editor-geometry": "./src/plugins/geo-editor-geometry.ts",
"./local-netcdf": "./src/plugins/local-netcdf.ts",
"./maplibre-graticule": "./src/plugins/maplibre-graticule.ts",
"./raster-symbology": "./src/plugins/raster-symbology.ts",
Expand Down
114 changes: 35 additions & 79 deletions tests/plugin-query-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,17 @@
import assert from "node:assert/strict";
import { createRequire, registerHooks } from "node:module";
import { before, beforeEach, describe, it } from "node:test";
import { beforeEach, describe, it } from "node:test";
import { useAppStore } from "@geolibre/core";
import { SKETCHES_SOURCE_KIND } from "@geolibre/plugins/geo-editor-geometry";
import type { GeoLibreSelection } from "@geolibre/plugins";
import { createPluginLayerQueries } from "../apps/geolibre-desktop/src/lib/plugin-layer-queries";

(globalThis as typeof globalThis & { window: typeof globalThis }).window = globalThis;
(globalThis as typeof globalThis & { location: { search: string } }).location = { search: "" };
const emptyStorage = { getItem: () => null };
(globalThis as typeof globalThis & { sessionStorage: typeof emptyStorage }).sessionStorage =
emptyStorage;
(globalThis as typeof globalThis & { localStorage: typeof emptyStorage }).localStorage =
emptyStorage;
const maplibreGl = createRequire(`${process.cwd()}/package.json`)("maplibre-gl") as Record<
string,
unknown
>;
(globalThis as typeof globalThis & { __maplibreGl: Record<string, unknown> }).__maplibreGl =
maplibreGl;
const maplibreGlModuleSource = [
"export default globalThis.__maplibreGl;",
...Object.keys(maplibreGl)
.filter((name) => name !== "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name))
.map((name) => `export const ${name} = globalThis.__maplibreGl[${JSON.stringify(name)}];`),
].join("\n");
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "maplibre-gl") {
return { url: "test:maplibre-gl", shortCircuit: true };
}
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
if (url === "test:maplibre-gl") {
return { format: "module", source: maplibreGlModuleSource, shortCircuit: true };
}
if (url.endsWith(".css")) {
return { format: "module", source: "", shortCircuit: true };
}
if (url === "virtual:bundled-plugins") {
return {
format: "module",
source: "export const bundledPluginManifestPaths = [];",
shortCircuit: true,
};
}
return nextLoad(url, context);
},
});
const SKETCHES_SOURCE_KIND = "geoeditor-sketches";
let useAppStore: typeof import("@geolibre/core").useAppStore;
let createAppAPI: typeof import("../apps/geolibre-desktop/src/hooks/usePlugins").createAppAPI;

before(async () => {
[{ useAppStore }, { createAppAPI }] = await Promise.all([
import("@geolibre/core"),
import("../apps/geolibre-desktop/src/hooks/usePlugins"),
]);
});
// These exercise `createPluginLayerQueries`, which `createAppAPI` spreads into
// the object it hands plugins, rather than reaching through `createAppAPI`
// itself. Loading `usePlugins.ts` pulls in the whole built-in plugin registry
// (and MapCanvas, CesiumCanvas, and every `maplibre-*` plugin with it), which
// forced this file to stub `maplibre-gl`, `window`, and `localStorage` just to
// import it, and put 39 browser-only modules into the coverage report. The
// wiring itself is a typed spread, so `npm run build` is what holds it.

describe("external plugin query API", () => {
beforeEach(() => {
Expand All @@ -74,18 +30,18 @@ describe("external plugin query API", () => {
store.selectLayer(layerId);
store.selectFeatures(["A", "B"]);

const app = createAppAPI();
assert.equal(app.getSelectedLayerId?.(), layerId);
const app = createPluginLayerQueries();
assert.equal(app.getSelectedLayerId(), layerId);
assert.deepEqual(
app.getSelectedFeatures?.().map((feature) => feature.id),
app.getSelectedFeatures().map((feature) => feature.id),
["A", "B"],
);
});

it("notifies and unsubscribes selection listeners", () => {
const app = createAppAPI();
const app = createPluginLayerQueries();
const events: unknown[] = [];
const unsubscribe = app.onSelectionChange?.((selection) => events.push(selection));
const unsubscribe = app.onSelectionChange((selection) => events.push(selection));
assert.ok(unsubscribe);
useAppStore.getState().selectFeatures(["A"]);
assert.equal(events.length, 1);
Expand All @@ -109,8 +65,8 @@ describe("external plugin query API", () => {
});
const before = JSON.stringify(useAppStore.getState());

const app = createAppAPI();
assert.deepEqual(app.listLayers?.(), [
const app = createPluginLayerQueries();
assert.deepEqual(app.listLayers(), [
{
id: layerId,
name: "Catchments",
Expand All @@ -119,17 +75,17 @@ describe("external plugin query API", () => {
opacity: 1,
},
]);
assert.deepEqual(app.getLayerFeatures?.(layerId), [
assert.deepEqual(app.getLayerFeatures(layerId), [
{
type: "Feature",
id: "A",
properties: { NAME: "Upper Basin", AREA_KM2: 12.5 },
geometry: { type: "Point", coordinates: [101.7, 3.1] },
},
]);
app.getSelectedFeatures?.();
app.getSelectedLayerId?.();
app.getDrawnFeatures?.();
app.getSelectedFeatures();
app.getSelectedLayerId();
app.getDrawnFeatures();

assert.equal(JSON.stringify(useAppStore.getState()), before);
});
Expand All @@ -152,19 +108,19 @@ describe("external plugin query API", () => {
});
store.selectLayer(layerId);

const app = createAppAPI();
const app = createPluginLayerQueries();
let callbackSelection: GeoLibreSelection | undefined;
const unsubscribe = app.onSelectionChange?.((selection) => {
const unsubscribe = app.onSelectionChange((selection) => {
callbackSelection = selection;
});
assert.ok(unsubscribe);
store.selectFeatures(["A"]);
assert.ok(callbackSelection);

const returnedFeatures = [
app.getLayerFeatures?.(layerId)[0],
app.getSelectedFeatures?.()[0],
app.getDrawnFeatures?.()[0],
app.getLayerFeatures(layerId)[0],
app.getSelectedFeatures()[0],
app.getDrawnFeatures()[0],
callbackSelection.features[0],
];
for (const feature of returnedFeatures) {
Expand All @@ -184,8 +140,8 @@ describe("external plugin query API", () => {
});

it("throws when a requested layer does not exist", () => {
const app = createAppAPI();
assert.throws(() => app.getLayerFeatures?.("missing-layer"), {
const app = createPluginLayerQueries();
assert.throws(() => app.getLayerFeatures("missing-layer"), {
message: 'No layer with id "missing-layer"',
});
});
Expand All @@ -203,8 +159,8 @@ describe("external plugin query API", () => {
store.selectFeatures(["1"]);

assert.deepEqual(
createAppAPI()
.getSelectedFeatures?.()
createPluginLayerQueries()
.getSelectedFeatures()
.map((feature) => feature.properties?.NAME),
["Second"],
);
Expand All @@ -218,9 +174,9 @@ describe("external plugin query API", () => {
});
store.selectLayer(layerId);

const app = createAppAPI();
assert.equal(app.getSelectedLayerId?.(), layerId);
assert.deepEqual(app.getSelectedFeatures?.(), []);
const app = createPluginLayerQueries();
assert.equal(app.getSelectedLayerId(), layerId);
assert.deepEqual(app.getSelectedFeatures(), []);
});

it("returns features from every sketch layer and excludes ordinary layers", () => {
Expand All @@ -244,7 +200,7 @@ describe("external plugin query API", () => {
metadata: { sourceKind: SKETCHES_SOURCE_KIND },
});

assert.deepEqual(createAppAPI().getDrawnFeatures?.(), [
assert.deepEqual(createPluginLayerQueries().getDrawnFeatures(), [
{
type: "Feature",
id: "drawn-1",
Expand Down
Loading