第21章 - 插件系统与插件开发

GeoLibre 的大部分功能是以插件形式交付的:从右下角的图层控件、开箱即用的 GeoEditor,到能连 NASA、USGS、STAC 目录的一大票数据浏览器,全部走同一套 GeoLibrePlugin 接口。本章先讲清楚插件从哪里来、宿主怎么管它们,然后把 Plugin API 按“图层 / 面板 / 菜单 / 凭据 / 助手 / 渲染引擎”逐块拆开,最后给出一个可运行的最小插件与发布到市场的完整路径。

说明:本章基于 GeoLibre 3.4 编写。插件接口、方法名、参数名一律保留英文原文;docs/plugin-api.md 是本仓库里最长的一篇参考(约 1800 行),遇到边界行为请以它和 packages/plugins/src/types.ts 为准。

21.1 插件体系总览

21.1.1 五种插件来源

GeoLibre 同时支持五种插件来源,它们最终都汇入同一个 PluginManager,差别只在“代码怎么进来、状态存在哪”。

来源 代码如何进入 持久化位置 典型用途
内置插件(built-in) 编译进主 JS bundle,packages/plugins/src/plugins/ 下实现,usePlugins.ts 的 BUILT_IN_PLUGINS 注册 随应用发布 图层控件、GeoEditor、Time Slider、各大数据门户浏览器
外部 zip Manage Plugins → Settings → Install from file 选择打包好的 .zip(根目录必须有 plugin.json) 桌面端复制到 app data 的 plugins/ 目录为 <plugin-id>.zip;Web 端解包后存 IndexedDB,下次访问重放 自研插件的分发
Manifest URL 给一个 HTTPS 的 plugin.json 地址,宿主 fetch 后按相对路径解析 entry 与 style 记在设置里的 manifest URL 列表 内部分发、CI 灰度
本地目录(桌面端) Settings 里添加本地开发目录;目录里可以是 zip、打包好的插件文件夹,或直接是单个插件包 目录本身 开发调试
烘进构建的 drop-in 把打包产物放到 apps/geolibre-desktop/public/plugins/<id>/,Vite 插件 bundledPlugins() 在构建/开发启动时扫描 随构建产物发布(web 与桌面共用同一份 frontendDist) 私有/企业插件随构建出货
市场(registry) Settings → Manage Plugins 安装;本质是“把 registry 条目里的 manifestUrl 写进 manifest URL 列表”,再走上面的 manifest 加载路径 同 manifest URL 公共插件分发

内置插件数量可以从生成物里读出来:docs/user-guide/plugins.md 的插件参考表由 npm run plugins:docs 从注册表生成,当前写着 GeoLibre registers 70 built-in plugins。这张表还有一列 Link name,就是 ?plugin= 深链用的短名(如 geo-editor、time-slider、nasa-earthdata)。

注意:插件不是“沙箱里的第三方代码”。它和主应用共享同一个 JavaScript 领域(realm)、同一个 DOM、同一份 window。官方文档在信任模型一节写得很直白:这是一层可绕过的客户端策略,既不签名也不隔离。

21.1.2 @geolibre/plugins 包里有什么

