第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 菜单按“激活/停用”的方式管理插件,结构分四段:
- Activate plugin 下方是所有内置插件的平铺列表,点击即切换,勾号表示当前激活。
- 带地图控件的插件(GeoEditor、Annotations、Dimensions、Street View、Mapillary)点开是子菜单:先 Activate,再选控件停靠的四个角——top left / top right / bottom left / bottom right。
- Web Services 与 DGGS 是子菜单,各自聚合一组插件(前者是各类数据门户浏览器,后者是 H3/S2/A5/DGGRID/DGGAL/OLC/Geohash/Tilecode 网格)。
- 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 的顺序值得背下来,插件里遇到的怪现象基本都能在这里找到解释:
- 未注册 / 引擎不支持 / 正在激活中 → 直接返回
false(正在激活中的同一个插件会返回已有的 Promise)。 - 已激活 → 返回
true,不重复调用。 - 互斥组:先失活同
exclusiveGroup的其他激活插件,并记下它们,失败时恢复。 - 写代际号(generation):后续的异步回滚只对本次尝试有效,避免旧 Promise 推翻新激活。
- 作用域化 app:注入 owner(插件 id),删除/包装只允许激活期使用的注册方法。
- 调用
activate:同步抛错 → 释放该 owner 的注册并恢复被顶掉的互斥插件,然后继续抛。 - 返回
false→ 释放注册、恢复互斥插件、返回false。 - 标记激活并通知订阅者(Plugins 菜单立刻更新)。
- 异步结果:
activate返回 Promise 时,先乐观置为激活,再监视结果;resolvefalse或 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 内置插件的三步接入
- 写实现:在
packages/plugins/src/plugins/新建文件,导出一个GeoLibrePlugin。 - 导出:在
packages/plugins/src/index.ts里export { myPlugin } from "./plugins/my-plugin";。 - 注册:在
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}` },
});
- 仅本会话、绑在图层上:只在内存里,不写图层/工程/分享;图层被删(面板、撤销、
removeLayer)、瓦片 URL 换主机或路径、换工程时都会丢。 - 必须限定在一个 host:模板在第一个
{之前要有固定主机与前缀。https://{s}.tiles.example.org/...会抛错,因为它的匹配前缀是https://,会把头带到所有服务器。 - 只有一个
transformRequest:宿主用同一个给所有 MapLibre 地图注入头,不要调getMap().setTransformRequest。 - 仅 MapLibre:Mapbox、ArcGIS、Cesium 不发这些头;桌面端原生 WMS 取瓦片也不发。
- CORS:带头会触发预检,服务端要允许应用源与该头。
- 校验:非法头名、浏览器自设头(
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 三条容易踩空的规则
paintMode: "plugin"时样式面板只显示真正生效的控件:插入位置、缩放范围、(有 bridge 时)不透明度;不再显示亮度/饱和度/对比度/色相这些点了没反应的滑块。可见性、排序、删除仍由图层面板正常工作——MapLibre 对 custom 图层也认visibility与缩放范围。- 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",
},
});
- 永远把自己的英文原文当
defaultValue传:没有对应语言、或老宿主没有这些方法时,它就是最终显示。 - 键按插件 id 命名(
plugin.<your-id>.<something>);registerTranslations只接受该前缀下的字符串值,其余丢弃并打警告。 - 宿主词条优先:自带 catalog 已有的键保持宿主文本;注册只在本会话有效,重复注册同一键是无操作。
- 复数键与宿主一致:注册
..._one/..._other,并在params里传{ count }。 - 面板标题与菜单标签用 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 凭据、OAuth 与 cookie 登录
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 实践练习
- 最小插件跑通:按 21.12 做一个“添加示例点图层”的插件并打包成 zip,用 Install from file 安装;卸载后确认面板与菜单项消失,而图层仍在(说明卸载不等于删数据)。
- 只读查询面板:用
listLayers、getSelectedLayerId、getSelectedFeatures实时显示“选中图层 / 要素数 / 第一个要素的属性键”;要求无选择时有空状态、选择变化后 100 ms 内更新、deactivate后不再有任何回调。 - 点击避让:给面板加“点地图取坐标”按钮,正确处理
getActiveMapTool()非空的情况(不取点并说明原因),并验证监听器被解绑。 - 时间维度绑定:用
addZarrLayer打开一个公开 Zarr 数据集(或自造 12 帧走registerTemporalLayer),{ bind: true }绑到 Time Slider;再重开工程确认layer.metadata.timeBinding在、而时间值需要重新注册。 - 发布检查:为你的插件写一份 registry 条目(含
minGeoLibreVersion、publishableSettings、bundleSha256),并回答:用户停在旧宿主上会怎样?某个 bundle 出问题时blocklist.json该写哪种条目、影响范围多大?
21.18 延伸阅读
- 第22章 - 服务端、自托管与部署运维:
plugins:install等策略位在容器里如何生成。 - 第23章 - 源码架构与二次开发:
usePlugins.ts、createAppAPI与 store 的关系。 - 第24章 - 测试、国际化与贡献流程:
plugins:docs、引擎审计测试与 i18n catalog 校验。 - 第03章 - 用户界面与工作区:用户视角的插件菜单与 Controls 菜单分工。