# GeoLibre Plugin API ## Interface ```typescript import type { Feature, FeatureCollection, Geometry } from "geojson"; import type { IControl } from "maplibre-gl"; import type * as Proj4 from "proj4"; export type GeoLibreMapControlPosition = | "top-left" | "top-right" | "bottom-left" | "bottom-right"; export type GeoLibreBuiltInMapControl = | "navigation" | "fullscreen" | "geolocate" | "globe" | "terrain" | "scale" | "attribution" | "logo" | "maptoolkit-logo" | "layer-control"; export interface GeoLibrePlugin { id: string; name: string; version: string; activeByDefault?: boolean; /** * Renderers this plugin supports. Defaults to `["maplibre"]`. * Engine-neutral plugins (e.g. catalog/service browsers that only write to * the GeoLibre store) or plugins with multi-engine adapters declare * `["maplibre", "cesium"]`; plugins that stay on the Style Spec surface the * two 2D engines share declare `["maplibre", "mapbox"]` (see "Supporting the * Mapbox renderer" below). The Plugins menu gates options against the active * renderer. */ engines?: ("maplibre" | "mapbox" | "cesium" | "arcgis")[]; /** Plugins in the same group cannot be active at the same time. */ exclusiveGroup?: string; /** At least one name is required for handleUrlParameters to be called. */ urlParameterNames?: string[]; // Return (or resolve) false to refuse activation; the host then rolls back // the optimistic active state. activate: (app: GeoLibreAppAPI) => boolean | void | Promise; deactivate: (app: GeoLibreAppAPI) => void; handleUrlParameters?: ( app: GeoLibreAppAPI, params: URLSearchParams ) => void | Promise; getMapControlPosition?: () => GeoLibreMapControlPosition; setMapControlPosition?: ( app: GeoLibreAppAPI, position: GeoLibreMapControlPosition ) => boolean | void; getProjectState?: () => unknown; applyProjectState?: (app: GeoLibreAppAPI, state: unknown) => boolean | void; /** * The plugin saves its own panel open/collapsed state through * getProjectState/applyProjectState, so the project-restore pass must not * collapse its control. */ restoresPanelCollapseState?: boolean; /** * The plugin's project state is project data, not a preference: every * project load calls applyProjectState, with undefined when the file has no * state for it, so the previous project's data is not carried over. */ clearsStateOnProjectLoad?: boolean; /** * A workspace tool, not part of a project (e.g. the S3 Browser panel): * project loads and map swaps leave it running, and it is never saved in * the project's active plugins. Only for plugins with no map controls. */ sessionScoped?: boolean; } // Resolved by app.getDeckGL(): GeoLibre's own deck.gl modules, so a plugin // renders on the host's single instance instead of bundling its own copy. export interface GeoLibreDeckGL { core: typeof import("@deck.gl/core"); layers: typeof import("@deck.gl/layers"); aggregationLayers: typeof import("@deck.gl/aggregation-layers"); geoLayers: typeof import("@deck.gl/geo-layers"); meshLayers: typeof import("@deck.gl/mesh-layers"); mapbox: typeof import("@deck.gl/mapbox"); } export interface GeoLibreLayerSummary { id: string; name: string; type: string; visible: boolean; opacity: number; } export interface GeoLibreLayerGroupSummary { id: string; name: string; parentId: string | null; // null for a group at the panel root visible: boolean; opacity: number; collapsed: boolean; } export interface GeoLibreSelection { layerId: string | null; features: Feature[]; } export type GeoLibreActiveMapTool = "identify" | "feature-selection" | "geo-editor" | null; export interface GeoLibreRasterWindowOptions { bounds: [number, number, number, number]; // WGS84 [west, south, east, north] width?: number; // sample grid, default 32 height?: number; band?: number; // 1-based, default 1 signal?: AbortSignal; } export interface GeoLibreRasterWindowReading { values: number[]; // row-major, width * height, nodata included width: number; height: number; band: number; nodata: number | null; overviewLevel: number; } // An excerpt: the full interface, with every optional method, is in // packages/plugins/src/types.ts. export interface GeoLibreAppAPI { setBasemap: (styleUrl: string) => void; addGeoJsonLayer: ( name: string, data: FeatureCollection, sourcePath?: string ) => string; // Replace a GeoJSON layer's features in place, or remove a layer. // See "Updating and removing layers". updateGeoJsonLayer?: (layerId: string, data: FeatureCollection) => boolean; removeLayer?: (layerId: string) => void; listLayers?: () => GeoLibreLayerSummary[]; getLayerFeatures?: (layerId: string) => Feature[]; // Dress a layer with an SLD, QML or Mapbox GL style. See "Layer styles". importLayerStyle?: ( layerId: string, text: string ) => GeoLibreImportLayerStyleResult; // Show or hide a layer like the Layers panel's eye. See "Layer visibility". setLayerVisibility?: (layerId: string, visible: boolean) => void; getSelectedFeatures?: () => Feature[]; getSelectedLayerId?: () => string | null; // Sample a raster layer over a geographic window. See "Sampling raster // values" below. readRasterWindow?: ( layerId: string, options: GeoLibreRasterWindowOptions ) => Promise; getDrawnFeatures?: () => Feature[]; onSelectionChange?: ( callback: (selection: GeoLibreSelection) => void ) => () => void; getActiveMapTool?: () => GeoLibreActiveMapTool; onActiveMapToolChange?: ( callback: (tool: GeoLibreActiveMapTool) => void ) => () => void; // GeoEditor sessions on a layer. See "GeoEditor sessions". getEditSessionLayerId?: () => string | null; onEditSessionChange?: ( callback: (change: GeoLibreEditSessionChange) => void ) => () => void; startEditSession?: (layerId: string) => Promise; // Native raster/tile layers (see "Raster and tile layers" below). Each // returns the new layer's id and the layer appears in the Layers panel and // persists with the project, like addGeoJsonLayer does for vector data. addTileLayer?: ( name: string, url: string, options?: GeoLibreTileLayerOptions ) => string; addWmtsLayer?: ( name: string, url: string, options?: GeoLibreTileLayerOptions ) => string; addVectorTileLayer?: ( name: string, url: string, options: GeoLibreVectorTileLayerOptions ) => string; setLayerRequestHeaders?: ( layerId: string, headers: Record | null ) => boolean; addWmsLayer?: (name: string, options: GeoLibreWmsLayerOptions) => string; addWfsLayer?: (name: string, options: GeoLibreWfsLayerOptions) => Promise; // Native client-side COG (reads the GeoTIFF directly; band/rescale/colormap/ // nodata controls). Resolves with the new layer's id (see "Raster and tile // layers" below). addCogLayer?: ( name: string, url: string, options?: GeoLibreCogLayerOptions ) => Promise; // Zarr through the host's own @carbonplan/zarr-layer instance, with // crs/proj4 reprojection (see "Zarr layers" below). addZarrLayer?: ( name: string, url: string, options: GeoLibreZarrLayerOptions ) => Promise; setZarrLayerSelector?: ( layerId: string, selector: Record ) => Promise; // Click-to-value / region statistics on a Zarr layer (see "Zarr layers"). queryZarrLayer?: ( layerId: string, geometry: GeoLibreZarrQueryGeometry, selector?: GeoLibreZarrQuerySelector, options?: GeoLibreZarrQueryOptions ) => Promise; // Register a layer the plugin added to the map itself, so it appears in the // Layers panel (see "Custom (WebGL) layers and paint ownership" below). registerExternalNativeLayer?: ( layer: GeoLibreExternalNativeLayerRegistration ) => void; unregisterExternalNativeLayer?: (id: string) => void; // Layers-panel groups (folders). See "Layer groups" below. addLayerGroup?: (name?: string, layerIds?: string[]) => string; listLayerGroups?: () => GeoLibreLayerGroupSummary[]; moveLayersToGroup?: (layerIds: string[], groupId: string | null) => void; moveLayerGroupToGroup?: (id: string, parentId: string | null) => void; removeLayerGroup?: (id: string) => void; // Time Slider adapters (see "Driving a layer's own time dimension" below). registerTemporalLayer?: ( layerId: string, adapter: TemporalLayerAdapter, options?: { bind?: boolean } ) => () => void; unregisterTemporalLayer?: (layerId: string) => void; // See "Activating and deactivating other plugins" below. activatePlugin?: (pluginId: string, state?: unknown) => Promise; deactivatePlugin?: (pluginId: string) => boolean; getActiveBasemap: () => string; onBasemapChange: (callback: (styleUrl: string) => void) => () => void; fetchArrayBuffer?: (url: string) => Promise; // Desktop only: fetch through the native client (see "Signing in with a session cookie"). nativeFetch?: typeof globalThis.fetch; fitBounds?: (bounds: [number, number, number, number]) => void; // The extent the primary map currently shows, [west, south, east, north] in // degrees, on either renderer. The engine-neutral replacement for // getMap()?.getBounds() — see "Reading the viewport" below. getViewBounds?: () => [number, number, number, number] | null; getMap?: () => import("maplibre-gl").Map | null; // The active primary renderer, including while its canvas is being replaced. getMapRenderer?: () => "maplibre" | "mapbox" | "cesium" | "arcgis"; // The native mapbox-gl map, only while Mapbox is the primary renderer; null // otherwise. Built-in plugins read the 2D map through getStyleMap(app), which // falls back to this when getMap() is null — see "Supporting the Mapbox // renderer" below. getMapboxMap?: () => import("mapbox-gl").Map | null; // The mapbox-gl namespace and the access token, only while Mapbox is the // primary renderer, for a plugin that must construct Mapbox classes or a // second Mapbox map. getMapboxGl?: () => typeof import("mapbox-gl").default | null; getMapboxAccessToken?: () => string | null; // The primary ArcGIS MapView or SceneView, or null on another renderer. // The shared deck overlay hosts flat maps and local scenes only. getArcgisView?: () => ReturnType< import("@geolibre/map").ArcgisEngine["getView"] >; // The MapLibre-shaped map controls receive on ArcGIS, or null on another // renderer. Its style calls are recorded, not drawn by the SDK: the host // draws the store layers they mirror and GeoJSON overlays itself (see // docs/arcgis-renderer.md, "Plugin controls"). Built-in plugins read it // through getControlMap(app) in style-map.ts. getArcgisControlMap?: () => import("maplibre-gl").Map | null; // The same recording map on the primary Cesium globe, or null on another // renderer and in a split pane: mirrored store layers draw through the // globe's layer sync and other GeoJSON overlays as ground-clamped entities // (see docs/cesium-renderer.md, "Plugin controls"). Custom layers throw. // Built-in plugins read it through getControlMap(app) as well. getCesiumControlMap?: () => import("maplibre-gl").Map | null; // The primary Cesium globe's scene (namespace, widget, scene, camera, clock, // canvas, readView), or null when the primary map is not a globe. The globe's // counterpart to getMap for plugins that declare engines: ["maplibre", "cesium"]. getCesiumScene?: () => import("@geolibre/map").CesiumSceneHandle | null; addMapControl: ( control: IControl, position?: GeoLibreMapControlPosition ) => boolean; removeMapControl: (control: IControl) => void; // The host's existing menu panels. See "Built-in control panels" below. openControlPanel?: { (id: "minimap", options: GeoLibreMinimapOptions): void; (id: GeoLibreControlPanelId): void; }; closeControlPanel?: (id: GeoLibreControlPanelId) => void; isControlPanelVisible?: (id: GeoLibreControlPanelId) => boolean; // Note: showing the "terrain" control (visible: true) also switches 3D // terrain on, mirroring the Controls menu so the user doesn't have to click // the control button as a second step. Hiding it turns terrain back off. setBuiltInMapControlVisible: ( control: GeoLibreBuiltInMapControl, visible: boolean ) => boolean; getBuiltInMapControlPosition: ( control: GeoLibreBuiltInMapControl ) => GeoLibreMapControlPosition; setBuiltInMapControlPosition: ( control: GeoLibreBuiltInMapControl, position: GeoLibreMapControlPosition ) => boolean; getDeckGL?: () => Promise; getProj4?: () => Promise; // Right-sidebar panels (see "Right sidebar panels" below). registerRightPanel?: (panel: GeoLibreRightPanelRegistration) => () => void; unregisterRightPanel?: (id: string) => void; openRightPanel?: (id: string) => boolean; collapseRightPanel?: (id: string) => void; closeRightPanel?: (id: string) => void; getActiveRightPanel?: () => string | null; setActiveRightPanelDock?: (dock: GeoLibreRightPanelDock) => void; getActiveRightPanelDock?: () => GeoLibreRightPanelDock | null; // Localization (see "Following the app language" below). getLocale?: () => string; onLocaleChange?: (listener: (locale: string) => void) => () => void; translate?: ( key: string, defaultValue: string, params?: Record ) => string; registerTranslations?: ( resources: Record> ) => void; // Credential storage (see "Saving credentials" below). credentials?: GeoLibrePluginCredentials; // OAuth authorization-code sign-in (see "Signing in with OAuth" below). getOAuthRedirectUri?: () => string; authorizeInPopup?: ( authorizationUrl: string, options?: { timeoutMs?: number } ) => Promise; // Top toolbar menus (see "Toolbar menus" below). registerToolbarMenu?: (menu: GeoLibreToolbarMenu) => () => void; unregisterToolbarMenu?: (id: string) => void; // Items in the built-in menus (see "Adding items to built-in menus" below). registerMenuContribution?: ( contribution: GeoLibreMenuContribution ) => () => void; unregisterMenuContribution?: (id: string) => void; // Floating panels (see "Floating panels" below). registerFloatingPanel?: ( panel: GeoLibreFloatingPanelRegistration ) => () => void; unregisterFloatingPanel?: (id: string) => void; openFloatingPanel?: (id: string) => boolean; closeFloatingPanel?: (id: string) => void; getOpenFloatingPanels?: () => string[]; // AI Assistant tools and guidance (see "Assistant tools" below). registerAssistantTool?: (tool: Tool) => () => void; registerAssistantToolSpec?: (spec: AssistantToolSpec) => () => void; registerAssistantGuidance?: (text: string) => () => void; } // A getter re-reads the label on every render (see "Toolbar menus" below). export type GeoLibreToolbarLabel = string | (() => string); export interface GeoLibreToolbarMenu { id: string; label: GeoLibreToolbarLabel; icon?: string; // URL or data: URI items: GeoLibreToolbarMenuItem[]; } export type GeoLibreToolbarMenuItem = | { type?: "action"; id: string; label: GeoLibreToolbarLabel; icon?: string; disabled?: boolean; onSelect: () => void; } | { type: "submenu"; id: string; label: GeoLibreToolbarLabel; icon?: string; items: GeoLibreToolbarMenuItem[]; } | { type: "separator"; id?: string }; export interface GeoLibreMenuContribution { id: string; menu: "addData" | "processing" | "controls"; items: GeoLibreToolbarMenuItem[]; } export interface GeoLibreFloatingPanelRegistration { id: string; // A getter makes the title re-localize live on language changes: it is // re-evaluated on every getFloatingPanel() call, so it picks up the current // language without re-registering the panel. Caveat: the registry itself // does not subscribe to i18n events, so the getter is only re-run when a // consumer re-reads the panel. In practice every host component that renders // the title also calls useTranslation(), whose languageChanged re-render // re-reads the panel as a side effect; a host that reads the title without // that subscription would show a stale title after a language switch until // the next registry mutation, and must re-read the panel itself on language // change. A plain string is frozen at registration time. title: string | (() => string); icon?: string; // URL or data: URI defaultWidth?: number; defaultHeight?: number; // omitted: the card sizes to its content position?: GeoLibreMapControlPosition; // corner it first opens at, default "top-left" render: (container: HTMLElement) => void | (() => void); onOpen?: () => void; onClose?: () => void; } export type GeoLibreRightPanelDock = | "left-of-layers" // left of the Layers panel | "right-of-layers" // between the Layers panel and the map | "left-of-style" // between the map and the Style panel | "right-of-style" // right of the Style panel | "replace-style" // share the Style sidebar's single rail (shared-rail mode, default) | "replace-layers"; // share the Layers sidebar's single rail (shared-rail mode) export interface GeoLibreRightPanelRegistration { id: string; // A getter makes the title re-localize live on language changes: it is // re-evaluated on every getRightPanel() call, so it picks up the current // language without re-registering the panel. Caveat: the registry itself // does not subscribe to i18n events, so the getter is only re-run when a // consumer re-reads the panel. In practice every host component that renders // the title also calls useTranslation(), whose languageChanged re-render // re-reads the panel as a side effect; a host that reads the title without // that subscription would show a stale title after a language switch until // the next registry mutation, and must re-read the panel itself on language // change. A plain string is frozen at registration time. title: string | (() => string); /** Initial dock position; "replace-style" (default). */ dock?: GeoLibreRightPanelDock; /** Optional rail icon: a URL or data: URI rendered as an image. */ icon?: string; /** Preferred expanded width in px (desktop only; host-clamped). */ defaultWidth?: number; /** Deactivate the owning plugin when the user closes the panel. */ deactivatePluginOnClose?: boolean; /** Fill the panel body with your own DOM. May return a cleanup function. */ render: (container: HTMLElement) => void | (() => void); onOpen?: () => void; onCollapse?: () => void; onClose?: () => void; /** Called only on an explicit close, not when another panel displaces it. */ onExplicitClose?: () => void; } ``` ## Register a plugin ```typescript import { PluginManager } from "@geolibre/plugins"; const manager = new PluginManager(); manager.register(myPlugin); manager.activate("my-plugin", appApi); ``` ## Add a built-in plugin (in this repository) Built-in plugins ship with GeoLibre itself. Most plugins do **not** need to be built in — see [External plugins](#external-plugins) to ship one without forking GeoLibre. To add one to this repository: 1. Create a plugin file in `packages/plugins/src/plugins/`. ```typescript import type { GeoLibreAppAPI, GeoLibrePlugin } from "../types"; export const myPlugin: GeoLibrePlugin = { id: "my-plugin", name: "My Plugin", version: "0.1.0", activate: (app: GeoLibreAppAPI) => { app.setBasemap("https://example.com/style.json"); }, deactivate: () => { /* basemap remains until the user changes it */ }, }; ``` Basemap plugins deliberately leave the style they applied in place on deactivate — the built-in `osm-basemap` and `carto-light` plugins do the same, since reverting the map under the user would be more surprising than keeping what they last selected. Plugins that add controls, listeners, or layers must undo them in `deactivate()`. 2. Export it from `packages/plugins/src/index.ts`. ```typescript export { myPlugin } from "./plugins/my-plugin"; ``` 3. Register it in `apps/geolibre-desktop/src/hooks/usePlugins.ts` by adding it to the `BUILT_IN_PLUGINS` array, which is passed to `manager.registerAll`. ```typescript import { myPlugin } from "@geolibre/plugins"; const BUILT_IN_PLUGINS: GeoLibrePlugin[] = [ maplibreLayerControlPlugin, maplibreGeoEditorPlugin, // ... myPlugin, ]; manager.registerAll(BUILT_IN_PLUGINS); ``` For a MapLibre control plugin, add the package dependency, then call `app.addMapControl(control, "top-left")` in `activate()` and `app.removeMapControl(control)` in `deactivate()`. If the control's npm package ships its own stylesheet, import that stylesheet in `apps/geolibre-desktop/src/main.tsx`, alongside the existing `maplibre-gl-*/style.css` imports. That is only for a dependency's own CSS — any app-specific fixes on top of it belong in `index.css`, as described under [Styling third-party controls](#styling-third-party-controls) below. Built-in MapLibre controls such as Navigation, Fullscreen, Geolocate, Globe, Terrain, Scale, Attribution, and Logo are toggled from the desktop app's Controls menu, which also opens Search, a standalone place search panel backed by the Components plugin. Keep project-specific controls such as Layer Control and Components in the Plugins menu when they use the plugin API or need plugin lifecycle behavior. The Components plugin wraps `maplibre-gl-components` controls and wires their layer events into the GeoLibre store. It provides Add Data shortcuts for FlatGeobuf, PMTiles, Zarr, LiDAR, and Gaussian splats, while raster COG and GeoTIFF layers can also be added through the standard Add Raster Layer dialog. ### Styling third-party controls If a third-party MapLibre control needs app-specific styling fixes, add scoped overrides in `apps/geolibre-desktop/src/index.css` instead of editing files in `node_modules`. Keep selectors limited to the plugin's control class. For example, GeoEditor toolbar buttons need a local override because MapLibre's default control button CSS can override their flex centering: ```css .geo-editor-control .geo-editor-tool-button { align-items: center; display: flex !important; justify-content: center; line-height: 0; padding: 0; } .geo-editor-control .geo-editor-tool-button svg { display: block; flex: 0 0 auto; margin: 0; } ``` Run `npm run build` and `pre-commit run --files ` before submitting the change. If you also touched pages under `docs/`, build the site — CI runs `zensical build --strict`, so a broken link or a page missing from the `mkdocs.yml` `nav` fails the build. See [Contributing](contributing.md#documentation) for both gates in full. ## Built-in plugins A selection of the built-in plugins: | ID | Description | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `osm-basemap` | OpenFreeMap Liberty style | | `carto-light` | CARTO Positron GL style | | `maplibre-gl-basemap-control` | Adds a MapLibre basemap picker | | `maplibre-gl-components` | Adds the MapLibre Components control grid and panels for FlatGeobuf, COG, PMTiles, Zarr, LiDAR, and Gaussian splats | | `maplibre-gl-geo-editor` | Adds GeoEditor drawing controls | | `maplibre-gl-dimensions` | Adds Dimension tools (linear/angular CAD-style dimension lines, with optional vertex snapping) | | `maplibre-gl-geoagent` | Adds GeoAgent map assistant controls | | `maplibre-gl-usgs-lidar` | Searches and streams USGS 3DEP LiDAR point clouds | | `geolibre-ign-lidar-hd` | Searches IGN LiDAR HD tile coverage (WFS) and downloads point cloud (COPC LAZ) files. Its live-network test is opt-in via `RUN_LIVE_TESTS` (see `tests/ign-lidar-hd.test.ts`) | | `maplibre-gl-streetview` | Adds street view controls | | `maplibre-gl-swipe` | Adds map swipe controls | ## Example plugin ```typescript import type { GeoLibreAppAPI, GeoLibrePlugin } from "@geolibre/plugins"; export const myPlugin: GeoLibrePlugin = { id: "my-plugin", name: "My Plugin", version: "0.1.0", activate(app: GeoLibreAppAPI) { app.setBasemap("https://example.com/style.json"); }, deactivate() { // Clean up controls, listeners, and plugin state here. }, }; ``` Map control plugins can optionally expose `getMapControlPosition()` and `setMapControlPosition()` so the desktop Plugins menu can move the control between map corners. Position-aware plugins should remove and recreate or re-add their control when the position changes. Plugins with serializable runtime settings can expose `getProjectState()` and `applyProjectState()` so GeoLibre can save and restore those settings in the project file. A wrapper should use these hooks to adapt upstream control APIs such as `getState()` without requiring every upstream package to implement a GeoLibre-specific interface. Plugins that render with deck.gl should call `app.getDeckGL()` (returns a promise) to obtain GeoLibre's own deck.gl modules — `core`, `layers`, `aggregationLayers`, `geoLayers`, `meshLayers`, and `mapbox` (use `mapbox.MapboxOverlay` for interleaved MapLibre rendering). Render on the host's single deck.gl instance rather than bundling a second copy: deck.gl and luma.gl throw on a version mismatch and share global singletons, so a bundled copy fails to render. Call it with optional chaining (`app.getDeckGL?.()`) since a host variant may not ship deck.gl. ### Shared projection library External plugins can use `app.getProj4()` to resolve the host's proj4 module namespace. Its `default` export is the callable library: ```typescript import type * as Proj4 from "proj4"; const module: typeof Proj4 | undefined = await app.getProj4?.(); if (!module) throw new Error("This plugin requires a host with getProj4 support."); const proj4 = module.default; proj4.defs("my-plugin:utm32n", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs"); const [longitude, latitude] = proj4("my-plugin:utm32n", "EPSG:4326", [500000, 0]); ``` This returns longitude 9 and latitude 0. See the [proj4 API](https://github.com/proj4js/proj4js/blob/master/README.md) for coordinate conversion and CRS definitions. The mutable definitions registry is shared with the host (including ArcGIS/Zarr reprojection) and all plugins. Register only plugin-prefixed names, as above; do not register or redefine `EPSG:*` names or names belonging to the host or other plugins. A conflicting definition can silently corrupt subsequently created transforms. These naming rules are a plugin-author responsibility, not an isolation boundary enforced by this API. Unknown EPSG definitions are not fetched automatically. For one-off conversions, pass the definition directly instead of modifying the shared registry: ```typescript const [longitude, latitude] = proj4( "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs", "EPSG:4326", [500000, 0], ); ``` Older or variant hosts may omit `getProj4`; plugins must handle that absence. Shared access does not change `addGeoJsonLayer`'s WGS84 longitude/latitude requirement: reproject source coordinates before adding the layer. Omit proj4's runtime import and dependency from the plugin bundle when using this API; type-only references, such as the namespace import above, need no runtime copy. Plugins can also declare URL query parameters and handle them when GeoLibre opens. URL parameter handlers run after the map is ready, external plugins are loaded, and project plugin state has been restored. GeoLibre calls handlers for plugins whose declared parameter names are present in the URL, and it suppresses repeated handling of the same URL context for the same plugin. If a matching plugin is registered (installed) but inactive, GeoLibre first attempts to activate it via `PluginManager.activate`; the handler runs only if activation succeeds (an `activate()` that returns `false` or throws leaves the plugin inactive and skips dispatch). Parameter names are case-sensitive, as URL query parameters are: declaring `exampleGeoJson` will not match `?ExampleGeoJson=…`. ```typescript import type { GeoLibreAppAPI, GeoLibrePlugin } from "@geolibre/plugins"; export const plugin: GeoLibrePlugin = { id: "example-url-loader", name: "Example URL Loader", version: "0.1.0", urlParameterNames: ["exampleGeoJson"], activate() { // Set up controls or plugin state here. }, deactivate() { // Clean up controls, listeners, and plugin state here. }, async handleUrlParameters(app: GeoLibreAppAPI, params: URLSearchParams) { for (const dataUrl of params.getAll("exampleGeoJson")) { // URL parameter values are attacker-controlled: only fetch HTTPS URLs // and verify the origin is one you trust before loading. Parsing the // value rejects malformed URLs, and the protocol check blocks // non-HTTPS schemes (file://, data:, http://); neither protects // against SSRF to loopback or private-network addresses. let parsedUrl: URL; try { parsedUrl = new URL(dataUrl); } catch { continue; } if (parsedUrl.protocol !== "https:") continue; const response = await fetch(parsedUrl.href); if (!response.ok) continue; app.addGeoJsonLayer("Example URL layer", await response.json(), dataUrl); } }, }; ``` Validate URL parameter values before acting on them. Anyone can craft a link to GeoLibre, so handlers that fetch a parameter value should reject unexpected schemes (`file://`, `data:`, plain `http://`) and only contact origins they trust. For example: ```text https://web.geolibre.app/?url=https://example.com/project.geolibre.json&exampleGeoJson=https://example.com/data.geojson ``` 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. ## Read-only layer and feature queries External plugins can inspect the current layer list, a layer's GeoJSON features, the current feature selection, and features in GeoEditor's Sketches layers. The methods are optional so the same plugin remains compatible with older hosts. ```typescript const layers = app.listLayers?.() ?? []; const selectedLayerId = app.getSelectedLayerId?.() ?? null; const selectedFeatures = app.getSelectedFeatures?.() ?? []; if (selectedLayerId && app.getLayerFeatures) { const layerFeatures = app.getLayerFeatures(selectedLayerId); console.log(layers, layerFeatures, selectedFeatures); } const drawnFeatures = app.getDrawnFeatures?.() ?? []; ``` `getSelectedFeatures` returns every selected feature in the selected layer, not only the most recently selected feature. Features without a GeoJSON `id` are matched using their zero-based array index converted to a string. An empty selection returns an empty array. `getLayerFeatures` throws when the layer id is unknown and returns an empty array for a layer that has no GeoJSON features. Selection subscriptions fire after the selected layer or selected feature-id array changes. Keep and call the returned unsubscribe function during plugin deactivation: ```typescript const unsubscribe = app.onSelectionChange?.(({ layerId, features }) => { console.log(layerId, features); }); // In deactivate or another cleanup path: unsubscribe?.(); ``` These methods are a read-only query surface: calling them does not change the GeoLibre store. Plugins must also treat returned GeoJSON features as read-only and use host APIs such as `addGeoJsonLayer` when they need to add data. ## Active map click tool Plugins that handle map clicks or hover can avoid presenting their own interaction while a GeoLibre map tool owns the pointer. The optional live getter and subscription cover Identify, feature selection and the GeoEditor across map renderers; they do not depend on the cursor style or saved project snapshot. ```typescript const currentTool = app.getActiveMapTool?.() ?? null; if (currentTool !== null) closePluginPopup(); const unsubscribe = app.onActiveMapToolChange?.((tool) => { if (tool !== null) closePluginPopup(); }); function handleMapClick(event: unknown) { if ((app.getActiveMapTool?.() ?? null) !== null) return; openPluginPopup(event); } function handleMapHover(event: unknown) { if ((app.getActiveMapTool?.() ?? null) !== null) return; showPluginTooltip(event); } // In deactivate or another cleanup path: unsubscribe?.(); ``` `getActiveMapTool` returns `"identify"` while Identify is enabled, `"feature-selection"` while a selection gesture owns map clicks, `"geo-editor"` while the GeoEditor plugin is on (its toolbar is on the map, so any click may draw, place a vertex or pick a feature to edit, including while a layer is in "Edit geometry"), and `null` otherwise. When states overlap, feature selection takes precedence over Identify, and both take precedence over the GeoEditor. Changing the Identify target does not emit a tool change. Subscribers receive only changes after registration, not an initial callback; read the getter to initialize plugin state. Keep and call the returned unsubscribe function on deactivation. Replacing one active selection gesture with another keeps `"feature-selection"` active without an intermediate `null`. Both methods are optional so plugins remain compatible with older hosts. ## GeoEditor sessions A plugin whose layer users edit in the GeoEditor can follow those sessions, for example to offer to sync the changes to a server when the user finishes, and can open one for the user. ```typescript const unsubscribe = app.onEditSessionChange?.((change) => { // `saved` is only on an "ended" change. if (change.layerId === myLayerId && change.state === "ended" && change.saved) { offerToSync(app.getLayerFeatures?.(myLayerId) ?? []); } }); // From a "Draw the location" button in the plugin's panel: if (!(await app.startEditSession?.(myLayerId))) { showMessage("This layer can't be edited here."); } ``` - **`getEditSessionLayerId`** returns the layer open in the GeoEditor, or `null`. - **`onEditSessionChange`** reports `{ layerId, state: "started" }`, and `{ layerId, state: "ended", saved }` when the session ends. `saved` is only on an `"ended"` change, and is `false` when the user cancelled, or the layer was removed mid-session. A callback that throws is logged, and still gets the next change (such as the new session's start on a switch). Switching to another layer reports the old session's end, then the new one's start. It doesn't call back immediately; read `getEditSessionLayerId` for the current state. - **`startEditSession`** opens a session the way a layer's **Edit geometry** action does, activating the GeoEditor if it's off and saving an open session on another layer first. It resolves `false` on a renderer the editor doesn't support, for a layer that isn't an editable vector layer, or if the GeoEditor can't be activated. Overlapping calls run one after another. Starting saves and ends any open session first, so after overlapping calls the open session is the last call's, but only if that call resolved `true`. A call still waiting for earlier ones after a minute gives up and resolves `false`. All three are optional, so call them with optional chaining for older hosts. ## Updating and removing layers A plugin that shows data held elsewhere, such as records in a remote service, can keep its layer current without removing and re-adding it. `updateGeoJsonLayer` replaces a GeoJSON layer's features, as saving a GeoEditor session or refreshing a layer does: the layer keeps its id, name, place in the layer order, group, style and visibility. `removeLayer` removes a layer the way the Layers panel does. ```typescript const layerId = app.addGeoJsonLayer("Survey records", await fetchRecords()); async function refresh() { // false when the layer is gone, isn't GeoJSON, or is being edited. if (!app.updateGeoJsonLayer?.(layerId, await fetchRecords())) { // Retry after the edit session ends, or tell the user. } } function deactivate() { app.removeLayer?.(layerId); } ``` - **Edit sessions win.** While the layer is open in the GeoEditor, `updateGeoJsonLayer` returns `false` and changes nothing, so the user's unsaved edits aren't overwritten. - **Removal is complete.** `removeLayer` clears the selection, legend and other references to the layer, and ends a GeoEditor session on it without saving. Unknown ids are ignored. - Both are optional, so call them with optional chaining for older hosts. ## Layer groups A plugin that adds several related layers can put them in a Layers-panel group (a folder) instead of leaving them loose at the panel root, and can nest one group inside another the same way a user can by hand. ```typescript // Create a folder, optionally moving existing layers into it. Returns its id. const basins = app.addLayerGroup?.("Basins", [catchmentLayerId]) ?? null; // Append to a folder you created earlier rather than creating a second one // with the same name. A null group id lifts the layers back to the root. app.moveLayersToGroup?.([outletLayerId], basins); // Nest a folder inside another one. A null parent lifts it back to the root. const subBasins = app.addLayerGroup?.("Sub-basins"); if (basins && subBasins) app.moveLayerGroupToGroup?.(subBasins, basins); // Remove the folder without removing the layers inside it. if (subBasins) app.removeLayerGroup?.(subBasins); ``` `moveLayerGroupToGroup` is the group-of-groups counterpart of `moveLayersToGroup`: the same reparenting the Layers panel's own "Move to group" menu performs. It is a no-op when either id is unknown, when the group is already in that parent, and when the move would make a group its own ancestor — the host refuses the cycle rather than corrupting the tree, so a plugin does not have to walk the parent chain itself. `addLayerGroup` is the only source of group ids for folders a plugin creates. To address a folder it did not create — one the user made, or one another plugin made — read the tree first: ```typescript const groups = app.listLayerGroups?.() ?? []; const existing = groups.find((group) => group.name === "Basins"); const roots = groups.filter((group) => group.parentId === null); ``` `listLayerGroups` returns every group with `parentId` set to the enclosing group's id, or `null` at the root, so the flat array describes the whole folder tree. It is the store's own group order, which is not the panel's: the panel re-orders a group after its parent for display, while a reparent leaves the array alone. Like the other read-only queries, calling it does not change the store. Group visibility and opacity are **combined** with each child layer's own: a hidden group hides its children on the map without touching their individual `visible` flags, and group opacity multiplies into each child's. `removeLayerGroup` through this API removes only the folder, never its contents: the layers it held move to the panel root, and any groups nested inside it are reparented to the removed folder's own parent. These methods are typed optional for forward-compatibility with host variants, so call them with optional chaining. ## Layer visibility `setLayerVisibility` shows or hides a layer the way the Layers panel's eye button does. It updates the layer store, so the eye icon, the map and a saved project all agree; hiding the MapLibre layer directly would leave the panel showing it as visible until the next sync turned it back on. It accepts any layer id, not only the plugin's own, throws for an unknown one, and throws a `TypeError` when `visible` is not a boolean. Read the current state with `listLayers`. ```typescript // After turning a raster into outlines, show only the outlines. app.setLayerVisibility?.(rasterLayerId, false); ``` ## Layer styles `importLayerStyle` applies a style written in another format to a layer, like the Layers panel's "Import style": an OGC SLD, a QGIS QML or a Mapbox GL style JSON, detected from the content. The style is merged over the layer's current one, so fields it does not describe keep their values, and it is saved with the project. Like the Layers panel, it styles only GeoJSON and vector-tile layers; it accepts any such layer id, not only the plugin's own, and throws for an unknown one. A plugin that adds features from a web service can dress them as the service does. A GeoServer, for example, returns a layer's SLD for WMS `GetStyles`: ```typescript const layerId = app.addGeoJsonLayer("Land cover", features); const sld = await( await fetch( `${wmsUrl}?service=WMS&version=1.1.1&request=GetStyles&layers=M5:L4` ) ).text(); const result = app.importLayerStyle?.(layerId, sld); if (result && !result.ok) console.warn(`Style not applied (${result.reason})`, result.warnings); ``` `result.warnings` lists what the style asked for that GeoLibre could not represent. On failure, `reason` is `invalid` when the text is not a style in any format read, `no-match` when it parsed but describes no symbology the layer can wear, `unsupported-layer` when the layer is not a vector layer; the layer is left untouched. ## Raster and tile layers `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). ```typescript export interface GeoLibreTileLayerOptions { tileSize?: number; // default 256 attribution?: string; bounds?: [number, number, number, number]; // [west, south, east, north] in WGS84 minzoom?: number; maxzoom?: number; scheme?: "xyz" | "tms"; // default "xyz" visible?: boolean; // default true opacity?: number; // default 1 beforeLayerId?: string; // insert beneath this layer metadata?: Record; // provenance merged into layer.metadata } export interface GeoLibreWmsLayerOptions extends GeoLibreTileLayerOptions { url: string; // WMS GetMap endpoint layers: string; // comma-separated layer name(s) styles?: string; format?: string; // default "image/png" transparent?: boolean; // default true version?: string; // "1.1.1" (default) or "1.3.0" (sends CRS instead of SRS) crs?: string; // "EPSG:3857" (default), "EPSG:4326", "CRS:84" (1.3.0 only), any "EPSG:" queryable?: boolean; // false when the capabilities mark the layers queryable="0": identify skips it } export interface GeoLibreWfsLayerOptions { url: string; // WFS GetFeature endpoint typeName: string; // advertised feature type version?: string; // defaults to "2.0.0" bbox?: [number, number, number, number]; // [west, south, east, north] in WGS84 maxFeatures?: number; // positive integer, default 1000; sent as count (WFS 2.x) or maxFeatures metadata?: Record; // provenance merged into layer.metadata } export interface GeoLibreCogLayerOptions { engine?: "maplibre-gl-raster" | "cog-tiler-wasm" | "titiler" | "auto"; // see below bands?: string; // "1" (single band) or "1,2,3" (RGB) colormap?: string; // named colormap for a single-band COG, e.g. "terrain" rescaleMin?: number; rescaleMax?: number; nodata?: number; // pixel value rendered transparent opacity?: number; // default 1 beforeLayerId?: string; zoomTo?: boolean; // fit the map to the COG once loaded (default true) } ``` ```typescript // XYZ tiles (e.g. an imagery, topo, or DEM hillshade endpoint). app.addTileLayer?.( "LINZ Aerial Imagery", "https://tiles.example.nz/aerial/{z}/{x}/{y}.png", { attribution: "Sourced from LINZ. CC BY 4.0", maxzoom: 22, bounds: [166.0, -47.5, 178.6, -34.0], } ); // WMTS tile URL template. app.addWmtsLayer?.( "LINZ Topo50", "https://tiles.example.nz/topo50/{z}/{x}/{y}.png" ); // WMS — pass the request parameters; the host builds the GetMap tile URL. app.addWmsLayer?.("LINZ Coverage", { url: "https://wms.example.nz/wms", layers: "coverage", transparent: true, }); // A WMS that does not offer EPSG:3857: request a CRS it lists instead. app.addWmsLayer?.("Cadastral parcels", { url: "https://wms.example.it/wms", layers: "parcels", version: "1.3.0", crs: "EPSG:6706", }); // The capabilities mark the layer queryable="0": identify skips it. app.addWmsLayer?.("Buildings", { url: "https://wms.example.it/wms", layers: "buildings", queryable: false, }); // A layer found in a catalogue: keep its provenance with it. app.addWmsLayer?.("Bathymetry", { url: "https://wms.example.org/wms", layers: "bathymetry", metadata: { catalogRecordId: "rndt:abc-123", catalogRecordUrl: "https://catalog.example.org/records/abc-123", publisher: "Example Hydrographic Office", license: "CC BY 4.0", }, }); // COG — read the GeoTIFF directly (client-side), with raster controls. const cogId = await app.addCogLayer?.( "LINZ DEM", "https://cog.example.nz/dem.tif", { colormap: "terrain", nodata: -9999 } ); ``` `addTileLayer`, `addWmtsLayer`, `addWmsLayer`, and `addWfsLayer` take an optional `metadata` object, merged into the new layer's `metadata`. Use it to leave a catalogue layer's provenance with it (the record id, a link to its metadata page, the publisher, the licence): it is shown in the layer's Metadata dialog and saved with the project. It must be a plain, JSON-serializable object, or the call throws (`addWfsLayer` rejects). GeoLibre's own keys win over a field of the same name, and credential-named fields (`token`, `apiKey`, ...) are stripped when the project is shared or exported. The Metadata dialog also shows the service address and request fields GeoLibre keeps on a service or tile layer's `source` (for WMS: `url`, `layers`, `styles`, `format`, `version`, `crs`, and `queryable` when false), with credentials removed. WFS layers use the host's GetFeature loader, including GeoJSON/GML fallback, reprojection, desktop native HTTP, and refresh. `addWfsLayer` resolves with the new layer id and rejects if loading fails or the service returns no features. Saved projects normally keep the request URL rather than embedding the downloaded collection, and reopening fetches it again. Exception: if saving strips credentials from the URL, the fetched collection is embedded so the layer remains visible without storing the secret; it is not refetched from the sanitized URL. The optional bbox is WGS84 `[west, south, east, north]` and must not cross the antimeridian (`west` must not exceed `east` — a Pacific-spanning box throws); `maxFeatures` (default 1,000) caps the request and is stored in the GetFeature URL, so refreshes and projects that retain the URL use the same limit. `addWfsLayer` requires an absolute HTTP(S) URL and sends its request through the same host-managed WFS path Add Data uses: the desktop app fetches through the native HTTP client (bypassing CORS) and the web build through its development proxy. Credentials in the URL are never written to diagnostics or a saved project: the native diagnostics log records userinfo and credential query values (including AWS `x-amz-*` parameters) as `[redacted]`, and a save strips the URL's userinfo and credential parameters while keeping the fetched collection embedded so it stays visible without persisting the secret. The example uses a hostname, which the desktop app and the Vite development server accept. Any destination your plugin chooses is fetched from the user's own browser or desktop network position, so treat the URL as untrusted input and do not embed credentials you would not want a shared project or diagnostics log to reveal. ```typescript const layerId = await app.addWfsLayer?.("Roads", { url: "https://services.example.org/geoserver/wfs", typeName: "transport:roads", bbox: [10, 40, 12, 42], maxFeatures: 5000, }); ``` `options.engine` picks the renderer (`"maplibre-gl-raster"` for the GPU/deck.gl path, `"cog-tiler-wasm"` for the WebAssembly tiler, `"titiler"` for a TiTiler server). Unlike the other options it is **not per layer**: the raster control holds one engine for every raster it manages, so naming one re-renders the rasters already on the map. Pass `"auto"` to leave whatever the control is on alone; omit it and the GPU renderer is used. The GPU renderer requires a Mercator projection, so a plugin that expects to work on the globe should ask for `"cog-tiler-wasm"`. `addWmsLayer` requests Web Mercator tiles unless `crs` names another CRS, for a server whose capabilities do not list EPSG:3857. Pick one the layer lists, preferably geographic (`EPSG:4326`, `EPSG:4258`, `EPSG:6706`, or `CRS:84` with `version: "1.3.0"`), otherwise a projected `EPSG:` such as `EPSG:25832`. Only the desktop app redraws those tiles into Web Mercator: the web build still sends the Web Mercator BBOX, so such a layer stays blank there. An unsupported value, or `CRS:84` with WMS 1.1.1, throws. `addTileLayer`/`addWmtsLayer`/`addWmsLayer` expect **pre-rendered tiles** (e.g. a COG already served through a tiler such as titiler as an XYZ endpoint). `addCogLayer` is different: it loads the **GeoTIFF itself** and renders it client-side, exposing band selection, rescale, colormap, and nodata in the raster panel. It is async (it fetches the file's header), so it returns a `Promise` and rejects if the COG cannot be read. The helpers are typed optional for forward-compatibility with host variants, so call them with optional chaining (`app.addTileLayer?.(...)`). > **Desktop (Tauri) note:** The desktop app enforces a Content Security Policy. Its `connect-src` allows any `https:` or `http:` host, so tile and data requests from a plugin reach their server, but `script-src` only allows the app itself, `blob:` URLs and a short list of version-pinned CDN paths. A plugin that injects a `