文件 职责
types.ts 全部公开类型:GeoLibrePlugin、GeoLibreAppAPI、各面板/菜单/图层选项接口(1700 余行,是接口的最终解释)
plugin-manager.ts 宿主:注册表、激活/失活、引擎门控、项目状态快照、URL 参数分发、作用域化 app
plugin-ids.ts / layer-ids.ts 保留 ID 与内部图层 ID 常量
right-panel-registry.ts 右侧栏面板注册表(停靠、拖拽、共享导轨)
toolbar-menu-registry.ts、menu-contribution-registry.ts 顶栏菜单与内置菜单项注册表
floating-panel-registry.ts 浮动面板注册表
assistant-tool-registry.ts 助手工具注册表(含 owner 命名与去重)
plugins/*.ts 70 个内置插件的实现,按功能分文件
index.ts 汇总导出(约 150 条 export),也是外部插件唯一应当依赖的入口

提示:写外部插件时不要深度引用 @geolibre/plugins/src/plugins/xxx。内置实现随时可能重构;对外稳定的是 types.ts 的类型与 GeoLibreAppAPI 的方法。

21.1.3 插件 ID 的命名约定

虽然没有强制校验,但仓库里有稳定的习惯,跟着走能减少与宿主的冲突:

前缀 含义 例子(来自文档)
maplibre-gl-* 包了一层 MapLibre 控件的插件,名字多与 npm 包同名 maplibre-gl-time-slider、maplibre-gl-swipe、maplibre-gl-geo-editor
geolibre-* GeoLibre 自研、需要自己写数据加载逻辑的插件 geolibre-ign-lidar-hd
语义短名 只改底图/极小状态的内置插件 osm-basemap、carto-light

?plugin= 深链的短名与插件 ID 是两套东西:短名是为了好记,ID 才是注册表主键。两者都能用,例如 ?plugin=geo-editor 与 ?plugin=maplibre-gl-geo-editor 等价。

21.2 插件管理界面、来源与信任

21.2.1 Plugins 菜单

顶栏 Plugins 菜单按“激活/停用”的方式管理插件,结构分四段:

  1. Activate plugin 下方是所有内置插件的平铺列表,点击即切换,勾号表示当前激活。
  2. 带地图控件的插件(GeoEditor、Annotations、Dimensions、Street View、Mapillary)点开是子菜单:先 Activate,再选控件停靠的四个角——top left / top right / bottom left / bottom right。
  3. Web Services 与 DGGS 是子菜单,各自聚合一组插件(前者是各类数据门户浏览器,后者是 H3/S2/A5/DGGRID/DGGAL/OLC/Geohash/Tilecode 网格)。
  4. Installed 段落列出所有外部加载的插件(registry 安装、zip、manifest URL、drop-in 混排),按显示名排序,末尾带 Manage Plugins… 快捷入口。只有存在外部插件时才出现。

外部插件若注册了顶栏菜单,它的 Installed 条目里会多一个 Show menu in toolbar 开关:关掉后菜单收进该条目成为子菜单,选择按插件记忆在本机。内置插件的菜单则始终留在顶栏。

注意:Measure、Bookmark、Legend、Colorbar、Minimap、View State、Search、HTML 这些“地图上的组件面板”不在 Plugins 菜单里,它们在 Controls 菜单;Print composer 在 Project → Print Layout。写教程/写脚本时别把它们当插件找。

21.2.2 Manage Plugins 对话框

Settings → Manage Plugins 打开一个仿 QGIS 插件管理器的独立对话框,分五段:

段落 内容 行为
All registry 全部条目 一键 Install(记录 manifest URL 并立即注册,无需重启)
Installed 已安装条目 Uninstall(确认后运行时反注册并拆除控件);有新版本时出现 Update
Not installed registry 中尚未安装的条目 一键 Install,与 All 的区别是只列未安装条目
Upgradeable registry 版本高于本地版本 Update:先取回并校验新版本,再替换旧的;失败则保留旧版本
Settings 安装与管理来源 Install from file(.zip)、额外本地目录(仅桌面)、手工 manifest URL

兼容性用每个条目的 minGeoLibreVersion 与当前应用版本比对,不满足的条目会被标记而不是被安装。

21.2.3 registry、blocklist 与策略

registry 的取址顺序是:部署策略里的 plugins.registryUrl → 旧变量 VITE_GEOLIBRE_PLUGIN_REGISTRY_URL → 默认 https://plugins.geolibre.app/plugin-registry.json(托管在 opengeos/geolibre-plugins 仓库的 GitHub Pages 上,带 CORS)。

与 registry 并排的还有一份 blocklist.json:每个会话在加载外部插件前拉一次,并缓存以便离线启动。

条目形态 效果
只有 id 该插件的所有版本、所有外部来源(registry / manifest URL / zip / 插件目录)都拒绝安装与加载,命中时展示条目里的 reason
id + bundleSha256 只封禁那一份 bundle:URL 插件、zip、插件目录中代码哈希相同的都不加载、不安装,Update 也拒绝

烘进构建的 drop-in 永远不会被 blocklist 拦,因为它属于发布方自己的构建内容。

21.2.4 信任模型与部署策略

官方把信任模型写成一节,值得完整引用其要点:

  • registry 是策展来源;manifest 必须是 HTTPS(例外:localhost、127.0.0.1、[::1] 允许 HTTP,供开发用);安装需要显式同意。
  • 部署插件策略额外决定“外部插件是否允许加载”,而信任确认不能绕过策略:被策略拒绝的插件不会加载或激活。
  • 该客户端策略可被绕过,且不提供签名与沙箱——这是设计取舍,不是遗漏。
  • 只读视图 layout=viewer 下:已安装的 registry 插件仍可通过链接打开,但跳过信任提示,且从不安装未安装的插件。这与项目 manifest URL 的 sideload: false 抑制是两件不同的事。

策略能力位对插件相关面可见的一条是 plugins:install:没有该授权时,插件的内置菜单项会整体隐藏。

21.2.5 ?plugin= 深链

在地址后追加 plugin=<链接名> 即可带着插件启动,逗号分隔多个:

https://web.geolibre.app/?plugin=nasa-earthdata
https://web.geolibre.app/?plugin=graticule,h3-grid
https://web.geolibre.app/?plugin=openrndt-geolibre

规则可以归纳成一张表:

情形 结果
内置插件且有链接名 直接激活
内置但 menu only(Directions、Reverse Geocode) 不通过深链激活,只能从菜单打开(它们会把点击发往公共服务器)
registry 插件,已安装 与内置一样激活
registry 插件,未安装 可提示信任一个兼容的 registry 条目(显示名称、版本、作者、描述、主页),策略允许才安装并激活
已安装但当前渲染引擎不支持 被挂起(suspend),引擎切回来时恢复
URL 参数指向未注册插件 不加载——URL 永远不会加载插件

提示:usePlugins.ts 里的 DEEP_LINKABLE_PLUGIN_IDS 就是“可深链”的内置插件白名单,由 BUILT_IN_PLUGINS 减去 CONSENT_GATED_PLUGIN_IDS 得到。写文档或写 e2e 断言时,从这份常量取,而不是手抄插件名。

21.3 插件接口与生命周期

21.3.1 GeoLibrePlugin 全字段

packages/plugins/src/types.ts 里的接口字段如下(activate/deactivate 必填,其余可选):

字段 类型 作用
id string 注册表主键,也是凭据作用域、菜单 owner、助手工具名的来源
name string 显示名;有 i18n 词条 toolbar.plugin.<id> 时以词条为准
version string 市场比较版本用
activeByDefault boolean? 注册时即标记为激活(不调用 activate,因为注册阶段还没有 app API)
engines ("maplibre"\|"mapbox"\|"cesium"\|"arcgis")[]? 支持的渲染引擎,默认 ["maplibre"];引擎不匹配时宿主挂起插件
exclusiveGroup string? 同组插件互斥,激活一个会先失活同组其他插件
urlParameterNames string[]? 声明的 URL 查询参数名(区分大小写);至少要有一个才会调用 handleUrlParameters
activate (app) => boolean \| void \| Promise<...> 激活;返回 false 或抛错表示拒绝,宿主回滚
deactivate (app) => void 清理控件、监听器、图层
handleUrlParameters (app, params) => void \| Promise<void> 处理声明的 URL 参数
getMapControlPosition / setMapControlPosition 位置读写 让 Plugins 菜单能把控件挪到四个角
getProjectState / applyProjectState 状态读写 随 .geolibre.json 保存/恢复插件设置
restoresPanelCollapseState boolean? 面板折叠状态由插件自己存取,恢复流程不要代劳
clearsStateOnProjectLoad boolean? 每次载入工程都调 applyProjectState(没有该插件状态时传 undefined),避免串项目
sessionScoped boolean? 工作区级工具(如 S3 Browser),不随工程保存、切工程不重启;仅限无地图控件的插件

最小声明形如:

import type { GeoLibreAppAPI, GeoLibrePlugin } from "@geolibre/plugins";

export const myPlugin: GeoLibrePlugin = {
  id: "my-plugin",
  name: "My Plugin",
  version: "0.1.0",
  engines: ["maplibre", "cesium"],
  activate(app: GeoLibreAppAPI) {
    app.setBasemap("https://example.com/style.json");
  },
  deactivate() {
    // 清理控件、监听器与插件状态。
  },
};

注意:底图类插件故意在 deactivate 时保留已应用的样式——内置的 osm-basemap、carto-light 就是这么做的,因为“把地图弹回用户没选过的样子”比“保留上次选择”更令人意外。但加了控件、监听器、图层的插件必须在 deactivate 里撤销干净。

21.3.2 激活流程(宿主视角)

PluginManager.activate 的顺序值得背下来,插件里遇到的怪现象基本都能在这里找到解释:

  1. 未注册 / 引擎不支持 / 正在激活中 → 直接返回 false(正在激活中的同一个插件会返回已有的 Promise)。
  2. 已激活 → 返回 true,不重复调用。
  3. 互斥组:先失活同 exclusiveGroup 的其他激活插件,并记下它们,失败时恢复。
  4. 写代际号(generation):后续的异步回滚只对本次尝试有效,避免旧 Promise 推翻新激活。
  5. 作用域化 app:注入 owner(插件 id),删除/包装只允许激活期使用的注册方法。
  6. 调用 activate:同步抛错 → 释放该 owner 的注册并恢复被顶掉的互斥插件,然后继续抛。
  7. 返回 false → 释放注册、恢复互斥插件、返回 false。
  8. 标记激活并通知订阅者(Plugins 菜单立刻更新)。
  9. 异步结果:activate 返回 Promise 时,先乐观置为激活,再监视结果;resolve false 或 reject → 回滚(调 deactivate 拆掉半成品)并通知。

对应的状态查询 API 也值得记住:

方法 语义
isActive(id) 只要开始激活就为 true(对 Plugins 菜单是正确语义,对“要撤销的人”不是)
pendingActivation(id) 拿到进行中的激活 Promise;要撤销异步插件前先 await 它
list() / getVersion() / subscribe() 注册表快照、版本号(给 useSyncExternalStore 用)与变更订阅
toggle(id, app) 激活/失活切换
getProjectState() / restoreProjectState() 工程状态的抓取与恢复
handleUrlParameters(params, app, contextKey?) URL 参数分发(带去重)

21.3.3 作用域化 app:哪些方法只在激活期可用

宿主并不把“原样的 app”交给插件,而是包一层 scopeAppToPlugin。这个包装做三件事:

  • 注入 owner:registerToolbarMenu、registerMenuContribution、registerAssistantTool*、app.credentials 的宿主实现都带一个插件 id 参数,插件自己看不到。
  • 激活期限定:助手工具、菜单贡献、右侧栏面板、工具栏菜单的注册只在“本次激活仍然有效”时才真正登记;过期或已停用的作用域调用注册会得到空操作(返回 () => {})。
  • 生命周期回调收窄:applyProjectState、setMapControlPosition、handleUrlParameters、deactivate 拿到的 app 不含助手工具与菜单贡献的注册方法——这些回调也会在插件未激活时执行,注册了也没有清理路径。

警告:不要在 deactivate 之后还持有并使用旧的 app 引用去注册面板。正确做法是把注册返回的 dispose 函数存起来,在 deactivate 里调用它。

21.4 注册插件:内置与外部

21.4.1 内置插件的三步接入

  1. 写实现:在 packages/plugins/src/plugins/ 新建文件,导出一个 GeoLibrePlugin。
  2. 导出:在 packages/plugins/src/index.ts 里 export { myPlugin } from "./plugins/my-plugin";。
  3. 注册:在 apps/geolibre-desktop/src/hooks/usePlugins.ts 的 BUILT_IN_PLUGINS 数组里加入(该数组整体传给 manager.registerAll)。
import { myPlugin } from "@geolibre/plugins";

const BUILT_IN_PLUGINS: GeoLibrePlugin[] = [
  maplibreLayerControlPlugin,
  maplibreGeoEditorPlugin,
  // ...
  myPlugin,
];
manager.registerAll(BUILT_IN_PLUGINS);

若插件包了一层 MapLibre 控件,还需要两件配套动作:

  • 在 packages/plugins/package.json 加依赖;
  • 控件自带样式表时,在 apps/geolibre-desktop/src/main.tsx 里 import "xxx/style.css";,与该文件里已有的一长串 maplibre-gl-*/style.css 并列。

