Skip to content

Commit 6093aa8

Browse files
feat: expose read-only layer queries to external plugins (#1784)
* feat: expose read-only layer queries to plugins * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix: detach plugin query features --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 65073e7 commit 6093aa8

4 files changed

Lines changed: 391 additions & 2 deletions

File tree

apps/geolibre-desktop/src/hooks/usePlugins.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
setEffectsSettings,
2727
type EffectsSettings,
2828
maplibreEarthdataGisPlugin,
29+
SKETCHES_SOURCE_KIND,
2930
setEarthdataCogSaver,
3031
maplibreEnviroAtlasPlugin,
3132
maplibreEsriWaybackPlugin,
@@ -93,6 +94,7 @@ import type {
9394
GeoLibreExternalNativeLayerRegistration,
9495
GeoLibreFileDialogOptions,
9596
GeoLibreMapControlPosition,
97+
GeoLibreSelection,
9698
GeoLibreTileLayerOptions,
9799
GeoLibreWmsLayerOptions,
98100
GeoLibreZarrLayerOptions,
@@ -829,6 +831,21 @@ export function useTimeSliderAutoClose(mapControllerRef: RefObject<MapController
829831
}, [mapControllerRef]);
830832
}
831833

834+
function readPluginSelection(): GeoLibreSelection {
835+
const state = useAppStore.getState();
836+
const layer = state.layers.find((item) => item.id === state.selectedLayerId);
837+
if (!layer || state.selectedFeatureIds.length === 0) {
838+
return { layerId: state.selectedLayerId, features: [] };
839+
}
840+
const selected = new Set(state.selectedFeatureIds);
841+
const features = structuredClone(
842+
(layer.geojson?.features ?? []).filter((feature, index) =>
843+
selected.has(String(feature.id ?? index)),
844+
),
845+
);
846+
return { layerId: state.selectedLayerId, features };
847+
}
848+
832849
export function createAppAPI(mapControllerRef?: RefObject<MapController | null>) {
833850
const store = useAppStore.getState();
834851
// Captured so methods that delegate to plugin helpers taking the AppAPI
@@ -840,6 +857,40 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>)
840857
const id = store.addGeoJsonLayer(name, data, sourcePath);
841858
return id;
842859
},
860+
listLayers: () =>
861+
useAppStore.getState().layers.map(({ id, name, type, visible, opacity }) => ({
862+
id,
863+
name,
864+
type,
865+
visible,
866+
opacity,
867+
})),
868+
getLayerFeatures: (layerId: string) => {
869+
const layer = useAppStore.getState().layers.find((item) => item.id === layerId);
870+
if (!layer) throw new Error(`No layer with id "${layerId}"`);
871+
return structuredClone(layer.geojson?.features ?? []);
872+
},
873+
getSelectedFeatures: () => readPluginSelection().features,
874+
getSelectedLayerId: () => useAppStore.getState().selectedLayerId,
875+
getDrawnFeatures: () =>
876+
structuredClone(
877+
useAppStore
878+
.getState()
879+
.layers.flatMap((layer) =>
880+
layer.metadata.sourceKind === SKETCHES_SOURCE_KIND
881+
? (layer.geojson?.features ?? [])
882+
: [],
883+
),
884+
),
885+
onSelectionChange: (callback: (selection: GeoLibreSelection) => void) =>
886+
useAppStore.subscribe((state, previous) => {
887+
if (
888+
state.selectedLayerId !== previous.selectedLayerId ||
889+
state.selectedFeatureIds !== previous.selectedFeatureIds
890+
) {
891+
callback(readPluginSelection());
892+
}
893+
}),
843894
addTileLayer: (name: string, url: string, options?: GeoLibreTileLayerOptions) =>
844895
store.addTileLayer(
845896
name,

docs/plugin-api.md

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Interface
44

55
```typescript
6-
import type { FeatureCollection } from "geojson";
6+
import type { Feature, FeatureCollection, Geometry } from "geojson";
77
import type { IControl } from "maplibre-gl";
88

99
export type GeoLibreMapControlPosition =
@@ -55,13 +55,34 @@ export interface GeoLibreDeckGL {
5555
mapbox: typeof import("@deck.gl/mapbox");
5656
}
5757

58+
export interface GeoLibreLayerSummary {
59+
id: string;
60+
name: string;
61+
type: string;
62+
visible: boolean;
63+
opacity: number;
64+
}
65+
66+
export interface GeoLibreSelection {
67+
layerId: string | null;
68+
features: Feature<Geometry | null>[];
69+
}
70+
5871
export interface GeoLibreAppAPI {
5972
setBasemap: (styleUrl: string) => void;
6073
addGeoJsonLayer: (
6174
name: string,
6275
data: FeatureCollection,
6376
sourcePath?: string,
6477
) => string;
78+
listLayers?: () => GeoLibreLayerSummary[];
79+
getLayerFeatures?: (layerId: string) => Feature<Geometry | null>[];
80+
getSelectedFeatures?: () => Feature<Geometry | null>[];
81+
getSelectedLayerId?: () => string | null;
82+
getDrawnFeatures?: () => Feature<Geometry | null>[];
83+
onSelectionChange?: (
84+
callback: (selection: GeoLibreSelection) => void,
85+
) => () => void;
6586
// Native raster/tile layers (see "Raster and tile layers" below). Each
6687
// returns the new layer's id and the layer appears in the Layers panel and
6788
// persists with the project, like addGeoJsonLayer does for vector data.
@@ -416,6 +437,48 @@ https://web.geolibre.app/?url=https://example.com/project.geolibre.json&exampleG
416437

417438
A URL parameter activates only an already-registered (installed) plugin that owns it; it never loads a plugin from the URL. For external plugins, include the plugin manifest URL in the project `plugins` state (so the plugin is registered) before relying on its URL handler — the matching parameter then activates and dispatches it even if it is not in the active set.
418439

440+
## Read-only layer and feature queries
441+
442+
External plugins can inspect the current layer list, a layer's GeoJSON features,
443+
the current feature selection, and features in GeoEditor's Sketches layers. The
444+
methods are optional so the same plugin remains compatible with older hosts.
445+
446+
```typescript
447+
const layers = app.listLayers?.() ?? [];
448+
const selectedLayerId = app.getSelectedLayerId?.() ?? null;
449+
const selectedFeatures = app.getSelectedFeatures?.() ?? [];
450+
451+
if (selectedLayerId && app.getLayerFeatures) {
452+
const layerFeatures = app.getLayerFeatures(selectedLayerId);
453+
console.log(layers, layerFeatures, selectedFeatures);
454+
}
455+
456+
const drawnFeatures = app.getDrawnFeatures?.() ?? [];
457+
```
458+
459+
`getSelectedFeatures` returns every selected feature in the selected layer, not
460+
only the most recently selected feature. Features without a GeoJSON `id` are
461+
matched using their zero-based array index converted to a string. An empty
462+
selection returns an empty array. `getLayerFeatures` throws when the layer id is
463+
unknown and returns an empty array for a layer that has no GeoJSON features.
464+
465+
Selection subscriptions fire after the selected layer or selected feature-id
466+
array changes. Keep and call the returned unsubscribe function during plugin
467+
deactivation:
468+
469+
```typescript
470+
const unsubscribe = app.onSelectionChange?.(({ layerId, features }) => {
471+
console.log(layerId, features);
472+
});
473+
474+
// In deactivate or another cleanup path:
475+
unsubscribe?.();
476+
```
477+
478+
These methods are a read-only query surface: calling them does not change the
479+
GeoLibre store. Plugins must also treat returned GeoJSON features as read-only
480+
and use host APIs such as `addGeoJsonLayer` when they need to add data.
481+
419482
## Raster and tile layers
420483

421484
`addGeoJsonLayer` registers vector data as a native layer. For raster and tile data there are three matching helpers — `addTileLayer` (XYZ), `addWmtsLayer` (WMTS), and `addWmsLayer` (WMS). Each returns the new layer's id, and the layer appears in the Layers panel with full opacity, reorder, and styling support and persists with the project, so a plugin no longer has to call `getMap().addSource()/addLayer()` directly (which leaves the layer invisible to GeoLibre's layer store).

packages/plugins/src/types.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
QueryResult as ZarrQueryResult,
1111
Selector as ZarrSelector,
1212
} from "@carbonplan/zarr-layer";
13-
import type { FeatureCollection, Geometry } from "geojson";
13+
import type { Feature, FeatureCollection, Geometry } from "geojson";
1414
import type { IControl, Map as MapLibreMap } from "maplibre-gl";
1515
import type { OvertureTheme } from "maplibre-gl-overture-maps";
1616
import type { TemporalLayerAdapter } from "./plugins/temporal-layers";
@@ -331,9 +331,28 @@ export interface GeoLibrePickedVectorFile {
331331
nativeData?: FeatureCollection;
332332
}
333333

334+
export interface GeoLibreLayerSummary {
335+
id: string;
336+
name: string;
337+
type: string;
338+
visible: boolean;
339+
opacity: number;
340+
}
341+
342+
export interface GeoLibreSelection {
343+
layerId: string | null;
344+
features: Feature<Geometry | null>[];
345+
}
346+
334347
export interface GeoLibreAppAPI {
335348
setBasemap: (styleUrl: string) => void;
336349
addGeoJsonLayer: (name: string, data: FeatureCollection, sourcePath?: string) => string;
350+
listLayers?: () => GeoLibreLayerSummary[];
351+
getLayerFeatures?: (layerId: string) => Feature<Geometry | null>[];
352+
getSelectedFeatures?: () => Feature<Geometry | null>[];
353+
getSelectedLayerId?: () => string | null;
354+
getDrawnFeatures?: () => Feature<Geometry | null>[];
355+
onSelectionChange?: (callback: (selection: GeoLibreSelection) => void) => () => void;
337356
/**
338357
* Add a native XYZ raster tile layer from a tile URL template (with
339358
* `{x}`/`{y}`/`{z}` placeholders) and return its layer id. Unlike calling

0 commit comments

Comments
 (0)