Commit Graph
98 Commits
Author SHA1 Message Date
ffe2665a3b feat(plugins): let plugins set a layer's visibility (#3182)
* feat(plugins): let plugins set a layer's visibility

Adds setLayerVisibility(layerId, visible) to the external plugin API, delegating to the
store action the Layers panel's eye button, the embed API and the AI Assistant already use,
so the panel, the map and a saved project stay in step. Throws for an unknown layer id, like
getLayerFeatures and importLayerStyle.

Plugins could read visibility through listLayers but not set it, so a plugin hiding one of
its own layers (e.g. a raster it has just vectorised) had to hide the MapLibre layer behind
the store's back, leaving the eye icon wrong until the next sync re-showed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(plugins): reject a non-boolean visibility in setLayerVisibility

An untyped JavaScript plugin could pass e.g. the string "false", which is truthy and would
leave a non-boolean in layer state. Throw a TypeError instead, as the embed API validates
its own setLayerVisibility. Requested in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: agraham <agraham@aslenv.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-22 17:44:31 -04:00
Qiusheng Wu f8cf4a4a3a feat(plugins): pass the cancel signal to assistant tool specs (#3161)
* feat(plugins): pass the cancel signal to assistant tool specs

registerAssistantToolSpec dropped the SDK's per-call context, so a plugin
tool waiting on the network kept the assistant busy after Stop and the
next message failed with "already in progress". The spec callback now
gets an optional second argument, { signal }, wired to the tool
context's cancelSignal; existing one-argument callbacks are unaffected.

Closes #3155

* test: type the spec helper callback with the optional context

* Address Claude review feedback

- Fall back to a never-aborting signal when the tool context has no
  cancelSignal: the fast path (runToolDirectly) invokes tools with a
  context built from toolUse alone, so plugins would have received
  signal: undefined and thrown on signal.aborted.
- Test that a spec callback gets a live AbortSignal without one.
2026-10-19 23:43:24 -04:00
Qiusheng Wu 12527f86b2 feat(assistant): let the assistant turn on an installed plugin and use its tools in the same turn (#3160)
* feat(assistant): let the assistant turn on an installed plugin

Plugin tools exist only while their plugin is on, so the assistant could
not reach an installed plugin that was off, and a plugin turned on during
a turn only showed its tools at the next prompt.

Plugins now declare `assistant: { description }` (in plugin.json or on
the plugin object). While such a plugin is installed and off, the
assistant gets an `activate_plugin` host tool listing it. Calling it
turns the plugin on the way the Plugins menu does, adds the tools it
registers to the live agent so they are callable in the same turn, and
returns them with the plugin's guidance.

Fixes #3156

* Address review feedback

- Keep polling after a plugin's first tool appears, until its tool list
  has been unchanged for 250 ms, so tools registered in several steps
  all reach the live agent in the same turn (CodeRabbit). Always waiting
  the full timeout, as suggested, would stall every activation 3 s.
- Collapse whitespace in plugin ids, names and descriptions before they
  go into the activate_plugin description, so a description cannot
  forge list entries or lines (Claude review).
- Document the wait, including the 3 s stall for a plugin that
  registers no tools.

* Address CodeRabbit review feedback

- State that 3 s is the total upper bound on waiting for a plugin's
  tools, with the 250 ms quiet period inside it, and rewrap the line.
2026-10-19 21:51:56 -04:00
Rob Gaston 39dde26e45 feat(map): request headers for authenticated tile services (#3142)
* feat(map): request headers for authenticated tile services

MapLibre takes one request transform per map, so a plugin that needs a
header on its tile requests has to call setTransformRequest itself and
replaces anyone else's (GeoLens did, for private rasters). GeoLibre now
builds its MapLibre maps with a single transform backed by a header
registry in @geolibre/map/tile-request-headers, keyed by owner and matched
by URL prefix, and GeoLens registers its API keys there.

Plugins get a headers option on addVectorTileLayer and
setLayerRequestHeaders for any tile layer, so a token refresh needs no
reload. Headers stay in memory: they never reach the store or a saved
project, and opening another project or removeLayer drops them. Only
MapLibre maps send them.

Closes #3127

* fix(map): keep tile request headers to their own server and layer

- A URL prefix must now name one host and the start of a path on it before
  it can carry headers. A template with a placeholder in its host matched on
  "https://", and a prefix without its trailing slash matched look-alike
  hosts, so a layer's credentials could reach basemaps and other servers.
  The registry throws for such a prefix, and setLayerRequestHeaders and
  addVectorTileLayer say why.
- A layer's headers are dropped when the layer leaves the store, however it
  is removed (the Layers panel, undo, a plugin), not only through the plugin
  API's removeLayer.
- Header names given twice in different cases are rejected, since fetch
  would join their values into one.

* fix(map): drop tile headers when a layer's URL moves, and match fetch's rules

- A layer's headers are recorded with the tile URL prefixes they were set
  for, and dropped when the layer's URL moves to other prefixes, so a new
  server doesn't inherit them and the old prefix doesn't keep them. A token
  refresh that only changes the query string after the placeholders keeps
  the same prefixes and the headers.
- Header validation now also rejects what fetch won't send: names the
  browser sets itself (Cookie, Host, Sec-*, Proxy-* and the rest of the
  Fetch standard's forbidden names), which fetch silently drops, and values
  with characters above U+00FF, which make fetch throw inside MapLibre.
- GeoLens checks the renderer before registering a private raster's API
  key, so the key isn't left registered when the call refuses.
2026-10-14 21:54:07 -04:00
Rob Gaston 7106dd6911 fix(plugins): keep plugin metadata from rerouting a vector tile layer (#3143)
* fix(plugins): keep plugin metadata from rerouting a vector tile layer

The map decides how to draw a layer from metadata keys such as sourceKind
and customLayerType. A plugin passing sourceKind "maplibre-gl-vector" and a
customLayerType in options.metadata sent its layer down the control-owned
path, where nothing drew it. The layer now gets its own sourceKind,
"plugin-vector-tiles", after the plugin's metadata, and the other
render-routing keys are dropped from it.

* test(plugins): cover every render-routing metadata key

The addVectorTileLayer test now passes all seven keys it drops from plugin metadata (sourceIds, externalDeckLayer, controlOwnsPaint and nativeFillExtrusion as well) and checks each is removed.

* fix(plugins): drop metadata.sourceId from vector tile layer metadata

MapController and the layer sync read the singular sourceId as well as sourceIds, for instance to tell basemap sources apart, so a plugin setting it to a basemap source's id could affect basemap handling. It is now dropped with the other render-routing keys, and the test and docs list it.
2026-10-14 21:53:03 -04:00
Rob Gaston de62b66163 feat(plugins): GeoEditor session events and startEditSession for plugins (#3138)
* feat(plugins): GeoEditor session events and startEditSession for plugins

Plugins couldn't tell when a GeoEditor session on their layer ended, or
start one. A plugin that syncs edits to a server had to infer a session's
end from the layer's visibility (hidden during a session, shown after),
which can't tell Save from Cancel and fires when the user toggles the
layer by hand (#3125).

- getEditSessionLayerId reports the layer being edited.
- onEditSessionChange reports sessions starting and ending, with whether
  the edits were saved. The GeoEditor records how its last session ended
  (getLastGeometryEditEnd) before notifying, covering save, cancel, and a
  layer removed mid-session.
- startEditSession opens a session as a layer's Edit geometry action does.
  That logic moves from useLayerEditActions into
  lib/geometry-edit-session.ts, shared by the Layers panel and the plugin
  API; it takes the GeoEditor functions as arguments, so app-api.ts still
  doesn't import plugin runtime code.

Closes #3125

* fix(plugins): serialize edit-session starts and report same-layer restarts

Overlapping startEditSession calls could end the first session while it was
still loading its features, so the first load might finish against the
second session. Starts now run one after another, on the Layers panel path
too.

onEditSessionChange also compares the last end record as well as the layer
id, so a session that ends and restarts on the same layer between two
notifications is still reported as an end and a start.

* fix(plugins): skip GeoEditor activation for a missing layer

startEditSession on a layer id that isn't in the project now resolves false
straight away instead of loading and activating the GeoEditor first. The
docs now say the session left open after overlapping starts is the last
call's only when that call succeeds.

* fix(plugins): report a GeoEditor activation that throws as activation-failed

PluginManager.activate rethrows when a plugin's activate() throws, so
startEditSession rejected instead of resolving false as documented, and the
Layers panel's Edit geometry action left an unhandled rejection. The shared
start now reports it as activation-failed, which the panel shows as its
existing error.

* fix(plugins): only report saved for an end recorded with the session

onEditSessionChange now reads saved from an end record only if it was written since the last notification, so a session that ends on a path that records nothing can't inherit an earlier session's saved value on the same layer.

* fix(plugins): don't report a session as saved when its layer is gone

syncEditTargetToStore returned silently when the target layer had been removed, and endLayerGeometryEdit still recorded the session as saved. It now returns whether it wrote the edits, and that is what the end record reports. A comment next to lastGeometryEditEnd states that every end path records before notifying, which onEditSessionChange relies on.

* fix(plugins): isolate session listeners and wait for the sketches restore

A geometry edit listener that threw (a plugin's onEditSessionChange
callback) stopped the rest from being notified, so the Layers panel could
miss a session ending. Each listener now runs on its own, and a failure is
logged.

A session ends and releases its layer before its sketches are restored, so a
start arriving in that gap (from a plugin, while the user pressed Save) ran
alongside the restore. A new session now waits for any restore in progress.

* refactor(plugins): make GeoLibreEditSessionChange a discriminated union

saved is now present exactly on an "ended" change, so plugins narrow on state instead of checking an optional field. The docs example narrows the same way, and the docs say a throwing callback is logged.

* fix(plugins): deliver each edit-session change to a callback separately

On a switch, onEditSessionChange delivers the old session's end and the new one's start in one notification. A callback that threw on the end never got the start, and its bookkeeping had already moved on, so that start was lost. Each change is now delivered on its own, and a throw is logged.

* fix(plugins): stop queued edit-session starts waiting forever

Starts run one at a time, so a start that never finished (a stalled editor
package load, say) kept every later one waiting, including the Layers
panel's Edit geometry toggle. A start that has waited a minute for earlier
ones now gives up with start-failed. A running start is still never
abandoned, and later starts keep waiting for it, so two starts never run at
once.

* fix(plugins): report a session start that throws as start-failed

startLayerGeometryEdit can reject while it ends the previous session (its write-back or sketches restore), so startEditSession rejected instead of resolving false and the Layers panel skipped its error message. The shared start now logs the error and reports start-failed.
2026-10-14 19:29:55 -04:00
Rob Gaston 5cf70dccd8 feat(plugins): add addVectorTileLayer to the plugin API (#3140)
* feat(plugins): add addVectorTileLayer to the plugin API

Plugins can add raster tile layers but not vector tile layers, so the
GeoLens plugin builds the store record itself and external plugins can't.
addVectorTileLayer takes a tile URL template or a TileJSON URL and the
source layers to draw, and adds the same record Add Data -> OGC Vector Tiles
builds: it appears in the Layers panel, takes the Style panel's controls,
and saves with the project.

Part of #3127. Request headers for authenticated tile services follow in a
separate change.

* fix(plugins): validate addVectorTileLayer's options more strictly

- A missing options object now throws the sourceLayers error rather than a
  raw TypeError.
- A URL with some but not all of {z}, {x} and {y} throws, instead of being
  read as TileJSON and failing when the map loads it.
- opacity must be a number from 0 to 1, and a NaN zoom is rejected.

* fix(plugins): check addVectorTileLayer's extent and TileJSON zoom options

- bounds must lie within WGS84 limits with west <= east and south <= north,
  so MapLibre's addSource can't reject it after the layer is in the store.
- minzoom and maxzoom throw for a TileJSON URL, where MapLibre takes the
  zoom range from the TileJSON; bounds is still kept for Zoom to layer.
- Duplicate source layer names are listed once.

* refactor(plugins): tidy addVectorTileLayer's import and source layer check

Sort the import with the other plugin-* modules, drop the array cast in validateSourceLayers, and note in the docs which tile URL placeholders are recognized.

* fix(plugins): copy addVectorTileLayer's bounds and source layers

The layer record kept the plugin's own bounds array, so a plugin that changed it later changed the stored layer outside the store. Both arrays are now copied. A test also covers a beforeLayerId that isn't a layer: the layer is added on top and the id is kept as a map anchor, as for the other add*Layer methods.

* fix(plugins): reject a blank or non-string addVectorTileLayer name

The name is the Layers panel label and is saved with the project, so a plain-JavaScript plugin passing a blank or non-string value now gets an error like the other options instead of an unlabelled layer.

* test(plugins): cover addVectorTileLayer's URL trimming

A TileJSON URL with surrounding whitespace is now tested to be stored trimmed, and a comment explains why source and metadata get separate copies of the arrays.
2026-10-14 19:28:25 -04:00
Rob Gaston a1c00ad86a feat(plugins): OAuth authorization-code sign-in for plugins (#3139)
* feat(plugins): OAuth authorization-code sign-in for plugins

Add app.getOAuthRedirectUri and app.authorizeInPopup so a plugin can sign
users in with OAuth 2.0 (authorization code with PKCE) without its own
callback page or deep-link handling. The host runs only the round trip: a
popup through oauth-callback.html on the web, the system browser and the
custom-scheme callback on the desktop. The plugin builds the request, keeps
its PKCE verifier and exchanges the code itself.

The popup wait used by ArcGIS sign-in moves to lib/oauth-popup.ts so both
flows share the origin, source and state checks.

Closes #3126

* fix(plugins): refuse a second OAuth sign-in before opening its popup

The popup has a fixed name, so a second authorizeInPopup call during a
sign-in got the first one's popup back from window.open, navigated it away
and closed it, cancelling the first sign-in. The request, the pending flag
and timeoutMs are now checked before window.open, so a second call rejects
with already-pending and leaves the first alone, and an invalid request
never opens a popup.

timeoutMs must be a positive number of milliseconds within setTimeout's
range, or the call rejects with invalid-request instead of timing out at
once. On the desktop, a failure to open the system browser rejects with the
new launch-failed reason rather than a raw error.

* fix(plugins): close the OAuth popup when a plugin sign-in fails

After a timeout, a denial or a bad callback the popup stayed on the
provider's page, and the next sign-in picked it up again by its fixed name.
It is now closed when the sign-in fails.

PluginOAuthFailure is now an alias of the public GeoLibreOAuthFailure rather
than a second copy of the union, and the desktop callback failures map
through a lookup instead of a nested ternary.
2026-10-14 18:42:14 -04:00
Rob Gaston e908594a10 feat(plugins): honor expected-status markers in nativeFetch diagnostics (#3124)
* feat(plugins): honour expected-status markers in nativeFetch diagnostics

Plugins' app.nativeFetch recorded every response of 400 or above as an
error in Diagnostics, even when the plugin expected it, such as the
400 authorization_pending answers an OAuth device sign-in polls through
(RFC 8628). The webview's fetch capture already lets a request mark
expected outcomes with the client-only x-geolibre-expected-status and
x-geolibre-optional-resource headers.

nativeFetch now honours the same markers: listed statuses, and any
failure of an optional request, are recorded as info, and the markers
are stripped before the request goes to the native client. Both markers
are documented in the plugin API guide.

* docs(plugins): document diagnosticRecord and installDiagnosticsCapture
2026-10-13 22:48:12 -04:00
Rob Gaston cc82ef4705 feat(plugins): add updateGeoJsonLayer and removeLayer to the plugin API (#3128)
* feat(plugins): add updateGeoJsonLayer and removeLayer to the plugin API

A plugin that shows data held elsewhere had no way to replace a layer's
features or remove a layer: it could only add layers. To show a remote
service's current data it had to remove the layer through
unregisterExternalNativeLayer, which removes any layer although it's meant
for plugin-registered native layers, and add a new one, losing the layer's
place in the order, group, style and visibility.

updateGeoJsonLayer replaces a GeoJSON layer's features through the store's
updateLayer, as saving a GeoEditor session or refreshing a layer does. It
returns false, changing nothing, for an unknown or non-GeoJSON layer, or one
open in the GeoEditor, so unsaved edits aren't overwritten. removeLayer
removes a layer through the store's removeLayer, as the Layers panel does.

* fix(plugins): clear a native layer's paint bridge when removeLayer removes it

removeLayer removed the store record but left a plugin-painted layer's paint bridge registered, since bridges live outside the store. unregisterExternalNativeLayer already cleared it; removeLayer now does too.
2026-10-13 22:46:50 -04:00
Qiusheng Wu b81b19b630 feat(cesium): recording control facade so plugin panels run on the globe (#3090)
* feat(cesium): recording control facade so plugin panels run on the globe

Port the ArcGIS control host's model to the Cesium globe (#3088). The
MapLibre-shaped facade a plugin control receives on the globe now records
its Style Spec calls into a shadow style instead of throwing: a layer the
plugin mirrors into the store is drawn by CesiumLayerSync, and any other
GeoJSON fill, line, circle or text layer is drawn as ground-clamped globe
entities. Layer-scoped events and queryRenderedFeatures answer from the
mirroring store layer or the overlay graphics, and dragPan/scrollZoom
switch the globe's own camera inputs.

- Move the pieces both hosts share into neutral modules
  (control-layer-events, shadow-overlay, geometry-hit) and share the
  store-mirror lookup (controlLayerMirrors).
- Overlay lines and polygon edges use rhumb arcs, matching MapLibre's
  straight Mercator segments; a geodesic ground polyline spanning the
  globe (a graticule parallel) did not draw at all. Points and labels
  past the horizon are hidden instead of drawing through the Earth.
- Carry text-anchor/text-offset in the overlay's text symbol, honoured
  by both hosts.
- Read a raster record's {level}/{row}/{col} tile template from
  source.url on the globe, as ArcGIS does (Esri Wayback).
- Graticule edge labels anchor to the screen edges on a globe projection.
- getControlMap(app) resolves the globe facade (getCesiumControlMap).

Opt in, each verified in the running app: Gridlines, the grid-factory
DGGS grids (H3, S2, A5, OLC, Geohash, Tilecode), DGGAL, DGGRID, FEMA
NFHL, USGS National Map, US EPA EnviroAtlas, NASA Earthdata and Esri
Wayback.

* feat(cesium): STAC, catalogs and COGs on the globe; guard GPU controls

Continue #3088.

- Refuse custom layers on the globe facade: a recording facade let
  maplibre-gl-raster's control mount on the globe, and the first COG add
  hung the tab. COG adds on the globe now import as store `cog` records
  (native-raster-import.ts, shared with ArcGIS).
- Re-dispatch the mousedown/mousemove/mouseup the browser drops after
  Cesium cancels pointerdown, so drag-drawing controls (the STAC bbox)
  receive the drag.
- Answer getLayer for a store layer's derived `layer-<id>-*` style
  layers and map them to that store layer for picks, so STAC footprint
  selection works on the globe.
- Draw a `wms` record whose tile is an ArcGIS REST export from that
  template rather than sending GetMap to the REST endpoint (Earthdata
  GIS ImageServers).
- Disable the LiDAR and Gaussian splat Add Data entries on the globe,
  where their controls cannot mount and used to do nothing.

Opt in, each verified in the running app: the STAC browsers, Ocean Data
Platform, Earthdata GIS, GeoLens and Hugging Face. Document the globe's
plugin control host.

* Address review feedback

- Reject keyed GeoLens rasters on the Cesium globe as on Mapbox and
  ArcGIS: the API key rides MapLibre's request transform, so a private
  raster added there would 401 on every tile (CodeRabbit). The error
  message no longer names Mapbox alone.
- Replace the per-entity CallbackProperty that hid overlay points and
  labels past the horizon with one pass over them on camera change and
  move end, writing only changed flags, so a dense grid's labels are not
  re-evaluated every frame (Claude review).

* Address Claude review feedback

- Probe both sides of a point for the globe overlay's hit tolerance and
  take the short way round across the antimeridian, so a probe that lands
  in space next to the limb no longer zeroes the tolerance and leaves
  overlay points and lines there unclickable.

* Address Claude review feedback

- Redraw the globe's plugin overlay on a store change only when the
  fields that decide mirroring (layer ids, nativeLayerIds, sourceId,
  sourceIds) change, so an opacity drag or restyle no longer rebuilds
  every overlay entity.

* Address Claude review feedback

- Match a store layer's derived style-layer id only when the whole
  remainder after `layer-<id>-` is a known suffix, so store layer `a` no
  longer claims `layer-a-b-fill` (layer `a-b`'s fill) for picks, mirror
  checks, or getLayer.
- Report a Mercator projection from the globe facade in Cesium's 2D and
  Columbus scene modes, as the docs state, instead of always "globe".
- Fix the test type-check ratchet: an excess-property object literal in
  the mirrorSignature test.
2026-10-10 08:56:09 -04:00
Qiusheng Wu 94af13fc0d feat(plugins): report the GeoEditor from getActiveMapTool (#3029)
* feat(plugins): report the GeoEditor from getActiveMapTool

getActiveMapTool now returns "geo-editor" while the GeoEditor plugin is
active, so plugins that stand down for Identify and feature selection
also stand down while the user draws or edits geometry (including a
layer in "Edit geometry"). onActiveMapToolChange fires when the editor
turns on and off by also subscribing to the plugin manager, where that
state lives. Feature selection and Identify keep precedence.

Adds a lightweight @geolibre/plugins/plugin-ids export so app-api.ts can
read the editor's id without loading the plugins barrel under node.

Fixes #3020

* test: type the map tool events with GeoLibreActiveMapTool

The existing map tools test typed its events array with the old literal
union, which no longer accepts the widened callback type and failed the
test type-check ratchet.

* Address Claude review feedback

- Wrap the getActiveMapTool JSDoc line in types.ts to match the
  surrounding comment width.
2026-10-07 15:20:22 -04:00
Qiusheng Wu 38ed979d3b docs: review the docs against the code and fix drift (#3008)
* docs: review the docs against the code and fix drift

Check every docs page against the current code and correct what no
longer matches: menu paths and labels, plugin placement (most plugins
now open in a docked panel), Cesium ion tokens being optional, desktop
OAuth sign-in for sharing, browser project save/open, project schema
fields, server and collaboration endpoints, Python and MCP APIs, and
stale paths in the source-code deep dives.

Fix the Spatial SQL tutorial's ST_Area_Spheroid query, which needs
ST_FlipCoordinates for lat/lon axis order. Document the Settings
sections that were listed but never described (Language, Appearance,
Interface, Geocoding, AI Providers), and add the USGS NLDI and ArcGIS
feature editing pages to the nav.

* docs: keep the NLDI and ArcGIS editing pages out of the nav

* Address CodeRabbit review feedback

- collaboration.md: give both snapshot-cap defaults, 10 MB for the
  relay and 1 MB in the Docker Compose setup.
- features.md, cesium-renderer.md: without an Ion token the globe still
  gets terrain from keyless Terrarium elevation (or a configured COG),
  not a bare ellipsoid (cesium-engine.ts enableWorldTerrain).

* Address CodeRabbit review feedback

- project-format.md: type blankBackgroundColor and selectedLayerId as
  string | null, matching ProjectFile in packages/core/src/types.ts.
2026-10-06 19:03:19 -04:00
Daniel Hofbauer c802b78ac5 feat(plugins): expose active map tool state (#2966) 2026-10-06 00:06:11 +02:00
Daniel Hofbauerandpre-commit-ci[bot] 9395772216 feat(plugins): make WFS feature limit configurable (#2963)
* feat(plugins): make WFS feature limit configurable

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* style(plugins): separate WFS option doc blocks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-05 21:11:31 +02:00
Qiusheng Wu a8ca595a73 feat(s3): keep the S3 Browser open across project loads, open it at startup (#2941)
* feat(s3): keep the S3 Browser open across project loads, open it at startup

Opening a project from the S3 Browser closed the panel: restoring the
loaded project's plugin state deactivates every plugin the project does
not list. Plugins can now declare `sessionScoped`, which a project load
or a map swap leaves running and which is never saved in the project's
active plugins. The S3 Browser declares it.

Settings > Startup gains "Open the S3 Browser at startup".

* Address Claude review feedback

- Warn when the startup activation of the S3 Browser returns false, not
  only when it throws, matching the deep-link activation path.
- Sort the useStartupPanels import after usePluginStateRestore.
2026-10-04 23:15:04 -04:00
Daniel HofbauerandQiusheng Wu fff0083a04 docs: document deployment policy cutover (#2900)
* docs: document deployment policy cutover

* docs: address deployment policy review feedback

* docs: clarify legacy profile lock field

* docs: drop dated release note and part numbers from availability

* docs: restore dropped headings and list spacing

* docs: separate Docker and desktop availability remedies

---------

Co-authored-by: Qiusheng Wu <giswqs@gmail.com>
2026-10-04 10:25:41 -04:00
55ce70447f feat(identify): skip WMS layers marked queryable="0", show WMS exceptions as errors (#2890)
* feat(identify): skip WMS layers marked queryable="0", show WMS exceptions as errors (#2887)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

* fix(identify): treat a WMS exception sent with an error status as an error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

* docs(plugin-api): document the WMS queryable option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

* fix(identify): send WMS GetFeatureInfo in the layer's CRS (#2889)

* feat(identify): skip non-queryable WMS and report exceptions

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(identify): fall back to EPSG:3857 when the projection cannot convert the click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Hofbauer <hofbauer.d@icloud.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-04 08:20:43 -04:00
Qiusheng Wu 94fb172104 feat(i18n): complete the catalogs and let plugins ship translations (#2893)
* feat(i18n): complete the catalogs and let plugins ship translations

Fill every key missing from the 20 non-English catalogs (the USGS 3DEP
panel, the newer Manage Plugins policy and registry-trust strings, the
keychain wording and the 3D model export), and translate the
identical-to-English values in de/nl/id that were genuinely
untranslated prose. All insertions are additive; no catalog is
reordered.

tests/i18n-catalogs.test.ts now fails when a bundled locale lacks a key
that en.json has (plural keys compared by base), replacing the
report-only coverage log, so new UI strings ship with translations.

Plugins can register their own per-locale strings through
app.registerTranslations (plugin.<id>.* keys only, never overriding the
host catalog). Built-in plugins get createPluginTranslator and
pluginDisplayTitle from @geolibre/plugins. The elevation profile, 3D
Tiles key toggle, GeoAgent Earth Engine sign-in, annotation element
list and point cloud annotation stop hard-coding English DOM text, and
15 docked plugin panels title their header from the translated plugin
name.

Part of #2858

* Address review feedback

- Export pluginDisplayTitle from @geolibre/plugins, as docs/plugin-api.md
  promises (Claude, CodeRabbit).
- Elevation Profile: title its panel with pluginDisplayTitle and reuse
  interpolatePluginText for the English fallback instead of a second
  regex (Claude, CodeRabbit).
- 3D Tiles: re-label the API-key toggle and the layer visibility
  checkboxes on a language change instead of only at build time
  (CodeRabbit).
- registerTranslations test: assert on Object.prototype and the i18next
  store, so it can catch a real prototype-pollution regression (Claude).
2026-10-04 07:39:47 -04:00
Qiusheng Wu 7099ceb16a feat(plugins): honor the plugin registry's blocklist (#2873)
* feat(plugins): honor the plugin registry's blocklist

The hosted plugin registry now publishes blocklist.json beside
plugin-registry.json (opengeos/geolibre-plugins#77): plugins, or single
bundles of them, that its maintainers have pulled. Removing a plugin
from the registry only stops new installs; the blocklist also stops
installed copies, which load from their saved manifest URL.

- plugin-blocklist: fetch blocklist.json next to the configured
  registry once per session (4 s timeout), cache it for offline starts,
  and fail open: with no copy, nothing is blocked. Malformed entries are
  dropped individually.
- evaluatePlugin: deny a plugin blocked outright ("blocklisted", with
  the reason) for every external source; bundled drop-ins are exempt.
  All install and load paths already go through this gate.
- external-plugins: refuse a URL bundle whose hash is blocked, both on
  load and on Update, before its code runs.
- External plugin loading waits for the blocklist first.

* fix(plugins): address review feedback on the blocklist

- plugin-blocklist: read the response through readBodyWithCap (now
  exported from plugin-registry with a label), so the 1 MB cap is
  enforced while streaming instead of after buffering the whole body.
- Wait for the blocklist before an Update and before both zip install
  paths, not only before startup loading, so nothing is checked against
  a list that is still loading. The desktop zip install now also reads
  the manifest id when the blocklist has entries.
- external-plugins: hash each startup bundle once and pass the hash to
  verifyPluginBundleIntegrity, which accepts a precomputed hash.
- ensurePluginBlocklistLoaded never throws: a registry URL that can't
  be resolved means nothing is blocked.

* fix(plugins): scope the cached blocklist to its registry

- plugin-blocklist: store the blocklist URL with the cached copy and use
  the cache only for the same URL, so switching registries never applies
  one registry's blocklist to another's plugins while offline.

* fix(plugins): treat a malformed blocklist as a failed fetch

- plugin-blocklist: a 200 response without a "blocked" array no longer
  replaces the active list or the cache with an empty one; it falls back
  to the cached copy like any other failure. A 404 still means the
  registry has no blocklist.

* fix(plugins): address review nits on the blocklist parser

- plugin-blocklist: cap the number of valid entries rather than raw ones,
  so malformed items can't push valid entries past the limit.
- plugin-blocklist: key single-bundle blocks with JSON.stringify([id,
  hash]) so the pair is unambiguous whatever an id contains.

* fix(plugins): keep a cached blocklist through 404s; block bundles in zips

- plugin-blocklist: a 404 keeps a cached list for that registry and
  never overwrites the cache, since a registry that published one is
  likelier mid-deploy than to have withdrawn it. A registry that never
  published a blocklist still blocks nothing.
- external-plugins: assertBundleNotBlocklisted() hashes a bundle and
  refuses a blocklisted release. Zip and plugin-directory bundles are
  now checked at startup, by the web zip install, and by the desktop zip
  install before it persists the archive; URL bundles already were.
- docs/plugin-api.md: hash blocks apply to every external source.

* fix(plugins): one translated denial for every blocklist block

- plugin-policy: blocklistedDecision() builds the "blocklisted" denial.
  The single-bundle checks (URL load, Update, zip and directory bundles)
  now raise PluginPolicyError with it instead of plain English errors,
  so the UI shows the same translated managePlugins.policyBlocklisted
  message as for a whole-plugin block.
- plugin-blocklist: remember which blocklist URL the active list came
  from; a failed fetch with no cache keeps that registry's own list but
  drops another registry's.

* perf(plugins): skip bundle hashing when the blocklist is empty

- external-plugins: assertBundleNotBlocklisted returns early when no
  plugin or bundle is blocklisted, so zip and directory bundles aren't
  hashed on every scan in the usual case.
2026-10-04 07:36:13 -04:00
Qiusheng Wu d625dcbe4a fix(desktop): pin the CSP's jsDelivr script-src to versioned paths (#2875)
* fix(desktop): pin the CSP's jsDelivr script-src to versioned paths

The desktop CSP allowed scripts from all of cdn.jsdelivr.net/npm/ and
/pyodide/. Replace those with one version-pinned path per package the
app actually executes from the CDN: Pyodide, PGlite and its PostGIS
extension, onnxruntime-web, the duckdb-wasm worker that the components
DuckDB converter loads, and the duckdb-wasm, sql.js, geojson-vt and
vt-pbf URLs maplibre-gl-vector hard-codes (plus the modules jsDelivr's
/+esm bundles for them import).

tests/tauri-csp.test.ts re-derives every path from its owner (lockfile,
PYODIDE_VERSION, ORT_VERSION, maplibre-gl-vector's dist) so a bump that
moves a URL fails CI instead of the packaged app.

'unsafe-eval' stays: maplibre-gl-vector calls new Function() at module
scope in the startup graph, and the packaged app boots to a blank window
without it. docs/maintenance.md records that, the other eval consumers,
and why connect-src keeps https: and http:.

Part of #2858.

* Address review feedback

- docs/maintenance.md: scope the security claim to what path pinning
  does (blocks other packages and versions), and say that it does not
  verify content at an allowed path or survive redirects (CodeRabbit).
- docs/maintenance.md: fix the garbled DuckDB converter table row
  (Claude review).
2026-10-04 00:35:00 -04:00
Qiusheng Wu 232627700d feat(plugins): verify registry installs and updates against the published bundle hash (#2857)
* feat(plugins): verify registry installs and updates against the published bundle hash

The hosted plugin registry now publishes a bundleSha256 for each plugin:
the hash computePluginBundleHash produces for the bundle that was
reviewed (opengeos/geolibre-plugins#71). Use it so a registry install no
longer trusts whatever its URL serves first.

- plugin-registry: parse bundleSha256 (lowercase hex SHA-256, otherwise
  ignored) and add pinRegistryEntryBundle.
- Installing from Manage Plugins or a ?plugin= deep link pins the
  announced hash before the URL is installed, so the existing load-time
  check holds back a download that differs.
- The Update action takes the entry's hash, hashes the download before
  importing it, and refuses a mismatch without evaluating the code.
  The computed hash is reused for the new pin.
- Entries without bundleSha256 keep trust-on-first-use.

* fix(plugins): warn when a registry bundleSha256 is malformed

- plugin-registry: log a console warning when an entry carries a
  bundleSha256 that isn't 64 lowercase hex characters, so a registry
  typo that silently falls back to trust-on-first-use gets noticed.

* Address CodeRabbit review feedback

- Reload coalescing in reloadExternalUrlPlugin keyed the in-flight
  promise on the manifest URL alone, so an update started mid-reload
  with a different registry hash (or version) received the first
  call's result without its own hash ever being checked. The in-flight
  entry now records its expectedVersion/expectedHash; only a matching
  call shares it, and any other waits for it to settle and then runs a
  reload that checks its own expectations. Covered by a new concurrent
  update test that fails without the fix.
2026-10-03 22:36:01 -04:00
Qiusheng Wu 6327719397 fix(plugins): close credential-store reads before external plugins load (#2861)
* fix(plugins): close credential-store reads before external plugins load

External plugins are imported with the app's own privileges and can call
any Tauri command, so secure_store_get_many handed every saved token
(Settings tokens, Share refresh token, PostGIS DSNs, other plugins'
app.credentials values) to any installed plugin.

The app already reads every account in one call during startup
hydration, before it renders and therefore before any external plugin
is imported. Make that the only read: secure_store_get_many now answers
once per page load of the calling webview (the read is used up even
when it fails), the page-load hook reopens it when a webview starts a
new document, and a new secure_store_seal command closes reads without
reading. Hydration now always makes its read, and importExternalPlugin
seals before evaluating any plugin code, refusing to load the plugin if
sealing fails. Writes and deletes stay open, since the app saves
credentials whenever Settings change and a write discloses nothing.

This is a narrowing, not isolation: plugins still share the JS realm,
so values already hydrated into memory remain reachable. The docs say
so.

Part of #2858

* Address Claude review feedback

- readSecureCredentials now marks reads sealed only after the read
  succeeds. A failed call may never have reached the native command (an
  IPC transport error), so it no longer short-circuits the
  sealSecureCredentialReads backstop before external plugins load; the
  Rust-side seal is idempotent, so the extra call is harmless when the
  read did claim the gate. Adds a test for the failed-read path.
2026-10-03 21:53:57 -04:00
Qiusheng Wu 5773b71463 feat(layers): show a service layer's source in its Metadata dialog, and let plugins add metadata (#2856)
* feat(layers): show a service layer's source in its Metadata dialog

The Metadata dialog showed only layer.metadata, the name, the type and
sourcePath, so a WMS layer listed little more than "service": "wms".
It now also shows the service address and request fields GeoLibre keeps
on the source of WMS, WMTS, XYZ and the other tile and service layers
(layers, styles, format, version, CRS), with inlined data left out.
The whole payload is scrubbed with the same credential rules a saved
project uses, since the dialog offers Copy.

WMS sources now record the request CRS, from both Add Data and the
plugin API.

Plugins can pass an optional metadata object to addTileLayer,
addWmtsLayer, addWmsLayer and addWfsLayer, merged into the layer's
metadata, so a catalogue plugin can leave a layer's provenance with it.

Fixes #2855

* Address review feedback

- Scrub the metadata, source summary, source URLs and sourcePath one by
  one, so a GeoJSON-looking `type` in the metadata cannot make the whole
  payload skip redaction.
- Keep only HTTP(S) URLs under the source's data/geojson keys and always
  omit kerchunkRefs, so inline text never lands in the dialog.
- Reject plugin metadata whose toJSON() serializes to a non-object.
2026-10-03 19:19:49 -04:00
Qiusheng Wu 59dd8cdc3e feat(plugins): let users move a plugin's menu out of the toolbar (#2853)
Each installed plugin that registered a top-level toolbar menu now has a
"Show menu in toolbar" switch in its Plugins → Installed entry. Turning
it off takes the plugin's menus out of the banner and renders them as
submenus inside that entry instead, so the user, not the plugin,
decides what occupies the menu bar. The choice persists per plugin in
the desktop settings (foldedPluginMenus). Built-in plugin menus are
unaffected.

Part of #2850.
2026-10-03 18:09:31 -04:00
Qiusheng Wu d24528d84a feat(plugins): let plugins add items to the built-in toolbar menus (#2852)
* feat(plugins): let plugins add items to the built-in toolbar menus

Add app.registerMenuContribution(), the GeoLibre counterpart of QGIS's
addPluginToVectorMenu: a plugin adds items to Add Data, Processing or
Controls instead of claiming a top-level menu of its own. The host nests
each plugin's items under a submenu named after the plugin, after a
separator at the end of the target menu, sorted alphabetically, so a
plugin cannot inject loose items or reorder the built-in entries.

The PluginManager injects the owner id and name the same way it tags
toolbar menus. An unknown target menu warns and is ignored rather than
failing activation, so plugins written for a newer host still load.
Contributions follow the plugins:install gate and are hidden in the
viewer.

Part of #2850.

* fix(plugins): release menu contributions with their plugin and skip malformed items

The PluginManager now drops a plugin's menu contributions wherever it
already drops its assistant tools (deactivate, unregister, failed
activation), so a plugin that forgets to dispose of them leaves nothing
behind. Replacing another plugin's contribution id warns, and the shared
menu renderer skips null items and submenus without an items array
instead of throwing.

* fix(plugins): only accept menu contributions from live plugins

A restore callback can run for a plugin that stays inactive
(applyProjectState); a contribution registered there would linger,
since owner cleanup runs on deactivation. The scoped registrar now
ignores contributions unless the plugin is active or activating.

* fix(plugins): skip menu contributions with nothing renderable

A contribution whose items are all malformed or separators would group
into a plugin submenu that opens to nothing once renderItems drops them.

* fix(plugins): scope unregisterMenuContribution to the calling plugin

A plugin's scoped unregisterMenuContribution now removes only
contributions that plugin registered, so one plugin cannot take
another's menu items away. The host can still remove any id.

* fix(plugins): build a plugin scope when only unregisterMenuContribution exists
2026-10-03 17:49:25 -04:00
Qiusheng Wu ec4722c2a7 feat(plugins): group external plugins in a Plugins → Installed submenu (#2851)
Plugins loaded from the registry, a zip, a manifest URL, or a bundled
drop-in were appended to the flat Plugins menu, which had grown long.
They now live in one "Installed" submenu at the bottom of the menu,
sorted alphabetically by display name, with a Manage Plugins… shortcut
at the end. The submenu only appears when an external plugin is loaded.

The Web Services sort moves into a shared sortPluginsByDisplayName
helper used by both submenus.

Part of #2850.
2026-10-03 20:29:59 +00:00
Qiusheng Wu 661fd5c791 feat(plugins): give desktop plugins a native fetch with a cookie jar (#2848)
* feat(plugins): give desktop plugins a native fetch with a cookie jar

The Data to Science plugin signs in with a session cookie. In the desktop
app the page runs at tauri://localhost, so the cookie D2S sets is
third-party, and WKWebView on macOS drops it: the login returns 200 and
the next request returns 401, shown as "Session expired". Plugins had no
other route: the host only offered a GET-only fetchArrayBuffer, and the
Tauri HTTP plugin is scoped to a fixed host list.

Add app.nativeFetch on the desktop. It has the fetch signature and runs
through a new plugin_http_request command: any method and body, no CORS,
a cookie jar kept for the app session, Set-Cookie kept out of
JavaScript, the existing SSRF guard (link-local and cloud-metadata
addresses refused), 64 MiB bodies, a 120 s timeout, and AbortSignal
cancellation. Requests appear in Diagnostics with credentials redacted.

The ArcGIS transport's async client setup (SSRF resolver, enterprise
CAs, mTLS identity) moves into a shared guarded_async_client_builder so
both transports use it.

Refs #2837

* Address review feedback

- Check literal-IP redirect hops against the SSRF block list. The
  guarded DNS resolver re-checks hostnames, but a literal IP never
  reaches a resolver, so a redirect to 169.254.169.254 was followed
  (Claude review).
- Accept any method token fetch accepts (PROPFIND and other extension
  methods) except CONNECT and TRACE, which fetch forbids too
  (CodeRabbit).
2026-10-03 15:48:47 -04:00
Daniel Hofbauerandpre-commit-ci[bot] ada13fce47 feat: expose WFS layer API to plugins (#2826)
* feat: expose WFS layer API to plugins

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix: guard WFS requests across project changes

* fix: hydrate WFS layers when panels are hidden

* fix plugin WFS review findings

* refactor: align plugin WFS with the shared Add Data request path

- Drop the plugin-specific destination/IP/DNS/redirect policy and the
  browser public-IP-literal restriction; validate only absolute HTTP(S)
  URLs, matching Add Data.
- Remove the fetch_plugin_wfs_response command and its Vite proxy route;
  plugin WFS rides fetch_url_response / the WFS dev proxy.
- Keep credential redaction in native diagnostics, now applied to every
  native command.
- Restore fetch_url_bytes in the Tauri invoke handler.
- Hoist refresh state into LayerRefreshProvider so WFS hydration runs
  outside the conditionally mounted layer panel.

* refactor: keep plugin WFS on the existing Add Data path

- Revert vite-proxy-guard.ts to main: the PR-added per-address IPv6
  transition checks and the leftover userinfo-stripping indirection had no
  place in the browser/dev-proxy path once the plugin-only policy was dropped.
- Desktop: keep the resolved-address checks in one place, the generic
  build_guarded_http_client_with_redirects; drop the single-caller generic
  resolver the plugin command needed.
- Drop the LayerRefreshProvider hoist; the shell owns useLayerRefresh and
  passes it to LayerPanel as before, removing the shell-wide re-indent.
- Drop the unused isCredentialUrlParam export; diagnostics redaction is
  unchanged.

* fix: review findings on the plugin WFS path

- Save a credential-free WFS reference with a `wfsCredentialsRedacted`
  marker: the embedded collection is the only copy a sanitized URL can
  render, and a later save of the reopened project could no longer tell
  that and dropped it.
- Read diagnostics credential names from @geolibre/core's URL registry
  (`isCredentialUrlParam`) instead of a second, narrower list, and drop
  the redundant text pattern.
- Drop a URL fragment before building the GetFeature request: the
  fragment swallowed the appended operation parameters.
- Only `source.url` makes edited WFS geometry restorable; a stray
  `metadata.originalUrl` (a tile-layer field) could let a no-embed save
  discard features nothing can re-fetch.
- Document that a plugin bbox must not cross the antimeridian.

* docs: clarify credentialed WFS project saves

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-03 13:48:54 -04:00
Qiusheng Wu 36c144e51f feat(plugins): let registry plugins keep project state through credential stripping (#2836)
* feat(plugins): let registry plugins keep project state through credential stripping

A registry entry can now declare publishableSettings (true or a list of
top-level keys). The host feeds those to the credential redaction so the
plugin's state survives Strip credentials and shared exports. Built-in
allowlists cannot be widened, and kept state is still swept for credentials.

* Address review feedback

- Gate the registry prefetch on the plugins:install capability like the marketplace
- Note that the Python mirror does not know registry-declared settings

* Address review feedback

- Ignore registry publishableSettings for built-in plugin ids
- Update the earlier plugin-state paragraph in the plugin API docs
2026-10-03 10:37:36 -04:00
Daniel Hofbauer b378efc5c9 feat(plugins): expose host proj4 to external plugins (#2824)
* feat(plugins): expose host proj4 to external plugins

* docs(plugins): recommend private proj4 CRS definitions
2026-10-03 07:45:56 -04:00
815985a912 plugins: move Hugging Face, Mapillary and God's Eye View tokens to app.credentials (#2793)
* plugins: store Hugging Face, Mapillary and God's Eye View tokens via app.credentials (#2729)

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* plugin-credentials: keep legacy-key cleanup from failing a landed write

* Update apps/geolibre-desktop/src/lib/plugin-credentials.ts

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* plugin-credentials: keep the session override when legacy cleanup fails

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-01 17:19:39 -04:00
Daniel Hofbauerandpre-commit-ci[bot] 5786f9741e feat(plugins): add app.credentials API backed by the OS keychain on desktop (#2778)
* feat(plugins): add app.credentials API backed by the OS keychain on desktop (#2729)

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(plugins): make desktop credential set/delete retry-safe

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-01 08:13:20 -04:00
Andrea BorrusoandClaude Opus 5.5 54305ad477 feat(plugins): add importLayerStyle to the plugin API (#2702)
* feat(plugins): add importLayerStyle to the plugin API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): import styles only into vector layers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugins): point the style module comment at both siblings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:35:51 -04:00
Andrea BorrusoandClaude Opus 5.5 6c0e453f3c feat(plugins): accept an optional crs in addWmsLayer (#2701)
* feat(plugins): accept an optional crs in addWmsLayer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): normalize the WMS version inside normalizeWmsCrs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:34:38 -04:00
Qiusheng Wu 27aad9ddd8 feat(arcgis): run MapLibre-control plugins on the ArcGIS renderer (#2683)
* feat(arcgis): run MapLibre-control plugins on the ArcGIS renderer

Most Plugins-menu entries were greyed out on ArcGIS because their
controls talk to a MapLibre style the SDK does not have. Give the
control facade a style that controls can use there:

- shadow-style.ts records addSource/addLayer/setPaintProperty/...
  with MapLibre's contract (errors as events, getStyle read-back).
- A layer the plugin mirrors into the store is drawn by the engine
  from that record; any other GeoJSON layer (grids, selection
  outlines, draw previews) is drawn in one graphics overlay, with
  filters and paint evaluated per feature by the style spec
  (style-layer-evaluator.ts in @geolibre/core).
- Layer-scoped events and point queryRenderedFeatures answer from
  whichever of the two draws the layer; dragPan.disable() stops the
  view panning so box drawing works; mousedown/mouseup are fired.
- getArcgisControlMap / getControlMap(app) hand the facade to docked
  panels and plugins.

Enabled on ArcGIS: the Web Services WMS catalogs (FEMA, NASA
Earthdata, EnviroAtlas, National Map), Esri Wayback, the eight DGGS
grids and the Graticule, STAC (plus Planet Open Data and Portolan),
OpenAerialMap, Vantor, Fields of the World, Satellite Embeddings, IGN
LiDAR HD, GeoLens, Earthdata GIS, Hugging Face, Source Cooperative
and Natural Earth.

Also fixes two pre-existing ArcGIS bugs found on the way: the engine
stacked store layers upside down (the store is bottom to top, as the
Layers panel and MapLibre's sync read it), and a WMS layer never drew
under the dev server because its proxied template hid the LAYERS
parameter from the WMSLayer parser.

* fix(arcgis): address review on the plugin control facade

- Shadow style: an unknown beforeId in addLayer/moveLayer reports an
  error instead of appending; a kept source view merges into the
  recorded spec and is inert after removeSource.
- Layer events: layer-scoped on returns a Subscription, a
  listener-less once resolves with the next hit, off with the
  original listener removes a once, mousedown/mouseup dispatch, and
  negative numeric feature ids stay numbers.
- Control host: no overlay redraw after destroy().
- IGN LiDAR HD: the unsupported-renderer message names ArcGIS in
  every locale, with a note tying the denylist to `engines`.

* fix(arcgis): allowlist point-cloud renderers; mirror check without a source

IGN LiDAR HD refuses any renderer not known to draw lidar-url layers
again (now MapLibre, Mapbox and ArcGIS), so a renderer added later is
not assumed to. The control host consults the store mirrors for a
layer whose source it cannot resolve, since a mirror can match by id.

* docs(arcgis): describe the control map's overlay; reattach moved JSDoc

getControlMap and getArcgisControlMap still said a grid or highlight
would not show on ArcGIS, which the overlay in this PR now draws. Two
doc blocks had been separated from their methods by the inserted
code, and a held shadow source's serialize() now reads the live
entry.
2026-09-26 08:54:25 -04:00
Qiusheng Wu 0f39d5be00 feat(plugins): add a Fields of the World plugin (#2612)
* feat(plugins): add a Fields of the World plugin

Adds Plugins > Web Services > Fields of the World, which visualizes,
searches, and downloads the FTW global agricultural field boundaries
from the same Source Cooperative files the FTW inference app reads:

- Field boundaries: the 2025 or 2024 global PMTiles archive as a
  regular pmtiles store layer, colored by confidence_mean with the FTW
  app's red-to-green ramp (expression mode, editable in the Style
  panel).
- Field density: the global 500 m density COG, kept at the current
  view (new optional addCogLayer zoomTo) and hidden above zoom 12.
- A confidence threshold slider (70% by default, mapped onto the
  model's 0-0.578 range like the FTW app) that writes each FTW field
  layer's filterExpression, so it persists with the project.
- Search of the 1-degree download grid by map view or drawn box, with
  footprints in the Layers panel and click-to-select.
- Per tile: Add to map, source GeoParquet, or GeoJSON, optionally
  clipped to the search area. Tiles are read one row group at a time
  (the largest hold ~3M fields in ~123k-row groups), so a small clip
  of a huge tile no longer exhausts the tab's memory.

* Address review feedback

- Mark the field-density COG with a plugin-owned metadata key instead of
  overwriting its sourceKind: the raster sync, Style panel and Mapbox COG
  path key on "maplibre-gl-raster" (CodeRabbit).
- Check the abort signal before addCogLayer, and finish configuring the
  layer (marker, maxZoom, attribution) even when the task is aborted
  while it loads, so no orphaned density layer is left (Claude,
  CodeRabbit).
- Report a cancelled save dialog as cancelled instead of "Saved":
  saveBlob now returns whether the host saver wrote the file
  (CodeRabbit).
- Build the GeoJSON Blob from per-feature strings so a large export does
  not exceed V8's maximum string length (CodeRabbit).
- Search a map view crossing the antimeridian as two boxes instead of the
  whole world; the grid search and clipping split such boxes (Claude).

* Address review feedback

- Disable the Field density button while a task runs, like the row
  actions, so a second click during addCogLayer cannot pass the
  duplicate check before the first layer lands (Claude, CodeRabbit).
2026-09-23 20:13:58 -04:00
Qiusheng Wu 9c5324773f feat(assistant): load plugin tools on demand past 12 of them (#2595)
* feat(assistant): load plugin tools on demand past 12 of them

Every active plugin's tools were sent to the model on every request, so
the tool list, and the ambiguity between same-shaped tools from different
domains, grew with every installed plugin.

Up to 12 plugin tools are still sent in full. Past that, the system
prompt lists each plugin tool by name and a one-line summary, grouped by
plugin, and the model loads the ones it needs with a new
load_plugin_tools host tool (exact names or a plugin id). The Strands
SDK re-reads the agent's tool registry before every model call, so
loaded tools are callable in the same turn; they stay loaded for the
conversation. The catalog depends only on what is registered, so the
prompt stays stable across turns. Host tools are always sent.

Fixes #2586

* fix(assistant): keep abbreviations from cutting a tool summary short

* Address review feedback

- Count plugin tools sent in full as loaded, so crossing the eager limit
  mid-conversation keeps tools the model could already call (Claude).
- Resolve an exact tool name before expanding a plugin id with the same
  spelling, with a collision regression test (CodeRabbit).

* Address Claude review feedback

- Move the per-conversation plugin tool bookkeeping (pruning removed
  tools, counting eagerly sent tools as loaded, recording loads) out of
  AssistantSession into ConversationPluginTools, and test it across
  refreshes, the eager-limit crossing, unregistration and reset.
2026-09-23 10:46:42 -04:00
Qiusheng Wu 08b6eadb93 feat(plugins): expose moveLayerGroupToGroup and listLayerGroups on the plugin API (#2554)
* feat(plugins): expose moveLayerGroupToGroup and listLayerGroups on the plugin API

The plugin API let a plugin create a Layers-panel group, move layers into one,
and remove one, but there was no way to nest a group inside another group: the
store's `moveLayerGroupToGroup` was wired into the Layers panel and the desktop
shell only. A plugin could build a flat row of folders and nothing deeper.

Expose that store action, and with it `listLayerGroups`. Without a read the new
method would only reach groups the plugin created itself, since `addLayerGroup`
was the one source of group ids a plugin could hold; the summary carries
`parentId` (null at the root) so the flat array spells out the whole tree.

The write is a passthrough: the store action already refuses an unknown id and
any move that would make a group its own ancestor, so a plugin cannot cycle the
tree through it. `listLayerGroups` goes in `plugin-layer-queries.ts` with the
other read-only queries, which keeps it testable without pulling the built-in
plugin registry into the test.

Also documents the whole layer-group surface in docs/plugin-api.md, which had
never mentioned groups at all.

* Address CodeRabbit review feedback

- Cover the public delegate, not just the store action. Every method on
  `GeoLibreAppAPI` is optional, so type checking would not notice
  `moveLayerGroupToGroup` going missing from `createAppAPI` or being handed its
  arguments in the wrong order, and the tests called the store directly. Moved
  the four group writes into `plugin-layer-groups.ts` (the same extraction
  `plugin-layer-queries.ts` already uses, and for the same reason: `usePlugins`
  pulls in the whole built-in plugin registry) and pointed the tests at that
  factory. Adds a case for the move-layers-in / remove-the-folder-alone round
  trip, which was untested too.
- Guard `subBasins` before `removeLayerGroup` in the docs example. It comes
  from an optional call, so a plugin copying it verbatim failed type checking.

* Address Claude review feedback

- The module doc claimed `addLayerGroup` drops `beforeLayerId`; the store's
  `addLayerGroup(name?, layerIds?)` never took one. Only `moveLayersToGroup`
  does, and that is what the sentence now says.
2026-09-22 12:44:01 -04:00
semehdiandpre-commit-ci[bot] 6a9c00a991 feat(plugins): IGN LiDAR HD Plugin (#2507)
* Added a plugin to load lidar hd data in geolibre

* Fixed some bugs

* Added translation files

* Simplify plugin panel text

* Prevent overlapping LiDAR auto-zoom suppression from corrupting shared state

* Simplified the docs & improved the tests

* Discarded package-lock.json modifications

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix: manage concurrent LiDAR restores

* fix: reject invalid tiles count values

* fix: compare matched tiles count with the returned page size

* fix: handle bulk-add setup failures and restore the controls

* fix: gate the live WFS test behind an explicit opt-in

* fix: updated the docs & a GUI misleading message

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix: changed the default bounding box values

* fix: zoom to extent of the resulting tiles

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-09-20 16:31:07 -04:00
Qiusheng Wu ccfbc730db docs: add rendering engines guide and renderer references (#2471)
* docs: add rendering engines guide and renderer references

GeoLibre now draws projects with MapLibre, Mapbox, Cesium, and ArcGIS, but the
docs still described a MapLibre-plus-Cesium setup. Add a user guide for choosing
and mixing engines, MapLibre and Cesium renderer references, and update the
existing pages to match.

* Address review feedback

- Correct Cesium credential docs: the web version bundles a demo ion token,
  while desktop and mobile require the user's own token.
- Restore the tutorial "limitations" bullet to an actual limitation (Claude
  review), in both the English and Chinese tutorials.
2026-09-18 09:45:16 -04:00
Qiusheng Wu b45f3859de Host deck.gl visualizations on ArcGIS maps and local scenes (#2449)
* Add ArcGIS basemap picker and native layer control

* Support native ArcGIS heatmaps, clusters, patterns and feature altitude

* Render custom COG terrain in ArcGIS scenes

* Render COG imagery on ArcGIS maps and scenes

* Host deck.gl visualizations on ArcGIS maps and local scenes

* Address review feedback

- Reuse the existing ArcGIS translation key for the basemap section.
- Read the basemap preferences action through component selectors.
- Synchronize native visibility callbacks to prevent store updates losing toggles; add a regression test.
- Clarify the shared primary-pane Layer Control default; keep the widget consistent with the active-by-default plugin.

* Address review feedback

- Read current preferences when selecting an ArcGIS basemap to preserve concurrent changes.
- Document all ten supported Esri basemap choices.
- Allow the two-boot Style Manager E2E flow sufficient total runtime on CI without changing assertion timeouts.

* Address review feedback

- Skip unused symbol evaluation and deduplication for heatmap points, preserving mixed geometry styling.
- Explain the inclusive cluster zoom cutoff already covered by the crossover test.

* Address review feedback

- Restore custom terrain only for 3D scenes and isolate DEM failures from view initialization.
- Keep a redacted diagnostic for failed restores while preserving the working map.

* Address review feedback

- Strip session-only raster byte URLs from saved and reopened projects while preserving desktop paths.
- Initialize COG extents synchronously in Web Mercator from known bounds.
- Fall back to COG when a source URL has no trailing name.

* Address review feedback

- Remove an unused vendored import and align ArcGIS scale constants and binding documentation.
- Honor ArcGIS diagram zoom limits and decluttering using the native projection and settled camera updates.
- Omit unprojectable diagram points and invalidate native-view decluttering on camera changes.

* Address review feedback

- Explicitly ground selection highlights when feature elevation is disabled.
- Preserve finite zero-Z coordinates for ArcGIS absolute-height placement and altitude offsets.
- Add regression coverage for both altitude cases without changing default Z detection in other renderers.

* Address review feedback

- Clear a failed remembered terrain source to avoid retrying it on every view rebuild.
- Refresh cache recency for reused terrain neighbours during panning.

* Address review feedback

- Reuse opened COG sources across symbology rebuilds and forget removed sources.
- Cache source statistics weakly and verify style edits use the new gamma without reopening data.

* Address review feedback

- Clear failed ArcGIS overlay mount promises so a transient CDN failure can be retried.
- Reuse the SDK module URL helper and verify retry succeeds on the same view.

* Address review feedback

- Restore terrain only when enabled without delaying view startup, retaining the selection after transient failures.
- Exercise overlapping terrain requests, failed replacements and disposal.
- Distinguish elevation axes and return zero outside world Y bounds.

* Address review feedback

- Type the terrain registration fixture explicitly so tile URLs retain their tuple shape.

* Address review feedback

- Retry COG preparation after transient failures instead of retaining a rejected promise.
- Skip raster lifetime scans when the layer list has not changed.

* Address review feedback

- Cover local raster restoration, blob lifetime, skipped sources and stale asynchronous reads.
- Document browser blob reuse in the raster restore path.

* Address review feedback

- Clarify that deck layer ordering is internal to the shared overlay.
- Include the lazy deck SDK modules in the ArcGIS upgrade checklist.
- Explain projection-driven capabilities during ArcGIS view replacement.

* Address review feedback

- Measure scene camera distance relative to the focal elevation
- Retry 2D resource initialization with bounded attempts and disposal guards

* Address review feedback

- Release every partial Deck and GPU allocation before retrying initialization
2026-09-17 12:13:24 -04:00
Qiusheng Wu 2fb8249d7e feat(geoagent): run GeoAgent on the Mapbox renderer (#2428)
* feat(geoagent): run GeoAgent on the Mapbox renderer

Almost every GeoAgent tool already sat on the Style Spec surface both 2D engines
share. Four did not, and each broke differently on a mapbox-gl map:
`add_marker` built MapLibre's `Marker`/`Popup` (whose position update reads
`map._camera.transform` and throws), `set_projection` wrote `{ type }` where
Mapbox takes a name string, `get_map_state` read `projection.type`, and
`run_maplibre_script` handed user-authored code this package's `maplibre-gl`, so
a script that built a marker built the wrong one.

That matters more here than in a control that simply fails to mount: an agent
run breaks mid-way, after it has already changed the map.

maplibre-gl-geoagent 0.6.1 takes the engine as one option and all four follow
it. The plugin names the host's engine once (`geoagent-map-engine.ts`, its own
module so it can be unit-tested — the plugin's entry pulls the Earth Engine
browser client in at import time) and hands over the whole mapbox-gl namespace
rather than a narrowed subset, because `run_maplibre_script` passes it straight
to the script it runs. On MapLibre it passes nothing and the upstream default
stands.

`set_sky` / `clear_sky` stay engine-limited: mapbox-gl has no `setSky` (it draws
sky through a style layer), and the tool reports that rather than failing
silently.

Verified in the browser on a `primaryRenderer: "mapbox"` project in both themes,
driving `tools.runCommand` — the same entry point a model's tool calls land on,
so the engine-specific paths are exercised without an LLM provider: the tools
report the Mapbox engine and the namespace the renderer is drawing with;
`add_marker` places a mapbox-gl marker (and no maplibre one) that tracks the map
through a pan; `set_projection` switches to globe and `get_map_state` reads it
back; a script that builds a `Marker` and adds it succeeds, which MapLibre's
would not; and a GeoJSON overlay reaches the Layers panel as a plugin-owned row.
Covered by e2e/mapbox-geoagent.spec.ts plus unit tests for the engine resolver.

Part of #2420 (GeoAgent item). Upstream: opengeos/maplibre-gl-geoagent#33
(v0.6.0) and #34 (v0.6.1).

* review: resolve the agent's engine on access, cache only the module

Both reviewers landed on the same real hazard. `geoAgentControlPromise` memoized
the dynamic import *and* the control construction together, so the control was
built from whichever activation started the import — and GeoAgent's chunk is
large enough that a renderer swap easily lands inside that window — after which
every later activation reused that instance.

Two changes, either of which would close it, and both are worth having:

- Cache the module, not the control. The module is engine-neutral and safe to
  share; the control is not.
- Commit to Mapbox on the renderer, which the store flips synchronously, and
  resolve the namespace when it is read. The control stores the descriptor and
  its tools only dereference `namespace` when a tool actually runs, long after
  the map exists — so a control built while the engine was still mounting still
  drives the right library. The unreachable case throws rather than silently
  handing the agent MapLibre's classes.

Also replaces the e2e's console-text filter with failed-response tracking that
keeps the URL and status, ignoring only 401/403 from the Mapbox API hosts and
cancelled requests (every pan and projection switch abandons the tiles for the
view it left).

* Address review feedback

- Only the current activation may build the shared GeoAgent control.
  Caching the module rather than the control was not enough on its own:
  continuations on the shared import run in registration order, so an
  activation superseded mid-import ran first and won the race to fill
  `geoAgentControl` — with its now-stale engine baked in — leaving the
  activation that was actually current to mount that wrong-engine
  instance. `loadGeoAgentControl` now takes the generation, refuses to
  construct when it is no longer the latest (or when GeoAgent was
  deactivated meanwhile, which would otherwise leave a control nothing
  tears down), and returns null; `mountGeoAgentControl` treats null as
  a superseded mount.
- `geoAgentMapEngine` treats the renderer as authoritative instead of
  OR-ing it with the namespace. Swapping Mapbox out flips
  `primaryRenderer` before the outgoing engine is cleared, so a
  `getMapboxGl()` that still answered built a Mapbox descriptor for a
  MapLibre host — the same wrong-engine bug in the other direction. The
  namespace now only stands in for a host that reports no renderer.
- e2e: moved the `bindAgent` doc comment back onto `bindAgent` (it had
  drifted above `RESOURCE_FAILURE_ECHO` in a reorder), and dropped the
  Mapbox-host exemption from the `requestfailed` watcher so a transport
  failure is reported at every host; only `ERR_ABORTED` is still
  ignored.
- Two unit cases cover the above: the renderer overruling a namespace
  the outgoing engine still answers, and the generation gate in front
  of the control construction.

* Address Claude review feedback

- Forget a GeoAgent chunk import that rejected. Splitting the memoized
  promise in two moved the reset: `geoAgentControlPromise` is still
  cleared in `.finally()`, but `geoAgentModulePromise` held the
  rejection forever, so one failed load (offline, or a chunk orphaned
  by a redeploy) made every later activation reject with the same stale
  error until a page reload. On desktop nothing masks that — the
  stale-chunk reload is web-only and skipped while the project is
  dirty. The import now clears the cached promise on rejection and
  rethrows, so the next activation retries, which is what
  `mountGeoAgentControl`'s catch and the host-side rollback assume.
- Unit case covering the retry.

* Address CodeRabbit review feedback

- Drop the adverbial "any more" from the merged mapbox-renderer.md
  sentence rather than spelling it "anymore": the clause reads the
  same without it.

* Address CodeRabbit review feedback

- Drop the plugin roll call from the sentence that replaced the now-empty
  "Still MapLibre-only" list. Naming GeoAgent, Street View and Layer Swipe
  there repeated the three bullets directly above it. The sentence itself
  stays: a reader scanning for what is still MapLibre-only deserves an
  answer rather than silence, and the later "The offline (local PMTiles)
  basemap is MapLibre-only as well" reads off it.
2026-09-16 13:24:26 -04:00
Qiusheng Wu c6f7c23365 feat(swipe): run Layer Swipe on the Mapbox renderer (#2427)
* feat(swipe): run Layer Swipe on the Mapbox renderer

The control drives both maps only through the Style Spec surface the two
engines share — `getStyle`, `addSource`, `addLayer`, `setLayoutProperty`,
`jumpTo`, `resize`, `isStyleLoaded`, `get`/`setProjection`, `on`, `remove`. The
one MapLibre object it built itself was the clipped comparison pane, and a
MapLibre map cannot be layered over a mapbox-gl canvas. maplibre-gl-swipe 0.13.0
takes a `createMap` factory, and the plugin feeds it mapbox-gl's `Map`.

Two Mapbox specifics come with it. The pane is handed the access token
explicitly: mapbox-gl reads its token from the global `mapboxgl.accessToken`
unless the constructor is given one, and GeoLibre passes it per map, so a second
map built without it renders nothing and logs every frame — `getMapboxAccessToken`
on the engine and the plugin API exists for exactly that. And the basemap
grouping is seeded with the upstream's new `basemapLayerIds` whenever the active
basemap URL is not fetchable, because `fetch` rejects a `mapbox://` scheme
outright; `getBasemapLayerIds` is the renderer-neutral read for it, the same
seam the layer control already uses.

The deck.gl raster provider stays MapLibre-only. It mirrors COG and
maplibre-gl-raster layers onto the comparison pane, and both of those controls
register MapLibre tile protocols, so neither draws on Mapbox at all. A project
authored on MapLibre can still carry such layers; the panel omits them rather
than offering sides for layers that are not on screen, and the store
subscription that refreshes them is not registered there either.

Also reads the style's layer order through `getStyle().layers` when
`getLayersOrder` is absent — that helper is MapLibre's, and the project-layer id
expansion runs on both engines now.

Verified in the browser on a `primaryRenderer: "mapbox"` project in both themes:
the comparison pane's canvas is mapbox-gl's (no maplibre canvas anywhere), the
grouped Background row is present, assigning a layer to the right side hides
every style layer it compiles to on the main map and draws it on the clipped
pane, the slider drags, and the console is clean. Covered by
e2e/mapbox-swipe.spec.ts plus unit tests for the factory, the token, the
provider gating and the basemap-id fallback.

Part of #2420 (Swipe item). Upstream: opengeos/maplibre-gl-swipe#26 (v0.12.0)
and #27 (v0.13.0).

* test(e2e): assert the swipe pane's token directly, not console text

CI has no MAPBOX_TOKEN and the spec falls back to a placeholder, so mapbox-gl
logs "A valid Mapbox access token is required" for the fake one — which the
console-text assertion could not tell apart from "no token was passed", the
regression it was actually for.

Read the comparison pane's own access token and compare it to the main map's
instead. That is exactly the thing being checked, and it is independent of
whether the token is real: verified by dropping the accessToken from the
factory, which fails the spec.

* review: resolve project layer ids under Mapbox's naming scheme too

The biggest of these: `styleLayerIdsForProjectLayer` only knew MapLibre's
`layer-<id>-*` over `source-<id>`, but the Mapbox engine compiles the same store
layer as `geolibre-mapbox-<id>-<sourceLayer>-<kind>` over `geolibre-mapbox-<id>`.
A saved project names its swipe sides by store id, so on Mapbox those sides
never resolved and the layer stayed drawn across the whole map — the bug #2161
is about, in a new engine. Both schemes are now matched unconditionally: they
cannot collide, and only one is ever on a given map. The e2e clicked checkboxes
rather than restoring saved sides, which is why it did not catch this
(CodeRabbit).

Also from review:

- `basemapLayerIds` is passed only when there are some. An empty array is truthy
  upstream, so passing one suppressed the fetch *and* left the grouping empty.
  Mapbox Standard is exactly that case — it arrives as a style import, so the
  root style has no layers of its own — and there the fetch failing harmlessly
  is the better outcome (CodeRabbit).
- `onBasemapChange` fires when the store's URL changes, before the engine has
  applied it, so a synchronous rebuild read the *previous* basemap's ids. Where
  the ids are the signal, the rebuild now waits for `style.load`, as the layer
  control host already does (CodeRabbit).
- The e2e checked only that the comparison pane had a canvas. It now asserts the
  assignment the control applied there — the right-side layer visible, the
  left-side one hidden, exactly inverted from the main map (CodeRabbit).
- Failed responses are recorded with their URL and status instead of dropping
  every "Failed to load resource" console line; only 401/403 from the Mapbox API
  hosts are ignored (CodeRabbit).
- docs called the comparison pane a "MapLibre object" two lines before saying it
  is built from mapbox-gl's `Map` (CodeRabbit).

* review: do not count a cancelled tile as a failed request

Every pan and projection switch abandons the requests for the view it left, and
`requestfailed` fires for those aborts too. A spec that drives the camera would
report them as failures on a slower runner.

* swipe: handle a refused control add, and record a Mapbox teardown leak

Browser-testing a basemap change with the swipe active on Mapbox turned up two
things.

One is fixed here: `rebuildSwipeControl` ignored the result of
`addMapControl`. The host refuses it once a later activation supersedes the app
a subscription closed over, and publishing the refused control anyway left
`swipeControl` naming one that was never mounted — a later remove would not find
it. Every other call site in this plugin already handles that; this one now does
too, and only publishes the control once the add succeeds.

The other is not fixed, and is documented rather than left to be discovered:
changing the basemap while the swipe is active leaves the previous comparison
pane — and the live map inside it — orphaned on the Mapbox canvas. The swipe
keeps working against the new basemap. The same sequence on MapLibre leaves one
pane with identical plugin code, so it sits in the Mapbox control lifecycle
rather than in this plugin. Filed as #2430 with the measurements and the five
hypotheses already ruled out, including that it is not caused by this PR's
deferred rebuild (forcing the previous synchronous path leaks identically).

Also picks up maplibre-gl-swipe 0.13.1, whose `onAdd` tears down a previous
mount instead of orphaning its slider and clipped pane — a real hazard for any
host that repositions a control, found while investigating the above, though not
the cause of it.

* fix(swipe): show layer names, not style layer ids, in the Mapbox panel

The Layer Swipe panel drives its two sides by *style* layer id, so its rows are
named from a bridge the engine publishes on the window
(`__GEOLIBRE_LAYER_LABELS__`, rewritten into the DOM by `swipe-style.ts`).
MapController has always published it; MapboxEngine never did, so on Mapbox
every row read `geolibre-mapbox-<uuid>-geojson-fill` while the same layer showed
"Counties Polygons" on MapLibre — and store layer ids are UUIDs, so the ids were
not merely ugly but useless.

MapboxEngine now publishes the same bridge for its own id scheme, and the two
engines share one module (`layer-labels.ts`) rather than two copies of the
window contract: the key, the basemap row's key, and the suffix vocabulary that
turns a style layer's kind into a word a user knows — fill to "Polygons", line
to "Lines", circle to "Points" — so a layer reads identically whichever engine
draws it. A layer that compiles to one style layer keeps its bare name.

`setBackgroundLabel` is implemented rather than ignored on Mapbox for the same
reason: the panel groups every basemap style layer under one row and takes its
name from the bridge, so the translated label has to reach it.

The bridge's change event is now dispatched defensively. The names are on the
window either way and the app re-reads them on its own panel mutations, but a
DOM that cannot dispatch — the headless one the engine tests run against — must
not take a layer sync down with it.

Verified in the browser: dropping a GeoJSON and activating the swipe shows
"states Points / Lines / Polygons" on Mapbox, matching "states Lines /
Polygons / Background" on MapLibre, where the labels are unchanged.

* swipe: read the label bridge's contract from one place

The app declared its own copy of the window interface and spelled the basemap
row's key as a literal, next to an engine that now exports both. Import them
instead, so the producer and the consumer cannot drift.

* swipe: take a layer's style rows from its plan, not from an id prefix

Naming scanned the whole style for `geolibre-mapbox-<id>-`, which a layer named
`a` shares with one named `a-b`. Take the ids the engine actually compiled for
the layer instead, plus any a plugin registered itself, filtered against the
live style so nothing is named before it exists.

The first version of the test for this passed either way: both layers' rows end
up correctly named regardless, because the neighbour is processed second and
overwrites them. The qualifier is what actually shows the over-reach — a layer
drawing through one style layer is named bare, and four rows would wrongly make
it "A Raster" — so the test now pairs a single-row raster with a three-row
neighbour, and fails against the prefix scan.

* swipe: reach the label bridge by subpath, not through the package barrel

`swipe-style.ts` is a side-effect import from the app entry, and I had it
importing from `@geolibre/map` — whose barrel pulls in the MapLibre, Mapbox and
Cesium canvases that each work to stay off the boot path. Give `layer-labels`
its own subpath export, as `style-layer-ids` and five other modules already
have, and import that.

To be clear about what this does and does not buy: Rollup tree-shook the barrel
fine, and the App chunk is the same size either way (1,963.25 kB with the
barrel, 1,963.72 kB with the subpath — noise). This is not a fix for measured
bloat. It is a guard: a package barrel reached from the entry is one
non-shakable side effect away from dragging an engine into the boot chunk, and
the subpath states the dependency that is actually wanted.

* test(e2e): wait for the published names before reading the swipe panel

CI needed a retry on both themes of this spec, on the label assertion: the panel
still held `geolibre-mapbox-West-geojson-fill` when it was read.

The rename is asynchronous in two places, and the assertion raced both. The
engine's `syncLayers` returns early while the style is still loading and
publishes names only on the pass that completes, so the panel can be built from
style layer ids before a name exists for any of them; and the app rewrites the
rows on the animation frame after the panel mutates. Both self-heal — the
publish fires the change event that re-runs the rewrite — which is exactly the
shape a single read gets wrong on a slow runner and right on a fast one.

So wait for the publish, then poll the DOM.

Honest about the verification: this passes locally, but locally it passed before
the change too. I tried to reproduce the CI timing by delaying the rewrite and
could not — that probe delays the wrong half, and the repeated mutations keep
re-scheduling it. CI is the real check.

* Address review feedback

- maplibre-swipe: keep at most one `style.load` rebuild outstanding, and
  cancel a waiting one on deactivate. Clicking through two basemaps
  before the first style landed left two handlers on one event; both fire
  in the same tick, so the first rebuild's control was torn down and
  replaced before it drew anything. A handler that outlived the
  activation rebuilt a control for an inactive plugin.
- mapbox-engine: derive the row suffix from a plugin-adopted native id
  too, not only from ids this engine compiled. ArcGIS names its own style
  layers, so `parcels-fill` and `parcels-line` both came out as bare
  "Parcels" and the swipe panel offered the same row twice.
- layer-labels: name a `raster` row, and title-case any suffix the table
  does not map (a vector-tile source layer's own name lands there) so it
  does not read as a stray lowercase token beside the mapped ones.
- mapbox-engine: `getMapboxAccessToken()` returns `null`, not `""`, when
  no token is configured — matching what the plugin API documents, and
  letting a caller omit the key instead of handing a second mapbox-gl map
  a blank `accessToken`.
- e2e/mapbox-swipe: forgive only Mapbox's bookkeeping URLs, not every
  request to a Mapbox host. The host check also swallowed DNS and
  connection failures, and both maps use a third-party style, so a failed
  Mapbox content request would be a genuine surprise.
- docs/mapbox-renderer.md: restore the two-space list-continuation indent
  on the Geo Editor bullet's wrapped line.

* Address review feedback

- mapbox-engine: only the primary pane publishes and clears the swipe
  panel's layer-name bridge. It is one window global, and MapGrid mounts
  an independent MapboxEngine per Mapbox pane: a secondary pane draws the
  same layers filtered by its own visibility, so its publish republished
  a subset (wrong sibling count, wrong qualifiers) and its destroy wiped
  the primary's names until the primary's next sync, leaving the panel on
  raw style layer ids. MapboxCanvas passes `ownsLayerLabels: !viewId`.
- maplibre-swipe: cancel a pending `style.load` rebuild on every basemap
  change, not only in the branch that queues one. Switching from a
  `mapbox://` basemap to an `https://` one rebuilt immediately while the
  earlier handler stayed attached, so it fired on the new style and tore
  down the just-built control and its comparison map's WebGL context.

Both paths are covered by tests that fail against the previous code.
2026-09-16 12:09:58 -04:00
Qiusheng Wu 37adc4725b feat(streetview): run Street View on the Mapbox renderer (#2426)
* feat(streetview): run Street View on the Mapbox renderer

Everything `maplibre-gl-streetview` touches is on the Style Spec surface both
2D engines share — `getContainer`, `on`/`off`, a click event's `lngLat`, and
`LngLat` only as a value carrier — except the location marker. MapLibre's
`Marker` reads `map._camera.transform` on every position update, which a
mapbox-gl map does not have, so the first map click threw there.

maplibre-gl-streetview 0.8.0 takes a `createMarker` factory: the control still
builds, styles and rotates its own marker element, and only the positioning
comes from the engine. On a Mapbox host the plugin feeds it mapbox-gl's `Marker`
through `app.getMapboxGl()`; on MapLibre it passes nothing and the upstream
default (MapLibre's own `Marker`) stands. A renderer swap tears every active
plugin down and re-activates it, so the rebuilt control picks up the factory of
whichever engine is now primary.

Verified in the browser on a `primaryRenderer: "mapbox"` project: the Plugins
menu no longer greys the entry out, the control mounts in the Mapbox control
corner, a map click places a `.mapboxgl-marker` (and no maplibre marker) that
tracks the map through pans, Mapillary imagery loads in the panel, and a swap
back to MapLibre rebuilds with MapLibre's marker — all with zero console errors.
Covered by e2e/mapbox-streetview.spec.ts, which feeds the control a key through
its own Keys tab and mocks the provider so it needs no Street View account, plus
unit tests for the factory.

Part of #2420 (Street View item). Upstream: opengeos/maplibre-gl-streetview#16, v0.8.0.

* test(e2e): ignore tokenless 401s in the Mapbox Street View spec

The spec asserted no console errors at all. CI has no MAPBOX_TOKEN, so every
request to api.mapbox.com comes back 401/403 and the browser logs a bare
"Failed to load resource" for each. That says nothing about the marker path
under test, which is asserted through the DOM the control produces — the marker
is mapbox-gl's, there is no maplibre one, and it tracks the map.

* review: decide the marker engine on the renderer, not the namespace

CodeRabbit spotted a real race. `getMapboxGl()` only answers once MapboxEngine
has mounted, but the Plugins menu and PluginManager.supportsEngine both gate on
the store's `primaryRenderer`, which flips synchronously. A plugin activated in
that window got `undefined`, kept MapLibre's Marker for the control's whole
lifetime, and threw on the first map click — the exact bug this PR is about.

Commit on the renderer instead, and read the namespace when a marker is actually
built: that happens inside the control's onAdd, by which point the map it is
being added to necessarily exists. The unreachable case throws rather than
silently placing a marker that would fail later with a confusing message.

Also replaces the e2e's console-text filter, which dropped every "Failed to load
resource" line and would have hidden a genuinely broken asset. Failed responses
are now recorded with their URL and status, and only 401/403 from the Mapbox API
hosts are ignored.

* review: do not count a cancelled tile as a failed request

Every pan and projection switch abandons the requests for the view it left, and
`requestfailed` fires for those aborts too. A spec that drives the camera would
report them as failures on a slower runner.

* Address review feedback

- streetViewMarkerFactory now decides on the renderer alone. OR-ing the
  renderer with the namespace was wrong in the reverse direction: swapping
  *away* from Mapbox flips `primaryRenderer` to "maplibre" while the engine ref
  still holds the outgoing MapboxEngine, so the OR committed a control being
  rebuilt for MapLibre to a Mapbox marker — which by `onAdd` has no namespace
  left and throws. The renderer is right in both directions; the namespace stays
  a fallback only for a host that reports no renderer (Claude review).
- Moved the stranded "Stand in for Google's metadata and embed endpoints" doc
  back above `mockStreetViewProvider`, where it belongs. My helper insertion had
  left it above an unrelated const with its own doc (Claude review, twice).
- The e2e no longer forgives Mapbox failures by token state. It ignores only the
  bookkeeping endpoints — telemetry and `map-sessions`, which serve no map
  content and reject even for a valid token — and does so unconditionally. The
  project points at a third-party style, so no Mapbox content is fetched and a
  content failure would be a genuine surprise worth reporting (CodeRabbit).
2026-09-16 11:30:20 -04:00
Qiusheng Wu bfb0c8ea3c feat(flight-simulator): fly the Mapbox renderer's free camera (#2425)
* feat(flight-simulator): fly the Mapbox renderer's free camera

MapLibre dropped Mapbox's `setFreeCameraOptions` and replaced it with
`calculateCameraOptionsFromCameraLngLatAltRotation`, which is how the simulator
has placed the camera on the 2D map; a mapbox-gl map has neither that helper nor
`setCenterClampedToGround`. It still has the free camera, so the plugin gains a
third adapter that writes the aircraft straight into it: a `MercatorCoordinate`
carrying the altitude plus `setPitchBearing`, from which mapbox-gl derives the
same center and zoom the MapLibre helper computes by hand. Center clamping has
no counterpart and needs none — the free camera positions the eye, not the map
center. The flight model, keyboard, HUD and animation loop are unchanged.

`flightTarget` now returns a tagged handle so the three engines pick their own
adapter, and the plugin declares `engines: ["maplibre", "mapbox", "cesium"]`.
Terrain is read through `app.isTerrainEnabled()` rather than `map.getTerrain()`:
the Mapbox Standard style imports its own terrain, so the map reports terrain
while GeoLibre's DEM is off, and the flight would measure its height above a DEM
it never turned on.

Each camera write carries the flight token, and `MapboxCanvas` skips tagged
`moveend` events the way `MapCanvas` does — without it the store's saved view
would be overwritten ~60 times a second. One setting cannot work here:
mapbox-gl 3 has no camera roll axis at all, so "Bank the horizon in turns"
leaves the horizon level (the aircraft still banks, and a bank still turns it);
the panel says so while Mapbox is primary.

Also guards the Atmospheric Effects halo against a non-finite disc. A steeply
pitched globe can project a silhouette sample behind the horizon, and
`createRadialGradient` throws on that, taking the effects render loop down —
reproducible on Mapbox with a bare `jumpTo({pitch: 78})`, and hit every frame
once a flight is in the air.

Verified in the browser on a `primaryRenderer: "mapbox"` project in both themes
(terrain on/off round trip, altitude above the queried DEM, arrow-key bank and
turn, zero console errors, full restore on Esc) and covered by
e2e/mapbox-flight-simulator.spec.ts plus unit tests for the new adapter.

Part of #2420 (Flight Simulator item).

* test(e2e): make the Mapbox flight spec survive a tokenless CI runner

Two CI-only failures, neither about the feature.

The spec asserted no console errors at all. CI has no MAPBOX_TOKEN, so every
request to api.mapbox.com — the DEM the simulator flies over among them — comes
back 401/403 and the browser logs a bare "Failed to load resource" for each.
Assert on app errors instead; the flight itself is checked through the map's own
camera and state, which those requests do not affect.

And the bank assertion held Arrow Right for a fixed 2s and expected a fixed
angle. The turn is integrated per animation frame, and a software-WebGL runner
draws far fewer of them, so it hold the key until the heading actually moves.
The movement and moveend assertions get the same treatment.

* review: name the roll axis in four locales, tighten the e2e failure filter

- ar/fa/ka/vi said "camera rotation", which is broader than the English
  "camera roll" and could read as Mapbox having no bearing or pitch either.
  Name the axis explicitly in each (CodeRabbit).
- The e2e dropped every "Failed to load resource" console line, which would
  also hide a genuinely broken app asset. Record failed responses with their URL
  and status instead, and ignore only 401/403 from the Mapbox API hosts — the
  ones a tokenless CI run is expected to produce. requestfailed is handled the
  same way (CodeRabbit).
- docs/architecture.md said these plugins "declare both engines" in a sentence
  that now covers three (CodeRabbit).

* review: do not count a cancelled tile as a failed request

Every pan and projection switch abandons the requests for the view it left, and
`requestfailed` fires for those aborts too. A spec that drives the camera would
report them as failures on a slower runner.
2026-09-16 10:35:44 -04:00
Qiusheng Wu 379dc20547 feat(arcgis): add the ArcGIS Maps SDK for JavaScript as an optional CDN-loaded rendering engine (#2423)
* feat(arcgis): add the ArcGIS Maps SDK for JavaScript as an optional CDN-loaded rendering engine

Adds "arcgis" as a fourth renderer next to MapLibre, Mapbox and Cesium. Nothing is bundled: the engine imports the SDK's ES modules from Esri's versioned CDN at runtime (packages/map/src/arcgis-sdk.ts), so dist/, the wheel and the installers stay the same size.

- arcgis-layers.ts compiles store layers into SDK layer plans; GeoJSON symbology is evaluated per feature with the style-spec engine and baked into a unique-value renderer, filters and labels included. XYZ/WMTS templates, WMS GetMap templates, vector tiles, ArcGIS services and image overlays have translations.
- arcgis-engine.ts implements MapEngine: camera (rotation vs bearing), layer sync and ordering, basemap translation via the shared cesium-imagery catalog plus Esri basemap styles when an API key is present, hit-test identify, highlight, extent drawing, manual placement, capture, and the SDK widgets as built-in controls.
- ArcgisCanvas.tsx owns construction and the store subscription; PrimaryArcgisCanvas hosts it in the shell and split panes.
- Device-local ArcGIS API key under Settings → Environment Variables, ARCGIS_API_KEY bridge in vite.config.ts, getArcgisApiKey in core.
- Add Data greys out sources without an adapter; layer panels badge "No ArcGIS".
- CSP allow-lists js.arcgis.com in tauri.conf.json and nginx.conf; the service worker caches the SDK for offline reuse.
- Python, MCP, embed and agent-skill renderer lists accept "arcgis"; project format carries preferences.map.arcgisBasemap.
- Tests: arcgis-layers, arcgis-engine (fake SDK), arcgis-renderer; opt-in e2e/arcgis-renderer.spec.ts; translations for every locale; docs/arcgis-renderer.md and a maintenance note.

Refs #2421

* fix(arcgis): use the view's own attribution and stop rebuilding layers on opacity changes

- Drop the deprecated Attribution widget: the 5.x view draws attribution itself while `attributionVisible` is on (Esri requires it), so the engine sets that instead of mounting a widget, which also removes the deprecation warning.
- Ignore the SDK's "Aborted" layer-view errors and errors for layers the engine has already removed: a restyle rebuilds the native layer and the outgoing one's cancelled load was surfacing in the error banner once per slider tick.
- Compile vector-tile styles at full opacity and visible so the VectorTileLayer's native opacity/visibility carry those, and an opacity change no longer rebuilds the layer.
- `fullExtent` is read-only on VectorTileLayer; only tile and WMS layers receive it.

Refs #2421

* Address CodeRabbit review feedback

- useArcgisApiKey: read the device-local key directly as a fallback so an ArcGIS pane that mounts with the app gets the saved key before the first runtime-env projection (which fires no change event); the resolver still wins once it answers.
- vite.config.ts: give the ArcGIS SDK its own `geolibre-arcgis-sdk` CacheFirst cache scoped to the versioned `/5.1/` path, so its few hundred modules cannot evict the other CDN engines from the shared 400-entry cache.
- DesktopShell / e2e: anchor the file-drop target with `data-testid="desktop-shell"` and fail loudly in the spec when it is missing instead of falling back to `document.body`.
- arcgis-sdk.ts: reject HTTP error responses before installing the stylesheet, and tag each theme request with a generation so a stale fetch can neither overwrite a newer theme nor clear its pending state.
- ArcgisCanvas: accept a translated `closeLabel` for the identify popup's close button; the desktop caller passes `t("common.close")`.
- test_map.py: round-trip a mixed 1x3 layout so a MapLibre pane survives beside the ArcGIS primary and pane.

* Address CodeRabbit review feedback

- Allow `https://js.arcgis.com/` (and `data:`) in `font-src` in both the Tauri and nginx content-security policies: the SDK's inlined theme stylesheet loads its Avenir Next and icon WOFF2 files from the CDN, which `default-src 'self'` alone would block. Docs updated to match.

* Address CodeRabbit review feedback

- docs/arcgis-renderer.md: name the dedicated `geolibre-arcgis-sdk` service-worker cache instead of the shared `geolibre-cdn-engines` rule the SDK no longer uses.

* Address CodeRabbit review feedback

- docs/arcgis-renderer.md: limit the offline claim to the cached SDK bootstrap; remote basemaps and services still need the network.

* Address Claude review feedback

- arcgis-layers.ts: translate a FeatureServer layer's filters (quick filters, expression, time and embed filters, rule visibility) into an SQL `definitionExpression` via `filterToSql`; a filter with no SQL form is flagged on the plan and the engine reports that the service draws unfiltered instead of staying silent.
- arcgis-layers.ts / arcgis-engine.ts: place georeferenced images by their four corners through a `ControlPointsGeoreference` (image pixel size read on load) instead of collapsing them to an axis-aligned extent, so rotated and skewed fits match MapLibre.
- arcgis-engine.ts: destroy the outgoing custom basemap on every swap, including into a named Esri style, and re-apply the stored basemap visibility/opacity once a named style's layers have loaded; a style that fails to load is reported.
- arcgis-sdk.ts: redact `token=`/`apiKey=`/`api_key=` values without a leading `?`/`&` as well.

* Address CodeRabbit review feedback

- docker/nginx.conf: drop the Esri CDN from the JupyterLite prefix's CSP (script-src and the font-src directive added for it). The in-app notebook is an iframe whose client drives the parent GeoLibre document over postMessage, so the ArcGIS SDK only ever loads under the main application policy, which keeps its entries.

* Address Claude review feedback

- arcgis-layers.ts / arcgis-engine.ts: point markers now render. A Style-panel marker (shape or custom SVG) compiles to a placeholder carrying its per-class colour and scale; the engine bakes the same sprites MapLibre uses (`renderMarkerCanvas`) into `picture-marker` symbols and swaps them in, drawing the circle fallback until then. A KML feature's own icon becomes a `picture-marker` directly.
- arcgis-engine.ts: `identifyFeatures` performs a real synchronous query for any coordinate by testing the store's GeoJSON geometry (point-in-polygon, pixel-tolerance for points and lines, filters and visibility honoured), so the scripting, notebook and command bridges get answers; the cached hit-test result still serves the clicked location.
- arcgis-engine.ts / ArcgisCanvas.tsx: the async hit test bails out when the engine was destroyed mid-flight, guards the post-await `toMap`, and the canvas catches a rejection instead of leaving it unhandled.
- Tests for the marker placeholder and KML icon plans, the geometry hit tests, the synchronous identify, and a hit test that outlives the engine.

* Address Claude review feedback

- scripts/credential-patterns.json: list `VITE_ARCGIS_API_KEY` so redistributable builds strip it and the credential scan covers it, like the sibling tokens.
- store.ts: a basemap pick clears the pinned ArcGIS style whenever any pane draws with ArcGIS, not only the primary, so split panes follow the picker (Blank included).
- arcgis-engine.ts: identify falls back to the hit graphic's own geometry (converted from the view's spatial reference) and object id for service layers whose features never live in the store.
- arcgis-engine.ts: GeoJSON layers declare their three synthetic fields with explicit widths instead of letting the SDK infer them from sampled features, so long labels are not truncated.
- arcgis-sdk.ts: redact both Esri key prefixes (`AAPK` legacy API keys and `AAPT` API key credentials).
- add-data-renderer.ts: use the catalog's real ids (`deckgl-viz`, `gltf-model`) and also grey out the Vector and Raster panels, which are MapLibre controls with nowhere to mount on this engine.
- useArcgisApiKey.ts: the device key fills in only while the runtime environment has no ArcGIS entry at all, so an explicit empty project override still suppresses it.
- arcgis-layers.ts: remote GeoJSON-URL plans carry the resolver's `zoomDependent` flag so zoom-interpolated styles re-bake on zoom.
2026-09-16 08:23:24 -04:00
Qiusheng Wu 7439287280 feat(overture): run Overture Maps on the Mapbox renderer (#2424)
* feat(overture): run Overture Maps on the Mapbox renderer

mapbox-gl 3.30+ reads .pmtiles archives through its own tile provider, so the
plugin no longer needs MapLibre's pmtiles:// protocol there. On a Mapbox host
it hands maplibre-gl-overture-maps 0.4.0 its new nativePmtiles option (plain
https archive URLs, no addProtocol) and a createPopup factory built from
mapbox-gl's Popup, and declares engines: ["maplibre", "mapbox"]. The store
mirrors record the engine-appropriate URL, so a renderer swap flips between
the protocol URL MapLibre needs and the plain URL Mapbox reads.

Engine side, the Overture mirrors are plugin-owned rows on Mapbox: their
source.url would otherwise be compiled into a second copy of every theme, and
the paint mirror now leaves control-rendered rows (customLayerType) alone the
way MapLibre's ordering-only path does, so the control keeps its x-ray colors.
The desktop CSP allows the PMTiles provider script mapbox-gl imports from
api.mapbox.com/mapbox-gl-js/.

Verified in the browser on the Mapbox renderer in both themes (buildings
theme loads 12k features, click popup is a mapbox-gl popup, Layers-panel
visibility round trip, Mapbox -> MapLibre -> Mapbox swap) and covered by
e2e/mapbox-overture.spec.ts plus unit tests for the plugin host and engine.

Part of #2420 (Overture Maps item). Upstream: opengeos/maplibre-gl-overture-maps#11, v0.4.0.

* review: keep story fades reaching control-rendered layers on Mapbox

The paint mirror now skips control-rendered rows, which also dropped a story
chapter's transient opacity for them. Apply that opacity directly to the
control's native layers (replacing it, as MapLibre's setStoryLayerOpacity
does), remembering the control's own paint so restoreLayerStyles hands it
back. The opacity-property table moves to map-engine.ts so both 2D engines
share it (CodeRabbit).

* review: mirror the PMTiles provider host into the web CSP, drop stale story backups

- docker/nginx.conf's two script-src lists allow api.mapbox.com/mapbox-gl-js/
  like the Tauri CSP, so the browser build can load mapbox-gl's PMTiles
  provider too.
- A control-rendered row that leaves the store while a story fade is active
  gets the control's paint back immediately and its snapshot is dropped, so a
  row re-added under the same id is not handed an outdated one (claude-review).
2026-09-16 00:08:19 -04:00
Qiusheng Wu 73728aca89 feat(geo-editor): run the Geo Editor on the Mapbox renderer (#2422)
* feat(geo-editor): run the Geo Editor on the Mapbox renderer

Geoman routes every engine call through one map adapter, and only three of
its members build MapLibre objects: the Marker behind vertex handles and the
cursor marker, the LngLatBounds behind the cut tool's query box, and the
promise-style loadImage for the default marker icon. On a mapbox-gl map
those throw or never resolve, so the plugin swaps them for mapbox-gl's
(geo-editor-mapbox.ts). Geoman also pushes MapLibre GeoJSONSource diffs
through updateData, which mapbox-gl only offers as a whole-feature upsert on
dynamic sources; each Geoman source wrapper now keeps a feature mirror and
replaces the source with setData instead. The toolbar's rotate and
feature-properties popups come from maplibre-gl-geo-editor's new createPopup
option, fed mapbox-gl's Popup.

Plugins reach the mapbox-gl namespace through the new app.getMapboxGl()
(MapboxEngine.getMapboxGl), the Mapbox counterpart of the Cesium scene
handle. The plugin reads the map through getStyleMap, declares both 2D
engines, suppresses the Sketches store layer under the Mapbox engine's layer
ids as well, and picks Mapbox's Open Sans glyphs for text markers when the
style has no font to borrow. The load-features dialog resolves the map on
either engine. mapbox-gl popups get the z-index lift MapLibre popups already
receive from maplibre-gl-components, so they open above the Effects canvas.

Refs #2420

* Address Claude review feedback

- Clone the features `replaceFeatures` seeds the Geoman source mirror with,
  as the diff's `add` path already did, so later in-place diff updates cannot
  leak into Geoman's own objects.
- Give `.mapboxgl-popup` its z-index lift with `!important`, matching the
  maplibre-gl-components rule it mirrors: mapbox-gl.css is loaded by a dynamic
  import after index.css, so a same-specificity rule there would win.
- Set `crossOrigin = "anonymous"` on the marker image element so a remote icon
  would not taint the WebGL texture `addImage` uploads.
- Drop the dialog's duplicate `styleMapOf`: `getStyleMap` now accepts anything
  with the `getMap` / `getMapboxMap` doors, is exported from @geolibre/plugins,
  and the load-features dialog passes the engine to it directly.

* Address review feedback

- Refuse to activate on a Mapbox map when the host does not hand out the
  mapbox-gl namespace, instead of mounting Geoman with MapLibre's marker and
  image paths on a mapbox-gl map (CodeRabbit).
- Drop the unused FeatureCollection type import (Claude review).
2026-09-15 22:53:17 -04:00
Qiusheng Wu 52ef0c399a feat(mapbox): enable the Plugins menu on the Mapbox renderer (#2419)
* feat(mapbox): enable the Plugins menu on the Mapbox renderer

Most Plugins-menu entries were greyed out on the Mapbox renderer because
every plugin defaulted to engines: ["maplibre"] and read the map through
app.getMap(), which the Mapbox engine leaves null. Thirty-five plugins now
declare Mapbox support and read the 2D map through getStyleMap(app), which
falls back to app.getMapboxMap() and is honest for the Style Spec surface
both engines share.

Shared infrastructure:
- style-map.ts (getStyleMap) and the docked right-panel bridge mount on the
  Mapbox map; the plugin engine audit scans Mapbox-declaring plugins for
  bare app.getMap() reads and MapLibre-only members.
- The Mapbox engine adopts plugin-created raster/WMS layers under their
  native ids (no duplicate copy, rebuilt on style.load), treats source-less
  external native records, time-slider, timelapse and OAM footprints as
  plugin-owned, mirrors store visibility/opacity onto their native layers
  through the shared paint builders, and flushes a deferred sync when a
  source finishes loading (a map with an animated canvas source never
  idles).
- index.css styles .mapboxgl-control-container like MapLibre's so the
  Effects canvases cannot bury the controls; DesktopShell reattaches the
  route animation above the nativeMapInstance gate.

Ported: FEMA NFHL, NASA Earthdata, EnviroAtlas, National Map, USGS NLDI,
Vantor, Planet/CKAN/Socrata/Natural Earth/Portolan, Earthdata GIS,
OpenAerialMap, ArcGIS Hub, Source Cooperative, Hugging Face, GeoLens
(public data), Esri Wayback, USGS LiDAR, Mapillary, Gridlines, H3, S2, A5,
DGGRID, DGGAL, OLC, Geohash, Tilecode, Time Slider, Timelapse, Elevation
Profile, Elements, Dimensions, Route Animation, Clouds, Precipitation,
Effects, Sun.

Still MapLibre-only, documented with reasons: Street View, Overture Maps,
GeoAgent, Swipe, Geo Editor, Flight Simulator.

* Address Claude review feedback

- mapbox-engine: mirror the whole paint object from the shared builders onto
  plugin-owned native layers (with a per-property try/catch), not only the
  opacity key, so Style panel colour/width/radius edits on Mapbox reach
  layers like Mapillary's coverage; NATIVE_OPACITY_PROPERTY is gone.
- Grid plugins (H3, S2, A5, DGGRID, DGGAL, OLC, Geohash, Tilecode,
  Gridlines): guard the teardown in deactivate against a removed mapbox-gl
  map, which throws from getLayer during a renderer swap.
- Gridlines: cache the upright basemap font in pickTextFont like its
  siblings do.
- Route Animation: the teardown catch is a no-op comment instead of an
  unrelated iconColor mutation.
- Plugin engine audit: the getMapboxMap fallback is checked within the
  statement on comment-blanked source (wrapped fallbacks pass, trailing
  comments do not), and destructured or aliased getMap reads count as reads.
- ArcGIS Hub: refresh the stale engines comment.

* Address review feedback

- mapbox-engine: derive the story-opacity layer before the plugin-owned
  branch so a chapter fade reaches mirrored native layers too (test added).
- Timelapse: guard dispose/removeStack in deactivate against a removed
  mapbox-gl map and reset the store and module state in a finally block.
- mapbox-engine test: the controlOwnsPaint case requests a different opacity
  and asserts no paint setter runs.
- Plugin engine audit: subdirectory plugins (elevation-profile/index.ts) are
  enumerated as entry points.
- Components: reword the two audit opt-outs to say the plugin is MapLibre-only
  and the reads are reached from the STAC closure.
- style-map: setTerrain/getTerrain exist on mapbox-gl; the docblock no longer
  lists them as missing (terrain goes through the host either way).
- Time Slider: enforceEngineSupport iterates a snapshot of getSources().

* test: note the receiver-name limit of the Mapbox getMap audit

* Address Claude review feedback

- Time Slider: explain why enforceEngineSupport tests the raw URL template
  synchronously (the extension it keys off never sits in the date
  placeholder), unlike the date-resolved read in ensureSourceBounds.
2026-09-15 20:52:04 -04:00