只写依赖自带 CSS;针对本应用的样式修补放 apps/geolibre-desktop/src/index.css(见 21.13)。

21.4.2 直接用 PluginManager 注册

PluginManager 是导出的,可以在自己的宿主壳里独立使用:

import { PluginManager } from "@geolibre/plugins";

const manager = new PluginManager();
manager.register(myPlugin);
manager.activate("my-plugin", appApi);

register 的语义细节:

行为 说明
同 id 覆盖 用新实例替换旧实例,并清掉它在所有 URL 上下文里的去重记录(热重载后能重新处理同一链接)
urlParameterNames 归一化 去重、去空白后存表,供 handleUrlParameters 匹配
getMapControlPosition() 默认值 注册时读一次,作为默认角位
activeByDefault 仅标记为激活,不调用 activate;初始副作用要由插件放在别处幂等地完成

unregister(id, app) 会先 deactivate(让激活中的插件拆掉地图控件),再释放该 owner 的所有注册、清空跟踪状态并通知订阅者——这就是市场“卸载后菜单立刻消失、无需刷新”的实现。

21.4.3 地图控件与内置控件

插件添加 MapLibre 控件就两行:activate 里 app.addMapControl(control, "top-left"),deactivate 里 app.removeMapControl(control)。宿主另外提供内置控件的位置与显隐管理:

方法 说明
setBuiltInMapControlVisible(control, visible) 控制内置控件的显隐。注意 "terrain" 有一个副作用:显示它会同时打开 3D 地形,隐藏它会关掉
getBuiltInMapControlPosition(control) / setBuiltInMapControlPosition(...) 读/写四个角位

GeoLibreBuiltInMapControl 的取值是:navigation、fullscreen、geolocate、globe、terrain、scale、attribution、logo、maptoolkit-logo、layer-control。

21.5 只读查询、选择与编辑会话

21.5.1 查询面:五个方法

外部插件可以读图层与要素,这些方法全是只读的——调用不会改 store:

const layers = app.listLayers?.() ?? [];
const selectedLayerId = app.getSelectedLayerId?.() ?? null;
const selectedFeatures = app.getSelectedFeatures?.() ?? [];

if (selectedLayerId && app.getLayerFeatures) {
  const layerFeatures = app.getLayerFeatures(selectedLayerId);
  console.log(layers, layerFeatures, selectedFeatures);
}

const drawnFeatures = app.getDrawnFeatures?.() ?? [];
方法 返回 边界行为
listLayers() GeoLibreLayerSummary[](id/name/type/visible/opacity) 顺序即图层顺序
getLayerFeatures(layerId) Feature[] 未知 id 会抛错;非 GeoJSON 图层返回空数组
getSelectedFeatures() 选中图层的全部选中要素 无 id 的要素用“零基下标转字符串”匹配;空选择返回 []
getSelectedLayerId() string \| null —
getDrawnFeatures() GeoEditor 的 Sketches 图层要素 —

订阅选择变化并务必在 deactivate 里反订阅:

const unsubscribe = app.onSelectionChange?.(({ layerId, features }) => {
  console.log(layerId, features);
});

// deactivate 或其它清理路径:
unsubscribe?.();

21.5.2 地图点击工具:别和宿主抢指针

当 Identify、要素选择或 GeoEditor 正在占用点击时,插件不该再弹自己的气泡:

const currentTool = app.getActiveMapTool?.() ?? null;
if (currentTool !== null) closePluginPopup();
const unsubscribe = app.onActiveMapToolChange?.((tool) => {
  if (tool !== null) closePluginPopup();
});

取值 含义
"identify" Identify 开启中
"feature-selection" 有选择手势占用点击
"geo-editor" GeoEditor 插件开着(工具栏在地图上,任何点击都可能画要素、放顶点或选要素,包括某图层处于 Edit geometry 时)
null 没有宿主工具占用

优先级是:要素选择 > Identify > GeoEditor。两种细节容易踩坑:换 Identify 目标不会触发工具变更事件;订阅者只收到注册之后的变更,不会有初始回调——初始状态必须自己读 getter。

21.5.3 GeoEditor 会话

const unsubscribe = app.onEditSessionChange?.((change) => {
  // saved 只出现在 "ended" 变更上。
  if (change.layerId === myLayerId && change.state === "ended" && change.saved) {
    offerToSync(app.getLayerFeatures?.(myLayerId) ?? []);
  }
});
方法 语义
getEditSessionLayerId() 当前在 GeoEditor 里编辑的图层,或 null
onEditSessionChange(cb) 报告 { layerId, state: "started" },结束时 { layerId, state: "ended", saved }
startEditSession(layerId) 等同于图层的 Edit geometry:必要时自动开 GeoEditor、先保存另一图层上未关闭的会话;不支持该操作时 resolve false

saved 在用户取消、或会话中途图层被删时为 false。回调抛错会被记录,但仍会收到下一次变更。切换到另一个图层时,顺序是“旧会话 ended、新会话 started”。重叠调用会串行执行;等待超过一分钟的那次会放弃并 resolve false。

21.5.4 更新与删除图层

const layerId = app.addGeoJsonLayer("Survey records", await fetchRecords());

async function refresh() {
  // 图层已不存在、不是 GeoJSON、或正在被编辑时为 false。
  if (!app.updateGeoJsonLayer?.(layerId, await fetchRecords())) {
    // 等编辑会话结束再试,或提示用户。
  }
}

function deactivate() {
  app.removeLayer?.(layerId);
}
  • updateGeoJsonLayer 原地换要素:图层保留 id、名称、顺序、分组、样式与可见性;编辑会话进行中会返回 false 且不改动,避免覆盖用户未保存的编辑。
  • removeLayer 是一次彻底删除:清选择、图例等引用,结束其上的编辑会话且不保存;未知 id 忽略。

21.5.5 图层组(文件夹)

// 建组,可把已有图层一并搬进去;返回组 id。
const basins = app.addLayerGroup?.("Basins", [catchmentLayerId]) ?? null;

// 往已有的组里追加;组 id 传 null 表示抬回根。
app.moveLayersToGroup?.([outletLayerId], basins);

// 组里套组;parent 传 null 抬回根。
const subBasins = app.addLayerGroup?.("Sub-basins");
if (basins && subBasins) app.moveLayerGroupToGroup?.(subBasins, basins);

// 只删文件夹,不删里面的图层。
if (subBasins) app.removeLayerGroup?.(subBasins);
规则 说明
组 id 来源 addLayerGroup 是插件自建组 id 的唯一来源;别人建的组要先 listLayerGroups() 找
可见性与不透明度 组的设置与每个子图层相乘/叠加:隐藏组只影响地图,不改子图层自身的 visible
环路 moveLayerGroupToGroup 会拒绝“把组挂到自己的后代下”,插件不必自己走父链
删除语义 removeLayerGroup 只删文件夹:里面的图层回到根,嵌套的子组改挂到被删组的父级
顺序 listLayerGroups() 返回 store 的组序,parentId 为 null 表示根层;面板会按父级重排显示

21.5.6 套用样式

importLayerStyle(layerId, text) 等价于图层面板的 Import style:从内容自动识别 OGC SLD、QGIS QML 或 Mapbox GL style JSON,合并到现有样式之上(未描述的字段保留),并随工程保存。只对 GeoJSON 与矢量瓦片图层生效,未知 id 抛错。

const layerId = app.addGeoJsonLayer("Land cover", features);
const sld = await (
  await fetch(`${wmsUrl}?service=WMS&version=1.1.1&request=GetStyles&layers=M5:L4`)
).text();
const result = app.importLayerStyle?.(layerId, sld);
if (result && !result.ok)
  console.warn(`Style not applied (${result.reason})`, result.warnings);
reason 含义
invalid 文本不是任何一种可识别样式
no-match 解析成功但没有任何能套到该图层上的符号化
unsupported-layer 图层不是矢量图层

失败时图层保持原样;warnings 列出“样式要求了但 GeoLibre 表达不了”的项。

21.6 栅格、瓦片、矢量瓦片与 Zarr

21.6.1 为什么不要直接 getMap().addSource()

直接往 MapLibre 上挂 source/layer 的后果是:图层不出现在图层面板、不进工程文件、样式面板也管不到。宿主因此提供了一组“注册成原生图层”的方法:

方法 输入 返回
addTileLayer(name, url, options?) XYZ 模板 图层 id
addWmtsLayer(name, url, options?) WMTS 模板 图层 id
addWmsLayer(name, options) WMS 参数(宿主拼 GetMap) 图层 id
addWfsLayer(name, options) WFS GetFeature Promise<图层 id>
addVectorTileLayer(name, url, options) MVT 模板或 TileJSON 图层 id
addCogLayer(name, url, options?) COG 文件本身 Promise<图层 id>
addZarrLayer(name, url, options) Zarr store Promise<图层 id>

公共瓦片选项 GeoLibreTileLayerOptions:tileSize(默认 256)、attribution、bounds(WGS84 [west, south, east, north])、minzoom、maxzoom、scheme("xyz" 默认 / "tms")、visible、opacity、beforeLayerId、metadata。

app.addTileLayer?.(
  "LINZ Aerial Imagery",
  "https://tiles.example.nz/aerial/{z}/{x}/{y}.png",
  { attribution: "Sourced from LINZ. CC BY 4.0", maxzoom: 22 }
);

app.addWmsLayer?.("LINZ Coverage", {
  url: "https://wms.example.nz/wms",
  layers: "coverage",
  transparent: true,
});

const cogId = await app.addCogLayer?.("LINZ DEM", "https://cog.example.nz/dem.tif", {
  colormap: "terrain",
  nodata: -9999,
});

21.6.2 WMS / WFS 的关键参数

选项 默认 说明
layers 必填 逗号分隔的图层名
version "1.1.1" 可选 "1.3.0"(用 CRS 取代 SRS)
crs "EPSG:3857" 也接受 EPSG:4326、EPSG:4258、EPSG:6706、CRS:84(仅 1.3.0)与任意 EPSG:<code>
queryable — capabilities 标了 queryable="0" 时传 false,Identify 跳过

警告:非 Web Mercator 的 WMS 只有桌面端会把瓦片重绘成 Web Mercator;Web 构建仍发 Web Mercator 的 BBOX,图层在那里是空白。不支持的 CRS、或用 1.1.1 配 CRS:84 会抛错。

WFS 侧:url 必须是绝对 HTTP(S);bbox 为 WGS84 且不能跨 180° 经线(west > east 抛错);maxFeatures 默认 1000,会写进 GetFeature URL,刷新与保存后的工程沿用同一上限。凭证不进诊断也不进工程:诊断把 userinfo 与凭证查询参数(含 AWS 的 x-amz-*)记为 [redacted];保存时剥掉 URL 里的凭证参数,并把已取回的集合内嵌进工程。

21.6.3 metadata 留痕

addTileLayer、addWmtsLayer、addWmsLayer、addWfsLayer 都接受 metadata(纯 JSON 可序列化对象,否则抛错/reject),合并进图层的 metadata:

app.addWmsLayer?.("Bathymetry", {
  url: "https://wms.example.org/wms",
  layers: "bathymetry",
  metadata: {
    catalogRecordId: "rndt:abc-123",
    catalogRecordUrl: "https://catalog.example.org/records/abc-123",
    publisher: "Example Hydrographic Office",
    license: "CC BY 4.0",
  },
});

它会出现在图层的 Metadata 对话框并随工程保存。两条规则:宿主自己的键优先(同名字段被覆盖);凭证命名的字段(token、apiKey 等)在分享或导出时被剥除。

21.6.4 矢量瓦片:sourceLayers 与 URL 规则

const layerId = app.addVectorTileLayer?.(
  "Sites",
  "https://tiles.example.org/sites/{z}/{x}/{y}.pbf",
  { sourceLayers: ["sites"], maxzoom: 14 }
);
规则 细节
sourceLayers 至少一个;矢量瓦片不带样式,必须点名画哪个源图层
URL 绝对 HTTP(S);模板必须同时含小写 {z}、{x}、{y};都不含时按 TileJSON 处理
不支持的占位符 {s}、{-y} 不识别;TMS 请改写或另搭 XYZ
TileJSON 缩放范围由 TileJSON 决定,此时传 minzoom/maxzoom 会抛错;bounds 仍作为 Zoom to layer 依据
bounds 落在 ±180°/±90° 内且 west <= east、south <= north(不能跨经线)

宿主用来决定绘制方式的 metadata 键(sourceKind、customLayerType、nativeLayerIds、sourceIds、sourceId、externalNativeLayer、externalDeckLayer、controlOwnsPaint、nativeFillExtrusion)会从你传的 metadata 剔除;sourceKind 固定记为 "plugin-vector-tiles"。

21.6.5 需要鉴权的瓦片请求

const layerId = app.addVectorTileLayer?.("Sites", tilesUrl, {
  sourceLayers: ["sites"],
  headers: { Authorization: `Bearer ${accessToken}` },
});
  1. 仅本会话、绑在图层上:只在内存里,不写图层/工程/分享;图层被删(面板、撤销、removeLayer)、瓦片 URL 换主机或路径、换工程时都会丢。
  2. 必须限定在一个 host:模板在第一个 { 之前要有固定主机与前缀。https://{s}.tiles.example.org/... 会抛错,因为它的匹配前缀是 https://,会把头带到所有服务器。
  3. 只有一个 transformRequest:宿主用同一个给所有 MapLibre 地图注入头,不要调 getMap().setTransformRequest。
  4. 仅 MapLibre:Mapbox、ArcGIS、Cesium 不发这些头;桌面端原生 WMS 取瓦片也不发。
  5. CORS:带头会触发预检,服务端要允许应用源与该头。
  6. 校验:非法头名、浏览器自设头(Cookie、Host、Origin、Sec-*、Proxy-* 等)、同名不同大小写、含换行或 U+00FF 以上字符、未限定作用域的 URL 都会抛错;图层不存在或无瓦片 URL 返回 false。

21.6.6 Zarr 图层与像元查询

const layerId = await app.addZarrLayer?.("seNorge tmax", "https://example.no/senorge.zarr", {
  variable: "tmax",
  selector: { time: 0 },
  clim: [-30, 30],
  colormap: "viridis",
  crs: "EPSG:32633",
});

if (layerId) await app.setZarrLayerSelector?.(layerId, { time: 12 });
选项 说明
variable 必填,要渲染的数组
selector 非空间维度切片,按坐标值而非下标(month 轴 1-12 时十二月写 { month: 12 })
clim / colormap 色标范围;命名色带("viridis")或十六进制色标
crs / proj4 投影坐标系;不写会按 WGS84 读,落到错误位置
bounds store 自身 CRS 下的 [xMin, yMin, xMax, yMax]
headers 需要鉴权的 store

不要在插件里再打包一份 @carbonplan/zarr-layer(第二份会重复加载 numcodecs 的 WASM);自己 getMap().addLayer() 加进去的是 MapLibre custom 图层,没有 paint 属性,样式面板无法驱动。

map.on("click", async (event) => {
  const { lng, lat } = event.lngLat;
  const result = await app.queryZarrLayer?.(layerId, {
    type: "Point",
    coordinates: [lng, lat],
  });
  const [value] = (result?.sst as number[]) ?? [];
  showReadout(value);
});

const region = await app.queryZarrLayer?.(layerId, aoi.geometry, { time: 12 });

行为要点:几何用 WGS84 传(直接拿点击的 lng/lat),重投影与 fill-value 屏蔽由渲染器做;返回的 coordinates 是 store 的源 CRS(EPSG:3857 是米,EPSG:4326 是度),不是 WGS84;落在网格外或首块数据未加载完时返回空值而不是报错;没有该 id 的活动 Zarr 图层时 resolve null;被 abort 的查询reject。这里的 selector 只影响本次读取、不移动地图显示。

21.6.7 读栅格像元

const bounds = app.getViewBounds?.();
if (!bounds) return; // 地图未挂载,或球面正在形态切换
const reading = await app.readRasterWindow?.(layerId, {
  bounds, band: 1, width: 32, height: 32, signal: controller.signal,
});
const values =
  reading?.values.filter(
    (value) => Number.isFinite(value) && value !== reading.nodata
  ) ?? [];
if (values.length === 0) return; // 窗口完全没盖到栅格

readRasterWindow 从最合适的 overview 取数,32×32 窗口是廉价批量读。两种“不可用”必须分开:读不到的瓦片是 NaN,而 NoData 像元保留哨兵值(-9999 是有限数)。只用 Number.isFinite 过滤会把填充值算进去,把 min/max 拉伸拖到哨兵上。

21.6.8 用 Time Slider 驱动图层自己的时间维

export interface TemporalLayerAdapter {
  getTimeValues: () => ReadonlyArray<Date | number | string>;
  setTime: (date: Date) => void | Promise<void>;
  dimension?: string;
  granularity?: "hour" | "day" | "month" | "year";
  displayUnits?: ("hour" | "day" | "month" | "year")[];
}
const times = frames.map((frame) => frame.timestamp);
const detach = app.registerTemporalLayer?.(
  layerId,
  { dimension: "time", getTimeValues: () => times, setTime: (date) => showFrame(nearestFrame(date)) },
  { bind: true }
);

滑块自己负责吸附:读 getTimeValues()、找最接近当前日期的下标、用该片自己的日期调 setTime(),跳过落在当前片上的一格;拖动时调用被节流,尾沿一定执行。注册后图层在 Layers 行菜单里多出 Bind time dimension to Time Slider。绑定记在 layer.metadata.timeBinding({ kind: "selector", dimension, min, max, granularity })并随工程往返,时间值本身不持久化——重开工程要重新注册 adapter。addZarrLayer 添加的 Zarr 图层会自己注册 time 轴 adapter(解析 CF units,如 "days since 1970-01-01")。

21.7 自定义 WebGL 图层与 paint 归属

21.7.1 把插件自绘的图层登记进 store

插件用 CustomLayerInterface 画自己的像素时必须登记,否则图层面板与工程文件都不知道它存在:

const layer = new MyWebGLLayer({ id: "my-layer" });
app.registerExternalNativeLayer?.({
  id: "my-layer",
  name: "My WebGL layer",
  type: "raster",
  nativeLayerIds: ["my-layer"],
  paintMode: "plugin",
  paintBridge: {
    setOpacity: (opacity) => layer.setOpacity(opacity),
    setVisibility: (visible) => layer.setActive(visible),
  },
});
字段 作用
nativeLayerIds 你在 MapLibre 上加的图层 id 列表
paintMode: "geolibre" \| "plugin" 谁负责画。"plugin" 告诉样式面板“别给那些到不了图层的滑块”
paintBridge.setOpacity / setVisibility 用户改不透明度/可见性时宿主调用的 setter
source / sourceIds / sourceId / geojson 让宿主能重建或镜像的源信息
beforeId / opacity / style / metadata / sourcePath 与普通图层一致的初始值

21.7.2 三条容易踩空的规则

  1. paintMode: "plugin" 时样式面板只显示真正生效的控件:插入位置、缩放范围、(有 bridge 时)不透明度;不再显示亮度/饱和度/对比度/色相这些点了没反应的滑块。可见性、排序、删除仍由图层面板正常工作——MapLibre 对 custom 图层也认 visibility 与缩放范围。
  2. setter 只在值变化时调用,且存放在图层记录之外(函数无法序列化进工程文件)。工程重载后要重新登记一次才能恢复 bridge;deactivate 里记得 unregisterExternalNativeLayer(id)。

提示:deck.gl 图层用 await app.getDeckGL() 拿宿主自己那套模块(core、layers、aggregationLayers、geoLayers、meshLayers、mapbox),不要再打包一份 deck.gl:luma.gl/deck.gl 共享全局单例,版本不一致会直接渲染失败。交错渲染用 mapbox.MapboxOverlay。

21.8 右侧栏面板、工具栏菜单与浮动面板

21.8.1 右侧栏面板

export const myPlugin: GeoLibrePlugin = {
  id: "my-workbench",
  name: "Workbench",
  version: "0.1.0",
  activate(app) {
    this._unregister = app.registerRightPanel?.({
      id: "my-workbench",
      title: "Workbench",
      defaultWidth: 360,
      render(container) {
        const button = document.createElement("button");
        button.textContent = "Run analysis";
        container.appendChild(button);
        return () => button.remove();
      },
    });
    app.openRightPanel?.("my-workbench");
  },
  deactivate(app) {
    app.closeRightPanel?.("my-workbench");
    this._unregister?.();
  },
} as GeoLibrePlugin & { _unregister?: () => void };

render(container) 只调一次,给你一个空元素自己填原生 DOM——外部插件拿不到宿主的 React 实例,契约就是 DOM。容器在折叠时保持挂载(DOM 状态不丢),返回的清理函数在关闭或反注册时执行。

停靠位 dock 位置
left-of-layers Layers 面板左侧
right-of-layers Layers 与地图之间
left-of-style 地图与 Style 面板之间
right-of-style Style 右侧(默认)
replace-style 与 Style 共用右侧单导轨(shared-rail)
replace-layers 与 Layers 共用左侧单导轨

同一时刻只有一个插件面板激活;它旁边内置面板会收成导轨,插件面板移开/折叠/关闭后恢复。面板是地图的 flex 兄弟节点,打开会挤小地图视图,不需要手动给地图加 padding。位置在关闭或换面板时重置为注册时声明的 dock。运行期还可切换形态:定位式面板头部有 merge 按钮并入本侧共享导轨,共享导轨里的面板有 detach 按钮弹回定位式;插件也可用 app.setActiveRightPanelDock?.(...) 直接切。左右箭头只在四个定位位之间步进,merge/detach 才是进出共享导轨的方式。

21.8.2 顶栏菜单与内置菜单项

const unregister = app.registerToolbarMenu?.({
  id: "my-plugin-menu",
  label: "Workbench",
  items: [
    { id: "open", label: "Open workbench", onSelect: () => app.openRightPanel?.("my-workbench") },
    {
      type: "submenu",
      id: "tools",
      label: "Tools",
      items: [{ id: "qa", label: "Data QA", onSelect: () => app.openFloatingPanel?.("my-qa") }],
    },
    { type: "separator" },
    { id: "about", label: "About", disabled: false, onSelect: () => {} },
  ],
});
  • 每项是 action(省略 type 时的默认)、submenu 或 separator;同 id 重注册即替换。
  • 外部插件的菜单渲染在顶栏末尾(Help 之后);内置插件的菜单渲染在内置菜单旁。
  • 只加几项时,用 registerMenuContribution 挂到内置菜单更自然:
const unregister = app.registerMenuContribution?.({
  id: "my-plugin-processing", menu: "processing",
  items: [{ id: "run", label: "Run analysis…", onSelect: () => app.openRightPanel?.("my-workbench") }],
});
规则 说明
可选的 menu "addData"、"processing"、"controls"
呈现 总是包进以插件名命名的子菜单,放在目标菜单末尾分隔线之后并按字母排序;不能插到内置项之间
同插件多次贡献同一菜单 合并成一个子菜单,之间加分隔线
id 全局唯一;一个插件的 id 覆盖另一个时宿主告警,unregisterMenuContribution 只删自己的
未知 menu 记录警告并忽略,不会让激活失败
可见性 只读视图、或部署未授予 plugins:install 时隐藏

21.8.3 浮动面板

const unregister = app.registerFloatingPanel?.({
  id: "my-qa",
  title: "Data QA",
  defaultWidth: 300,
  render(container) {
    container.textContent = "Rendered by the plugin via registerFloatingPanel().";
    return () => { /* 关闭/反注册时的清理 */ };
  },
});

app.openFloatingPanel?.("my-qa");
app.closeFloatingPanel?.("my-qa");
app.getOpenFloatingPanels?.();

与右侧栏面板的分工:右侧栏面板放首要的、常驻的工作区(同一时刻只有一个);浮动面板放辅助工具/仪表盘,可同时开多个、不挤小地图、位置由 position 指定(默认左上)。

21.8.4 国际化(插件侧)

const locale = app.getLocale?.() ?? "en";
const title = app.translate?.("plugin.my-plugin.title", "Workbench") ?? "Workbench";
const label = app.translate?.("plugin.my-plugin.count", "{{n}} features", { n: 3 });
const stop = app.onLocaleChange?.((next) => renderPanel(container, next));

app.registerTranslations?.({
  de: {
    "plugin.my-plugin.title": "Werkbank",
    "plugin.my-plugin.count": "{{n}} Objekte",
  },
});
  1. 永远把自己的英文原文当 defaultValue 传:没有对应语言、或老宿主没有这些方法时,它就是最终显示。
  2. 键按插件 id 命名(plugin.<your-id>.<something>);registerTranslations 只接受该前缀下的字符串值,其余丢弃并打警告。
  3. 宿主词条优先:自带 catalog 已有的键保持宿主文本;注册只在本会话有效,重复注册同一键是无操作。
  4. 复数键与宿主一致:注册 ..._one / ..._other,并在 params 里传 { count }。
  5. 面板标题与菜单标签用 getter;一次性构建的 DOM(按钮、提示、状态行)用 onLocaleChange 重写文本。

内置插件还有两个 @geolibre/plugins 导出的辅助:createPluginTranslator(app, pluginId) 返回 tr(key, english, params?)(解析 plugin.<id>.<key> 并自行插值英文回退,键以 @ 开头表示用宿主键);pluginDisplayTitle(app, pluginId, name) 返回面板标题 getter,读 toolbar.plugin.<pluginId>,让面板头与 Plugins 菜单同名。

21.9.1 app.credentials

const apiKey = app.credentials?.get("api-key") ?? "";
const persisted = app.credentials?.set("api-key", value);
const where = app.credentials?.location();
规则 说明
存储位置 桌面端进 OS 凭据库(Keychain / Credential Manager / Secret Service);Web、Jupyter、移动端进 localStorage
名称 1-64 个字符,仅字母、数字、_、-,否则抛 TypeError
作用域 宿主自动加前缀,落盘为 plugin.<your-id>.<name>,插件不传 id
读 同步;桌面端在任何插件运行前就把所有已存值读进内存
写 持久化成功返回 true;只在本会话有效(写失败、keychain 不可用)返回 false
传空串 删除该项

警告:这是存储,不是隔离。插件是宿主窗口里的可信代码,仍能通过内存或包一层 fetch 读到别的插件的值。桌面端确实收窄了可达面:启动时一次性读入凭据,随后关闭读取通道(外部插件加载器在 import 前调 secure_store_seal)。只安装你信任的插件。

还有两条硬规则:绝不把密钥放进 getProjectState(插件状态会写进工程并随分享/导出外发);内置插件(Hugging Face、Mapillary、Gods Eye View)已从各自 localStorage 键迁到 app.credentials,宿主首次启动迁移旧明文值,且新值写成功后才删旧的。

21.9.2 OAuth(授权码 + PKCE)

const redirectUri = app.getOAuthRedirectUri?.();
const state = crypto.randomUUID();
const verifier = createCodeVerifier();
const authorizationUrl = new URL(`${server}/o/authorize/`);
authorizationUrl.search = new URLSearchParams({
  response_type: "code",
  client_id: clientId,
  redirect_uri: redirectUri,
  state,
  code_challenge: await createCodeChallenge(verifier),
  code_challenge_method: "S256",
}).toString();

signInButton.onclick = async () => {
  const code = await app.authorizeInPopup?.(authorizationUrl.toString());
  const tokens = await exchangeCode(code, verifier, redirectUri);
  app.credentials?.set("refresh-token", tokens.refresh_token);
};
要点 说明
必须在点击处理器里、在任何 await 之前调用 浏览器只允许“用户手势”期间开弹窗,授权 URL(含 PKCE challenge)要提前算好
redirect_uri 必须等于 app.getOAuthRedirectUri():Web 是应用回调页(跟随站点 base path),桌面是 org.geolibre.desktop:/oauth/callback
回调校验 只接受来自该弹窗、应用源、且 state 匹配的回调
并发 一次只允许一个插件登录;第二个调用 reject already-pending
token 交换 由插件自己做;跨域受限时用 app.nativeFetch

失败错误带 reason:invalid-request(缺 state、redirect URI 不对、非 HTTP(S)、timeoutMs 非法)、popup-blocked、already-pending、access-denied、invalid-callback、cancelled(弹窗被关)、timeout(默认 5 分钟,可用 { timeoutMs })、restart-required(桌面回调接收器未运行)、launch-failed(桌面打不开系统浏览器)。

21.9.3 会话 cookie:app.nativeFetch

桌面端页面跑在 tauri://localhost,服务端设置的 cookie 属第三方,WebKit(macOS/Linux)会丢弃——登录返回 200、下一个请求却 401。此时改用原生 HTTP 客户端:

const http = app.nativeFetch ?? fetch;
await http(`${server}/api/v1/auth/access-token`, {
  method: "POST",
  body: new URLSearchParams({ username: email, password }),
});
const user = await (await http(`${server}/api/v1/users/current`)).json();
特性 说明
签名 与 fetch 相同,支持任意方法与 body,不受 CORS 限制
cookie 存原生 cookie jar,不暴露给 JS;credentials 选项无效
生命周期 整会话共享一个 jar(所有插件共用),应用重启清空
限制 请求/响应体最大 64 MiB,120 秒超时,禁止 link-local 与云元数据地址
取消 支持 AbortSignal

21.9.4 预期内的失败降级为 info

设备码登录轮询这类协议会把 400 authorization_pending 当正常返回。给请求加客户端头即可让诊断降级:x-geolibre-expected-status(逗号分隔的预期状态码,其它失败仍记错误)、x-geolibre-optional-resource(任意值,整个请求可选,任何失败都记 info)。两个头都由 app.nativeFetch 与 GeoLibre 的浏览器 fetch 在发送前剥除,不会到达服务器,服务端 CORS 无需放行。

21.10 助手工具:让 AI 调用你的插件

外部插件不需要打包 @strands-agents/sdk 或 zod,用 registerAssistantToolSpec 传 JSON Schema 即可:

let disposeTool;
export default {
  id: "city-loader",
  name: "City loader",
  version: "1.0.0",
  activate(app) {
    async function addCities(input, context) {
      if (!input || typeof input.name !== "string") throw new Error("name must be a string");
      const data = await fetch(citiesUrl, { signal: context?.signal }).then((r) => r.json());
      return { layerId: app.addGeoJsonLayer(input.name, data), featureCount: data.features.length };
    }
    disposeTool = app.registerAssistantToolSpec?.({
      name: "add_cities",
      description: "Add US cities to the map as a GeoJSON layer",
      inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
      callback: addCities,
    });
  },
  deactivate() { disposeTool?.(); disposeTool = undefined; },
};
规则 细节
输入校验 JSON Schema 不会自动校验,回调必须自己检查模型给来的输入
返回值 需可 JSON 序列化;返回 undefined 会变成 null;抛出的错误变成工具错误结果
取消 第二个参数是 { signal };网络调用要传 signal,分步之间检查 signal.aborted;老宿主不传,写成 context?.signal
模型可见名 plugin_<owner-length>_<plugin-id>_<tool-name>;全名 ≤ 64 字符,仅字母/数字/下划线/连字符
生效期 工具只活一次激活;停用/激活失败/卸载即移除;拆除后的滞后异步注册被忽略
渐进披露 激活插件合计 ≤ 12 个工具时全量发送;超过后只给摘要(描述首句、≤160 字符),模型用 load_plugin_tools 载入
让助手开插件 在 plugin.json 或插件对象上声明 assistant: { description }(≤300 字符),助手就得到 activate_plugin 宿主工具
助手指引 registerAssistantGuidance(text):非空、≤4000 字符,追加在 Plugin guidance: 下,每块标 [plugin <id>],永不改宿主文本

内置插件也可用 SDK:app.registerAssistantTool(tool({ name, description, inputSchema, callback }))(zod schema 自动校验)。两个入口共用一个注册表,都返回反注册函数。

提示:一个什么都不注册的插件会把 activate_plugin 那一回合拖满 3 秒(宿主等待“250 ms 内无新工具”,总上限 3 秒),所以要在 activate 里尽快注册。

21.11 渲染引擎适配

engines 由插件管理器在激活、URL 参数、工程恢复、延迟控件注册四处强制执行;渲染器变化时不支持的插件被挂起,设置与激活状态保留,切回来重新挂载。

声明 适用
["maplibre"](默认) 直接操作 MapLibre 对象/扩展
["maplibre", "mapbox"] 只用两个 2D 引擎共享的 Style Spec 面
["maplibre", "cesium"] 有球面分支,或只通过 store + 控制宿主记录的 Style Spec 面工作

读视口只该用 getViewBounds():getMap()?.getBounds() 在球面上是 null,把“拿不到 bounds”当“不过滤”会在“current view only”勾选时静默搜全球。getViewBounds 也有自己的 null(地图未挂载、球面形态切换、相机背对地球),同样要拒绝搜索而不是放宽条件。前端测试会扫描每个声明 Cesium 支持的插件,发现走 getMap() 的 bounds 读取就失败;确需分支的写 engine-audit-allow: getMap-bounds 注释,豁免只作用于该调用。

句柄 何时非空 用途
getMap() MapLibre 为主渲染器 完整 MapLibre API
getMapboxMap() / getMapboxGl() / getMapboxAccessToken() Mapbox 为主渲染器 共享面读取;构造 Mapbox 类时拿命名空间;再建第二个 Mapbox 地图必须传 token
getCesiumScene() 主图是球面 带 @cesium/engine 命名空间的句柄(widget/scene/camera/clock),primary 区分主图与 pane
getArcgisControlMap() / getCesiumControlMap() ArcGIS / Cesium MapLibre 形状的记录式地图:样式调用被记录而非绘制,custom 图层会抛错

Mapbox 渲染器下不能用的能力:addProtocol/removeProtocol(pmtiles://、COG 协议不加载)、setTransformRequest(瓦片请求头仅 MapLibre)、CustomLayerInterface(capabilities.customLayers 为 false)、从 maplibre-gl import 的 Popup/Marker(更新路径读 map._camera.transform,第一次移动就抛错)、getProjection() 形状差异({ type } vs { name })。写法是 app.getMap?.() ?? app.getMapboxMap?.(),同一套审计用 engine-audit-allow: getMap-mapbox / maplibre-only 豁免。

注意:球面上绑定的插件必须在 deactivate 里恢复它改过的场景状态,因为两种引擎在渲染器切换时都会重建,宿主会对新引擎重新激活兼容插件。

21.12 一个最小可运行插件

{
  "id": "example-plugin",
  "name": "Example Plugin",
  "version": "0.1.0",
  "entry": "dist/index.js",
  "style": "dist/style.css",
  "engines": ["maplibre", "cesium"],
  "assistant": { "description": "Search the example catalogue and add its layers." }
}

清单与入口的硬规则:entry 必须以默认导出或具名 plugin 导出 GeoLibrePlugin;导出对象的 id/name/version 必须与清单一致;入口必须是自包含的 .js/.mjs(第一个加载器不解析包内相对 import);manifest 路径是 zip 内相对路径,正斜杠、不以 / 开头、无反斜杠、无 ..;style 可选但会全局注入;外部插件的导出对象不能设 activeByDefault(该标志只对烘进构建的 drop-in 生效)。

入口只用四个 API 就能闭环(面板 + 数据):registerRightPanel 注册一个 render(container) 面板,openRightPanel 打开它,按钮里调 addGeoJsonLayer("Sample", featureCollection),deactivate 里 closeRightPanel + 调 dispose。真实插件再按需叠加 onLocaleChange、credentials、registerAssistantToolSpec。

本地加载的五条路:① 桌面端 Settings 里的开发目录(先于 app data 扫描,可覆盖同 id 已装版本);② 桌面端 app data 的 plugins/ 放 zip;③ Install from file;④ HTTPS 的 manifest URL(本地开发允许 localhost/127.0.0.1/[::1] 的 HTTP);⑤ 放到 apps/geolibre-desktop/public/plugins/<id>/ 烘进构建——bundledPlugins() 在构建/开发启动时扫描并经 virtual:bundled-plugins 交给 usePlugins.ts,所以增删插件后要重启 dev server 或重新构建。私有 drop-in 应被 git 忽略,构建/部署时再拷入。

21.13 样式与 UI 约定

场景 规则
修补第三方控件样式 改 apps/geolibre-desktop/src/index.css,永不改 node_modules;选择器收窄到控件类名
依赖自带 CSS 在 main.tsx 里 import "xxx/style.css";,与现有 maplibre-gl-*/style.css 并列
插件自带 CSS 用插件前缀收窄选择器;记住 url()/@import 也能发网络请求,信任预期与代码同级
桌面端 CSP script-src 只允许应用自身、blob: 与固定 CDN 路径;插件里 import() 别处模块会被拦,该依赖打包进插件

21.14 打包发布到市场

registry 是 JSON(数组,或带 plugins 数组的对象)。条目字段:id、name、version、manifestUrl 必填,其余可选——description、author、homepage、categories、minGeoLibreVersion、publishableSettings、bundleSha256。相对 manifestUrl 按 registry 位置解析,所以同宿主插件可写 sample/plugin.json。

字段 作用
minGeoLibreVersion 安装门槛,按运行中应用版本判断(不满足时“标记而不安装”)
publishableSettings 允许哪些顶层状态键在“Strip credentials”、分享/导出后保留;true 表示整份保留。声明随条目审核、永不从工程读取;保留内容仍按凭证命名字段清洗
bundleSha256 发布 bundle 的小写 SHA-256:entry 的 SHA-256、style(或空串)的 SHA-256,再对这两个摘要求 SHA-256(computePluginBundleHash)。命中时在安装 URL 之前钉住哈希,首次加载即校验

发布步骤:打包自包含 bundle → 三条安装路径各测一次并验证卸载 → 托管到 HTTPS 静态主机 → 算 bundleSha256 → 向 opengeos/geolibre-plugins 提条目 PR(含作者、主页、分类、最低版本)→ 等 publishableSettings 与哈希被审核。

撤下/升级场景 做法
普通升级 提升清单 version,重发 bundle 并更新条目;Update 先取回校验再替换,失败保留旧版
破坏性升级 提升 minGeoLibreVersion,让老客户端拒绝安装而不是装上崩
单个坏 bundle blocklist.json 写 { "id", "bundleSha256", "reason" },只封这一份
整插件撤下 只写 id,所有版本、所有外部来源都拒绝;drop-in 不受影响

21.15 调试与安全边界

调试手段 用法
npm run dev Web 开发服务器(localhost:5173);插件源码热更,外部插件改完要重新打包
?plugin= 深链 复现“带插件启动”,也用于测 registry 首次安装的信任流程
安全边界 事实
代码执行 与宿主同 realm、完整 DOM/window 权限,没有沙箱;只能靠“只装信任的插件”
加载路径 外部入口用 import(URL.createObjectURL(...)) 执行,桌面 CSP script-src 必须含 blob:(配合 unsafe-eval,能造 blob URL 即可执行脚本)
凭据 桌面端启动时一次性读入并 secure_store_seal 关闭读取通道;同 realm 内存里的值仍可读
工程外发 分享、HTML 导出、嵌入快照、协作快照都会整段删除 plugins.settings,除非条目声明 publishableSettings

21.16 本章小结

  • 插件有五种来源(内置、zip、manifest URL、本地目录、烘进构建的 drop-in),市场只是“manifest URL + 策展 registry”的薄壳;GeoLibrePlugin 的字段围绕三件事:能开关(activate/deactivate + 异步回滚)、能持久化(getProjectState/applyProjectState)、能被门控(engines、exclusiveGroup、sessionScoped、urlParameterNames)。
  • 宿主用 PluginManager 统一管理,用 scopeAppToPlugin 注入 owner 并限定激活期可用的注册方法;菜单与助手的注册一律“激活期专属”。
  • 数据入口统一走 store:addGeoJsonLayer、addTileLayer/addWmtsLayer/addWmsLayer/addWfsLayer/addVectorTileLayer、addCogLayer、addZarrLayer,自绘图层用 registerExternalNativeLayer + paintMode/paintBridge。
  • UI 有四个挂载点:右侧栏面板(含共享导轨)、顶栏菜单、内置菜单贡献、浮动面板;文案用 getLocale/onLocaleChange/translate/registerTranslations 跟随语言。密钥进 app.credentials、绝不进工程状态;OAuth 在点击里发起;cookie 登录走 nativeFetch;助手工具要自己校验输入并尊重 signal。插件是可信代码:没有沙箱,靠来源审核、部署策略与 blocklist 兜底。

21.17 实践练习

  1. 最小插件跑通:按 21.12 做一个“添加示例点图层”的插件并打包成 zip,用 Install from file 安装;卸载后确认面板与菜单项消失,而图层仍在(说明卸载不等于删数据)。
  2. 只读查询面板:用 listLayers、getSelectedLayerId、getSelectedFeatures 实时显示“选中图层 / 要素数 / 第一个要素的属性键”;要求无选择时有空状态、选择变化后 100 ms 内更新、deactivate 后不再有任何回调。
  3. 点击避让:给面板加“点地图取坐标”按钮,正确处理 getActiveMapTool() 非空的情况(不取点并说明原因),并验证监听器被解绑。
  4. 时间维度绑定:用 addZarrLayer 打开一个公开 Zarr 数据集(或自造 12 帧走 registerTemporalLayer),{ bind: true } 绑到 Time Slider;再重开工程确认 layer.metadata.timeBinding 在、而时间值需要重新注册。
  5. 发布检查:为你的插件写一份 registry 条目(含 minGeoLibreVersion、publishableSettings、bundleSha256),并回答:用户停在旧宿主上会怎样?某个 bundle 出问题时 blocklist.json 该写哪种条目、影响范围多大?

21.18 延伸阅读